Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
260daf2
fix(ui): act on review of the Profile and Drawer
maxyinger Sep 8, 2026
d86e68c
refactor(ui): act on Profile review β€” measuring hook, shared focus ta…
maxyinger Sep 8, 2026
f2ecaae
refactor(ui): measure the Profile with a general useMeasure hook
maxyinger Sep 8, 2026
7716619
feat(ui): the inline Profile is the page's content
maxyinger Sep 8, 2026
0c89a67
chore(repo): show the user profile story inline
maxyinger Sep 8, 2026
30227ec
fix(ui): level the inline Profile's headline with its navigation
maxyinger Sep 8, 2026
5eb7f1d
fix(ui): inline Profile columns carry no padding, a 2.5rem gap apart
maxyinger Sep 8, 2026
2d5644f
feat(ui): Profile takes an elevation, flush being the page's own content
maxyinger Sep 8, 2026
a42faa3
fix(ui): let the flush Profile fill to its cap before centring
maxyinger Sep 8, 2026
07e0397
fix(ui): the navigation sheet opens at two thirds; a press is not a drag
maxyinger Sep 8, 2026
4394777
fix(ui): the Drawer grip deepens on press of the handle, not on drag
maxyinger Sep 8, 2026
db14dea
fix(ui): hide the in-place Profile navigation under the compact query…
maxyinger Sep 8, 2026
4d2dae3
refactor(ui): the Drawer always stands two thirds tall
maxyinger Sep 8, 2026
0260cf9
Merge remote-tracking branch 'origin/main' into max/profile-component
maxyinger Sep 8, 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 @@
---
---
1 change: 1 addition & 0 deletions packages/headless/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ export {
type UseTransitionReturn,
} from './use-transition';
export { type TransitionStatus, useTransitionStatus } from './use-transition-status';
export { type FocusTarget, useFinalFocus, useInitialFocus } from './use-focus-target';
120 changes: 120 additions & 0 deletions packages/headless/src/hooks/use-focus-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
'use client';

import type { FloatingContext } from '@floating-ui/react';
import React from 'react';

import { type InteractionType, interactionTypeFromEvent } from '../utils/interaction-modality';

/**
* Where focus goes when the dialog opens (`initialFocus`) or closes (`finalFocus`),
* mirroring Base UI:
*
* - `true` or omitted β€” the default: first tabbable element on open, the trigger (with the
* pointer-close downgrade `useReturnFocus` applies) on close
* - `false` β€” do not move focus
* - a ref β€” focus that element
* - a function of the interaction type behind the open/close (`''` when programmatic) β€”
* returns any of the above, with `void`/`null` meaning the default
*/
export type FocusTarget =
| boolean
| React.RefObject<HTMLElement | null>
| ((interactionType: InteractionType) => boolean | void | HTMLElement | null);

/**
* Resolves `initialFocus` into the `number | ref` form `FloatingFocusManager` takes (a negative
* index disables the focus move). The function form reads the open event floating-ui has
* already recorded by the time the popup mounts; it must be pure, as re-renders re-invoke it.
*/
export function useInitialFocus(
initialFocus: FocusTarget | undefined,
open: boolean,
floatingContext: FloatingContext,
): number | React.MutableRefObject<HTMLElement | null> {
const elementRef = React.useRef<HTMLElement | null>(null);
return React.useMemo(() => {
if (!open || initialFocus === undefined || initialFocus === true) {
return 0;
}
if (initialFocus === false) {
return -1;
}
if (typeof initialFocus !== 'function') {
return initialFocus as React.MutableRefObject<HTMLElement | null>;
}
const result = initialFocus(interactionTypeFromEvent(floatingContext.dataRef.current.openEvent));
if (result === false) {
return -1;
}
if (result instanceof HTMLElement) {
elementRef.current = result;
return elementRef;
}
return 0;
}, [open, initialFocus, floatingContext]);
}

