Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3432ac6
feat(ui): formalize the Mosaic Profile surface and add a Drawer
maxyinger Sep 3, 2026
91ab040
feat(ui): name the Profile with a hidden title, tighten its scroll inset
maxyinger Sep 3, 2026
620969d
fix(ui): shrink the Profile scroll inset to 4px
maxyinger Sep 3, 2026
134b865
fix(ui): lift the Profile only as an overlay
maxyinger Sep 3, 2026
b5ee747
fix(ui): set the Profile scroll inset to 6px
maxyinger Sep 3, 2026
16d5799
feat(ui): widen the Profile frame and hold its pages to a reading width
maxyinger Sep 3, 2026
616eedd
chore(repo): give the user profile story fixture every page
maxyinger Sep 3, 2026
2c11688
feat(ui): darken the Drawer grip while dragging and add sheet heights
maxyinger Sep 3, 2026
f2d020d
fix(ui): wire the Drawer height prop and soften the held grip
maxyinger Sep 3, 2026
d215db9
chore(repo): temporary nav-sheet height knob on the user profile story
maxyinger Sep 3, 2026
dd54675
chore(repo): move the nav-sheet height knob to the overlay example, f…
maxyinger Sep 3, 2026
a14e380
feat(ui): let Profile pages force-mount, with a page-transition example
maxyinger Sep 3, 2026
452dc9f
refactor(ui): rename Profile.Page to Profile.TabPanel
maxyinger Sep 3, 2026
fd5cfe1
Merge remote-tracking branch 'origin/main' into max/profile-component
maxyinger Sep 3, 2026
c784cb5
chore(repo): stub page content in the Profile transition example
maxyinger Sep 3, 2026
79770b1
fix(ui): hold the Profile content's scrollbar gutter open
maxyinger Sep 3, 2026
69df38a
chore(repo): make the Profile transition example pure CSS over the hi…
maxyinger Sep 3, 2026
ce075d3
chore(repo): hand off rather than cross-fade in the Profile transitio…
maxyinger Sep 3, 2026
5e24fef
chore(repo): quicken the Profile transition example
maxyinger Sep 3, 2026
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
2 changes: 2 additions & 0 deletions .changeset/mosaic-profile-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
4 changes: 2 additions & 2 deletions packages/headless/src/primitives/drawer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,12 @@ The headless parts emit raw inputs only — the styled layer composes them. The
(`swipe-movement-y`, `snap-point-offset`, `swipe-progress`) are registered as non-inheriting custom
properties via `registerDrawerCssVars()` (a no-op where `CSS.registerProperty` is unavailable).

### CSS custom properties (on `Drawer.Popup`)
### CSS custom properties (on `Drawer.Popup`, mirrored onto `Drawer.Backdrop`)

