Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,14 @@ function App() {
</p>

<div className="flex flex-col sm:flex-row gap-4 items-center">
<Link to="/docs" tabIndex={-1}>
<Button variant="filled" rightIcon={<FiArrowRight />}>
Get Started
</Button>
</Link>
<a href="https://github.com/carllosnc/dashkit" target="_blank" rel="noopener noreferrer">
<Button variant="outlined" leftIcon={<FiGithub />}>
<Button asChild variant="filled" rightIcon={<FiArrowRight />}>
<Link to="/docs">Get Started</Link>
</Button>
<Button asChild variant="outlined" leftIcon={<FiGithub />}>
<a href="https://github.com/carllosnc/dashkit" target="_blank" rel="noopener noreferrer">
View on GitHub
</Button>
</a>
</a>
</Button>
</div>

{/* CLI Emulator */}
Expand Down
10 changes: 5 additions & 5 deletions src/dashkit/AreaChart/AreaChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import './area-chart.css';
import { useAreaChart, type AreaChartSeries } from './useAreaChart';

const DEFAULT_COLORS = [
'var(--color-primary)',
'var(--color-primary-400)',
'var(--color-primary-200)',
'var(--color-primary-800)',
'var(--color-primary-300)',
'var(--color-ds-primary-600)',
'var(--color-ds-primary-400)',
'var(--color-ds-primary-200)',
'var(--color-ds-primary-800)',
'var(--color-ds-primary-300)',
];

export interface AreaChartProps {
Expand Down
4 changes: 2 additions & 2 deletions src/dashkit/Backdrop/Backdrop.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ describe('Backdrop', () => {
expect(overlay).toHaveClass('backdrop__overlay');
});

it('calls onClick when Escape key is pressed', () => {
it('does not handle Escape itself (overlay hooks own that)', () => {
const handleClick = vi.fn();
render(<Backdrop show={true} onClick={handleClick} />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(handleClick).toHaveBeenCalledTimes(1);
expect(handleClick).not.toHaveBeenCalled();
});
});
15 changes: 2 additions & 13 deletions src/dashkit/Backdrop/Backdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,8 @@ export function Backdrop({
children,
...props
}: BackdropProps) {
React.useEffect(() => {
if (!show || !onClick) return;

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClick({} as React.MouseEvent<HTMLDivElement>);
}
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [show, onClick]);

// Note: Escape-to-close is handled by the overlay hooks (useModal/useDrawer)
// instead of here, so `onClose` is invoked exactly once with a real event.
const overlay = (
<motion.div
initial={{ opacity: 0 }}
Expand Down
10 changes: 5 additions & 5 deletions src/dashkit/BarChart/useBarChart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import * as React from 'react';
import type { BarChartSeries } from './BarChart';

const DEFAULT_COLORS = [
'var(--color-primary)',
'var(--color-primary-400)',
'var(--color-primary-200)',
'var(--color-primary-800)',
'var(--color-primary-300)',
'var(--color-ds-primary-600)',
'var(--color-ds-primary-400)',
'var(--color-ds-primary-200)',
'var(--color-ds-primary-800)',
'var(--color-ds-primary-300)',
];

interface UseBarChartProps {
Expand Down
67 changes: 53 additions & 14 deletions src/dashkit/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import {
forwardRef,
cloneElement,
Children,
isValidElement,
type ButtonHTMLAttributes,
type ReactNode,
type ReactElement,
} from 'react';
import clsx, { type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { Spinner } from '../Spinner/Spinner';
Expand All @@ -15,6 +23,12 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
loading?: boolean;
/**
* Render the button styles onto its single child element instead of a
* <button> — use for links (<Link>, <a>) so interactive content is never
* nested inside an anchor.
*/
asChild?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
Expand All @@ -29,29 +43,54 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
rightIcon,
loading,
disabled,
asChild = false,
...props
},
ref
) {
const isIconOnly = !children && (!!leftIcon || !!rightIcon);

const classes = cn(
'button',
`button--${variant}-${color}`,
`button--${size}`,
{
"button--loading": loading,
"button--with-text": !isIconOnly,
"button--with-text-sm": size === 'sm' && !isIconOnly,
"button--with-text-lg": size === 'lg' && !isIconOnly,
"button--icon-only": isIconOnly,
},
className
);

if (asChild) {
const child = Children.only(children);
if (!isValidElement(child)) {
throw new Error('Button with asChild must have a single React element as its child.');
}

const childProps = child.props as { className?: string; children?: ReactNode };

return cloneElement(child as ReactElement<Record<string, unknown>>, {
ref,
className: twMerge(classes, childProps.className),
children: (
<>
{loading && <Spinner size={16} color="currentColor" thickness={3} />}
{!loading && leftIcon && <span className="inline-flex shrink-0">{leftIcon}</span>}
{childProps.children}
{!loading && rightIcon && <span className="inline-flex shrink-0">{rightIcon}</span>}
</>
),
});
}

return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
'button',
`button--${variant}-${color}`,
`button--${size}`,
{
"button--loading": loading,
"button--with-text": !isIconOnly,
"button--with-text-sm": size === 'sm' && !isIconOnly,
"button--with-text-lg": size === 'lg' && !isIconOnly,
"button--icon-only": isIconOnly,
},
className
)}
className={classes}
{...props}
>
{loading && <Spinner size={16} color="currentColor" thickness={3} />}
Expand Down
3 changes: 2 additions & 1 deletion src/dashkit/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function Combobox({
containerRef,
inputWrapperRef,
inputRef,
portalRef,
selectedOptions,
filteredOptions,
handleSelect,
Expand Down Expand Up @@ -148,7 +149,7 @@ export function Combobox({

{isOpen && triggerRect && createPortal(
<div
id="dashkit-combobox-portal"
ref={portalRef}
role="listbox"
style={{
position: 'fixed',
Expand Down
6 changes: 4 additions & 2 deletions src/dashkit/Combobox/useCombobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function useCombobox({
const containerRef = useRef<HTMLDivElement>(null);
const inputWrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const portalRef = useRef<HTMLDivElement>(null);

const selectedOptions = useMemo(() => {
if (multiple && Array.isArray(value)) {
Expand Down Expand Up @@ -86,7 +87,7 @@ export function useCombobox({

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const menu = document.getElementById('dashkit-combobox-portal');
const menu = portalRef.current;
if (
containerRef.current && !containerRef.current.contains(event.target as Node) &&
(!menu || !menu.contains(event.target as Node))
Expand All @@ -96,7 +97,7 @@ export function useCombobox({
};

const handleScroll = (event: Event) => {
const menu = document.getElementById('dashkit-combobox-portal');
const menu = portalRef.current;
if (isOpen && menu && !menu.contains(event.target as Node)) {
setIsOpen(false);
}
Expand Down Expand Up @@ -177,6 +178,7 @@ export function useCombobox({
containerRef,
inputWrapperRef,
inputRef,
portalRef,
selectedOptions,
filteredOptions,
handleSelect,
Expand Down
3 changes: 2 additions & 1 deletion src/dashkit/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function DatePicker({
position,
containerRef,
buttonRef,
portalRef,
toggleOpen,
currentMonth,
currentYear,
Expand Down Expand Up @@ -69,7 +70,7 @@ export function DatePicker({

{isOpen && triggerRect && createPortal(
<div
id="dashkit-datepicker-portal"
ref={portalRef}
style={{
position: 'fixed',
top: position === 'bottom' ? triggerRect.bottom + 8 : triggerRect.top - 8,
Expand Down
4 changes: 3 additions & 1 deletion src/dashkit/DatePicker/useDatePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function useDatePicker({ value, onChange, disabled }: UseDatePickerProps)
const [position, setPosition] = useState<'bottom' | 'top'>('bottom');
const containerRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const portalRef = useRef<HTMLDivElement>(null);

const toggleOpen = () => {
if (disabled) return;
Expand All @@ -42,7 +43,7 @@ export function useDatePicker({ value, onChange, disabled }: UseDatePickerProps)

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const menu = document.getElementById('dashkit-datepicker-portal');
const menu = portalRef.current;
if (
containerRef.current && !containerRef.current.contains(event.target as Node) &&
(!menu || !menu.contains(event.target as Node))
Expand Down Expand Up @@ -127,6 +128,7 @@ export function useDatePicker({ value, onChange, disabled }: UseDatePickerProps)
position,
containerRef,
buttonRef,
portalRef,
toggleOpen,
currentMonth,
currentYear,
Expand Down
13 changes: 13 additions & 0 deletions src/dashkit/Drawer/Drawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ describe('Drawer', () => {
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText('Test Desc')).toBeInTheDocument();
});

it('calls onClose exactly once when Escape is pressed', () => {
const handleClose = vi.fn();
render(
<Drawer isOpen={true} onClose={handleClose}>
<div>Content</div>
</Drawer>
);

fireEvent.keyDown(window, { key: 'Escape' });

expect(handleClose).toHaveBeenCalledTimes(1);
});
});


19 changes: 12 additions & 7 deletions src/dashkit/Drawer/useDrawer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { type Variants, type PanInfo } from 'framer-motion';
import { lockBodyScroll } from '../utils/scrollLock';

export type DrawerPosition = 'left' | 'right' | 'top' | 'bottom';

Expand Down Expand Up @@ -36,15 +37,19 @@ export interface UseDrawerProps {

export function useDrawer({ isOpen, onClose, position }: UseDrawerProps) {
useEffect(() => {
if (!isOpen) return;
return lockBodyScroll();
}, [isOpen]);

useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
window.addEventListener('keydown', handleEsc);
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);



Expand Down
6 changes: 4 additions & 2 deletions src/dashkit/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export function Dropdown({ children }: DropdownProps) {
setTriggerRect,
triggerElement,
setTriggerElement,
containerRef
containerRef,
portalRef
} = useDropdown();

return (
Expand All @@ -24,7 +25,8 @@ export function Dropdown({ children }: DropdownProps) {
triggerRect,
setTriggerRect,
triggerElement,
setTriggerElement
setTriggerElement,
portalRef
}}>
<div className="dropdown" ref={containerRef}>
{children}
Expand Down
12 changes: 5 additions & 7 deletions src/dashkit/Dropdown/DropdownContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@ export function DropdownContent({
side = 'bottom',
sideOffset = 8
}: DropdownContentProps) {
const { open, triggerElement, setTriggerRect, triggerRect } = useDropdownContext();
const { open, triggerElement, setTriggerRect, triggerRect, portalRef } = useDropdownContext();
const [contentRect, setContentRect] = React.useState<DOMRect | null>(null);
const contentRef = React.useRef<HTMLDivElement>(null);

React.useLayoutEffect(() => {
if (open && contentRef.current) {
setContentRect(contentRef.current.getBoundingClientRect());
if (open && portalRef.current) {
setContentRect(portalRef.current.getBoundingClientRect());
}
}, [open]);
}, [open, portalRef]);

React.useEffect(() => {
if (!open) return;
Expand Down Expand Up @@ -105,8 +104,7 @@ export function DropdownContent({

return createPortal(
<div
ref={contentRef}
id="dashkit-dropdown-portal"
ref={portalRef}
style={style}
className={cn(
"dropdown-content",
Expand Down
Loading
Loading