/**
* Resolves `finalFocus` into the `boolean | ref` form `FloatingFocusManager`'s `returnFocus`
* takes.
*
* The function form needs the event behind the close, so it runs inside floating-ui's
* synchronous `openchange` emit β€” the root routes every close through
* `floatingContext.onOpenChange`, and the emit precedes both the state commit and any focus
* restoration. Only the function's decision is stored; the ref handed to the focus manager
* materialises it lazily, at restore time, by which point `useReturnFocus` has applied its
* pointer-close downgrade to the default.
*/
export function useFinalFocus(
finalFocus: FocusTarget | undefined,
returnFocusRef: React.MutableRefObject<HTMLElement | null>,
floatingContext: FloatingContext,
): boolean | React.MutableRefObject<HTMLElement | null> {
const finalFocusRef = React.useRef(finalFocus);
React.useLayoutEffect(() => {
finalFocusRef.current = finalFocus;
});

// The function form is resolved when focus is restored, not when the close is requested: a
// controlled close never passes through floating-ui's `openchange` emit, and a decision taken
// early would be taken against the page as it was. What the emit does carry β€” the event behind a
// close it drove β€” is kept for the interaction type, and consumed by the restore that follows.
const closeEventRef = React.useRef<Event | undefined>(undefined);
const resolvedRef = React.useMemo(
() => ({
get current() {
const target = finalFocusRef.current;
if (typeof target !== 'function') {
return returnFocusRef.current;
}
const event = closeEventRef.current;
closeEventRef.current = undefined;
const result = target(interactionTypeFromEvent(event));
if (result instanceof HTMLElement) {
return result;
}
return result === false ? null : returnFocusRef.current;
},
}),
[returnFocusRef],
);

React.useLayoutEffect(() => {
function onOpenChange({ open, event }: { open: boolean; event?: Event }) {
closeEventRef.current = open ? undefined : event;
}
floatingContext.events.on('openchange', onOpenChange);
return () => floatingContext.events.off('openchange', onOpenChange);
}, [floatingContext.events]);

if (finalFocus === undefined || finalFocus === true) {
return returnFocusRef;
}
if (finalFocus === false) {
return false;
}
if (typeof finalFocus === 'function') {
return resolvedRef;
}
return finalFocus as React.MutableRefObject<HTMLElement | null>;
}
116 changes: 4 additions & 112 deletions packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line number Diff line number Diff line change
@@ -1,122 +1,14 @@
'use client';

import { type FloatingContext, FloatingFocusManager } from '@floating-ui/react';
import { FloatingFocusManager } from '@floating-ui/react';
import React from 'react';

import { type FocusTarget, useFinalFocus, useInitialFocus } from '../../hooks/use-focus-target';
import { type ComponentProps, type DefaultProps, Freeze, mergeProps, useRender } from '../../utils';
import { type InteractionType, interactionTypeFromEvent } from '../../utils/interaction-modality';
import { useDialogContext } from './dialog-context';

/**
* Where focus goes when the dialog opens (`initialFocus`) or closes (`finalFocus`),
* mirroring Base UI:
*
* - `true` or omitted β€” the default: first tabbable element on open, the trigger (with the
* pointer-close downgrade `useReturnFocus` applies) on close
* - `false` β€” do not move focus
* - a ref β€” focus that element
* - a function of the interaction type behind the open/close (`''` when programmatic) β€”
* returns any of the above, with `void`/`null` meaning the default
*/
export type DialogFocusTarget =
| boolean
| React.RefObject<HTMLElement | null>
| ((interactionType: InteractionType) => boolean | void | HTMLElement | null);