| Variable | Written by | Meaning |
| ---------------------------------- | ------------- | ------------------------------------------------------------------------------------- |
| `--cl-drawer-swipe-movement-y` | drag engine | px live drag delta on the Y axis (0 at rest) |
| `--cl-drawer-swipe-progress` | drag engine | 0..1 dismiss progress (drives backdrop fade) |
| `--cl-drawer-swipe-progress` | drag engine | 0..1 dismiss progress (drives backdrop fade; also written to the backdrop, a sibling) |
| `--cl-drawer-snap-point-offset` | snap layer | px resting translateY of the active snap point |
| `--cl-drawer-swipe-strength` | drag engine | 0.1..1 from release velocity (scales exit speed) |
| `--cl-drawer-nested-drawers` | nesting layer | count of open nested children |
Expand Down
3 changes: 3 additions & 0 deletions packages/headless/src/primitives/drawer/drawer-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,11 @@ function DrawerInner(props: DrawerProps) {
// CSS-var writers. `setSwipe` is the single writer of the live swipe-y, keeping
// the var and the `curSwipe` ref in lockstep so drag decisions can read the ref.
const curSwipe = useRef(0);
// Written to the backdrop as well: it is the popup's sibling, so nothing it needs — the dismiss
// progress its fade follows — would otherwise reach it through inheritance.
const setVar = useCallback((name: string, value: string) => {
popupRef.current?.style.setProperty(name, value);
backdropRef.current?.style.setProperty(name, value);
}, []);
const setSwipe = useCallback(
(px: number) => {
Expand Down
135 changes: 134 additions & 1 deletion packages/headless/src/primitives/drawer/drawer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';

Expand Down Expand Up @@ -568,6 +568,22 @@ describe('Drawer', () => {
expect(onOpenChange).not.toHaveBeenCalledWith(false);
});

it('mirrors the swipe vars onto the backdrop, which cannot inherit them from the popup', () => {
render(<DrawerFixture defaultOpen />);
const popup = screen.getByRole('dialog');
stubHeight(popup, 400);
clock.t += OPEN_GRACE_PERIOD + 50;
fireEvent.pointerDown(popup, { pointerId: 1, clientY: 0, button: 0, isPrimary: true, pointerType: 'touch' });
clock.t += 50;
fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 });

const backdrop = screen.getByTestId('backdrop');
expect(swipeProgress(backdrop)).toBe(swipeProgress(popup));
expect(swipeY(backdrop)).toBe('100px');

fireEvent.pointerUp(popup, { pointerId: 1, clientY: 100 });
});

it('updates the swipe-progress var and swiping attribute during a drag', () => {
render(<DrawerFixture defaultOpen />);
const popup = screen.getByRole('dialog');
Expand All @@ -585,6 +601,97 @@ describe('Drawer', () => {
expect(popup).not.toHaveAttribute('data-swiping');
});

it('rubber-bands an upward drag at rest when nothing under the finger can scroll', () => {
render(<DrawerFixture defaultOpen />);
const popup = screen.getByRole('dialog');
stubHeight(popup, 400);

clock.t += OPEN_GRACE_PERIOD + 50;
fireEvent.pointerDown(popup, { pointerId: 1, clientY: 300, button: 0, pointerType: 'touch' });
clock.t += 30;
fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 }); // up 200px, never dragged down

expect(parseFloat(swipeY(popup))).toBeLessThan(0);
expect(popup).toHaveAttribute('data-swiping');

fireEvent.pointerUp(popup, { pointerId: 1, clientY: 100 });
expect(swipeY(popup)).toBe('0px');
});

// The styled sheet bleeds below the screen, so its viewport measures taller than it shows; that
// is not inner content, and must not swallow the upward drag.
it('rubber-bands upward at rest even when the viewport above the sheet overflows', () => {
render(<DrawerFixture defaultOpen />);
const popup = screen.getByRole('dialog');
stubHeight(popup, 400);
makeScrollable(screen.getByTestId('viewport'), { scrollHeight: 940, clientHeight: 844, scrollTop: 0 });

clock.t += OPEN_GRACE_PERIOD + 50;
fireEvent.pointerDown(popup, { pointerId: 1, clientY: 300, button: 0, pointerType: 'touch' });
clock.t += 30;
fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 });

expect(parseFloat(swipeY(popup))).toBeLessThan(0);

fireEvent.pointerUp(popup, { pointerId: 1, clientY: 100 });
});

it('lets inner content scroll on an upward drag at rest when it has room to', () => {
render(<DrawerFixture defaultOpen />);
const popup = screen.getByRole('dialog');
stubHeight(popup, 400);
const list = screen.getByTestId('scrollable');
makeScrollable(list, { scrollHeight: 500, clientHeight: 100, scrollTop: 0 });

clock.t += OPEN_GRACE_PERIOD + 50;
fireEvent.pointerDown(list, { pointerId: 1, clientY: 300, button: 0, pointerType: 'touch' });
clock.t += 30;
fireEvent.pointerMove(list, { pointerId: 1, clientY: 100 });

expect(swipeY(popup)).toBe('');

fireEvent.pointerUp(list, { pointerId: 1, clientY: 100 });
});

// Pointer capture is asked for, not guaranteed. A release the popup never receives used to leave
// the engine armed: the sheet held its drag offset and `data-swiping`, and the next open started
// that way too, until a fresh press on the sheet released it.
it('ends the gesture on a release that reaches only the window', () => {
render(<DrawerFixture defaultOpen />);
const popup = screen.getByRole('dialog');
stubHeight(popup, 400);

clock.t += OPEN_GRACE_PERIOD + 50;
fireEvent.pointerDown(popup, { pointerId: 1, clientY: 300, button: 0, pointerType: 'mouse' });
clock.t += 30;
fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 });
expect(popup).toHaveAttribute('data-swiping');

fireEvent.pointerUp(window, { pointerId: 1, clientY: 100 });

expect(popup).not.toHaveAttribute('data-swiping');
expect(swipeY(popup)).toBe('0px');
});

