From 75574a4774739ede98dda71ade6cfdba438ca1de Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 10 Sep 2026 17:43:49 -0400 Subject: [PATCH 1/5] feat(ui): add Mosaic Select component --- .changeset/mosaic-select-component.md | 2 + .../headless/src/primitives/select/README.md | 14 +- .../select/align-selected-item.test.ts | 346 ++++++++++++++++++ .../primitives/select/align-selected-item.ts | 184 ++++++++++ .../src/primitives/select/select-context.ts | 3 + .../primitives/select/select-positioner.tsx | 31 +- .../src/primitives/select/select-root.tsx | 114 +++--- .../src/primitives/select/select-trigger.tsx | 14 +- .../src/primitives/select/select.test.tsx | 39 ++ packages/headless/src/utils/css-vars.test.ts | 9 + packages/headless/src/utils/css-vars.ts | 13 +- .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 20 + .../swingset/src/stories/select.component.mdx | 185 ++++++++++ .../src/stories/select.component.stories.tsx | 154 ++++++++ .../ui/src/mosaic/components/select/index.ts | 9 + .../select/select.markers.stylex.ts | 3 + .../mosaic/components/select/select.styles.ts | 160 ++++++++ .../mosaic/components/select/select.test.tsx | 248 +++++++++++++ .../src/mosaic/components/select/select.tsx | 235 ++++++++++++ packages/ui/src/mosaic/styles/index.ts | 9 + 21 files changed, 1717 insertions(+), 76 deletions(-) create mode 100644 .changeset/mosaic-select-component.md create mode 100644 packages/headless/src/primitives/select/align-selected-item.test.ts create mode 100644 packages/headless/src/primitives/select/align-selected-item.ts create mode 100644 packages/swingset/src/stories/select.component.mdx create mode 100644 packages/swingset/src/stories/select.component.stories.tsx create mode 100644 packages/ui/src/mosaic/components/select/index.ts create mode 100644 packages/ui/src/mosaic/components/select/select.markers.stylex.ts create mode 100644 packages/ui/src/mosaic/components/select/select.styles.ts create mode 100644 packages/ui/src/mosaic/components/select/select.test.tsx create mode 100644 packages/ui/src/mosaic/components/select/select.tsx diff --git a/.changeset/mosaic-select-component.md b/.changeset/mosaic-select-component.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-select-component.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/select/README.md b/packages/headless/src/primitives/select/README.md index 0da68184076..470196e0570 100644 --- a/packages/headless/src/primitives/select/README.md +++ b/packages/headless/src/primitives/select/README.md @@ -141,13 +141,13 @@ Typeahead is active only while the popup is open. It highlights the matching opt ## Data Attributes -| Attribute | Applies To | Description | -| --------------------------- | ---------- | ------------------------------- | -| `data-open` / `data-closed` | Trigger | Popup open state | -| `data-selected` | Option | The currently selected option | -| `data-active` | Option | The keyboard-highlighted option | -| `data-disabled` | Option | Disabled option | -| `data-side` | Positioner | Resolved placement side | +| Attribute | Applies To | Description | +| --------------------------- | ---------- | --------------------------------------------------------------------------------- | +| `data-open` / `data-closed` | Trigger | Popup open state | +| `data-selected` | Option | The currently selected option | +| `data-active` | Option | The keyboard-highlighted option | +| `data-disabled` | Option | Disabled option | +| `data-side` | Positioner | Resolved placement side, or `none` while the selected option overlays the trigger | ## Important Notes diff --git a/packages/headless/src/primitives/select/align-selected-item.test.ts b/packages/headless/src/primitives/select/align-selected-item.test.ts new file mode 100644 index 00000000000..ffa3753fa98 --- /dev/null +++ b/packages/headless/src/primitives/select/align-selected-item.test.ts @@ -0,0 +1,346 @@ +import type { MiddlewareState } from '@floating-ui/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { alignSelectedItem } from './align-selected-item'; + +function rect(top: number, height: number, left = 0, width = 100): DOMRect { + return { + top, + height, + left, + width, + bottom: top + height, + right: left + width, + x: left, + y: top, + toJSON: () => ({}), + }; +} + +function define(el: HTMLElement, props: Record) { + for (const [key, value] of Object.entries(props)) { + Object.defineProperty(el, key, { value, configurable: true, writable: true }); + } +} + +interface Setup { + referenceTop: number; + floatingHeight: number; + selectedOffset: number; + referenceY?: number; +} + +function setup({ referenceTop, floatingHeight, selectedOffset, referenceY = referenceTop }: Setup) { + const reference = document.createElement('button'); + const floating = document.createElement('div'); + const scroller = document.createElement('div'); + const selected = document.createElement('button'); + floating.appendChild(scroller); + scroller.appendChild(selected); + document.body.append(reference, floating); + + reference.getBoundingClientRect = () => rect(referenceTop, 32, 100, 120); + // Like the browser, the popup is only as tall as its cap allows. + floating.getBoundingClientRect = () => + rect(0, Math.min(floatingHeight, parseFloat(floating.style.maxHeight) || Infinity)); + define(scroller, { + offsetTop: 0, + offsetParent: floating, + scrollHeight: floatingHeight, + clientHeight: floatingHeight, + }); + define(selected, { offsetTop: selectedOffset, offsetParent: scroller }); + // Whatever the cap, this list can scroll. + floating.style.maxHeight = ''; + Object.defineProperty(scroller, 'clientHeight', { + configurable: true, + get: () => Math.min(floatingHeight, parseFloat(floating.style.maxHeight) || floatingHeight), + }); + + const openRef = { current: true }; + const selectedItemRef = { current: selected as HTMLElement | null }; + const onFallback = vi.fn(); + const requestUpdate = vi.fn(); + const middleware = alignSelectedItem({ selectedItemRef, openRef, onFallback, requestUpdate }); + + const state = { + x: 0, + y: 0, + placement: 'bottom-start', + strategy: 'absolute', + initialPlacement: 'bottom-start', + elements: { reference, floating }, + rects: { + reference: { x: 100, y: referenceY, width: 120, height: 32 }, + floating: { x: 0, y: 0, width: 100, height: floatingHeight }, + }, + middlewareData: {}, + platform: {}, + } as unknown as MiddlewareState; + + return { middleware, state, floating, scroller, selected, openRef, selectedItemRef, onFallback, requestUpdate }; +} + +function scrollTo(scroller: HTMLElement, top: number) { + scroller.scrollTop = top; + scroller.dispatchEvent(new Event('scroll')); +} + +describe('alignSelectedItem', () => { + beforeEach(() => { + Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, writable: true }); + document.body.innerHTML = ''; + }); + + it('places the popup so the selected option sits over the trigger', async () => { + const { middleware, state, floating } = setup({ referenceTop: 300, floatingHeight: 200, selectedOffset: 60 }); + + const result = await middleware.fn(state); + + expect(result).toEqual({ x: 100, y: 240, data: { availableHeight: 784 } }); + expect(floating.style.maxHeight).toBe('784px'); + expect(floating.style.getPropertyValue('--cl-available-height')).toBe('784px'); + }); + + it('returns coordinates in the floating element space, not the viewport', async () => { + // The document is scrolled 1000px: the trigger is at 300 in the viewport, 1300 on the page. + const { middleware, state } = setup({ + referenceTop: 300, + floatingHeight: 200, + selectedOffset: 60, + referenceY: 1300, + }); + + const result = await middleware.fn(state); + + expect(result).toMatchObject({ x: 100, y: 1240 }); + }); + + it('ignores the scale transform when measuring the selected option', async () => { + const { middleware, state, selected } = setup({ referenceTop: 300, floatingHeight: 200, selectedOffset: 60 }); + selected.getBoundingClientRect = () => rect(62.4, 30.72); + + const result = await middleware.fn(state); + + expect(result).toMatchObject({ y: 240 }); + }); + + it('cuts the list at the top of the viewport and scrolls it so the option stays on the trigger', async () => { + const { middleware, state, floating, scroller } = setup({ + referenceTop: 100, + floatingHeight: 600, + selectedOffset: 300, + }); + + const result = await middleware.fn(state); + + expect(result).toEqual({ x: 100, y: 8, data: { availableHeight: 392 } }); + expect(floating.style.maxHeight).toBe('392px'); + expect(scroller.scrollTop).toBe(208); + }); + + it('cuts a list taller than the viewport at both edges from its natural height', async () => { + const { middleware, state, floating, scroller } = setup({ + referenceTop: 300, + floatingHeight: 1200, + selectedOffset: 500, + }); + + const result = await middleware.fn(state); + + expect(result).toEqual({ x: 100, y: 8, data: { availableHeight: 784 } }); + expect(floating.style.maxHeight).toBe('784px'); + expect(scroller.scrollTop).toBe(208); + }); + + it('cuts the list at the bottom of the viewport without scrolling it', async () => { + const { middleware, state, floating, scroller } = setup({ + referenceTop: 500, + floatingHeight: 600, + selectedOffset: 0, + }); + + const result = await middleware.fn(state); + + expect(result).toEqual({ x: 100, y: 500, data: { availableHeight: 292 } }); + expect(floating.style.maxHeight).toBe('292px'); + expect(scroller.scrollTop).toBe(0); + }); + + it('falls back to anchored positioning when the aligned popup would be too short', async () => { + const { middleware, state, onFallback } = setup({ + referenceTop: 30, + floatingHeight: 600, + selectedOffset: 560, + }); + + // Would be 54px tall, from 8 to 62: not worth aligning. + expect(await middleware.fn(state)).toEqual({}); + expect(onFallback).toHaveBeenCalledTimes(1); + }); + + it('falls back to anchored positioning when the trigger hugs a viewport edge', async () => { + const nearTop = setup({ referenceTop: 10, floatingHeight: 200, selectedOffset: 0 }); + expect(await nearTop.middleware.fn(nearTop.state)).toEqual({}); + expect(nearTop.onFallback).toHaveBeenCalledTimes(1); + + const nearBottom = setup({ referenceTop: 760, floatingHeight: 200, selectedOffset: 160 }); + expect(await nearBottom.middleware.fn(nearBottom.state)).toEqual({}); + expect(nearBottom.onFallback).toHaveBeenCalledTimes(1); + }); + + it('grows downward as the user scrolls up toward the rows cut off at the top', async () => { + const { middleware, state, floating, scroller, requestUpdate } = setup({ + referenceTop: 100, + floatingHeight: 600, + selectedOffset: 300, + }); + await middleware.fn(state); + expect(scroller.scrollTop).toBe(208); + + // The rows slide down 50px and the popup's bottom edge follows them into the free space. + scrollTo(scroller, 158); + expect(floating.style.maxHeight).toBe('442px'); + expect(scroller.scrollTop).toBe(158); + expect(await middleware.fn(state)).toEqual({ x: 100, y: 8, data: { availableHeight: 442 } }); + + // Scrolled all the way up, the list is back to its natural height. + scrollTo(scroller, 0); + expect(floating.style.maxHeight).toBe('600px'); + expect(requestUpdate).not.toHaveBeenCalled(); + }); + + it('grows upward as the user scrolls down toward the rows cut off at the bottom', async () => { + const { middleware, state, floating, scroller, requestUpdate } = setup({ + referenceTop: 500, + floatingHeight: 600, + selectedOffset: 0, + }); + await middleware.fn(state); + + // The rows slide up 50px by moving the popup, not the list, so the top edge takes the free space. + scrollTo(scroller, 50); + expect(scroller.scrollTop).toBe(0); + expect(floating.style.maxHeight).toBe('342px'); + expect(requestUpdate).toHaveBeenCalledTimes(1); + expect(await middleware.fn(state)).toEqual({ x: 100, y: 450, data: { availableHeight: 342 } }); + }); + + it('stops growing at the viewport edge and scrolls the list from there', async () => { + const { middleware, state, floating, scroller } = setup({ + referenceTop: 500, + floatingHeight: 1200, + selectedOffset: 0, + }); + await middleware.fn(state); + + scrollTo(scroller, 600); + expect(floating.style.maxHeight).toBe('784px'); + expect(scroller.scrollTop).toBe(108); + expect(await middleware.fn(state)).toEqual({ x: 100, y: 8, data: { availableHeight: 784 } }); + + scrollTo(scroller, 200); + expect(floating.style.maxHeight).toBe('784px'); + expect(scroller.scrollTop).toBe(200); + expect(await middleware.fn(state)).toEqual({ x: 100, y: 8, data: { availableHeight: 784 } }); + }); + + it('stops reacting to list scroll once closed', async () => { + const { middleware, state, floating, scroller, openRef } = setup({ + referenceTop: 500, + floatingHeight: 600, + selectedOffset: 0, + }); + await middleware.fn(state); + openRef.current = false; + + scrollTo(scroller, 50); + expect(floating.style.maxHeight).toBe('292px'); + expect(scroller.scrollTop).toBe(50); + }); + + it('holds the trigger-relative position for the rest of the open, and through the close', async () => { + const { middleware, state, selected, scroller, openRef } = setup({ + referenceTop: 300, + floatingHeight: 200, + selectedOffset: 60, + }); + const first = await middleware.fn(state); + + // The user scrolled the list and the page; the popup follows the trigger, nothing else. + scroller.scrollTop = 40; + state.rects.reference.y = 1300; + expect(await middleware.fn(state)).toEqual({ ...first, y: 1240 }); + + // Closing moved the selection to another option. + openRef.current = false; + define(selected, { offsetTop: 180 }); + expect(await middleware.fn(state)).toEqual({ ...first, y: 1240 }); + expect(scroller.scrollTop).toBe(40); + }); + + it('measures again on the next open', async () => { + const { middleware, state, selected, openRef } = setup({ + referenceTop: 300, + floatingHeight: 200, + selectedOffset: 60, + }); + await middleware.fn(state); + openRef.current = false; + await middleware.fn(state); + + openRef.current = true; + define(selected, { offsetTop: 180 }); + + expect(await middleware.fn(state)).toMatchObject({ y: 120 }); + }); + + it('measures again when the popup remounts without a close ever repositioning', async () => { + const { middleware, state, floating, selected } = setup({ + referenceTop: 300, + floatingHeight: 200, + selectedOffset: 60, + }); + await middleware.fn(state); + + const remounted = document.createElement('div'); + remounted.getBoundingClientRect = floating.getBoundingClientRect; + remounted.appendChild(selected.parentElement ?? selected); + define(selected.parentElement ?? selected, { offsetParent: remounted }); + define(selected, { offsetTop: 180 }); + state.elements.floating = remounted; + + expect(await middleware.fn(state)).toMatchObject({ y: 120 }); + expect(remounted.style.maxHeight).toBe('784px'); + }); + + it('measures again when the viewport height changes while open', async () => { + const { middleware, state, floating, scroller } = setup({ + referenceTop: 400, + floatingHeight: 600, + selectedOffset: 300, + }); + Object.defineProperty(window, 'innerHeight', { value: 2000, configurable: true, writable: true }); + expect(await middleware.fn(state)).toEqual({ x: 100, y: 100, data: { availableHeight: 1984 } }); + + Object.defineProperty(window, 'innerHeight', { value: 500, configurable: true, writable: true }); + + expect(await middleware.fn(state)).toEqual({ x: 100, y: 100, data: { availableHeight: 392 } }); + expect(floating.style.maxHeight).toBe('392px'); + expect(scroller.scrollTop).toBe(0); + }); + + it('caps the height but leaves the position alone when nothing is selected', async () => { + const { middleware, state, floating, selectedItemRef } = setup({ + referenceTop: 300, + floatingHeight: 200, + selectedOffset: 60, + }); + selectedItemRef.current = null; + + const result = await middleware.fn(state); + + expect(result).toEqual({ data: { availableHeight: 784 } }); + expect(floating.style.maxHeight).toBe('784px'); + }); +}); diff --git a/packages/headless/src/primitives/select/align-selected-item.ts b/packages/headless/src/primitives/select/align-selected-item.ts new file mode 100644 index 00000000000..40da6fba647 --- /dev/null +++ b/packages/headless/src/primitives/select/align-selected-item.ts @@ -0,0 +1,184 @@ +import type { Middleware } from '@floating-ui/react'; +import type { RefObject } from 'react'; + +const VIEWPORT_PADDING = 8; +const MIN_HEIGHT = 100; +const TRIGGER_EDGE_THRESHOLD = 20; + +export interface AlignSelectedItemOptions { + selectedItemRef: RefObject; + openRef: RefObject; + /** Called when the trigger sits where an aligned popup would be unusable; position it like a menu instead. */ + onFallback: () => void; + /** Called when the list scroll has moved the popup and floating-ui must reapply the position. */ + requestUpdate: () => void; +} + +interface Alignment { + dy: number; + availableHeight: number; + viewportHeight: number; + /** Free space between the popup's edges and the viewport padding, consumed as the list scrolls. */ + freeAbove: number; + freeBelow: number; +} + +// Layout offset, immune to the popup's enter/exit scale transform. +function offsetWithin(child: HTMLElement, ancestor: HTMLElement): number { + let top = 0; + let el: HTMLElement | null = child; + while (el && el !== ancestor) { + top += el.offsetTop; + el = el.offsetParent instanceof HTMLElement ? el.offsetParent : null; + } + for (let scroller = child.parentElement; scroller && scroller !== ancestor; scroller = scroller.parentElement) { + top -= scroller.scrollTop; + } + return top; +} + +function findScrollParent(from: HTMLElement, until: HTMLElement): HTMLElement | null { + for (let el = from.parentElement; el && el !== until; el = el.parentElement) { + if (el.scrollHeight > el.clientHeight) { + return el; + } + } + return null; +} + +function setHeight(floating: HTMLElement, height: number | null) { + floating.style.maxHeight = height === null ? '' : `${height}px`; + floating.style.setProperty('--cl-available-height', height === null ? '' : `${height}px`); +} + +/** + * Positions the popup so the selected option sits over the trigger, like a native ``. Defaults to `true`. + * trigger — like a native `` does. + + + +## Usage + +```tsx +import { Select } from '@clerk/ui/mosaic/components/select'; + +const roles = [ + { value: 'all', label: 'All roles' }, + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, +]; + + + + +; +``` + +`items` is required. The options only exist while the listbox is open, so the items are what let +the closed trigger show the selected label. `Select.Popup` renders one `Select.Option` per item +when it is given no children, and it renders the portal and the floating positioner itself, so in +the common case there is nothing else to write. + +### Trigger + +`Select.Trigger` renders a neutral `md` `Button` that shows the selected label and a chevron. The +`outline` variant (the default) is the bordered form control; `ghost` drops the border for a select +that sits inside a dense surface such as a table row. Pass `placeholder` for the text shown until +something is chosen, or `render` to supply your own element — it receives the computed props (ARIA +attributes, click and keyboard handlers) to spread. + +```tsx + + + +``` + + + + + +### Alignment + +By default the popup opens with the selected option sitting exactly over the trigger. The option +row is built to the trigger's measurements — the same height, the same text inset, the same type — +so the label does not appear to move when the list opens. + +That only holds while every row is the height of the trigger. An item with a `description` renders +a second line, so a list with descriptions hangs below the trigger instead: pass +`alignItemWithTrigger={false}`. + +The overlay also gives way on its own where it would not work: a touch opening, or a trigger within +20px of the top or bottom of the viewport, places the list below the trigger like a menu. + +```tsx +const roles = [ + { value: 'member', label: 'Member', description: 'Role with non-privileged permissions.' }, + { value: 'admin', label: 'Admin', description: 'Role with elevated permissions.' }, +]; + + + + +; +``` + + + +### Options + +Every row reserves space for the check, so the labels stay put when the selection moves. A +`disabled` item can still be reached with the keyboard but cannot be selected. The description is +read to assistive technology as the option's description, not as part of its name. + +To compose the rows yourself, give `Select.Popup` children: one `Select.Option` per item, taking the +same `value`, `label`, `description`, and `disabled`. + +```tsx + + {roles.map(role => ( + + ))} + +``` + +### Controlled + +```tsx +const [value, setValue] = useState('member'); + + + + +; +``` + + + +### Scrolling + +A list with more options than fit is cut at the viewport edge and scrolled so the selected row still +lands on the trigger. Scrolling toward the hidden rows first grows the popup into whatever space is +free on the other side, then scrolls the list in place. The page is scroll-locked underneath. The rows scroll +with the shared ScrollArea treatment. Nothing to opt into — `Select.Popup` composes it. + + + +## Parts + +| Part | Slot | Description | +| ---------------- | ------------------------------------ | ----------------------------------------------------------------------------------- | +| `Select.Root` | — | State provider; owns the value, the items, open/close, and keyboard nav. | +| `Select.Trigger` | `select-trigger` | Opens the listbox. Defaults to a neutral `md` `Button`; `variant` picks the chrome. | +| ↳ value | `select-value` | The selected label, or the placeholder. | +| ↳ icon | `select-trigger-icon` | The trailing chevron. | +| `Select.Popup` | `select-positioner` / `select-popup` | Portals, positions, and renders the listbox surface. | +| ↳ viewport | `select-viewport` | The scrolling box inside the popup. Carries the ScrollArea fade and scrollbar. | +| `Select.Option` | `select-option` | A single choice. `data-selected`, `data-active`, `data-disabled`, `data-described`. | +| ↳ content | `select-option-content` | Stacks the label over the description. | +| ↳ label | `select-option-label` | The option's text. Truncates to one line. | +| ↳ description | `select-option-description` | The optional second line. | +| ↳ indicator | `select-option-indicator` | The check. Present on every row, visible on the selected one. | + +## Styling + +The Mosaic select is themed with **StyleX**. Each styled part carries a stable `.cl-` class +(the slots above) alongside the StyleX atoms. Consumers never target the hashed atomic classes — +override by targeting the `.cl-*` slot from a CSS layer that wins over `@clerk/ui/styles.css`: + +```css +@import '@clerk/ui/styles.css' layer(components); + +@layer overrides { + .cl-select-popup { + border-radius: 20px; + } +} +``` + +The popup's enter/exit transition is driven off its own `data-starting-style` /`data-ending-style` +attributes and is disabled under `prefers-reduced-motion: reduce`. Option hover state is gated +behind `@media (hover: hover)`, and the keyboard-active option is styled off `data-active`, so +pointer and keyboard highlighting stay in sync. diff --git a/packages/swingset/src/stories/select.component.stories.tsx b/packages/swingset/src/stories/select.component.stories.tsx new file mode 100644 index 00000000000..78f55c62a6a --- /dev/null +++ b/packages/swingset/src/stories/select.component.stories.tsx @@ -0,0 +1,154 @@ +import { Field } from '@clerk/ui/mosaic/components/field'; +import { Select } from '@clerk/ui/mosaic/components/select'; +import { Text } from '@clerk/ui/mosaic/components/text'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './select.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + status: 'stable', + title: 'Select', + source: 'packages/ui/src/mosaic/components/select/select.tsx', +}; + +const roleFilters = [ + { value: 'all', label: 'All roles' }, + { value: 'admin', label: 'Admin' }, + { value: 'member', label: 'Member' }, +]; + +/** The selected option opens over the trigger, like a native `