/**
* Resolves `initialFocus` into the `number | ref` form `FloatingFocusManager` takes (a negative
* index disables the focus move). The function form reads the open event floating-ui has
* already recorded by the time the popup mounts; it must be pure, as re-renders re-invoke it.
*/
function useInitialFocus(
initialFocus: DialogFocusTarget | undefined,
open: boolean,
floatingContext: FloatingContext,
): number | React.MutableRefObject<HTMLElement | null> {
const elementRef = React.useRef<HTMLElement | null>(null);
return React.useMemo(() => {
if (!open || initialFocus === undefined || initialFocus === true) {
return 0;
}
if (initialFocus === false) {
return -1;
}
if (typeof initialFocus !== 'function') {
return initialFocus as React.MutableRefObject<HTMLElement | null>;
}
const result = initialFocus(interactionTypeFromEvent(floatingContext.dataRef.current.openEvent));
if (result === false) {
return -1;
}
if (result instanceof HTMLElement) {
elementRef.current = result;
return elementRef;
}
return 0;
}, [open, initialFocus, floatingContext]);
}

/**
* Resolves `finalFocus` into the `boolean | ref` form `FloatingFocusManager`'s `returnFocus`
* takes.
*
* The function form needs the event behind the close, so it runs inside floating-ui's
* synchronous `openchange` emit β€” the root routes every close through
* `floatingContext.onOpenChange`, and the emit precedes both the state commit and any focus
* restoration. Only the function's decision is stored; the ref handed to the focus manager
* materialises it lazily, at restore time, by which point `useReturnFocus` has applied its
* pointer-close downgrade to the default.
*/
function useFinalFocus(
finalFocus: DialogFocusTarget | undefined,
returnFocusRef: React.MutableRefObject<HTMLElement | null>,
floatingContext: FloatingContext,
): boolean | React.MutableRefObject<HTMLElement | null> {
const finalFocusRef = React.useRef(finalFocus);
React.useLayoutEffect(() => {
finalFocusRef.current = finalFocus;
});

// The function's last decision: an element, `false` for "don't move focus", `true` for the
// default (the trigger, via `returnFocusRef`).
const decisionRef = React.useRef<HTMLElement | boolean>(true);
const resolvedRef = React.useMemo(
() => ({
get current() {
const decision = decisionRef.current;
if (decision instanceof HTMLElement) {
return decision;
}
return decision ? returnFocusRef.current : null;
},
}),
[returnFocusRef],
);

React.useLayoutEffect(() => {
function onOpenChange({ open, event }: { open: boolean; event?: Event }) {
const target = finalFocusRef.current;
if (open || typeof target !== 'function') {
return;
}
const result = target(interactionTypeFromEvent(event));
decisionRef.current = result instanceof HTMLElement ? result : result !== false;
}
floatingContext.events.on('openchange', onOpenChange);
return () => floatingContext.events.off('openchange', onOpenChange);
}, [floatingContext.events]);

if (finalFocus === undefined || finalFocus === true) {
return returnFocusRef;
}
if (finalFocus === false) {
return false;
}
if (typeof finalFocus === 'function') {
return resolvedRef;
}
return finalFocus as React.MutableRefObject<HTMLElement | null>;
}
/** Where a popup's focus goes on open (`initialFocus`) or close (`finalFocus`); see `useFocusTarget`. */
export type DialogFocusTarget = FocusTarget;