it('does not carry a lost gesture into the next open', async () => {
const user = userEvent.setup();
render(<DrawerFixture />);
await user.click(screen.getByTestId('trigger'));
const popup = screen.getByRole('dialog');
stubHeight(popup, 400);

clock.t += OPEN_GRACE_PERIOD + 50;
fireEvent.pointerDown(popup, { pointerId: 1, clientY: 300, button: 0, pointerType: 'mouse' });
clock.t += 30;
fireEvent.pointerMove(popup, { pointerId: 1, clientY: 100 });
// No release at all; close from the keyboard instead.
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());

await user.click(screen.getByTestId('trigger'));
expect(screen.getByRole('dialog')).not.toHaveAttribute('data-swiping');
});

it('rubber-bands upward over-drag without ever moving the sheet downward', () => {
render(<DrawerFixture defaultOpen />);
const popup = screen.getByRole('dialog');
Expand Down Expand Up @@ -753,6 +860,32 @@ describe('Drawer', () => {
expect(swipeY(popup)).toBe('');
});

// A portalled sheet's ancestors above the viewport are the page itself; the walk used to reach a
// scrolled `<html>` and read it as inner content, so a drawer over a scrolled page could not be
// dragged at all unless the sheet happened to scroll.
it('drags when the page behind the sheet is scrolled', () => {
const onOpenChange = vi.fn();
render(
<DrawerFixture
defaultOpen
onOpenChange={onOpenChange}
/>,
);
const popup = screen.getByRole('dialog');
stubHeight(popup, 400);
makeScrollable(document.documentElement, { scrollHeight: 3000, clientHeight: 800, scrollTop: 900 });

try {
drag(popup, 0, 120, 200);
} finally {
delete (document.documentElement as unknown as Record<string, unknown>).scrollHeight;
delete (document.documentElement as unknown as Record<string, unknown>).clientHeight;
document.documentElement.scrollTop = 0;
}

expect(onOpenChange).toHaveBeenCalledWith(false);
});

it('ignores cross-axis (horizontal) jitter during a vertical drag', () => {
const onOpenChange = vi.fn();
render(
Expand Down
85 changes: 78 additions & 7 deletions packages/headless/src/primitives/drawer/use-drawer-drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn {
// Removes the current iOS `touchend` fallback listener (see `onPointerDown`),
// so it never outlives its gesture or piles up across gestures.
const removeTouchEnd = useRef<(() => void) | null>(null);
// Removes the window-level release listeners armed for the current gesture (see `onPointerDown`).
const removeWindowRelease = useRef<(() => void) | null>(null);
// `onRelease` is defined after `onPointerDown`, which needs to arm it; read through a ref.
const onReleaseRef = useRef<((e: { clientY: number }) => void) | null>(null);

// Latest options, read at event time so the handlers can stay referentially stable.
const cfg = useRef(opts);
Expand All @@ -86,7 +90,27 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn {
}, [open, now]);

// Drop any pending iOS touchend fallback if the drawer unmounts mid-gesture.
useEffect(() => () => removeTouchEnd.current?.(), []);
useEffect(
() => () => {
removeTouchEnd.current?.();
removeWindowRelease.current?.();
},
[],
);

// A gesture never outlives the open state. Should a release be lost anyway, closing (or the
// sheet unmounting under the finger) must not leave the engine armed for the next open.
useEffect(() => {
if (open) {
return;
}
draggingRef.current = false;
allowed.current = false;
pid.current = null;
removeWindowRelease.current?.();
removeWindowRelease.current = null;
setIsDragging(false);
}, [open]);

const shouldDrag = useCallback((target: HTMLElement, down: boolean): boolean => {
const { now: clock, curSwipe, snap } = cfg.current;
Expand All @@ -104,8 +128,18 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn {
if (target.closest(`[${DrawerAttrs.noDrag}]`)) {
return false;
}
// A text selection is in progress (contenteditable / regular DOM text).
if (window.getSelection()?.toString().length) {
// A text selection is in progress INSIDE the sheet (contenteditable / regular DOM text). One
// elsewhere on the page is none of the sheet's business, and would otherwise veto every drag
// for as long as it stood.
const selection = window.getSelection();
const sheet = cfg.current.popupRef.current;
if (
selection &&
!selection.isCollapsed &&
selection.toString().length &&
selection.anchorNode &&
sheet?.contains(selection.anchorNode)
) {
return false;
}
// A focused input/textarea with a non-collapsed selection: dragging is
Expand All @@ -130,11 +164,27 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn {
lastScrollAt.current = clock();
return false;
}
// Upward at rest: let inner content scroll instead.
// Upward at rest: inner content with room left to scroll takes the gesture; otherwise the sheet
// rubber-bands, so the drag is never simply swallowed.
if (!down) {
return false;
const sheet = cfg.current.popupRef.current;
for (let el: HTMLElement | null = target; el; el = el.parentElement) {
if (el.scrollHeight > el.clientHeight && el.scrollTop + el.clientHeight < el.scrollHeight - 1) {
return false;
}
// Nothing above the sheet is inner content — its box may well be taller than the screen.
if (el === sheet) {
break;
}
}
return true;
}
for (let el: HTMLElement | null = target; el; el = el.parentElement) {
// The page behind the sheet is never inner content: a scrolled document must not veto the
// drag, which it otherwise would whenever the sheet itself has nothing to scroll.
if (el === document.body || el === document.documentElement) {
return true;
}
if (el.scrollHeight > el.clientHeight) {
if (el.scrollTop !== 0) {
lastScrollAt.current = clock();
Expand Down Expand Up @@ -186,6 +236,23 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn {
captured.current = target;
safeCapture(target, e.pointerId, 'setPointerCapture');

// The release is expected on the captured target, but capture is not a guarantee — a release
// the popup never sees would leave the sheet held mid-drag, across opens. Whatever lands on
// `window` for this pointer ends the gesture; the popup's own handler then finds nothing to do.
removeWindowRelease.current?.();
const pointerId = e.pointerId;
const onWindowRelease = (ev: PointerEvent): void => {
if (ev.pointerId === pointerId) {
onReleaseRef.current?.(ev);
}
};
window.addEventListener('pointerup', onWindowRelease, true);
window.addEventListener('pointercancel', onWindowRelease, true);
removeWindowRelease.current = () => {
window.removeEventListener('pointerup', onWindowRelease, true);
window.removeEventListener('pointercancel', onWindowRelease, true);
};

// iOS doesn't dispatch pointerup after a scroll-cancelled gesture, so reset
// `allowed` on touchend. Track the listener (and drop any stale one from a
// prior gesture that never fired) so it's removed on release/unmount instead
Expand Down Expand Up @@ -234,13 +301,15 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn {
[shouldDrag, sample],
);

const onRelease = useCallback((e: ReactPointerEvent<HTMLElement>): void => {
const onRelease = useCallback((e: { clientY: number }): void => {
if (!draggingRef.current) {
return;
}
// Normal release: the iOS touchend fallback is no longer needed.
// Normal release: neither fallback is needed any more.
removeTouchEnd.current?.();
removeTouchEnd.current = null;
removeWindowRelease.current?.();
removeWindowRelease.current = null;
const {
snapPoints,
snap,
Expand Down Expand Up @@ -300,6 +369,8 @@ export function useDrawerDrag(opts: UseDrawerDragOptions): UseDrawerDragReturn {
onNestedRelease?.(!dismiss);
}, []);

onReleaseRef.current = onRelease;

return {
onPointerDown,
onPointerMove,
Expand Down
26 changes: 26 additions & 0 deletions packages/headless/src/utils/use-render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,30 @@ describe('useRender', () => {
}),
);
});

// React writes changed attributes in key order, and Chrome flushes style when `tabindex` changes on
// the focused element. A state marker that lands after that write is absent from the flush, which
// is how a CSS anchor on `[data-selected]` loses its transition. So the markers lead, and still win.
it('emits state attributes ahead of the other props, and lets them win', () => {
function Probe() {
return useRender({
defaultTagName: 'button',
state: { selected: true },
stateAttributesMapping: { selected: (v: boolean) => (v ? { 'data-selected': '' } : null) },
props: { tabIndex: 0, 'data-selected': 'stale', 'data-testid': 'probe' },
});
}
render(<Probe />);
const element = screen.getByTestId('probe');
expect(element).toHaveAttribute('data-selected', '');
expect(
Array.from(element.attributes)
.map(attribute => attribute.name)
.indexOf('data-selected'),
).toBeLessThan(
Array.from(element.attributes)
.map(attribute => attribute.name)
.indexOf('tabindex'),
);
});
});
8 changes: 7 additions & 1 deletion packages/headless/src/utils/use-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,13 @@ export function useRender<
}
}

const computedProps = { ...props, ...dataAttrs };
// State attributes lead, and still win: a key's position is fixed by its first spread, its value
// by its last. React writes changed attributes in key order, and Chrome flushes style when
// `tabindex` changes on the focused element, so a state marker written after a roving `tabindex`
// would be missing from that flush — an anchor named on `[data-selected]` resolves to nothing for
// one recalc and its transition snaps. Ahead of the rest, the marker is in place before any write
// that can flush.
const computedProps = { ...dataAttrs, ...props, ...dataAttrs };

if (typeof render === 'function') {
return render({ ...computedProps, ref: mergedRef });
Expand Down
Loading
Loading