|
1 | 1 | import * as React from 'react' |
2 | 2 | import { twMerge } from 'tailwind-merge' |
3 | 3 |
|
4 | | -type Props = React.SelectHTMLAttributes<HTMLSelectElement> |
5 | | - |
6 | | -/** Styled native <select> with a carved-in chevron. */ |
7 | | -export const ShopSelect = React.forwardRef<HTMLSelectElement, Props>( |
8 | | - function ShopSelect({ className, children, ...rest }, ref) { |
9 | | - return ( |
10 | | - <select |
11 | | - ref={ref} |
12 | | - {...rest} |
| 4 | +type OptionData = { value: string; label: string } |
| 5 | + |
| 6 | +type Props = { |
| 7 | + value?: string |
| 8 | + onChange?: (e: { target: { value: string } }) => void |
| 9 | + className?: string |
| 10 | + triggerClassName?: string |
| 11 | + children?: React.ReactNode |
| 12 | +} |
| 13 | + |
| 14 | +function extractOptions(children: React.ReactNode): Array<OptionData> { |
| 15 | + const options: Array<OptionData> = [] |
| 16 | + React.Children.forEach(children, (child) => { |
| 17 | + if (!React.isValidElement(child) || child.type !== 'option') return |
| 18 | + const el = child as React.ReactElement<{ |
| 19 | + value?: string |
| 20 | + children?: React.ReactNode |
| 21 | + }> |
| 22 | + const value = String(el.props.value ?? '') |
| 23 | + const label = |
| 24 | + typeof el.props.children === 'string' |
| 25 | + ? el.props.children |
| 26 | + : String(el.props.value ?? '') |
| 27 | + options.push({ value, label }) |
| 28 | + }) |
| 29 | + return options |
| 30 | +} |
| 31 | + |
| 32 | +/** Custom themed dropdown. Keeps the same onChange API as a native <select> |
| 33 | + * so all call-sites stay unchanged. */ |
| 34 | +export function ShopSelect({ |
| 35 | + value, |
| 36 | + onChange, |
| 37 | + className, |
| 38 | + triggerClassName, |
| 39 | + children, |
| 40 | +}: Props) { |
| 41 | + const [open, setOpen] = React.useState(false) |
| 42 | + const [focused, setFocused] = React.useState<string | null>(null) |
| 43 | + const triggerRef = React.useRef<HTMLButtonElement>(null) |
| 44 | + const menuRef = React.useRef<HTMLDivElement>(null) |
| 45 | + |
| 46 | + const options = extractOptions(children) |
| 47 | + const selected = options.find((o) => o.value === value) |
| 48 | + |
| 49 | + // Close on outside click |
| 50 | + React.useEffect(() => { |
| 51 | + if (!open) return |
| 52 | + const handler = (e: MouseEvent) => { |
| 53 | + if ( |
| 54 | + !triggerRef.current?.contains(e.target as Node) && |
| 55 | + !menuRef.current?.contains(e.target as Node) |
| 56 | + ) { |
| 57 | + setOpen(false) |
| 58 | + } |
| 59 | + } |
| 60 | + document.addEventListener('mousedown', handler) |
| 61 | + return () => document.removeEventListener('mousedown', handler) |
| 62 | + }, [open]) |
| 63 | + |
| 64 | + // Keyboard navigation |
| 65 | + const onKeyDown = (e: React.KeyboardEvent) => { |
| 66 | + if (!open) { |
| 67 | + if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { |
| 68 | + e.preventDefault() |
| 69 | + setOpen(true) |
| 70 | + setFocused(value ?? options[0]?.value ?? null) |
| 71 | + } |
| 72 | + return |
| 73 | + } |
| 74 | + const idx = options.findIndex((o) => o.value === focused) |
| 75 | + if (e.key === 'ArrowDown') { |
| 76 | + e.preventDefault() |
| 77 | + setFocused(options[Math.min(idx + 1, options.length - 1)]?.value ?? null) |
| 78 | + } else if (e.key === 'ArrowUp') { |
| 79 | + e.preventDefault() |
| 80 | + setFocused(options[Math.max(idx - 1, 0)]?.value ?? null) |
| 81 | + } else if (e.key === 'Enter' || e.key === ' ') { |
| 82 | + e.preventDefault() |
| 83 | + if (focused) select(focused) |
| 84 | + } else if (e.key === 'Escape') { |
| 85 | + setOpen(false) |
| 86 | + triggerRef.current?.focus() |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + const select = (optValue: string) => { |
| 91 | + onChange?.({ target: { value: optValue } }) |
| 92 | + setOpen(false) |
| 93 | + triggerRef.current?.focus() |
| 94 | + } |
| 95 | + |
| 96 | + return ( |
| 97 | + <div className={twMerge('relative', className)} onKeyDown={onKeyDown}> |
| 98 | + <button |
| 99 | + ref={triggerRef} |
| 100 | + type="button" |
| 101 | + aria-haspopup="listbox" |
| 102 | + aria-expanded={open} |
| 103 | + onClick={() => { |
| 104 | + setOpen((o) => !o) |
| 105 | + if (!open) setFocused(value ?? options[0]?.value ?? null) |
| 106 | + }} |
13 | 107 | className={twMerge( |
14 | | - 'appearance-none border border-shop-line-2 rounded-xl', |
15 | | - 'py-2.5 pl-4 pr-8 bg-transparent text-shop-text-2 font-shop-mono text-shop-ui', |
16 | | - 'bg-no-repeat bg-[right_12px_center]', |
17 | | - // inline SVG chevron, colored with the muted text token |
18 | | - "bg-[url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'><path d='M2 4l3 3 3-3' stroke='%23a8a8b0' fill='none' stroke-width='1.4'/></svg>\")]", |
19 | | - 'cursor-pointer transition-colors hover:border-shop-muted hover:text-shop-text', |
20 | | - className, |
| 108 | + 'flex items-center gap-2 border border-shop-line-2 rounded-xl', |
| 109 | + 'py-2.5 pl-4 pr-3 bg-transparent text-shop-text-2 font-shop-mono text-shop-ui', |
| 110 | + 'cursor-pointer transition-colors select-none whitespace-nowrap', |
| 111 | + 'hover:border-shop-muted hover:text-shop-text', |
| 112 | + open && 'border-shop-muted text-shop-text', |
| 113 | + triggerClassName, |
21 | 114 | )} |
22 | 115 | > |
23 | | - {children} |
24 | | - </select> |
25 | | - ) |
26 | | - }, |
27 | | -) |
| 116 | + {selected?.label ?? '—'} |
| 117 | + <svg |
| 118 | + width="10" |
| 119 | + height="10" |
| 120 | + viewBox="0 0 10 10" |
| 121 | + aria-hidden |
| 122 | + className={twMerge( |
| 123 | + 'transition-transform duration-150 shrink-0', |
| 124 | + open && 'rotate-180', |
| 125 | + )} |
| 126 | + > |
| 127 | + <path |
| 128 | + d="M2 4l3 3 3-3" |
| 129 | + stroke="currentColor" |
| 130 | + fill="none" |
| 131 | + strokeWidth="1.4" |
| 132 | + strokeLinecap="round" |
| 133 | + /> |
| 134 | + </svg> |
| 135 | + </button> |
| 136 | + |
| 137 | + {open ? ( |
| 138 | + <div |
| 139 | + ref={menuRef} |
| 140 | + role="listbox" |
| 141 | + className={twMerge( |
| 142 | + 'absolute right-0 top-[calc(100%+6px)] z-[200] min-w-full', |
| 143 | + 'bg-shop-panel border border-shop-line rounded-xl overflow-hidden', |
| 144 | + 'shadow-[0_8px_24px_-4px_rgba(0,0,0,0.18),0_2px_8px_-2px_rgba(0,0,0,0.12)]', |
| 145 | + )} |
| 146 | + > |
| 147 | + {options.map((opt) => { |
| 148 | + const isSelected = opt.value === value |
| 149 | + const isFocused = opt.value === focused |
| 150 | + return ( |
| 151 | + <button |
| 152 | + key={opt.value} |
| 153 | + type="button" |
| 154 | + role="option" |
| 155 | + aria-selected={isSelected} |
| 156 | + onMouseEnter={() => setFocused(opt.value)} |
| 157 | + onClick={() => select(opt.value)} |
| 158 | + className={twMerge( |
| 159 | + 'w-full text-left px-4 py-2.5 font-shop-mono text-shop-ui whitespace-nowrap', |
| 160 | + 'transition-colors duration-75 flex items-center gap-2', |
| 161 | + isSelected ? 'text-shop-accent' : 'text-shop-text-2', |
| 162 | + isFocused && 'bg-shop-surface text-shop-text', |
| 163 | + )} |
| 164 | + > |
| 165 | + <span |
| 166 | + className={twMerge( |
| 167 | + 'w-1.5 h-1.5 rounded-full shrink-0 transition-opacity', |
| 168 | + isSelected ? 'bg-shop-accent opacity-100' : 'opacity-0', |
| 169 | + )} |
| 170 | + /> |
| 171 | + {opt.label} |
| 172 | + </button> |
| 173 | + ) |
| 174 | + })} |
| 175 | + </div> |
| 176 | + ) : null} |
| 177 | + </div> |
| 178 | + ) |
| 179 | +} |
0 commit comments