Skip to content

Commit ed7f3fa

Browse files
Abeutyclaudeautofix-ci[bot]tannerlinsley
authored
feat(shop): animation polish, sticky filter bar & custom dropdowns (#898)
* feat(shop): animation polish, sticky filter bar, and custom dropdowns - Fix ProductDrawer open artifact: use derived-state pattern so displayHandle updates synchronously (no empty-frame flash on first open) - Fix ProductDrawer close glitch: always set inline width so the panel doesn't snap to min-width while sliding out - Delay drawer open animation until product data is pre-fetched, so it never opens on a loading skeleton - Replace native <select> with a fully themed custom ShopSelect dropdown that respects shop-scope light/dark tokens, with keyboard navigation - Switch ShopTab to twMerge so className overrides compose correctly - Add sticky filter + sort bar to shop index that stays visible on scroll, with frosted-glass background and smaller compact sizing - Product card hover background is #EFEFE3 in light mode, shop-bg-2 in dark Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: apply automated fixes --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Tanner Linsley <tannerlinsley@gmail.com>
1 parent 1cc3b6c commit ed7f3fa

5 files changed

Lines changed: 301 additions & 117 deletions

File tree

src/components/shop/ProductCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export function ProductCard({
150150
className="
151151
group flex flex-col min-w-[340px] max-w-[400px] w-full rounded-xl
152152
border border-transparent bg-transparent
153-
hover:bg-shop-bg-2 hover:border-shop-line-2
153+
hover:bg-[#EFEFE3] dark:hover:bg-shop-bg-2 hover:border-shop-line-2
154154
transition-[border-color,background-color] duration-200
155155
px-[22px] pt-7 pb-5
156156
"

src/components/shop/ProductDrawer.tsx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -145,18 +145,37 @@ export function ProductDrawer({
145145
const isOpen = !!productHandle
146146

147147
// Keep the last-known handle alive through the exit animation so the drawer
148-
// slides out with content visible (not empty). Cleared after 400ms — just
149-
// past the 380ms transition — so the body unmounts cleanly after exit.
148+
// slides out with content visible (not empty). Uses the derived-state pattern
149+
// so displayHandle is updated synchronously on open (no empty-frame flash).
150150
const [displayHandle, setDisplayHandle] = React.useState<string | null>(null)
151+
const [prevProductHandle, setPrevProductHandle] = React.useState<
152+
string | null
153+
>(null)
154+
if (productHandle !== prevProductHandle) {
155+
setPrevProductHandle(productHandle)
156+
if (productHandle) setDisplayHandle(productHandle)
157+
}
158+
159+
// Clear displayHandle after exit animation completes
151160
React.useEffect(() => {
152-
if (productHandle) {
153-
setDisplayHandle(productHandle)
154-
} else {
161+
if (!productHandle) {
155162
const t = setTimeout(() => setDisplayHandle(null), 400)
156163
return () => clearTimeout(t)
157164
}
158165
}, [productHandle])
159166

167+
// Pre-fetch product data so the drawer only animates open once content is ready,
168+
// preventing the skeleton flash. Same query key as DrawerBody so cache is shared.
169+
const { data: prefetchedProduct } = useQuery({
170+
queryKey: ['shopify', 'product', displayHandle ?? ''],
171+
queryFn: () => getProduct({ data: { handle: displayHandle! } }),
172+
enabled: !!displayHandle,
173+
staleTime: 5 * 60 * 1000,
174+
})
175+
176+
// Delay the open animation until data is in cache. Close animates immediately.
177+
const isAnimatedOpen = isOpen && !!prefetchedProduct
178+
160179
const effectiveWidth = width
161180

162181
const navigateStep = React.useCallback(
@@ -222,11 +241,11 @@ export function ProductDrawer({
222241
<button
223242
type="button"
224243
aria-label="Close product drawer"
225-
tabIndex={isOpen ? 0 : -1}
244+
tabIndex={isAnimatedOpen ? 0 : -1}
226245
onClick={onClose}
227246
className={twMerge(
228247
'fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm transition-opacity duration-300',
229-
isOpen
248+
isAnimatedOpen
230249
? 'opacity-100 pointer-events-auto'
231250
: 'opacity-0 pointer-events-none',
232251
)}
@@ -236,8 +255,8 @@ export function ProductDrawer({
236255
<aside
237256
ref={drawerRef}
238257
aria-label="Product detail"
239-
aria-hidden={!isOpen}
240-
style={{ width: isOpen ? effectiveWidth : undefined }}
258+
aria-hidden={!isAnimatedOpen}
259+
style={{ width: effectiveWidth }}
241260
className={twMerge(
242261
'fixed top-[48px] right-0 bottom-0 z-[70]',
243262
'border-l border-shop-line flex flex-col',
@@ -246,7 +265,7 @@ export function ProductDrawer({
246265
isDragging
247266
? 'transition-none select-none'
248267
: 'transition-transform duration-[380ms] ease-[cubic-bezier(0.2,0.8,0.2,1)]',
249-
isOpen ? 'translate-x-0' : 'translate-x-full',
268+
isAnimatedOpen ? 'translate-x-0' : 'translate-x-full',
250269
)}
251270
>
252271
{/* Splitter handle */}

src/components/shop/ui/Select.tsx

Lines changed: 173 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,179 @@
11
import * as React from 'react'
22
import { twMerge } from 'tailwind-merge'
33

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+
}}
13107
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,
21114
)}
22115
>
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+
}

src/components/shop/ui/Tab.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react'
2+
import { twMerge } from 'tailwind-merge'
23

34
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
45
isActive?: boolean
@@ -11,18 +12,18 @@ export const ShopTab = React.forwardRef<HTMLButtonElement, Props>(
1112
{ isActive, count: _count, children, className, ...rest },
1213
ref,
1314
) {
14-
const base =
15-
'inline-flex items-center px-4 py-2.5 rounded-xl font-shop-mono text-shop-ui transition-colors cursor-pointer'
16-
const state = isActive
17-
? 'border-2 border-shop-muted font-medium text-shop-text'
18-
: 'border border-shop-line-2 font-normal text-shop-text-2 hover:text-shop-text hover:border-shop-muted'
19-
2015
return (
2116
<button
2217
ref={ref}
2318
type="button"
2419
{...rest}
25-
className={`${base} ${state}${className ? ` ${className}` : ''}`}
20+
className={twMerge(
21+
'inline-flex items-center px-4 py-2.5 rounded-xl font-shop-mono text-shop-ui transition-colors cursor-pointer',
22+
isActive
23+
? 'border-2 border-shop-muted font-medium text-shop-text'
24+
: 'border border-shop-line-2 font-normal text-shop-text-2 hover:text-shop-text hover:border-shop-muted',
25+
className,
26+
)}
2627
>
2728
{children}
2829
</button>

0 commit comments

Comments
 (0)