-
-
Notifications
You must be signed in to change notification settings - Fork 342
feat(shop): animation polish, sticky filter bar & custom dropdowns #898
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,27 +1,179 @@ | ||
| import * as React from 'react' | ||
| import { twMerge } from 'tailwind-merge' | ||
|
|
||
| type Props = React.SelectHTMLAttributes<HTMLSelectElement> | ||
|
|
||
| /** Styled native <select> with a carved-in chevron. */ | ||
| export const ShopSelect = React.forwardRef<HTMLSelectElement, Props>( | ||
| function ShopSelect({ className, children, ...rest }, ref) { | ||
| return ( | ||
| <select | ||
| ref={ref} | ||
| {...rest} | ||
| type OptionData = { value: string; label: string } | ||
|
|
||
| type Props = { | ||
| value?: string | ||
| onChange?: (e: { target: { value: string } }) => void | ||
| className?: string | ||
| triggerClassName?: string | ||
| children?: React.ReactNode | ||
| } | ||
|
|
||
| function extractOptions(children: React.ReactNode): Array<OptionData> { | ||
| const options: Array<OptionData> = [] | ||
| React.Children.forEach(children, (child) => { | ||
| if (!React.isValidElement(child) || child.type !== 'option') return | ||
| const el = child as React.ReactElement<{ | ||
| value?: string | ||
| children?: React.ReactNode | ||
| }> | ||
| const value = String(el.props.value ?? '') | ||
| const label = | ||
| typeof el.props.children === 'string' | ||
| ? el.props.children | ||
| : String(el.props.value ?? '') | ||
| options.push({ value, label }) | ||
| }) | ||
| return options | ||
| } | ||
|
|
||
| /** Custom themed dropdown. Keeps the same onChange API as a native <select> | ||
| * so all call-sites stay unchanged. */ | ||
| export function ShopSelect({ | ||
| value, | ||
| onChange, | ||
| className, | ||
| triggerClassName, | ||
| children, | ||
| }: Props) { | ||
| const [open, setOpen] = React.useState(false) | ||
| const [focused, setFocused] = React.useState<string | null>(null) | ||
| const triggerRef = React.useRef<HTMLButtonElement>(null) | ||
| const menuRef = React.useRef<HTMLDivElement>(null) | ||
|
|
||
| const options = extractOptions(children) | ||
| const selected = options.find((o) => o.value === value) | ||
|
|
||
| // Close on outside click | ||
| React.useEffect(() => { | ||
| if (!open) return | ||
| const handler = (e: MouseEvent) => { | ||
| if ( | ||
| !triggerRef.current?.contains(e.target as Node) && | ||
| !menuRef.current?.contains(e.target as Node) | ||
| ) { | ||
| setOpen(false) | ||
| } | ||
| } | ||
| document.addEventListener('mousedown', handler) | ||
| return () => document.removeEventListener('mousedown', handler) | ||
| }, [open]) | ||
|
|
||
| // Keyboard navigation | ||
| const onKeyDown = (e: React.KeyboardEvent) => { | ||
| if (!open) { | ||
| if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { | ||
| e.preventDefault() | ||
| setOpen(true) | ||
| setFocused(value ?? options[0]?.value ?? null) | ||
| } | ||
| return | ||
| } | ||
| const idx = options.findIndex((o) => o.value === focused) | ||
| if (e.key === 'ArrowDown') { | ||
| e.preventDefault() | ||
| setFocused(options[Math.min(idx + 1, options.length - 1)]?.value ?? null) | ||
| } else if (e.key === 'ArrowUp') { | ||
| e.preventDefault() | ||
| setFocused(options[Math.max(idx - 1, 0)]?.value ?? null) | ||
| } else if (e.key === 'Enter' || e.key === ' ') { | ||
| e.preventDefault() | ||
| if (focused) select(focused) | ||
| } else if (e.key === 'Escape') { | ||
| setOpen(false) | ||
| triggerRef.current?.focus() | ||
| } | ||
| } | ||
|
|
||
| const select = (optValue: string) => { | ||
| onChange?.({ target: { value: optValue } }) | ||
| setOpen(false) | ||
| triggerRef.current?.focus() | ||
| } | ||
|
|
||
| return ( | ||
| <div className={twMerge('relative', className)} onKeyDown={onKeyDown}> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move keyboard handler off the static wrapper to fix a11y/lint blocker. At Line 97, Suggested fix- <div className={twMerge('relative', className)} onKeyDown={onKeyDown}>
+ <div className={twMerge('relative', className)}>
<button
ref={triggerRef}
type="button"
+ onKeyDown={onKeyDown}
aria-haspopup="listbox"
aria-expanded={open}
@@
{open ? (
<div
ref={menuRef}
role="listbox"
+ onKeyDown={onKeyDown}
className={twMerge(Also applies to: 138-140 🧰 Tools🪛 GitHub Check: PR[failure] 97-97: eslint-plugin-jsx-a11y(no-static-element-interactions) 🤖 Prompt for AI Agents |
||
| <button | ||
| ref={triggerRef} | ||
| type="button" | ||
| aria-haspopup="listbox" | ||
| aria-expanded={open} | ||
| onClick={() => { | ||
| setOpen((o) => !o) | ||
| if (!open) setFocused(value ?? options[0]?.value ?? null) | ||
| }} | ||
| className={twMerge( | ||
| 'appearance-none border border-shop-line-2 rounded-xl', | ||
| 'py-2.5 pl-4 pr-8 bg-transparent text-shop-text-2 font-shop-mono text-shop-ui', | ||
| 'bg-no-repeat bg-[right_12px_center]', | ||
| // inline SVG chevron, colored with the muted text token | ||
| "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>\")]", | ||
| 'cursor-pointer transition-colors hover:border-shop-muted hover:text-shop-text', | ||
| className, | ||
| 'flex items-center gap-2 border border-shop-line-2 rounded-xl', | ||
| 'py-2.5 pl-4 pr-3 bg-transparent text-shop-text-2 font-shop-mono text-shop-ui', | ||
| 'cursor-pointer transition-colors select-none whitespace-nowrap', | ||
| 'hover:border-shop-muted hover:text-shop-text', | ||
| open && 'border-shop-muted text-shop-text', | ||
| triggerClassName, | ||
| )} | ||
| > | ||
| {children} | ||
| </select> | ||
| ) | ||
| }, | ||
| ) | ||
| {selected?.label ?? '—'} | ||
| <svg | ||
| width="10" | ||
| height="10" | ||
| viewBox="0 0 10 10" | ||
| aria-hidden | ||
| className={twMerge( | ||
| 'transition-transform duration-150 shrink-0', | ||
| open && 'rotate-180', | ||
| )} | ||
| > | ||
| <path | ||
| d="M2 4l3 3 3-3" | ||
| stroke="currentColor" | ||
| fill="none" | ||
| strokeWidth="1.4" | ||
| strokeLinecap="round" | ||
| /> | ||
| </svg> | ||
| </button> | ||
|
|
||
| {open ? ( | ||
| <div | ||
| ref={menuRef} | ||
| role="listbox" | ||
| className={twMerge( | ||
| 'absolute right-0 top-[calc(100%+6px)] z-[200] min-w-full', | ||
| 'bg-shop-panel border border-shop-line rounded-xl overflow-hidden', | ||
| 'shadow-[0_8px_24px_-4px_rgba(0,0,0,0.18),0_2px_8px_-2px_rgba(0,0,0,0.12)]', | ||
| )} | ||
| > | ||
| {options.map((opt) => { | ||
| const isSelected = opt.value === value | ||
| const isFocused = opt.value === focused | ||
| return ( | ||
| <button | ||
| key={opt.value} | ||
| type="button" | ||
| role="option" | ||
| aria-selected={isSelected} | ||
| onMouseEnter={() => setFocused(opt.value)} | ||
| onClick={() => select(opt.value)} | ||
| className={twMerge( | ||
| 'w-full text-left px-4 py-2.5 font-shop-mono text-shop-ui whitespace-nowrap', | ||
| 'transition-colors duration-75 flex items-center gap-2', | ||
| isSelected ? 'text-shop-accent' : 'text-shop-text-2', | ||
| isFocused && 'bg-shop-surface text-shop-text', | ||
| )} | ||
| > | ||
| <span | ||
| className={twMerge( | ||
| 'w-1.5 h-1.5 rounded-full shrink-0 transition-opacity', | ||
| isSelected ? 'bg-shop-accent opacity-100' : 'opacity-0', | ||
| )} | ||
| /> | ||
| {opt.label} | ||
| </button> | ||
| ) | ||
| })} | ||
| </div> | ||
| ) : null} | ||
| </div> | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Scrim gated on
isAnimatedOpenleaves users with zero click feedback on slow connections.isAnimatedOpen = isOpen && !!prefetchedProductmeans that while data is fetching (cold cache), both the scrim and the drawer stay fully invisible. A user clicking a product card sees nothing happen for the entire round-trip duration — a regression compared to the previous skeleton approach.The scrim dimming the background is itself a cheap, immediate affordance that doesn't require product data. Only the drawer slide needs to wait for
isAnimatedOpen. A simple split:💡 Proposed fix — show scrim immediately, gate only the drawer on `isAnimatedOpen`
The drawer's
translate-x-full→translate-x-0transition still only fires whenisAnimatedOpenis true, so the drawer slides in with content already rendered and no skeleton flash.Also applies to: 241-251
🤖 Prompt for AI Agents