/** Props for {@link DialogPopup}. */
export interface DialogPopupProps extends ComponentProps<'div'> {
Expand Down
34 changes: 20 additions & 14 deletions packages/headless/src/primitives/drawer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ sheet resists overshooting.
| -------- | -------------- | ---------------------------------------------------------------- |
| `handle` | `DrawerHandle` | Drive a detached handle instead of the surrounding `Drawer.Root` |

### `Drawer.Popup`

| Prop | Type | Default | Description |
| ------------ | ----------------------------------------------------------- | ----------- | --------------------------------------------------------------- |
| `finalFocus` | `boolean \| RefObject \| (interactionType) => Element \| …` | the trigger | Where focus returns on close; same contract as `Dialog.Popup`'s |

### `Drawer.Viewport`

| Prop | Type | Default | Description |
Expand All @@ -157,31 +163,31 @@ 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 |
| `--cl-drawer-nested-drag-progress` | nesting layer | 0..1 dismiss progress of the dragged nested child (drives the parent's live scale-in) |

### Data attributes

| Attribute | Applies to | Meaning |
| ------------------------------------------- | ---------------------------------- | ------------------------------- |
| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| `data-starting-style` / `data-ending-style` | Backdrop, Viewport, Popup | Enter / exit transition phase |
| `data-swiping` | Popup, Backdrop | A drag is in progress |
| `data-snap` | Popup | Active snap index |
| `data-expanded` | Popup | Resting at the full-height snap |
| `data-nested` | Popup | This drawer is itself nested |
| `data-nested-drawer-open` | Popup | A nested child is open |
| `data-nested-drawer-swiping` | Popup | A nested child is being dragged |
| `data-drawer-handle` | Handle | Grip / `handleOnly` hit-test |
| `data-drawer-no-drag` | (consumer-set) | Opt a subtree out of dragging |
| Attribute | Applies to | Meaning |
| ------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------- |
| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
| `data-starting-style` / `data-ending-style` | Backdrop, Viewport, Popup | Enter / exit transition phase |
| `data-swiping` | Popup, Backdrop | A drag is in progress (from the first move that commits, not the press) |
| `data-snap` | Popup | Active snap index |
| `data-expanded` | Popup | Resting at the full-height snap |
| `data-nested` | Popup | This drawer is itself nested |
| `data-nested-drawer-open` | Popup | A nested child is open |
| `data-nested-drawer-swiping` | Popup | A nested child is being dragged |
| `data-drawer-handle` | Handle | Grip / `handleOnly` hit-test |
| `data-drawer-no-drag` | (consumer-set) | Opt a subtree out of dragging |

The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above.

Expand Down
15 changes: 12 additions & 3 deletions packages/headless/src/primitives/drawer/drawer-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@
import { FloatingFocusManager } from '@floating-ui/react';
import React, { useEffect } from 'react';

import { type FocusTarget, useFinalFocus } from '../../hooks/use-focus-target';
import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils';
import { DrawerAttrs, DrawerCssVars } from './css-vars';
import { useDrawerContext } from './drawer-context';

/** Props for {@link DrawerPopup}. */
export type DrawerPopupProps = ComponentProps<'div'>;
export interface DrawerPopupProps extends ComponentProps<'div'> {
/**
* Where focus returns when the drawer closes. Default: the trigger, via `useReturnFocus`. The
* function form is called with the close's interaction type and may return an element.
*/
finalFocus?: FocusTarget;
}

/**
* The drawer sheet (`role="dialog"`). Hosts the drag gesture, focus trapping
Expand All @@ -17,7 +24,7 @@ export type DrawerPopupProps = ComponentProps<'div'>;
* opening on touch does not summon the keyboard.
*/
export const DrawerPopup = React.forwardRef<HTMLDivElement, DrawerPopupProps>(function DrawerPopup(props, ref) {
const { render, ...otherProps } = props;
const { render, finalFocus, ...otherProps } = props;
const {
popupRef,
refs,
Expand Down Expand Up @@ -98,6 +105,8 @@ export const DrawerPopup = React.forwardRef<HTMLDivElement, DrawerPopupProps>(fu
props: mergeProps<'div'>(defaultProps, otherProps),
});

const resolvedReturnFocus = useFinalFocus(finalFocus, returnFocusRef, floatingContext);

if (!element) {
return null;
}
Expand All @@ -108,7 +117,7 @@ export const DrawerPopup = React.forwardRef<HTMLDivElement, DrawerPopupProps>(fu
modal={modal}
outsideElementsInert={modal}
initialFocus={autoFocus ? undefined : popupRef}
returnFocus={returnFocusRef}
returnFocus={resolvedReturnFocus}
>
{element}
</FloatingFocusManager>
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
Loading
Loading