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
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'

- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ next-env.d.ts


# personal doc
.documents/*
.documents/daily-report.md
Comment thread
Jieunsse marked this conversation as resolved.
110 changes: 110 additions & 0 deletions src/components/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use client';

import clsx from 'clsx';
import { useEffect, useRef } from 'react';

import DropdownItem from './DropdownItem';
import useDropdown from './hooks/useDropdown';
import styles from './styles/Dropdown.module.css';
import type { DropdownMenuSize, DropdownProps } from './types/types';

const DEFAULT_SIZE: DropdownMenuSize = 'default';
const MENU_SIZE_CLASS: Record<DropdownMenuSize, string> = {
default: styles.menuDefault,
small: styles.menuSmall,
repeat: styles.menuRepeat,
};

export default function Dropdown({
items,
defaultValue,
value,
placeholder,
size = DEFAULT_SIZE,
disabled = false,
ariaLabel,
className,
buttonClassName,
menuClassName,
itemClassName,
onChange,
}: DropdownProps) {
const {
listboxId,
isOpen,
selectedItem,
activeIndex,
openByKeyboard,
shouldRestoreFocus,
setActiveIndex,
clearRestoreFocus,
handleSelect,
handleToggle,
handleBlur,
handleKeyDown,
} = useDropdown({ items, defaultValue, value, disabled, onChange });
const triggerAriaLabel = selectedItem || placeholder ? undefined : ariaLabel;
const displayLabel = selectedItem?.label ?? placeholder;
const triggerRef = useRef<HTMLButtonElement | null>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);

useEffect(() => {
if (!isOpen || !openByKeyboard || activeIndex < 0) return;
optionRefs.current[activeIndex]?.focus();
}, [activeIndex, isOpen, openByKeyboard]);

useEffect(() => {
if (isOpen || !shouldRestoreFocus) return;
triggerRef.current?.focus();
clearRestoreFocus();
}, [clearRestoreFocus, isOpen, shouldRestoreFocus]);

return (
<div className={clsx(styles.dropdown, className)} onBlur={handleBlur} onKeyDown={handleKeyDown}>
<button
ref={triggerRef}
type="button"
className={clsx(styles.button, disabled && styles.disabled, buttonClassName)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-label={triggerAriaLabel}
disabled={disabled}
onClick={handleToggle}
>
<span className={clsx(styles.label, !selectedItem && placeholder && styles.placeholder)}>
{displayLabel}
</span>
<span className={clsx(styles.icon, isOpen && styles.iconOpen)} aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3.5 6L8 10.5L12.5 6" stroke="currentColor" strokeWidth="1.6" />
</svg>
</span>
</button>
{isOpen ? (
<div
id={listboxId}
className={clsx(styles.menu, MENU_SIZE_CLASS[size], menuClassName)}
role="listbox"
aria-label={ariaLabel}
>
{items.map((item, index) => (
<DropdownItem
key={item.value}
label={item.label}
isSelected={item.value === selectedItem?.value}
size={size}
className={itemClassName}
tabIndex={openByKeyboard && activeIndex === index ? 0 : -1}
onFocus={() => setActiveIndex(index)}
onSelect={() => handleSelect(item.value)}
ref={(node) => {
optionRefs.current[index] = node;
}}
/>
))}
</div>
) : null}
</div>
);
}
29 changes: 29 additions & 0 deletions src/components/dropdown/DropdownItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import clsx from 'clsx';
import { forwardRef } from 'react';

import styles from './styles/DropdownItem.module.css';
import type { DropdownItemProps } from './types/types';

const DropdownItem = forwardRef<HTMLButtonElement, DropdownItemProps>(function DropdownItem(
{ label, isSelected, size, className, onSelect, tabIndex, onFocus },
ref,
) {
return (
<button
ref={ref}
type="button"
className={clsx(styles.item, styles[size], isSelected && styles.selected, className)}
role="option"
aria-selected={isSelected}
tabIndex={tabIndex}
onClick={onSelect}
onFocus={onFocus}
>
{label}
</button>
);
});

DropdownItem.displayName = 'DropdownItem';

export default DropdownItem;
170 changes: 170 additions & 0 deletions src/components/dropdown/hooks/useDropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { FocusEvent, KeyboardEvent } from 'react';
import { useId, useMemo, useState } from 'react';

import type { UseDropdownOptions } from '../types/types';
import { getSelectedItem, resolveInitialValue } from '../utils/dropdown';

export default function useDropdown({
items,
defaultValue,
value,
disabled = false,
onChange,
}: UseDropdownOptions) {
const listboxId = useId();
const [isOpen, setIsOpen] = useState(false);
const [openByKeyboard, setOpenByKeyboard] = useState(false);
const [shouldRestoreFocus, setShouldRestoreFocus] = useState(false);
const [uncontrolledValue, setUncontrolledValue] = useState(() =>
resolveInitialValue(items, defaultValue),
);
const [activeIndex, setActiveIndex] = useState<number | null>(null);

const currentValue = value ?? uncontrolledValue;
const selectedItem = getSelectedItem(items, currentValue);
const selectedIndex = useMemo(
() => items.findIndex((item) => item.value === currentValue),
[items, currentValue],
);
const fallbackIndex = useMemo(
() => (selectedIndex >= 0 ? selectedIndex : items.length > 0 ? 0 : -1),
[items.length, selectedIndex],
);
const resolvedActiveIndex = useMemo(() => {
if (items.length === 0) return -1;
const base = activeIndex ?? fallbackIndex;
if (base < 0) return 0;
return Math.min(base, items.length - 1);
}, [activeIndex, fallbackIndex, items.length]);

const handleSelect = (nextValue: string) => {
if (disabled) return;
if (value === undefined) {
setUncontrolledValue(nextValue);
}
onChange?.(nextValue);
setIsOpen(false);
setActiveIndex(null);
setShouldRestoreFocus(true);
};
Comment thread
Jieunsse marked this conversation as resolved.

const handleToggle = () => {
if (disabled) return;
setIsOpen((prev) => {
const next = !prev;
if (next) {
setOpenByKeyboard(false);
setActiveIndex(fallbackIndex);
} else {
setActiveIndex(null);
}
return next;
});
};

const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
const nextFocus = event.relatedTarget as Node | null;
if (nextFocus && event.currentTarget.contains(nextFocus)) return;
setIsOpen(false);
setActiveIndex(null);
setShouldRestoreFocus(false);
};

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const target = event.target as HTMLElement | null;
const isTrigger =
target instanceof HTMLButtonElement && target.getAttribute('aria-haspopup') === 'listbox';

if (items.length === 0) {
if (event.key === 'Escape') {
event.stopPropagation();
setIsOpen(false);
setActiveIndex(null);
setShouldRestoreFocus(true);
}
return;
}

if (!isOpen) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
setIsOpen(true);
setOpenByKeyboard(true);
setActiveIndex(fallbackIndex);
}
return;
}

if (isTrigger) {
if (event.key === 'Escape') {
event.stopPropagation();
setIsOpen(false);
setActiveIndex(null);
setShouldRestoreFocus(true);
}
return;
}

const maxIndex = items.length - 1;
setOpenByKeyboard(true);

switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const nextIndex = resolvedActiveIndex + 1 > maxIndex ? 0 : resolvedActiveIndex + 1;
setActiveIndex(nextIndex);
break;
}
case 'ArrowUp': {
event.preventDefault();
const nextIndex = resolvedActiveIndex <= 0 ? maxIndex : resolvedActiveIndex - 1;
setActiveIndex(nextIndex);
break;
}
case 'Home': {
event.preventDefault();
setActiveIndex(0);
break;
}
case 'End': {
event.preventDefault();
setActiveIndex(maxIndex);
break;
}
case 'Enter':
case ' ': {
event.preventDefault();
const item = items[resolvedActiveIndex];
if (item) {
handleSelect(item.value);
}
break;
}
case 'Escape': {
event.stopPropagation();
setIsOpen(false);
setActiveIndex(null);
setShouldRestoreFocus(true);
break;
}
default:
break;
}
};
Comment thread
Jieunsse marked this conversation as resolved.

return {
listboxId,
isOpen,
selectedItem,
activeIndex: resolvedActiveIndex,
openByKeyboard,
shouldRestoreFocus,
setActiveIndex,
clearRestoreFocus: () => setShouldRestoreFocus(false),
handleSelect,
handleToggle,
handleBlur,
handleKeyDown,
};
}
Loading