Skip to content

Commit 9333d0d

Browse files
committed
fix scroll locking
1 parent fa747ec commit 9333d0d

6 files changed

Lines changed: 132 additions & 452 deletions

File tree

app/components/AppShell.tsx

Lines changed: 22 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { CSSProperties, MouseEvent as ReactMouseEvent, PropsWithChildren, useEffect, useRef, useState } from 'react';
44
import Link from 'next/link';
55
import { usePathname } from 'next/navigation';
6+
import { Dialog } from '@base-ui/react/dialog';
67
import { AnimatePresence, motion, useMotionTemplate, useMotionValue, useReducedMotion, type MotionValue } from 'motion/react';
78
import { Toaster } from 'sonner';
89

@@ -722,21 +723,6 @@ export function AppShell({ children }: PropsWithChildren) {
722723
}
723724
};
724725

725-
useEffect(() => {
726-
if (!menuOpen) return;
727-
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenuOpen(false); };
728-
document.addEventListener('keydown', onKey);
729-
return () => document.removeEventListener('keydown', onKey);
730-
}, [menuOpen]);
731-
732-
useEffect(() => {
733-
// The document scroller is <html> (`overflow-y-scroll` in layout.tsx),
734-
// not body — hiding overflow on body alone does not stop page scroll.
735-
const root = document.documentElement;
736-
root.style.overflow = menuOpen ? 'hidden' : '';
737-
return () => { root.style.overflow = ''; };
738-
}, [menuOpen]);
739-
740726
return (
741727
<div className="flex min-h-dvh flex-col">
742728
{/* Desktop: banner spans the full width above the shell. On mobile it is
@@ -758,45 +744,34 @@ export function AppShell({ children }: PropsWithChildren) {
758744
</motion.aside>
759745

760746
{/* Mobile header (logo + hamburger) */}
761-
<header className="mobile-header">
762-
{/* Static on touch: the morph is hover-driven, so base.org leaves its
763-
mobile mark static too. */}
764-
<Link href="/" aria-label="Base home" style={styles.brandLink} onClick={() => setMenuOpen(false)}>
765-
<BaseMark size={MOBILE_BRAND_MARK_SIZE} />
766-
</Link>
767-
<button
768-
className="hamburger-btn"
769-
onClick={() => setMenuOpen(!menuOpen)}
770-
aria-label="Toggle menu"
771-
aria-expanded={menuOpen}
772-
>
773-
<svg width={20} height={20} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round">
774-
<line className={`hamburger-line hamburger-top ${menuOpen ? 'open' : ''}`} x1="4" y1="10" x2="16" y2="10" />
775-
<line className={`hamburger-line hamburger-bottom ${menuOpen ? 'open' : ''}`} x1="4" y1="10" x2="16" y2="10" />
776-
</svg>
777-
</button>
778-
</header>
779-
780-
{/* Mobile drawer (full-screen from right) */}
781-
<AnimatePresence>
782-
{menuOpen && (
783-
<motion.aside
784-
className="drawer"
785-
initial={{ x: '100%' }}
786-
animate={{ x: 0 }}
787-
exit={{ x: '100%' }}
788-
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
789-
>
747+
<Dialog.Root open={menuOpen} onOpenChange={setMenuOpen}>
748+
<header className="mobile-header">
749+
{/* Static on touch: the morph is hover-driven, so base.org leaves its
750+
mobile mark static too. */}
751+
<Link href="/" aria-label="Base home" style={styles.brandLink} onClick={() => setMenuOpen(false)}>
752+
<BaseMark size={MOBILE_BRAND_MARK_SIZE} />
753+
</Link>
754+
<Dialog.Trigger className="hamburger-btn" aria-label={menuOpen ? 'Close menu' : 'Open menu'}>
755+
<svg width={20} height={20} viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round">
756+
<line className={`hamburger-line hamburger-top ${menuOpen ? 'open' : ''}`} x1="4" y1="10" x2="16" y2="10" />
757+
<line className={`hamburger-line hamburger-bottom ${menuOpen ? 'open' : ''}`} x1="4" y1="10" x2="16" y2="10" />
758+
</svg>
759+
</Dialog.Trigger>
760+
</header>
761+
762+
<Dialog.Portal>
763+
<Dialog.Popup className="drawer outline-none [transform:translate3d(0,0,0)] transition-transform duration-300 ease-out data-[ending-style]:[transform:translate3d(100%,0,0)] data-[starting-style]:[transform:translate3d(100%,0,0)] motion-reduce:transition-none">
764+
<Dialog.Title className="sr-only">Menu</Dialog.Title>
790765
<SidebarContent
791766
dark={dark}
792767
onToggleTheme={toggleTheme}
793768
onNavigate={() => setMenuOpen(false)}
794769
hideBrand
795770
layoutScope="mobile"
796771
/>
797-
</motion.aside>
798-
)}
799-
</AnimatePresence>
772+
</Dialog.Popup>
773+
</Dialog.Portal>
774+
</Dialog.Root>
800775

801776
<div className="pt-14 md:pt-0" style={styles.main}>
802777
{/* Mobile: banner sits below the fixed header (which the top slot is

app/components/ui/Button.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ComponentPropsWithoutRef } from 'react';
1+
import { forwardRef, type ComponentPropsWithoutRef } from 'react';
22
import Link from 'next/link';
33

44
import { cn } from './cn';
@@ -25,18 +25,21 @@ const variantClasses = {
2525
'text-foreground bg-transparent border border-bds-gray-10 hover:bg-bds-gray-5 dark:border-white/[.12] dark:hover:bg-white/[.06]',
2626
} as const;
2727

28-
export function Button({
29-
className = '',
30-
type = 'button',
31-
variant = 'primary',
32-
size = 'default',
33-
arrow = false,
34-
href,
35-
target,
36-
rel,
37-
children,
38-
...props
39-
}: ButtonProps) {
28+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
29+
{
30+
className = '',
31+
type = 'button',
32+
variant = 'primary',
33+
size = 'default',
34+
arrow = false,
35+
href,
36+
target,
37+
rel,
38+
children,
39+
...props
40+
},
41+
ref,
42+
) {
4043
let sizeClasses: string;
4144
if (size === 'sm') {
4245
sizeClasses = 'h-[34px] px-3 gap-1 pb-px';
@@ -65,9 +68,9 @@ export function Button({
6568
}
6669

6770
return (
68-
<button type={type === 'submit' ? 'submit' : 'button'} className={classes} {...props}>
71+
<button ref={ref} type={type === 'submit' ? 'submit' : 'button'} className={classes} {...props}>
6972
{children}
7073
{arrow && <AnimatedArrowIcon className="transition-transform duration-200 ease-out group-hover:translate-x-[3px]" />}
7174
</button>
7275
);
73-
}
76+
});

app/components/ui/FilterSelect.tsx

Lines changed: 49 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client';
22

3-
import { useCallback, useEffect, useRef, useState } from 'react';
3+
import { Select } from '@base-ui/react/select';
44

5-
import { cn } from './cn';
5+
import { Button } from './Button';
66

77
type Option = {
88
value: string;
@@ -18,107 +18,54 @@ type FilterSelectProps = {
1818
};
1919

2020
export function FilterSelect({ value, onChange, ariaLabel, options, minDropdownWidth }: FilterSelectProps) {
21-
const [open, setOpen] = useState(false);
22-
const ref = useRef<HTMLDivElement>(null);
23-
const sizerRef = useRef<HTMLSpanElement>(null);
24-
const [dropdownW, setDropdownW] = useState<number | undefined>();
25-
const selected = options.find((o) => o.value === value);
26-
27-
useEffect(() => {
28-
if (!sizerRef.current) return;
29-
const spans = sizerRef.current.children;
30-
let max = 0;
31-
for (let i = 0; i < spans.length; i++) {
32-
max = Math.max(max, (spans[i] as HTMLElement).offsetWidth);
33-
}
34-
setDropdownW(max + 24);
35-
}, [options]);
36-
37-
const toggle = useCallback(() => setOpen((o) => !o), []);
38-
39-
const handleSelect = useCallback(
40-
(v: string) => {
41-
onChange(v);
42-
setOpen(false);
43-
},
44-
[onChange],
45-
);
46-
47-
useEffect(() => {
48-
if (!open) return;
49-
const onDown = (e: MouseEvent) => {
50-
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
51-
};
52-
const onKey = (e: KeyboardEvent) => {
53-
if (e.key === 'Escape') setOpen(false);
54-
};
55-
document.addEventListener('mousedown', onDown);
56-
document.addEventListener('keydown', onKey);
57-
return () => {
58-
document.removeEventListener('mousedown', onDown);
59-
document.removeEventListener('keydown', onKey);
60-
};
61-
}, [open]);
62-
6321
return (
64-
<div ref={ref} className="relative inline-flex">
65-
<button
66-
type="button"
67-
onClick={toggle}
68-
aria-label={ariaLabel}
69-
aria-expanded={open}
70-
aria-haspopup="listbox"
71-
className="flex h-9 items-center gap-1.5 rounded-full border border-bds-gray-10 bg-background px-3 text-[14px] text-foreground outline-none transition-colors hover:bg-bds-gray-5"
72-
>
73-
<span className="whitespace-nowrap">
74-
{selected?.label ?? value}
75-
</span>
76-
<svg
77-
aria-hidden="true"
78-
width="16"
79-
height="16"
80-
viewBox="0 0 16 16"
81-
fill="none"
82-
stroke="currentColor"
83-
strokeWidth="1.5"
84-
strokeLinecap="round"
85-
strokeLinejoin="round"
86-
className={cn('shrink-0 text-bds-gray-40 transition-transform duration-150', open && 'rotate-180')}
87-
>
88-
<path d="M4 6L8 10L12 6" />
89-
</svg>
90-
</button>
91-
92-
<span ref={sizerRef} aria-hidden className="pointer-events-none invisible fixed left-0 top-0 flex flex-col whitespace-nowrap text-[14px]">
93-
{options.map((o) => (
94-
<span key={o.value}>{o.label}</span>
95-
))}
96-
</span>
97-
98-
{open && (
99-
<div
100-
role="listbox"
101-
aria-label={ariaLabel}
102-
style={{ minWidth: Math.max(dropdownW ?? 0, minDropdownWidth ?? 0) || undefined }}
103-
className="absolute left-0 top-full z-50 mt-1 max-h-64 min-w-full overflow-y-auto rounded-xl border border-bds-gray-10 bg-background py-1 shadow-lg"
22+
<Select.Root
23+
value={value}
24+
onValueChange={(next) => {
25+
if (next != null) onChange(next);
26+
}}
27+
items={options}
28+
>
29+
<Select.Trigger aria-label={ariaLabel} render={<Button variant="outline" size="sm" />}>
30+
<Select.Value className="whitespace-nowrap" />
31+
<Select.Icon
32+
className="shrink-0 text-bds-gray-40 transition-transform duration-150 group-data-[popup-open]:rotate-180"
10433
>
105-
{options.map((option) => (
106-
<button
107-
key={option.value}
108-
type="button"
109-
role="option"
110-
aria-selected={option.value === value}
111-
onClick={() => handleSelect(option.value)}
112-
className={cn(
113-
'flex w-full items-center px-3 py-2 text-left text-[14px] transition-colors hover:bg-bds-gray-5',
114-
option.value === value ? 'text-foreground' : 'text-bds-gray-60',
115-
)}
116-
>
117-
{option.label}
118-
</button>
119-
))}
120-
</div>
121-
)}
122-
</div>
34+
<svg
35+
aria-hidden="true"
36+
width="16"
37+
height="16"
38+
viewBox="0 0 16 16"
39+
fill="none"
40+
stroke="currentColor"
41+
strokeWidth="1.5"
42+
strokeLinecap="round"
43+
strokeLinejoin="round"
44+
>
45+
<path d="M4 6L8 10L12 6" />
46+
</svg>
47+
</Select.Icon>
48+
</Select.Trigger>
49+
<Select.Portal>
50+
<Select.Positioner className="z-50 outline-none" align="start" sideOffset={4} alignItemWithTrigger={false}>
51+
<Select.Popup
52+
style={minDropdownWidth ? { minWidth: `max(var(--anchor-width), ${minDropdownWidth}px)` } : undefined}
53+
className="min-w-[var(--anchor-width)] origin-[var(--transform-origin)] overflow-hidden rounded-xl border border-bds-gray-10 bg-background py-1 shadow-lg outline-none [transform:scale(1)] transition-[opacity,transform] duration-150 ease-out data-[ending-style]:opacity-0 data-[ending-style]:[transform:scale(0.97)] data-[starting-style]:opacity-0 data-[starting-style]:[transform:scale(0.97)]"
54+
>
55+
<Select.List className="max-h-64 overflow-y-auto">
56+
{options.map((option) => (
57+
<Select.Item
58+
key={option.value}
59+
value={option.value}
60+
className="flex w-full cursor-pointer items-center px-3 py-2 text-left text-[14px] text-bds-gray-60 outline-none data-[highlighted]:bg-bds-gray-5 data-[selected]:text-foreground"
61+
>
62+
<Select.ItemText>{option.label}</Select.ItemText>
63+
</Select.Item>
64+
))}
65+
</Select.List>
66+
</Select.Popup>
67+
</Select.Positioner>
68+
</Select.Portal>
69+
</Select.Root>
12370
);
12471
}

0 commit comments

Comments
 (0)