diff --git a/.changeset/headless-remove-data-cl-slot.md b/.changeset/headless-remove-data-cl-slot.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/headless-remove-data-cl-slot.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/README.md b/packages/headless/README.md index ef20f91d068..3669443dd66 100644 --- a/packages/headless/README.md +++ b/packages/headless/README.md @@ -45,14 +45,14 @@ import { Select } from '@clerk/headless/select'; ; ``` -All primitives follow the same compound component pattern. They emit zero styles — all visual styling is applied externally via `data-cl-*` attribute selectors. +All primitives follow the same compound component pattern. They emit zero styles — all visual styling is applied externally via `data-*` attribute selectors. ## Architecture - **Compound components** — each primitive exports a namespace (e.g. `Select.Trigger`, `Select.Popup`) backed by per-part files so unused parts tree-shake out - **`renderElement`** — every part uses this instead of returning JSX directly, enabling consumer `render` prop overrides and automatic state-to-data-attribute mapping -- **`data-cl-*` attributes** — structural (`data-cl-slot`), state (`data-cl-open`, `data-cl-selected`, `data-cl-active`), and animation lifecycle (`data-cl-starting-style`, `data-cl-ending-style`) -- **CSS-driven animations** — the transition system uses `data-cl-*` attributes and the Web Animations API (`getAnimations().finished`) so all timing lives in CSS +- **`data-*` attributes** — state (`data-open`, `data-selected`, `data-active`) and animation lifecycle (`data-starting-style`, `data-ending-style`); parts are targeted by consumer-supplied classNames, not by an emitted slot attribute +- **CSS-driven animations** — the transition system uses `data-*` attributes and the Web Animations API (`getAnimations().finished`) so all timing lives in CSS - **Floating UI** — positioning, interactions, focus management, dismiss handling, list navigation, and ARIA are all delegated to `@floating-ui/react` ## Consuming from `@clerk/ui` diff --git a/packages/headless/src/hooks/use-animations-finished.test.ts b/packages/headless/src/hooks/use-animations-finished.test.ts index 8b81961ffad..c2c891bb084 100644 --- a/packages/headless/src/hooks/use-animations-finished.test.ts +++ b/packages/headless/src/hooks/use-animations-finished.test.ts @@ -121,7 +121,7 @@ describe('useAnimationsFinished', () => { }); it('waits for starting-style attribute removal when open=true', async () => { - const el = createMockElement([], { 'data-cl-starting-style': '' }); + const el = createMockElement([], { 'data-starting-style': '' }); const ref = { current: el } as RefObject; const { result } = renderHook(() => useAnimationsFinished(ref, true)); @@ -134,7 +134,7 @@ describe('useAnimationsFinished', () => { // Remove the attribute — MutationObserver should fire await act(async () => { - el.removeAttribute('data-cl-starting-style'); + el.removeAttribute('data-starting-style'); // MutationObserver is async; wait a tick await new Promise(r => setTimeout(r, 0)); }); diff --git a/packages/headless/src/hooks/use-animations-finished.ts b/packages/headless/src/hooks/use-animations-finished.ts index 77ef78fac52..3ff4083e1b4 100644 --- a/packages/headless/src/hooks/use-animations-finished.ts +++ b/packages/headless/src/hooks/use-animations-finished.ts @@ -10,7 +10,7 @@ import { flushSync } from 'react-dom'; * Uses the Web Animations API (`element.getAnimations()` + `animation.finished`) * so we're duration-agnostic — CSS owns all timing. * - * When `open` is true, waits for `[data-cl-starting-style]` to be removed + * When `open` is true, waits for `[data-starting-style]` to be removed * before polling animations. This avoids a race where `getAnimations()` returns * an empty array before the enter transition has been registered. * @@ -75,16 +75,16 @@ export function useAnimationsFinished(ref: RefObject, open: }); }; - if (open && element.hasAttribute('data-cl-starting-style')) { + if (open && element.hasAttribute('data-starting-style')) { const observer = new MutationObserver(() => { - if (!element.hasAttribute('data-cl-starting-style')) { + if (!element.hasAttribute('data-starting-style')) { observer.disconnect(); runCheck(); } }); observer.observe(element, { attributes: true, - attributeFilter: ['data-cl-starting-style'], + attributeFilter: ['data-starting-style'], }); signal.addEventListener('abort', () => observer.disconnect()); return; diff --git a/packages/headless/src/hooks/use-transition.test.ts b/packages/headless/src/hooks/use-transition.test.ts index 07c421db2eb..4d507ec7e7d 100644 --- a/packages/headless/src/hooks/use-transition.test.ts +++ b/packages/headless/src/hooks/use-transition.test.ts @@ -64,20 +64,20 @@ describe('useTransition', () => { expect(result.current.mounted).toBe(true); expect(result.current.transitionProps).toEqual({ - 'data-cl-open': '', - 'data-cl-starting-style': '', + 'data-open': '', + 'data-starting-style': '', style: { transition: 'none' }, }); }); - it('clears starting-style after first frame, keeps data-cl-open', () => { + it('clears starting-style after first frame, keeps data-open', () => { const ref = createElementRef(); const { result } = renderHook(() => useTransition({ open: true, ref })); act(() => flushRaf()); expect(result.current.transitionProps).toEqual({ - 'data-cl-open': '', + 'data-open': '', }); }); @@ -97,8 +97,8 @@ describe('useTransition', () => { expect(result.current.mounted).toBe(true); expect(result.current.transitionProps).toEqual({ - 'data-cl-closed': '', - 'data-cl-ending-style': '', + 'data-closed': '', + 'data-ending-style': '', }); // Cleanup: resolve to prevent hanging @@ -186,12 +186,12 @@ describe('useTransition', () => { rerender({ open: true }); expect(result.current.mounted).toBe(true); - expect(result.current.transitionProps['data-cl-open']).toBe(''); + expect(result.current.transitionProps['data-open']).toBe(''); // After rAF, ending-style is cleared and we're in stable open state act(() => flushRaf()); - expect(result.current.transitionProps['data-cl-ending-style']).toBeUndefined(); - expect(result.current.transitionProps['data-cl-open']).toBe(''); + expect(result.current.transitionProps['data-ending-style']).toBeUndefined(); + expect(result.current.transitionProps['data-open']).toBe(''); // Cleanup el.getAnimations = vi.fn(() => [] as unknown as Animation[]); diff --git a/packages/headless/src/hooks/use-transition.ts b/packages/headless/src/hooks/use-transition.ts index b5f67bd22d4..783edaff9b7 100644 --- a/packages/headless/src/hooks/use-transition.ts +++ b/packages/headless/src/hooks/use-transition.ts @@ -11,10 +11,10 @@ export interface UseTransitionOptions { } export interface TransitionProps { - 'data-cl-open'?: ''; - 'data-cl-closed'?: ''; - 'data-cl-starting-style'?: ''; - 'data-cl-ending-style'?: ''; + 'data-open'?: ''; + 'data-closed'?: ''; + 'data-starting-style'?: ''; + 'data-ending-style'?: ''; style?: CSSProperties; } @@ -30,13 +30,13 @@ export interface UseTransitionReturn { * Returns: * - `mounted`: whether the element should render. Gate your JSX on this. * - `transitionProps`: spread onto the element. Exposes - * `data-cl-open` / `data-cl-closed` / `data-cl-starting-style` / - * `data-cl-ending-style` data attributes and an inline `transition: none` on + * `data-open` / `data-closed` / `data-starting-style` / + * `data-ending-style` data attributes and an inline `transition: none` on * the first mount frame. * * Consumers drive all animation via CSS — no durations in JS. Works with CSS - * transitions (via `[data-cl-starting-style]` / `[data-cl-ending-style]`) and - * CSS keyframe animations (via `[data-cl-open]` / `[data-cl-closed]`). + * transitions (via `[data-starting-style]` / `[data-ending-style]`) and + * CSS keyframe animations (via `[data-open]` / `[data-closed]`). */ export function useTransition({ open, ref }: UseTransitionOptions): UseTransitionReturn { const { mounted, transitionStatus, setMounted } = useTransitionStatus(open); @@ -54,15 +54,15 @@ export function useTransition({ open, ref }: UseTransitionOptions): UseTransitio const transitionProps = useMemo(() => { const props: TransitionProps = {}; if (open) { - props['data-cl-open'] = ''; + props['data-open'] = ''; } else if (mounted) { - props['data-cl-closed'] = ''; + props['data-closed'] = ''; } if (transitionStatus === 'starting') { - props['data-cl-starting-style'] = ''; + props['data-starting-style'] = ''; props.style = { transition: 'none' }; } else if (transitionStatus === 'ending') { - props['data-cl-ending-style'] = ''; + props['data-ending-style'] = ''; } return props; }, [open, mounted, transitionStatus]); diff --git a/packages/headless/src/primitives/accordion/README.md b/packages/headless/src/primitives/accordion/README.md index b881a4ebfcb..f652562fff3 100644 --- a/packages/headless/src/primitives/accordion/README.md +++ b/packages/headless/src/primitives/accordion/README.md @@ -98,24 +98,23 @@ All parts accept a `render` prop for polymorphic rendering and standard HTML att ## Data Attributes -| Attribute | Applies To | Description | -| ------------------ | -------------------- | ------------------------------------------------- | -| `data-cl-slot` | All parts | Identifies each part (e.g. `"accordion-trigger"`) | -| `data-cl-open` | Item, Trigger, Panel | Present when the item is expanded | -| `data-cl-closed` | Item, Trigger, Panel | Present when the item is collapsed | -| `data-cl-disabled` | Item, Trigger | Present when the item is disabled | +| Attribute | Applies To | Description | +| --------------- | -------------------- | ---------------------------------- | +| `data-open` | Item, Trigger, Panel | Present when the item is expanded | +| `data-closed` | Item, Trigger, Panel | Present when the item is collapsed | +| `data-disabled` | Item, Trigger | Present when the item is disabled | ## CSS Animation `Accordion.Panel` exposes a `--cl-accordion-panel-height` CSS custom property set to the panel's `scrollHeight` in pixels. Use this for height-based expand/collapse animations: ```css -[data-cl-slot='accordion-panel'] { +.cl-accordion-panel { overflow: hidden; height: var(--cl-accordion-panel-height); transition: height 200ms ease; } -[data-cl-slot='accordion-panel'][data-cl-closed] { +.cl-accordion-panel[data-closed] { height: 0; } ``` diff --git a/packages/headless/src/primitives/accordion/accordion-header.tsx b/packages/headless/src/primitives/accordion/accordion-header.tsx index 903235f2a07..240a5e73808 100644 --- a/packages/headless/src/primitives/accordion/accordion-header.tsx +++ b/packages/headless/src/primitives/accordion/accordion-header.tsx @@ -7,9 +7,7 @@ export type AccordionHeaderProps = ComponentProps<'h3'>; export function AccordionHeader(props: AccordionHeaderProps) { const { render, ...otherProps } = props; - const defaultProps = { - 'data-cl-slot': 'accordion-header', - }; + const defaultProps = {}; return useRender({ defaultTagName: 'h3', diff --git a/packages/headless/src/primitives/accordion/accordion-item.tsx b/packages/headless/src/primitives/accordion/accordion-item.tsx index f772793544c..c86ee81b2de 100644 --- a/packages/headless/src/primitives/accordion/accordion-item.tsx +++ b/packages/headless/src/primitives/accordion/accordion-item.tsx @@ -28,9 +28,7 @@ export function AccordionItem(props: AccordionItemProps) { const state = { open, disabled }; - const defaultProps = { - 'data-cl-slot': 'accordion-item', - }; + const defaultProps = {}; return ( @@ -39,8 +37,8 @@ export function AccordionItem(props: AccordionItemProps) { render, state, stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), })} diff --git a/packages/headless/src/primitives/accordion/accordion-panel.tsx b/packages/headless/src/primitives/accordion/accordion-panel.tsx index fab5eca48ee..222183f8d9f 100644 --- a/packages/headless/src/primitives/accordion/accordion-panel.tsx +++ b/packages/headless/src/primitives/accordion/accordion-panel.tsx @@ -94,13 +94,12 @@ export const AccordionPanel = React.forwardRef = { - 'data-cl-slot': 'accordion-panel', id: panelId, role: 'region' as const, 'aria-labelledby': triggerId, @@ -123,7 +122,7 @@ export const AccordionPanel = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: merged, }); diff --git a/packages/headless/src/primitives/accordion/accordion-root.tsx b/packages/headless/src/primitives/accordion/accordion-root.tsx index c7f6e93d7ae..03de991b028 100644 --- a/packages/headless/src/primitives/accordion/accordion-root.tsx +++ b/packages/headless/src/primitives/accordion/accordion-root.tsx @@ -57,14 +57,13 @@ export function AccordionRoot(props: AccordionProps) { const { 'aria-orientation': _ao, ...restCompositeProps } = compositeProps; const defaultProps: Record = { - 'data-cl-slot': 'accordion-root', onKeyDown: (event: React.KeyboardEvent) => { if (event.key !== 'Home' && event.key !== 'End') { return; } event.preventDefault(); const items = Array.from( - event.currentTarget.querySelectorAll('[data-cl-slot="accordion-trigger"]:not([disabled])'), + event.currentTarget.querySelectorAll('[aria-expanded]:not([disabled])'), ); if (items.length === 0) { return; diff --git a/packages/headless/src/primitives/accordion/accordion-trigger.tsx b/packages/headless/src/primitives/accordion/accordion-trigger.tsx index 4e6ca83f46e..4aded2a67db 100644 --- a/packages/headless/src/primitives/accordion/accordion-trigger.tsx +++ b/packages/headless/src/primitives/accordion/accordion-trigger.tsx @@ -20,7 +20,6 @@ export function AccordionTrigger(props: AccordionTriggerProps) { disabled={disabled} render={(compositeProps: React.HTMLAttributes) => { const defaultProps: Record = { - 'data-cl-slot': 'accordion-trigger', id: triggerId, type: 'button' as const, 'aria-expanded': open, @@ -59,9 +58,8 @@ export function AccordionTrigger(props: AccordionTriggerProps) { ref: compositeRef as React.Ref, state, stateAttributesMapping: { - open: (v: boolean): Record | null => - v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }, - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergedProps, }); diff --git a/packages/headless/src/primitives/accordion/accordion.test.tsx b/packages/headless/src/primitives/accordion/accordion.test.tsx index 3b4587f4a7c..2f02972102e 100644 --- a/packages/headless/src/primitives/accordion/accordion.test.tsx +++ b/packages/headless/src/primitives/accordion/accordion.test.tsx @@ -10,59 +10,38 @@ afterEach(() => cleanup()); function renderAccordion(props: Partial> = {}) { return render( - - - Section 1 + + + Section 1 - Content 1 + Content 1 - - - Section 2 + + + Section 2 - Content 2 + Content 2 - - - Section 3 + + + Section 3 - Content 3 + Content 3 , ); } describe('Accordion', () => { - describe('slot attributes', () => { - it('renders root with data-cl-slot', () => { - renderAccordion(); - expect(document.querySelector('[data-cl-slot="accordion-root"]')).toBeInTheDocument(); - }); - - it('renders items with data-cl-slot', () => { - renderAccordion(); - const items = document.querySelectorAll('[data-cl-slot="accordion-item"]'); - expect(items).toHaveLength(3); - }); - - it('renders headers with data-cl-slot', () => { - renderAccordion(); - const headers = document.querySelectorAll('[data-cl-slot="accordion-header"]'); - expect(headers).toHaveLength(3); - }); - - it('renders triggers with data-cl-slot', () => { - renderAccordion(); - const triggers = document.querySelectorAll('[data-cl-slot="accordion-trigger"]'); - expect(triggers).toHaveLength(3); - }); - - it('renders panels with data-cl-slot when open', () => { - renderAccordion({ defaultValue: ['item1'] }); - expect(document.querySelector('[data-cl-slot="accordion-panel"]')).toBeInTheDocument(); - }); - }); - describe('expand/collapse', () => { it('opens an item on trigger click', async () => { const user = userEvent.setup(); @@ -70,8 +49,8 @@ describe('Accordion', () => { await user.click(screen.getByRole('button', { name: 'Section 1' })); - const item = document.querySelectorAll('[data-cl-slot="accordion-item"]')[0]; - expect(item).toHaveAttribute('data-cl-open', ''); + const item = document.querySelectorAll('[data-testid="accordion-item"]')[0]; + expect(item).toHaveAttribute('data-open', ''); expect(screen.getByText('Content 1')).toBeInTheDocument(); }); @@ -81,8 +60,8 @@ describe('Accordion', () => { await user.click(screen.getByRole('button', { name: 'Section 1' })); - const item = document.querySelectorAll('[data-cl-slot="accordion-item"]')[0]; - expect(item).toHaveAttribute('data-cl-closed', ''); + const item = document.querySelectorAll('[data-testid="accordion-item"]')[0]; + expect(item).toHaveAttribute('data-closed', ''); }); it('calls onValueChange when toggled', async () => { @@ -152,7 +131,7 @@ describe('Accordion', () => { it('trigger has aria-controls linked to panel id', () => { renderAccordion({ defaultValue: ['item1'] }); const trigger = screen.getByRole('button', { name: 'Section 1' }); - const panel = document.querySelector('[data-cl-slot="accordion-panel"]'); + const panel = document.querySelector('[data-testid="accordion-panel"]'); expect(trigger).toHaveAttribute('aria-controls', panel?.getAttribute('id')); }); @@ -160,7 +139,7 @@ describe('Accordion', () => { it('panel has aria-labelledby linked to trigger id', () => { renderAccordion({ defaultValue: ['item1'] }); const trigger = screen.getByRole('button', { name: 'Section 1' }); - const panel = document.querySelector('[data-cl-slot="accordion-panel"]'); + const panel = document.querySelector('[data-testid="accordion-panel"]'); expect(panel).toHaveAttribute('aria-labelledby', trigger.getAttribute('id')); }); @@ -170,14 +149,19 @@ describe('Accordion', () => { - Section 1 + + Section 1 + - Content 1 + Content 1 , ); const trigger = screen.getByRole('button', { name: 'Section 1' }); - const panel = document.querySelector('[data-cl-slot="accordion-panel"]'); + const panel = document.querySelector('[data-testid="accordion-panel"]'); // The wired ids are owned by the primitive: a consumer-supplied id must // not silently break the aria pairing between trigger and panel. @@ -194,19 +178,19 @@ describe('Accordion', () => { describe('animation lifecycle', () => { it('panel is not in DOM when closed', () => { renderAccordion(); - expect(document.querySelector('[data-cl-slot="accordion-panel"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="accordion-panel"]')).not.toBeInTheDocument(); }); - it('applies data-cl-open on panel when open', () => { + it('applies data-open on panel when open', () => { renderAccordion({ defaultValue: ['item1'] }); - const panel = document.querySelector('[data-cl-slot="accordion-panel"]'); - expect(panel).toHaveAttribute('data-cl-open', ''); + const panel = document.querySelector('[data-testid="accordion-panel"]'); + expect(panel).toHaveAttribute('data-open', ''); }); it('does not apply starting-style on initially open panels', () => { renderAccordion({ defaultValue: ['item1'] }); - const panel = document.querySelector('[data-cl-slot="accordion-panel"]'); - expect(panel).not.toHaveAttribute('data-cl-starting-style'); + const panel = document.querySelector('[data-testid="accordion-panel"]'); + expect(panel).not.toHaveAttribute('data-starting-style'); }); it('pins --cl-accordion-panel-height while the open animation is in progress', () => { @@ -217,7 +201,7 @@ describe('Accordion', () => { ]; try { renderAccordion({ defaultValue: ['item1'] }); - const panel = document.querySelector('[data-cl-slot="accordion-panel"]') as HTMLElement; + const panel = document.querySelector('[data-testid="accordion-panel"]') as HTMLElement; expect(panel.getAttribute('style')).toContain('--cl-accordion-panel-height'); } finally { if (original) { @@ -232,7 +216,7 @@ describe('Accordion', () => { // With no running animations the panel drops the measured pixel height so it // flows naturally with its content. renderAccordion({ defaultValue: ['item1'] }); - const panel = document.querySelector('[data-cl-slot="accordion-panel"]') as HTMLElement; + const panel = document.querySelector('[data-testid="accordion-panel"]') as HTMLElement; expect(panel.getAttribute('style') ?? '').not.toContain('--cl-accordion-panel-height'); }); }); @@ -286,25 +270,26 @@ describe('Accordion', () => { expect(onValueChange).toHaveBeenCalledWith(['item2']); }); - it('applies data-cl-disabled on item and trigger', () => { + it('applies data-disabled on item and trigger', () => { render( - Disabled + Disabled Content , ); - const item = document.querySelector('[data-cl-slot="accordion-item"]'); - const trigger = document.querySelector('[data-cl-slot="accordion-trigger"]'); - expect(item).toHaveAttribute('data-cl-disabled', ''); - expect(trigger).toHaveAttribute('data-cl-disabled', ''); + const item = document.querySelector('[data-testid="accordion-item"]'); + const trigger = document.querySelector('[data-testid="accordion-trigger"]'); + expect(item).toHaveAttribute('data-disabled', ''); + expect(trigger).toHaveAttribute('data-disabled', ''); }); }); diff --git a/packages/headless/src/primitives/autocomplete/README.md b/packages/headless/src/primitives/autocomplete/README.md index 46e45ce37f3..a0b04ba7968 100644 --- a/packages/headless/src/primitives/autocomplete/README.md +++ b/packages/headless/src/primitives/autocomplete/README.md @@ -129,14 +129,13 @@ Navigation loops and auto-scrolls the active option into view. ## Data Attributes -| Attribute | Applies To | Description | -| --------------------------------- | ----------------- | --------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"autocomplete-input"`) | -| `data-cl-open` / `data-cl-closed` | Input | Popup open state | -| `data-cl-selected` | Option | The currently selected option | -| `data-cl-active` | Option | The keyboard-highlighted option | -| `data-cl-disabled` | Option | Disabled option | -| `data-cl-side` | Positioner, Arrow | Resolved placement side | +| Attribute | Applies To | Description | +| --------------------------- | ----------------- | ------------------------------- | +| `data-open` / `data-closed` | Input | 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, Arrow | Resolved placement side | ## Open/Close Behavior diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx index f1172044e17..9da7ecdbe63 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-arrow.tsx @@ -13,8 +13,7 @@ export function AutocompleteArrow(props: AutocompleteArrowProps) { return ( | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: mergeProps<'input'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx index 6820b00d6d1..90283f416df 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-list.tsx @@ -21,9 +21,7 @@ export const AutocompleteList = React.forwardRef; + const ownProps = {} satisfies DefaultProps<'div'>; const defaultProps = { ...ownProps, ...floatingProps }; diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx index 4d13f5525b7..e1a7fdda31f 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-option.tsx @@ -43,7 +43,6 @@ export const AutocompleteOption = React.forwardRef (v ? { 'data-cl-selected': '' } : null), - active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + selected: (v: boolean) => (v ? { 'data-selected': '' } : null), + active: (v: boolean) => (v ? { 'data-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: merged, }); diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx index 8acfaf2dff2..06d6a582fd3 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-popup.tsx @@ -13,7 +13,6 @@ export const AutocompletePopup = React.forwardRef; diff --git a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx index fd2eba8a36c..daa6f5581d5 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete-root.tsx @@ -101,7 +101,8 @@ function AutocompleteInner(props: AutocompleteProps) { flip({ padding: 5 }), size({ apply({ rects, availableHeight, elements }) { - if (elements.floating.getAttribute('data-cl-slot') !== 'autocomplete-positioner') { + // Only size the positioner (identified by its data-side), never the input reference. + if (!elements.floating.hasAttribute('data-side')) { return; } Object.assign(elements.floating.style, { diff --git a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx index a47f4aa662c..bbdad88c7ba 100644 --- a/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx +++ b/packages/headless/src/primitives/autocomplete/autocomplete.test.tsx @@ -36,13 +36,14 @@ function FilteredAutocomplete( onValueChange={props.onValueChange} > - - + + {filtered.map(f => ( {f.label} @@ -57,13 +58,14 @@ function StaticAutocomplete(props: Partial - - + + {fruits.map(f => ( {f.label} @@ -75,22 +77,6 @@ function StaticAutocomplete(props: Partial { - describe('slot attributes', () => { - it('renders input with data-cl-slot', () => { - render(); - const input = screen.getByPlaceholderText('Search fruits...'); - expect(input).toHaveAttribute('data-cl-slot', 'autocomplete-input'); - }); - - it('renders all parts with correct slot attributes when open', () => { - render(); - - expect(document.querySelector('[data-cl-slot="autocomplete-positioner"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument(); - expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4); - }); - }); - describe('open/close', () => { it('opens when user types', async () => { const user = userEvent.setup(); @@ -99,7 +85,7 @@ describe('Autocomplete', () => { const input = screen.getByPlaceholderText('Search fruits...'); await user.type(input, 'a'); - expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="autocomplete-popup"]')).toBeInTheDocument(); }); it('closes when input is cleared', async () => { @@ -109,11 +95,11 @@ describe('Autocomplete', () => { const input = screen.getByPlaceholderText('Search fruits...'); await user.type(input, 'a'); - expect(document.querySelector('[data-cl-slot="autocomplete-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="autocomplete-popup"]')).toBeInTheDocument(); await user.clear(input); - expect(input).toHaveAttribute('data-cl-closed', ''); + expect(input).toHaveAttribute('data-closed', ''); }); it('closes on Escape', async () => { @@ -124,7 +110,7 @@ describe('Autocomplete', () => { await user.type(input, 'a'); await user.keyboard('{Escape}'); - expect(input).toHaveAttribute('data-cl-closed', ''); + expect(input).toHaveAttribute('data-closed', ''); }); it('calls onOpenChange when toggled', async () => { @@ -147,7 +133,7 @@ describe('Autocomplete', () => { const input = screen.getByPlaceholderText('Search fruits...'); await user.type(input, 'ch'); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); expect(options).toHaveLength(1); expect(options[0]).toHaveTextContent('Cherry'); }); @@ -159,7 +145,7 @@ describe('Autocomplete', () => { const input = screen.getByPlaceholderText('Search fruits...'); await user.type(input, 'a'); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); expect(options).toHaveLength(1); // Only "Apple" starts with "a" }); }); @@ -197,7 +183,7 @@ describe('Autocomplete', () => { await user.type(input, 'b'); await user.click(screen.getByText('Banana')); - expect(input).toHaveAttribute('data-cl-closed', ''); + expect(input).toHaveAttribute('data-closed', ''); }); it('returns focus to input after click selection', async () => { @@ -221,7 +207,7 @@ describe('Autocomplete', () => { await user.type(input, 'a'); await user.keyboard('{ArrowDown}'); - const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const activeOption = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(activeOption).toBeInTheDocument(); }); @@ -263,18 +249,18 @@ describe('Autocomplete', () => { }); describe('option state attributes', () => { - it('marks active option with data-cl-active', async () => { + it('marks active option with data-active', async () => { const user = userEvent.setup(); render(); await user.type(screen.getByPlaceholderText('Search fruits...'), 'a'); // First option is active by default (activeIndex starts at 0) - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); - expect(options[0]).toHaveAttribute('data-cl-active', ''); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); + expect(options[0]).toHaveAttribute('data-active', ''); }); - it('marks selected option with data-cl-selected', () => { + it('marks selected option with data-selected', () => { render( { />, ); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); - expect(options[1]).toHaveAttribute('data-cl-selected', ''); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); + expect(options[1]).toHaveAttribute('data-selected', ''); }); }); @@ -319,32 +305,32 @@ describe('Autocomplete', () => { describe('animation lifecycle', () => { it('positioner is not rendered when closed', () => { render(); - expect(document.querySelector('[data-cl-slot="autocomplete-positioner"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="autocomplete-positioner"]')).not.toBeInTheDocument(); }); - it('applies data-cl-open on popup when open', async () => { + it('applies data-open on popup when open', async () => { const user = userEvent.setup(); render(); await user.type(screen.getByPlaceholderText('Search fruits...'), 'a'); - const popup = document.querySelector('[data-cl-slot="autocomplete-popup"]'); - expect(popup).toHaveAttribute('data-cl-open', ''); + const popup = document.querySelector('[data-testid="autocomplete-popup"]'); + expect(popup).toHaveAttribute('data-open', ''); }); - it('positioner has data-cl-side', async () => { + it('positioner has data-side', async () => { const user = userEvent.setup(); render(); await user.type(screen.getByPlaceholderText('Search fruits...'), 'a'); - const positioner = document.querySelector('[data-cl-slot="autocomplete-positioner"]'); - expect(positioner).toHaveAttribute('data-cl-side'); + const positioner = document.querySelector('[data-testid="autocomplete-positioner"]'); + expect(positioner).toHaveAttribute('data-side'); }); }); describe('disabled option', () => { - it('renders disabled option with data-cl-disabled', () => { + it('renders disabled option with data-disabled', () => { render( @@ -353,6 +339,7 @@ describe('Autocomplete', () => { Apple @@ -360,6 +347,7 @@ describe('Autocomplete', () => { value='banana' label='Banana' disabled + data-testid='autocomplete-option' > Banana @@ -368,8 +356,8 @@ describe('Autocomplete', () => { , ); - const disabledOption = screen.getByText('Banana').closest('[data-cl-slot="autocomplete-option"]'); - expect(disabledOption).toHaveAttribute('data-cl-disabled', ''); + const disabledOption = screen.getByText('Banana').closest('[data-testid="autocomplete-option"]'); + expect(disabledOption).toHaveAttribute('data-disabled', ''); expect(disabledOption).toHaveAttribute('aria-disabled', 'true'); }); @@ -419,12 +407,14 @@ describe('Autocomplete', () => { value='apple' label='Apple' disabled + data-testid='autocomplete-option' > Apple Banana @@ -436,7 +426,7 @@ describe('Autocomplete', () => { const input = screen.getByPlaceholderText('Search...'); // Typing opens the list and highlights the first option (the disabled "Apple"). await user.type(input, 'a'); - const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(active).toHaveTextContent('Apple'); await user.keyboard('{Enter}'); @@ -459,12 +449,13 @@ describe('Autocomplete', () => { onValueChange={props.onValueChange} > - + {filtered.map(f => ( {f.label} @@ -474,21 +465,21 @@ describe('Autocomplete', () => { ); } - it('renders options with data-cl-slot', () => { + it('renders all options', () => { render(); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); expect(options).toHaveLength(4); }); - it('renders list with data-cl-slot', () => { + it('renders list in inline mode', () => { render(); - expect(document.querySelector('[data-cl-slot="autocomplete-list"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="autocomplete-list"]')).toBeInTheDocument(); }); - it('marks selected option with data-cl-selected via controlled value', () => { + it('marks selected option with data-selected via controlled value', () => { render(); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); - expect(options[1]).toHaveAttribute('data-cl-selected', ''); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); + expect(options[1]).toHaveAttribute('data-selected', ''); }); it('selects option on click', async () => { @@ -509,7 +500,7 @@ describe('Autocomplete', () => { await user.click(input); await user.keyboard('{ArrowDown}'); - const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const activeOption = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(activeOption).toBeInTheDocument(); }); @@ -517,7 +508,7 @@ describe('Autocomplete', () => { render(); const input = screen.getByRole('combobox'); - const list = document.querySelector('[data-cl-slot="autocomplete-list"]'); + const list = document.querySelector('[data-testid="autocomplete-list"]'); expect(list).toHaveAttribute('id'); expect(input).toHaveAttribute('aria-controls', list?.getAttribute('id')); @@ -527,10 +518,14 @@ describe('Autocomplete', () => { render( - + Apple @@ -539,7 +534,7 @@ describe('Autocomplete', () => { ); const input = screen.getByRole('combobox'); - const list = document.querySelector('[data-cl-slot="autocomplete-list"]'); + const list = document.querySelector('[data-testid="autocomplete-list"]'); // The listbox id is owned by floating-ui: a consumer-supplied id must not // override it, or the input's aria-controls pairing would silently break. @@ -555,7 +550,7 @@ describe('Autocomplete', () => { await user.click(input); await user.keyboard('{ArrowDown}'); - const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const activeOption = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(activeOption).toHaveAttribute('id'); expect(input).toHaveAttribute('aria-activedescendant', activeOption?.getAttribute('id')); }); @@ -579,7 +574,7 @@ describe('Autocomplete', () => { const input = screen.getByPlaceholderText('Search fruits...'); await user.type(input, 'ch'); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); expect(options).toHaveLength(1); expect(options[0]).toHaveTextContent('Cherry'); }); @@ -587,15 +582,15 @@ describe('Autocomplete', () => { it('preserves selected state after unmount and remount', () => { const { unmount } = render(); - let options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); - expect(options[2]).toHaveAttribute('data-cl-selected', ''); + let options = document.querySelectorAll('[data-testid="autocomplete-option"]'); + expect(options[2]).toHaveAttribute('data-selected', ''); unmount(); render(); - options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); - expect(options[2]).toHaveAttribute('data-cl-selected', ''); + options = document.querySelectorAll('[data-testid="autocomplete-option"]'); + expect(options[2]).toHaveAttribute('data-selected', ''); }); it('shows selected state after selecting then remounting', async () => { @@ -628,13 +623,13 @@ describe('Autocomplete', () => { // Unmount (simulates popover close) await user.click(screen.getByTestId('toggle')); - expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="autocomplete-option"]')).not.toBeInTheDocument(); // Remount (simulates popover reopen) await user.click(screen.getByTestId('toggle')); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); - expect(options[1]).toHaveAttribute('data-cl-selected', ''); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); + expect(options[1]).toHaveAttribute('data-selected', ''); }); }); @@ -656,7 +651,7 @@ describe('Autocomplete', () => { } }} > - {selectedLabel || 'Pick a fruit...'} + {selectedLabel || 'Pick a fruit...'} { }} > - + {filtered.map(f => ( {f.label} @@ -694,7 +690,7 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Pick a fruit...')); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); expect(options).toHaveLength(4); }); @@ -705,7 +701,7 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Pick a fruit...')); const input = screen.getByRole('combobox'); - const list = document.querySelector('[data-cl-slot="autocomplete-list"]'); + const list = document.querySelector('[data-testid="autocomplete-list"]'); expect(list).toHaveAttribute('id'); expect(input).toHaveAttribute('aria-controls', list?.getAttribute('id')); @@ -718,7 +714,7 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Pick a fruit...')); // Verify options rendered and input has focus - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); expect(options.length).toBeGreaterThan(0); const input = screen.getByPlaceholderText('Search...'); @@ -726,7 +722,7 @@ describe('Autocomplete', () => { await user.keyboard('{ArrowDown}'); - const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const activeOption = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(activeOption).toBeInTheDocument(); }); @@ -738,10 +734,10 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Banana')); expect(screen.getByText('Banana')).toBeInTheDocument(); - expect(screen.getByText('Banana').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument(); + expect(screen.getByText('Banana').closest('[data-testid="popover-trigger"]')).toBeInTheDocument(); }); - it('shows data-cl-selected on previously selected option after reopen', async () => { + it('shows data-selected on previously selected option after reopen', async () => { const user = userEvent.setup(); render(); @@ -752,9 +748,9 @@ describe('Autocomplete', () => { // Reopen await user.click(screen.getByText('Cherry')); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); const cherryOption = Array.from(options).find(o => o.textContent === 'Cherry'); - expect(cherryOption).toHaveAttribute('data-cl-selected', ''); + expect(cherryOption).toHaveAttribute('data-selected', ''); }); it('selects option with Enter after arrow navigation inside popover', async () => { @@ -765,7 +761,7 @@ describe('Autocomplete', () => { await user.keyboard('{ArrowDown}{Enter}'); // Should have selected the first option - expect(screen.getByText('Apple').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument(); + expect(screen.getByText('Apple').closest('[data-testid="popover-trigger"]')).toBeInTheDocument(); }); }); @@ -798,7 +794,7 @@ describe('Autocomplete', () => { } }} > - {selectedLabel || 'Pick a fruit...'} + {selectedLabel || 'Pick a fruit...'} { }} > - + {filtered.map(f => ( {f.label} @@ -837,11 +837,11 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Pick a fruit...')); await user.keyboard('{ArrowDown}'); - const activeOption = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const activeOption = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(activeOption).toBeInTheDocument(); }); - it('selected item has data-cl-active on reopen', async () => { + it('selected item has data-active on reopen', async () => { const user = userEvent.setup(); render(); @@ -850,12 +850,12 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Grape')); // Reopen — trigger now shows "Grape" - await user.click(screen.getByText('Grape').closest('[data-cl-slot="popover-trigger"]')!); + await user.click(screen.getByText('Grape').closest('[data-testid="popover-trigger"]')!); // The selected option should be active - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); const grapeOption = Array.from(options).find(o => o.textContent === 'Grape'); - expect(grapeOption).toHaveAttribute('data-cl-active', ''); + expect(grapeOption).toHaveAttribute('data-active', ''); }); it('selected item is active on open when defaultValue is set', async () => { @@ -864,9 +864,9 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Grape')); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); const grapeOption = Array.from(options).find(o => o.textContent === 'Grape'); - expect(grapeOption).toHaveAttribute('data-cl-active', ''); + expect(grapeOption).toHaveAttribute('data-active', ''); }); }); @@ -888,7 +888,7 @@ describe('Autocomplete', () => { } }} > - {selectedLabel || 'Pick a fruit...'} + {selectedLabel || 'Pick a fruit...'} { }} > - + {filtered.map(f => ( {f.label} @@ -926,12 +927,12 @@ describe('Autocomplete', () => { const trigger = screen.getByText('Pick a fruit...'); await user.click(trigger); - expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]').length).toBeGreaterThan(0); + expect(document.querySelectorAll('[data-testid="autocomplete-option"]').length).toBeGreaterThan(0); await user.keyboard('{Escape}'); // Popover should close — no options visible - expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="autocomplete-option"]')).not.toBeInTheDocument(); expect(document.activeElement).toBe(trigger); }); @@ -972,7 +973,7 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Banana')); const input = screen.getByPlaceholderText('Search...'); - const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(document.activeElement).toBe(input); expect(active).toHaveTextContent('Banana'); @@ -1001,7 +1002,7 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Banana')); // Banana (index 1) should be active, not Apple (index 0) - const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(active).toHaveTextContent('Banana'); }); @@ -1014,7 +1015,7 @@ describe('Autocomplete', () => { await user.type(input, 'ch'); // Should be filtered to Cherry only - expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(1); + expect(document.querySelectorAll('[data-testid="autocomplete-option"]')).toHaveLength(1); await user.keyboard('{Escape}'); @@ -1024,7 +1025,7 @@ describe('Autocomplete', () => { // Input should be cleared, all options visible const newInput = screen.getByPlaceholderText('Search...'); expect((newInput as HTMLInputElement).value).toBe(''); - expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4); + expect(document.querySelectorAll('[data-testid="autocomplete-option"]')).toHaveLength(4); }); it('navigates all options with repeated ArrowDown', async () => { @@ -1034,19 +1035,19 @@ describe('Autocomplete', () => { await user.click(screen.getByText('Pick a fruit...')); await user.keyboard('{ArrowDown}'); - let active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + let active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(active).toHaveTextContent('Apple'); await user.keyboard('{ArrowDown}'); - active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(active).toHaveTextContent('Banana'); await user.keyboard('{ArrowDown}'); - active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(active).toHaveTextContent('Cherry'); await user.keyboard('{ArrowDown}'); - active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(active).toHaveTextContent('Date'); }); @@ -1058,7 +1059,7 @@ describe('Autocomplete', () => { // Navigate past all 4 options to loop back await user.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}'); - const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(active).toHaveTextContent('Apple'); }); @@ -1070,7 +1071,7 @@ describe('Autocomplete', () => { // ArrowUp from no active item should go to last item (loop) await user.keyboard('{ArrowUp}'); - const active = document.querySelector('[data-cl-slot="autocomplete-option"][data-cl-active]'); + const active = document.querySelector('[data-testid="autocomplete-option"][data-active]'); expect(active).toHaveTextContent('Date'); }); @@ -1084,12 +1085,12 @@ describe('Autocomplete', () => { await user.type(input, 'b'); // Only Banana should be shown, activeIndex should be 0 - expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(1); + expect(document.querySelectorAll('[data-testid="autocomplete-option"]')).toHaveLength(1); await user.keyboard('{Enter}'); // Popover should close and trigger should show Banana - expect(screen.getByText('Banana').closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument(); + expect(screen.getByText('Banana').closest('[data-testid="popover-trigger"]')).toBeInTheDocument(); }); it('selects the highlighted item with Enter, closes the popover, and returns focus to the trigger', async () => { @@ -1101,8 +1102,8 @@ describe('Autocomplete', () => { await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); const updatedTrigger = screen.getByText('Banana'); - expect(updatedTrigger.closest('[data-cl-slot="popover-trigger"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="autocomplete-option"]')).not.toBeInTheDocument(); + expect(updatedTrigger.closest('[data-testid="popover-trigger"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="autocomplete-option"]')).not.toBeInTheDocument(); expect(document.activeElement).toBe(updatedTrigger); }); @@ -1131,7 +1132,7 @@ describe('Autocomplete', () => { const input = screen.getByPlaceholderText('Search...'); await user.type(input, 'd'); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); expect(options).toHaveLength(1); expect(options[0]).toHaveTextContent('Date'); }); @@ -1150,10 +1151,10 @@ describe('Autocomplete', () => { // Input should be empty, all options should show const input = screen.getByPlaceholderText('Search...'); expect((input as HTMLInputElement).value).toBe(''); - expect(document.querySelectorAll('[data-cl-slot="autocomplete-option"]')).toHaveLength(4); + expect(document.querySelectorAll('[data-testid="autocomplete-option"]')).toHaveLength(4); }); - it('selected option retains data-cl-selected on reopen', async () => { + it('selected option retains data-selected on reopen', async () => { const user = userEvent.setup(); render(); @@ -1163,28 +1164,28 @@ describe('Autocomplete', () => { // Reopen await user.click(screen.getByText('Banana')); - const options = document.querySelectorAll('[data-cl-slot="autocomplete-option"]'); + const options = document.querySelectorAll('[data-testid="autocomplete-option"]'); const bananaOption = Array.from(options).find(o => o.textContent === 'Banana'); - expect(bananaOption).toHaveAttribute('data-cl-selected', ''); + expect(bananaOption).toHaveAttribute('data-selected', ''); }); }); describe('input state attributes', () => { - it('input has data-cl-open when list is visible', async () => { + it('input has data-open when list is visible', async () => { const user = userEvent.setup(); render(); const input = screen.getByPlaceholderText('Search fruits...'); await user.type(input, 'a'); - expect(input).toHaveAttribute('data-cl-open', ''); + expect(input).toHaveAttribute('data-open', ''); }); - it('input has data-cl-closed when list is hidden', () => { + it('input has data-closed when list is hidden', () => { render(); const input = screen.getByPlaceholderText('Search fruits...'); - expect(input).toHaveAttribute('data-cl-closed', ''); + expect(input).toHaveAttribute('data-closed', ''); }); }); diff --git a/packages/headless/src/primitives/collapsible/README.md b/packages/headless/src/primitives/collapsible/README.md index 3382bf6c8cd..c32ad10f71d 100644 --- a/packages/headless/src/primitives/collapsible/README.md +++ b/packages/headless/src/primitives/collapsible/README.md @@ -73,12 +73,11 @@ All parts accept a `render` prop for polymorphic rendering and standard HTML att ## Data Attributes -| Attribute | Applies To | Description | -| ------------------ | -------------------- | -------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"collapsible-panel"`) | -| `data-cl-open` | Root, Trigger, Panel | Present when the panel is open | -| `data-cl-closed` | Root, Trigger, Panel | Present when the panel is closed | -| `data-cl-disabled` | Root, Trigger | Present when disabled | +| Attribute | Applies To | Description | +| --------------- | -------------------- | -------------------------------- | +| `data-open` | Root, Trigger, Panel | Present when the panel is open | +| `data-closed` | Root, Trigger, Panel | Present when the panel is closed | +| `data-disabled` | Root, Trigger | Present when disabled | ## CSS Animation @@ -92,12 +91,12 @@ All parts accept a `render` prop for polymorphic rendering and standard HTML att Use these for height/width-based animations: ```css -[data-cl-slot='collapsible-panel'] { +.cl-collapsible-panel { overflow: hidden; height: var(--collapsible-panel-height); transition: height 200ms ease; } -[data-cl-slot='collapsible-panel'][data-cl-closed] { +.cl-collapsible-panel[data-closed] { height: 0; } ``` diff --git a/packages/headless/src/primitives/collapsible/collapsible-panel.tsx b/packages/headless/src/primitives/collapsible/collapsible-panel.tsx index 65257b9fb9d..cd9a77cdbf0 100644 --- a/packages/headless/src/primitives/collapsible/collapsible-panel.tsx +++ b/packages/headless/src/primitives/collapsible/collapsible-panel.tsx @@ -95,13 +95,12 @@ export const CollapsiblePanel = React.forwardRef = { - 'data-cl-slot': 'collapsible-panel', id: panelId, role: 'region' as const, 'aria-labelledby': triggerId, @@ -125,7 +124,7 @@ export const CollapsiblePanel = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: merged, }); diff --git a/packages/headless/src/primitives/collapsible/collapsible-root.tsx b/packages/headless/src/primitives/collapsible/collapsible-root.tsx index eccdbd10aa8..75556c31508 100644 --- a/packages/headless/src/primitives/collapsible/collapsible-root.tsx +++ b/packages/headless/src/primitives/collapsible/collapsible-root.tsx @@ -35,7 +35,6 @@ export function CollapsibleRoot(props: CollapsibleProps) { const state = { open, disabled }; const defaultProps: Record = { - 'data-cl-slot': 'collapsible-root', children, }; @@ -46,8 +45,8 @@ export function CollapsibleRoot(props: CollapsibleProps) { render, state, stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), })} diff --git a/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx b/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx index c034db78fb7..96523fd53bc 100644 --- a/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx +++ b/packages/headless/src/primitives/collapsible/collapsible-trigger.tsx @@ -12,7 +12,6 @@ export function CollapsibleTrigger(props: CollapsibleTriggerProps) { const state = { open, disabled }; const defaultProps: Record = { - 'data-cl-slot': 'collapsible-trigger', id: triggerId, type: 'button' as const, 'aria-expanded': open, @@ -35,8 +34,8 @@ export function CollapsibleTrigger(props: CollapsibleTriggerProps) { render, state, stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: merged, }); diff --git a/packages/headless/src/primitives/collapsible/collapsible.test.tsx b/packages/headless/src/primitives/collapsible/collapsible.test.tsx index e56cc09316d..869b1c477d5 100644 --- a/packages/headless/src/primitives/collapsible/collapsible.test.tsx +++ b/packages/headless/src/primitives/collapsible/collapsible.test.tsx @@ -9,31 +9,17 @@ afterEach(() => cleanup()); function renderCollapsible(props: Partial> = {}) { return render( - - Toggle - Content + + Toggle + Content , ); } describe('Collapsible', () => { - describe('slot attributes', () => { - it('renders root with data-cl-slot', () => { - renderCollapsible(); - expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toBeInTheDocument(); - }); - - it('renders trigger with data-cl-slot', () => { - renderCollapsible(); - expect(document.querySelector('[data-cl-slot="collapsible-trigger"]')).toBeInTheDocument(); - }); - - it('renders panel with data-cl-slot when open', () => { - renderCollapsible({ defaultOpen: true }); - expect(document.querySelector('[data-cl-slot="collapsible-panel"]')).toBeInTheDocument(); - }); - }); - describe('open/close', () => { it('opens panel on trigger click', async () => { const user = userEvent.setup(); @@ -42,7 +28,7 @@ describe('Collapsible', () => { await user.click(screen.getByRole('button', { name: 'Toggle' })); expect(screen.getByText('Content')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-open', ''); + expect(document.querySelector('[data-testid="collapsible-root"]')).toHaveAttribute('data-open', ''); }); it('closes panel on second trigger click', async () => { @@ -51,7 +37,7 @@ describe('Collapsible', () => { await user.click(screen.getByRole('button', { name: 'Toggle' })); - expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-closed', ''); + expect(document.querySelector('[data-testid="collapsible-root"]')).toHaveAttribute('data-closed', ''); }); it('calls onOpenChange when toggled', async () => { @@ -67,7 +53,7 @@ describe('Collapsible', () => { it('starts closed by default', () => { renderCollapsible(); expect(screen.queryByText('Content')).not.toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-closed', ''); + expect(document.querySelector('[data-testid="collapsible-root"]')).toHaveAttribute('data-closed', ''); }); it('starts open with defaultOpen=true', () => { @@ -106,14 +92,14 @@ describe('Collapsible', () => { it('trigger has aria-controls linked to panel id', () => { renderCollapsible({ defaultOpen: true }); const trigger = screen.getByRole('button', { name: 'Toggle' }); - const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + const panel = document.querySelector('[data-testid="collapsible-panel"]'); expect(trigger).toHaveAttribute('aria-controls', panel?.getAttribute('id')); }); it('panel has aria-labelledby linked to trigger id', () => { renderCollapsible({ defaultOpen: true }); const trigger = screen.getByRole('button', { name: 'Toggle' }); - const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + const panel = document.querySelector('[data-testid="collapsible-panel"]'); expect(panel).toHaveAttribute('aria-labelledby', trigger.getAttribute('id')); }); @@ -126,11 +112,11 @@ describe('Collapsible', () => { render( Toggle - Content + Content , ); const trigger = screen.getByRole('button', { name: 'Toggle' }); - const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); + const panel = document.querySelector('[data-testid="collapsible-panel"]'); // The wired ids are owned by the primitive: a consumer-supplied id must // not silently break the aria pairing between trigger and panel. @@ -142,19 +128,19 @@ describe('Collapsible', () => { describe('animation lifecycle', () => { it('panel is not in DOM when closed', () => { renderCollapsible(); - expect(document.querySelector('[data-cl-slot="collapsible-panel"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="collapsible-panel"]')).not.toBeInTheDocument(); }); - it('applies data-cl-open on panel when open', () => { + it('applies data-open on panel when open', () => { renderCollapsible({ defaultOpen: true }); - const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); - expect(panel).toHaveAttribute('data-cl-open', ''); + const panel = document.querySelector('[data-testid="collapsible-panel"]'); + expect(panel).toHaveAttribute('data-open', ''); }); it('does not apply starting-style on initially open panel', () => { renderCollapsible({ defaultOpen: true }); - const panel = document.querySelector('[data-cl-slot="collapsible-panel"]'); - expect(panel).not.toHaveAttribute('data-cl-starting-style'); + const panel = document.querySelector('[data-testid="collapsible-panel"]'); + expect(panel).not.toHaveAttribute('data-starting-style'); }); it('pins --collapsible-panel-height/width while the open animation is in progress', () => { @@ -165,7 +151,7 @@ describe('Collapsible', () => { ]; try { renderCollapsible({ defaultOpen: true }); - const panel = document.querySelector('[data-cl-slot="collapsible-panel"]') as HTMLElement; + const panel = document.querySelector('[data-testid="collapsible-panel"]') as HTMLElement; expect(panel.getAttribute('style')).toContain('--collapsible-panel-height'); expect(panel.getAttribute('style')).toContain('--collapsible-panel-width'); } finally { @@ -181,7 +167,7 @@ describe('Collapsible', () => { // With no running animations the panel drops the measured pixel dimensions so // it flows naturally with its content. renderCollapsible({ defaultOpen: true }); - const panel = document.querySelector('[data-cl-slot="collapsible-panel"]') as HTMLElement; + const panel = document.querySelector('[data-testid="collapsible-panel"]') as HTMLElement; const style = panel.getAttribute('style') ?? ''; expect(style).not.toContain('--collapsible-panel-height'); expect(style).not.toContain('--collapsible-panel-width'); @@ -204,10 +190,10 @@ describe('Collapsible', () => { expect(screen.getByRole('button', { name: 'Toggle' })).toHaveAttribute('aria-disabled', 'true'); }); - it('applies data-cl-disabled on root and trigger', () => { + it('applies data-disabled on root and trigger', () => { renderCollapsible({ disabled: true }); - expect(document.querySelector('[data-cl-slot="collapsible-root"]')).toHaveAttribute('data-cl-disabled', ''); - expect(document.querySelector('[data-cl-slot="collapsible-trigger"]')).toHaveAttribute('data-cl-disabled', ''); + expect(document.querySelector('[data-testid="collapsible-root"]')).toHaveAttribute('data-disabled', ''); + expect(document.querySelector('[data-testid="collapsible-trigger"]')).toHaveAttribute('data-disabled', ''); }); }); diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index 49c02236eaf..43a3a6614e0 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -97,11 +97,11 @@ No additional props beyond standard HTML attributes and the `render` prop. ## Data Attributes -| Attribute | Applies To | Description | -| --------------------------------- | ---------------------------------- | ----------- | -| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state | +| Attribute | Applies To | Description | +| --------------------------- | ---------------------------------- | ----------- | +| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state | -Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts. +The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above. ## Important Notes diff --git a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx index 59b28aea094..00aee9a2431 100644 --- a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx +++ b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx @@ -27,7 +27,7 @@ export const DialogBackdrop = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: mergeProps<'div'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/dialog/dialog-trigger.tsx b/packages/headless/src/primitives/dialog/dialog-trigger.tsx index 33cc82b4263..97dcc3c6cae 100644 --- a/packages/headless/src/primitives/dialog/dialog-trigger.tsx +++ b/packages/headless/src/primitives/dialog/dialog-trigger.tsx @@ -32,7 +32,7 @@ export const DialogTrigger = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: mergeProps<'button'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/dialog/dialog-viewport.tsx b/packages/headless/src/primitives/dialog/dialog-viewport.tsx index 00303b17604..5fd3868c423 100644 --- a/packages/headless/src/primitives/dialog/dialog-viewport.tsx +++ b/packages/headless/src/primitives/dialog/dialog-viewport.tsx @@ -39,7 +39,7 @@ export const DialogViewport = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: mergeProps<'div'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/dialog/dialog.test.tsx b/packages/headless/src/primitives/dialog/dialog.test.tsx index e3103375e17..d0c1be1124e 100644 --- a/packages/headless/src/primitives/dialog/dialog.test.tsx +++ b/packages/headless/src/primitives/dialog/dialog.test.tsx @@ -36,7 +36,7 @@ describe('Dialog', () => { const trigger = screen.getByRole('button', { name: 'Open dialog' }); await user.click(trigger); - expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(trigger).toHaveAttribute('data-open', ''); expect(screen.getByRole('dialog')).toBeInTheDocument(); }); @@ -47,7 +47,7 @@ describe('Dialog', () => { await user.keyboard('{Escape}'); const trigger = screen.getByRole('button', { name: 'Open dialog' }); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); it('closes via Close button', async () => { @@ -58,7 +58,7 @@ describe('Dialog', () => { await user.click(closeBtn); const trigger = screen.getByRole('button', { name: 'Open dialog' }); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); it('calls onOpenChange when toggled', async () => { @@ -124,31 +124,31 @@ describe('Dialog', () => { expect(screen.queryByTestId('dialog-backdrop')).not.toBeInTheDocument(); }); - it('applies data-cl-open on popup when open', async () => { + it('applies data-open on popup when open', async () => { const user = userEvent.setup(); renderDialog(); await user.click(screen.getByRole('button', { name: 'Open dialog' })); - expect(screen.getByRole('dialog')).toHaveAttribute('data-cl-open', ''); + expect(screen.getByRole('dialog')).toHaveAttribute('data-open', ''); }); - it('applies data-cl-open on backdrop when open', async () => { + it('applies data-open on backdrop when open', async () => { const user = userEvent.setup(); renderDialog(); await user.click(screen.getByRole('button', { name: 'Open dialog' })); - expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-cl-open', ''); + expect(screen.getByTestId('dialog-backdrop')).toHaveAttribute('data-open', ''); }); - it('applies data-cl-open on viewport when open', async () => { + it('applies data-open on viewport when open', async () => { const user = userEvent.setup(); renderDialog(); await user.click(screen.getByRole('button', { name: 'Open dialog' })); - expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-cl-open', ''); + expect(screen.getByTestId('dialog-viewport')).toHaveAttribute('data-open', ''); }); it('viewport is not rendered when closed', () => { @@ -196,17 +196,17 @@ describe('Dialog', () => { }); describe('trigger state attributes', () => { - it('trigger has data-cl-closed when dialog is hidden', () => { + it('trigger has data-closed when dialog is hidden', () => { renderDialog(); const trigger = screen.getByRole('button', { name: 'Open dialog' }); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); - it('trigger has data-cl-open when dialog is visible', () => { + it('trigger has data-open when dialog is visible', () => { renderDialog({ defaultOpen: true }); // When modal is open, the trigger's container gets aria-hidden, so query by test id. const trigger = screen.getByTestId('dialog-trigger'); - expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(trigger).toHaveAttribute('data-open', ''); }); }); diff --git a/packages/headless/src/primitives/drawer/README.md b/packages/headless/src/primitives/drawer/README.md index adbab4164a8..b6d71ac5034 100644 --- a/packages/headless/src/primitives/drawer/README.md +++ b/packages/headless/src/primitives/drawer/README.md @@ -5,7 +5,7 @@ awareness, and nesting. Built on the same Floating UI infrastructure as `Dialog` trap, scroll lock, dismiss, `FloatingTree` nesting, enter/exit transitions) with a hand-rolled pointer/transform drag engine layered on top. -The headless layer emits **raw** CSS custom properties and `data-cl-*` attributes and ships **zero +The headless layer emits **raw** CSS custom properties and `data-*` attributes and ships **zero CSS**. The styled (mosaic) layer composes the actual `transform` / `opacity` / `calc()` chains from those inputs. @@ -170,20 +170,20 @@ properties via `registerDrawerCssVars()` (a no-op where `CSS.registerProperty` i ### Data attributes -| Attribute | Applies to | Meaning | -| ------------------------------------------------- | ---------------------------------- | ------------------------------- | -| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Open state | -| `data-cl-starting-style` / `data-cl-ending-style` | Backdrop, Viewport, Popup | Enter / exit transition phase | -| `data-cl-swiping` | Popup, Backdrop | A drag is in progress | -| `data-cl-snap` | Popup | Active snap index | -| `data-cl-expanded` | Popup | Resting at the full-height snap | -| `data-cl-nested` | Popup | This drawer is itself nested | -| `data-cl-nested-drawer-open` | Popup | A nested child is open | -| `data-cl-nested-drawer-swiping` | Popup | A nested child is being dragged | -| `data-cl-drawer-handle` | Handle | Grip / `handleOnly` hit-test | -| `data-cl-drawer-no-drag` | (consumer-set) | Opt a subtree out of dragging | - -Slot identity (`data-cl-slot`) is applied by the styled (mosaic) layer, not by the headless parts. +| 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 | + +The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above. ### Nested drawer animation (styled-layer recipe) @@ -192,30 +192,30 @@ displacement constant. `--cl-drawer-nested-drag-progress` is `0` at the scaled-b full size, so the same var drives both the enter/exit scale-back and the live drag coupling: ```css -[data-cl-slot='drawer-popup'] { +.cl-drawer-popup { --rest-scale: calc((100vw - 16px) / 100vw); /* vaul's NESTED_DISPLACEMENT = 16px */ transform-origin: center top; transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1); } /* A child is open: scale = lerp(rest-scale, 1, progress); translateY = lerp(-16px, 0, progress). */ -[data-cl-slot='drawer-popup'][data-cl-nested-drawer-open] { +.cl-drawer-popup[data-nested-drawer-open] { --p: var(--cl-drawer-nested-drag-progress); transform: translateY(calc(-16px * (1 - var(--p)))) scale(calc(var(--rest-scale) + (1 - var(--rest-scale)) * var(--p))); } /* While the child drags, follow the finger 1:1 (no animation). */ -[data-cl-slot='drawer-popup'][data-cl-nested-drawer-swiping] { +.cl-drawer-popup[data-nested-drawer-swiping] { transition: none; } ``` Lifecycle of the driving signals (all handled by the headless layer): -- **Child opens** → `data-cl-nested-drawer-open` is set and `--cl-drawer-nested-drag-progress` is +- **Child opens** → `data-nested-drawer-open` is set and `--cl-drawer-nested-drag-progress` is reset to `0`, so the parent animates from full to the scaled-back rest. -- **Child drags** → `data-cl-nested-drawer-swiping` is set and progress tracks the drag `0..1`, so the +- **Child drags** → `data-nested-drawer-swiping` is set and progress tracks the drag `0..1`, so the parent follows the finger back toward full. - **Child releases** → swiping clears and progress settles to `0` (child stays open) or `1` (child dismisses). The dismiss target matches the open-count dropping, so the scale animates in one @@ -230,9 +230,9 @@ Lifecycle of the driving signals (all handled by the headless layer): - **`Drawer.Handle` is presentational** (no ARIA role). Keyboard users dismiss via `Escape` / `Drawer.Close`; add your own `role` / `aria-*` (via props or `render`) if you need it announced. - **Drag never hijacks form controls.** `shouldDrag` excludes `` internally, so you never render one yourself. It -emits zero styles — everything is driven by `data-cl-*` attributes. +emits zero styles — everything is driven by `data-*` attributes. ## Usage @@ -147,13 +147,12 @@ standard HTML attributes for their default element. ## Data Attributes -| Attribute | Applies To | Description | -| ------------------ | ----------------------------------- | ----------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"file-upload-dropzone"`) | -| `data-cl-empty` | Root | Present when no files are selected | -| `data-cl-dragging` | Dropzone | Present while a valid drag is over the zone | -| `data-cl-image` | Item | Present when the item's file is an image | -| `data-cl-disabled` | Root, Trigger, Dropzone, ItemDelete | Present when disabled | +| Attribute | Applies To | Description | +| --------------- | ----------------------------------- | ------------------------------------------- | +| `data-empty` | Root | Present when no files are selected | +| `data-dragging` | Dropzone | Present while a valid drag is over the zone | +| `data-image` | Item | Present when the item's file is an image | +| `data-disabled` | Root, Trigger, Dropzone, ItemDelete | Present when disabled | ## ARIA diff --git a/packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx b/packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx index 1da90680ea9..dbd015b6762 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-dropzone.tsx @@ -19,7 +19,6 @@ export function FileUploadDropzone(props: FileUploadDropzoneProps) { const state = { dragging, disabled }; const defaultProps: Record = { - 'data-cl-slot': 'file-upload-dropzone', 'aria-disabled': disabled || undefined, onDragEnter: (event: DragEvent) => { event.preventDefault(); @@ -66,8 +65,8 @@ export function FileUploadDropzone(props: FileUploadDropzoneProps) { render, state, stateAttributesMapping: { - dragging: (v: boolean) => (v ? { 'data-cl-dragging': '' } : null), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + dragging: (v: boolean) => (v ? { 'data-dragging': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx b/packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx index 3e8138d5680..6411db7d6de 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-item-delete.tsx @@ -13,7 +13,6 @@ export function FileUploadItemDelete(props: FileUploadItemDeleteProps) { const state = { disabled }; const defaultProps: Record = { - 'data-cl-slot': 'file-upload-item-delete', type: 'button' as const, disabled, onClick: () => { @@ -28,7 +27,7 @@ export function FileUploadItemDelete(props: FileUploadItemDeleteProps) { render, state, stateAttributesMapping: { - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergeProps<'button'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx b/packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx index bdd30807d6e..0e7e082f5c0 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-item-preview.tsx @@ -33,7 +33,6 @@ export const FileUploadItemPreview = React.forwardRef = { - 'data-cl-slot': 'file-upload-item-preview', src: objectUrl, alt: file.name, }; diff --git a/packages/headless/src/primitives/file-upload/file-upload-item.tsx b/packages/headless/src/primitives/file-upload/file-upload-item.tsx index 8706328f387..f433f4ccfc5 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-item.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-item.tsx @@ -20,9 +20,7 @@ export function FileUploadItem(props: FileUploadItemProps) { const state = { image: file.type.startsWith('image/') }; - const defaultProps: Record = { - 'data-cl-slot': 'file-upload-item', - }; + const defaultProps: Record = {}; return ( @@ -31,7 +29,7 @@ export function FileUploadItem(props: FileUploadItemProps) { render, state, stateAttributesMapping: { - image: (v: boolean) => (v ? { 'data-cl-image': '' } : null), + image: (v: boolean) => (v ? { 'data-image': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), })} diff --git a/packages/headless/src/primitives/file-upload/file-upload-root.tsx b/packages/headless/src/primitives/file-upload/file-upload-root.tsx index 48458999567..0510be56553 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-root.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-root.tsx @@ -137,7 +137,6 @@ export function FileUploadRoot(props: FileUploadProps) { const state = { disabled, empty: files.length === 0 }; const defaultProps: Record = { - 'data-cl-slot': 'file-upload-root', children: ( <> { const list = event.currentTarget.files; @@ -175,8 +173,8 @@ export function FileUploadRoot(props: FileUploadProps) { render, state, stateAttributesMapping: { - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), - empty: (v: boolean) => (v ? { 'data-cl-empty': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + empty: (v: boolean) => (v ? { 'data-empty': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), })} diff --git a/packages/headless/src/primitives/file-upload/file-upload-trigger.tsx b/packages/headless/src/primitives/file-upload/file-upload-trigger.tsx index 3eefb0a1356..8178c884c46 100644 --- a/packages/headless/src/primitives/file-upload/file-upload-trigger.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload-trigger.tsx @@ -12,7 +12,6 @@ export function FileUploadTrigger(props: FileUploadTriggerProps) { const state = { disabled }; const defaultProps: Record = { - 'data-cl-slot': 'file-upload-trigger', type: 'button' as const, disabled, onClick: () => openFilePicker(), @@ -23,7 +22,7 @@ export function FileUploadTrigger(props: FileUploadTriggerProps) { render, state, stateAttributesMapping: { - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergeProps<'button'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/file-upload/file-upload.test.tsx b/packages/headless/src/primitives/file-upload/file-upload.test.tsx index 8ebd0a9d66c..1022cdcdd5d 100644 --- a/packages/headless/src/primitives/file-upload/file-upload.test.tsx +++ b/packages/headless/src/primitives/file-upload/file-upload.test.tsx @@ -14,9 +14,12 @@ function makeFile(name: string, type: string) { /** A default composition exercising every part, driven by the live file list. */ function Harness(props: Partial> = {}) { return ( - - - Choose files + + + Choose files Drag files here @@ -31,7 +34,7 @@ function Previews() { {files.map(file => (
  • - + {file.name} Remove {file.name} @@ -42,30 +45,11 @@ function Previews() { } describe('FileUpload', () => { - describe('slot attributes', () => { - it('renders root, dropzone, trigger, and hidden input slots', () => { - render(); - expect(document.querySelector('[data-cl-slot="file-upload-root"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="file-upload-dropzone"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="file-upload-trigger"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="file-upload-input"]')).toBeInTheDocument(); - }); - - it('marks the root empty until a file is selected', () => { - render(); - expect(document.querySelector('[data-cl-slot="file-upload-root"]')).not.toHaveAttribute('data-cl-empty'); - - cleanup(); - render(); - expect(document.querySelector('[data-cl-slot="file-upload-root"]')).toHaveAttribute('data-cl-empty', ''); - }); - }); - describe('trigger', () => { it('opens the native picker by clicking the hidden input', async () => { const user = userEvent.setup(); render(); - const input = document.querySelector('[data-cl-slot="file-upload-input"]') as HTMLInputElement; + const input = document.querySelector('input[type="file"]') as HTMLInputElement; const clickSpy = vi.spyOn(input, 'click'); await user.click(screen.getByRole('button', { name: 'Choose files' })); @@ -76,7 +60,7 @@ describe('FileUpload', () => { it('adds files chosen through the input', () => { const onValueChange = vi.fn(); render(); - const input = document.querySelector('[data-cl-slot="file-upload-input"]') as HTMLInputElement; + const input = document.querySelector('input[type="file"]') as HTMLInputElement; fireEvent.change(input, { target: { files: [makeFile('photo.png', 'image/png')] } }); @@ -87,26 +71,26 @@ describe('FileUpload', () => { }); describe('drag and drop', () => { - it('toggles data-cl-dragging across enter/leave', () => { + it('toggles data-dragging across enter/leave', () => { render(); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; fireEvent.dragEnter(dropzone, { dataTransfer: { files: [] } }); - expect(dropzone).toHaveAttribute('data-cl-dragging', ''); + expect(dropzone).toHaveAttribute('data-dragging', ''); fireEvent.dragLeave(dropzone, { dataTransfer: { files: [] } }); - expect(dropzone).not.toHaveAttribute('data-cl-dragging'); + expect(dropzone).not.toHaveAttribute('data-dragging'); }); it('adds dropped files and clears the dragging state', () => { render(); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; fireEvent.dragEnter(dropzone, { dataTransfer: { files: [makeFile('a.png', 'image/png')] } }); fireEvent.drop(dropzone, { dataTransfer: { files: [makeFile('a.png', 'image/png')] } }); expect(screen.getByText('a.png')).toBeInTheDocument(); - expect(dropzone).not.toHaveAttribute('data-cl-dragging'); + expect(dropzone).not.toHaveAttribute('data-dragging'); }); it('filters dropped files by accept', () => { @@ -118,7 +102,7 @@ describe('FileUpload', () => { onValueChange={onValueChange} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; fireEvent.drop(dropzone, { dataTransfer: { files: [makeFile('a.png', 'image/png'), makeFile('doc.pdf', 'application/pdf')] }, @@ -144,7 +128,7 @@ describe('FileUpload', () => { onReject={onReject} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; fireEvent.drop(dropzone, { dataTransfer: { files: [makeFile('a.png', 'image/png'), makeFile('doc.pdf', 'application/pdf')] }, @@ -166,7 +150,7 @@ describe('FileUpload', () => { onValueChange={onValueChange} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; const small = makeSizedFile('small.png', 'image/png', 50); const big = makeSizedFile('big.png', 'image/png', 500); @@ -189,7 +173,7 @@ describe('FileUpload', () => { onReject={onReject} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; fireEvent.drop(dropzone, { dataTransfer: { files: [makeSizedFile('ok.png', 'image/png', 100)] } }); @@ -206,7 +190,7 @@ describe('FileUpload', () => { onValueChange={onValueChange} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; const first = makeFile('first.png', 'image/png'); const second = makeFile('second.png', 'image/png'); @@ -226,7 +210,7 @@ describe('FileUpload', () => { onReject={onReject} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; // A PDF that is also oversized should be rejected for the type, not the size. fireEvent.drop(dropzone, { dataTransfer: { files: [makeSizedFile('big.pdf', 'application/pdf', 500)] } }); @@ -239,7 +223,7 @@ describe('FileUpload', () => { describe('single vs multiple', () => { it('replaces the file in single mode', () => { render(); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; fireEvent.drop(dropzone, { dataTransfer: { files: [makeFile('second.png', 'image/png')] } }); @@ -254,7 +238,7 @@ describe('FileUpload', () => { defaultValue={[makeFile('first.png', 'image/png')]} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; fireEvent.drop(dropzone, { dataTransfer: { files: [makeFile('second.png', 'image/png')] } }); @@ -266,7 +250,7 @@ describe('FileUpload', () => { describe('items', () => { it('renders an preview for image files', () => { render(); - const img = document.querySelector('[data-cl-slot="file-upload-item-preview"]'); + const img = document.querySelector('[data-testid="file-upload-item-preview"]'); expect(img?.tagName).toBe('IMG'); expect(img).toHaveAttribute('alt', 'a.png'); expect(img?.getAttribute('src')).toBeTruthy(); @@ -274,7 +258,7 @@ describe('FileUpload', () => { it('renders no preview for non-image files', () => { render(); - expect(document.querySelector('[data-cl-slot="file-upload-item-preview"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="file-upload-item-preview"]')).not.toBeInTheDocument(); }); it('removes a file via ItemDelete', async () => { @@ -302,7 +286,7 @@ describe('FileUpload', () => { onValueChange={onValueChange} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; expect(screen.getByText('locked.png')).toBeInTheDocument(); @@ -319,7 +303,7 @@ describe('FileUpload', () => { it('disables trigger and hidden input', () => { render(); expect(screen.getByRole('button', { name: 'Choose files' })).toBeDisabled(); - expect(document.querySelector('[data-cl-slot="file-upload-input"]')).toBeDisabled(); + expect(document.querySelector('input[type="file"]')).toBeDisabled(); }); it('ignores drops when disabled', () => { @@ -330,20 +314,20 @@ describe('FileUpload', () => { onValueChange={onValueChange} />, ); - const dropzone = document.querySelector('[data-cl-slot="file-upload-dropzone"]') as HTMLElement; + const dropzone = document.querySelector('[data-testid="file-upload-dropzone"]') as HTMLElement; fireEvent.dragEnter(dropzone, { dataTransfer: { files: [makeFile('a.png', 'image/png')] } }); fireEvent.drop(dropzone, { dataTransfer: { files: [makeFile('a.png', 'image/png')] } }); expect(onValueChange).not.toHaveBeenCalled(); - expect(dropzone).not.toHaveAttribute('data-cl-dragging'); + expect(dropzone).not.toHaveAttribute('data-dragging'); }); - it('applies data-cl-disabled on root, dropzone, and trigger', () => { + it('applies data-disabled on root, dropzone, and trigger', () => { render(); - expect(document.querySelector('[data-cl-slot="file-upload-root"]')).toHaveAttribute('data-cl-disabled', ''); - expect(document.querySelector('[data-cl-slot="file-upload-dropzone"]')).toHaveAttribute('data-cl-disabled', ''); - expect(document.querySelector('[data-cl-slot="file-upload-trigger"]')).toHaveAttribute('data-cl-disabled', ''); + expect(document.querySelector('[data-testid="file-upload-root"]')).toHaveAttribute('data-disabled', ''); + expect(document.querySelector('[data-testid="file-upload-dropzone"]')).toHaveAttribute('data-disabled', ''); + expect(document.querySelector('[data-testid="file-upload-trigger"]')).toHaveAttribute('data-disabled', ''); }); }); @@ -366,7 +350,7 @@ describe('FileUpload', () => { ); const trigger = screen.getByRole('button', { name: 'Upload' }); expect(trigger).toHaveAttribute('data-custom', 'yes'); - expect(trigger).toHaveAttribute('data-cl-slot', 'file-upload-trigger'); + expect(trigger).toHaveAttribute('type', 'button'); }); }); diff --git a/packages/headless/src/primitives/menu/README.md b/packages/headless/src/primitives/menu/README.md index b07feffab81..ee2c9dda87e 100644 --- a/packages/headless/src/primitives/menu/README.md +++ b/packages/headless/src/primitives/menu/README.md @@ -127,13 +127,12 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall ## Data Attributes -| Attribute | Applies To | Description | -| --------------------------------- | ----------------- | ------------------------------------ | -| `data-cl-slot` | All parts | Part identifier (e.g. `"menu-item"`) | -| `data-cl-open` / `data-cl-closed` | Trigger | Menu open state | -| `data-cl-active` | Item | Keyboard-focused item | -| `data-cl-disabled` | Item | Disabled item | -| `data-cl-side` | Positioner, Arrow | Resolved placement side | +| Attribute | Applies To | Description | +| --------------------------- | ----------------- | ----------------------- | +| `data-open` / `data-closed` | Trigger | Menu open state | +| `data-active` | Item | Keyboard-focused item | +| `data-disabled` | Item | Disabled item | +| `data-side` | Positioner, Arrow | Resolved placement side | ## Nested Menu Behavior @@ -144,7 +143,7 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall ## Important Notes -- **No built-in animations.** The positioner simply mounts/unmounts. Use `data-cl-open`/`data-cl-closed` for CSS-driven transitions. +- **No built-in animations.** The positioner simply mounts/unmounts. Use `data-open`/`data-closed` for CSS-driven transitions. - **Disabled items use `aria-disabled`, not `disabled`.** They remain focusable for keyboard users. - **`label` is required on `Menu.Item`** — it drives typeahead matching. Disabled items are excluded from typeahead. diff --git a/packages/headless/src/primitives/menu/menu-arrow.tsx b/packages/headless/src/primitives/menu/menu-arrow.tsx index a7a1f9d1813..b2ec4fb839f 100644 --- a/packages/headless/src/primitives/menu/menu-arrow.tsx +++ b/packages/headless/src/primitives/menu/menu-arrow.tsx @@ -13,8 +13,7 @@ export function MenuArrow(props: MenuArrowProps) { return ( (funct }; const ownProps = { - 'data-cl-slot': 'menu-item', type: 'button', role: 'menuitem', tabIndex: isActive ? 0 : -1, @@ -54,8 +53,8 @@ export const MenuItem = React.forwardRef(funct ref: [item.ref, ref], state, stateAttributesMapping: { - active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + active: (v: boolean) => (v ? { 'data-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergeProps<'button'>(defaultProps, safeOtherProps), }); diff --git a/packages/headless/src/primitives/menu/menu-popup.tsx b/packages/headless/src/primitives/menu/menu-popup.tsx index 94cc628d1e1..5eb0fa511f5 100644 --- a/packages/headless/src/primitives/menu/menu-popup.tsx +++ b/packages/headless/src/primitives/menu/menu-popup.tsx @@ -12,7 +12,6 @@ export const MenuPopup = React.forwardRef(functi const { popupRef, transitionProps } = useMenuContext(); const defaultProps = { - 'data-cl-slot': 'menu-popup', ...transitionProps, }; diff --git a/packages/headless/src/primitives/menu/menu-positioner.tsx b/packages/headless/src/primitives/menu/menu-positioner.tsx index 844e1290f66..d64fec49d3a 100644 --- a/packages/headless/src/primitives/menu/menu-positioner.tsx +++ b/packages/headless/src/primitives/menu/menu-positioner.tsx @@ -50,8 +50,7 @@ export const MenuPositioner = React.forwardRef; diff --git a/packages/headless/src/primitives/menu/menu-separator.tsx b/packages/headless/src/primitives/menu/menu-separator.tsx index c6ea5fd2bab..916b4bc84f1 100644 --- a/packages/headless/src/primitives/menu/menu-separator.tsx +++ b/packages/headless/src/primitives/menu/menu-separator.tsx @@ -8,7 +8,6 @@ export function MenuSeparator(props: MenuSeparatorProps) { const { render, ...otherProps } = props; const defaultProps = { - 'data-cl-slot': 'menu-separator', role: 'separator' as const, }; diff --git a/packages/headless/src/primitives/menu/menu-trigger.tsx b/packages/headless/src/primitives/menu/menu-trigger.tsx index c6dc10f3c2c..dfcc7b3d2e8 100644 --- a/packages/headless/src/primitives/menu/menu-trigger.tsx +++ b/packages/headless/src/primitives/menu/menu-trigger.tsx @@ -26,7 +26,6 @@ export const MenuTrigger = React.forwardRef const ownProps = { type: 'button', - 'data-cl-slot': 'menu-trigger', ...(isNested && { role: 'menuitem', tabIndex: parentContext?.activeIndex === item.index ? 0 : -1, @@ -45,7 +44,7 @@ export const MenuTrigger = React.forwardRef ref: [refs.setReference, isNested ? item.ref : null, ref ?? null], state, stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: mergeProps<'button'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/menu/menu.test.tsx b/packages/headless/src/primitives/menu/menu.test.tsx index 765f19a4006..dbc19745c69 100644 --- a/packages/headless/src/primitives/menu/menu.test.tsx +++ b/packages/headless/src/primitives/menu/menu.test.tsx @@ -8,52 +8,16 @@ import { Menu } from './index'; afterEach(() => cleanup()); describe('Menu', () => { - describe('slot attributes', () => { - it('renders trigger with data-cl-slot', () => { - render( - - Actions - - - Cut - - - , - ); - expect(screen.getByText('Actions')).toHaveAttribute('data-cl-slot', 'menu-trigger'); - }); - - it('renders all parts with correct slot attributes when open', async () => { - const user = userEvent.setup(); - render( - - Actions - - - Cut - - Paste - - - , - ); - - await user.click(screen.getByText('Actions')); - - expect(document.querySelector('[data-cl-slot="menu-positioner"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="menu-popup"]')).toBeInTheDocument(); - expect(document.querySelectorAll('[data-cl-slot="menu-item"]')).toHaveLength(2); - expect(document.querySelector('[data-cl-slot="menu-separator"]')).toBeInTheDocument(); - }); - }); - describe('ARIA attributes', () => { it('keeps the trigger/menu aria-controls pairing intact when a custom id is passed to the positioner', async () => { const user = userEvent.setup(); render( Actions - + Cut @@ -64,7 +28,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); const trigger = screen.getByText('Actions'); - const positioner = document.querySelector('[data-cl-slot="menu-positioner"]'); + const positioner = document.querySelector('[data-testid="menu-positioner"]'); // The menu id is owned by floating-ui: a consumer-supplied id must not // override it, or the trigger's aria-controls pairing would silently break. @@ -90,7 +54,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); expect(screen.getByText('Cut')).toBeInTheDocument(); - expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', ''); + expect(screen.getByText('Actions')).toHaveAttribute('data-open', ''); }); it('closes on trigger click when open', async () => { @@ -109,7 +73,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); await user.click(screen.getByText('Actions')); - expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', ''); + expect(screen.getByText('Actions')).toHaveAttribute('data-closed', ''); }); it('closes on Escape', async () => { @@ -128,7 +92,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); await user.keyboard('{Escape}'); - expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', ''); + expect(screen.getByText('Actions')).toHaveAttribute('data-closed', ''); }); it('closes when item is clicked', async () => { @@ -154,7 +118,7 @@ describe('Menu', () => { await user.click(screen.getByText('Cut')); expect(onClick).toHaveBeenCalledTimes(1); - expect(screen.getByText('Actions')).toHaveAttribute('data-cl-closed', ''); + expect(screen.getByText('Actions')).toHaveAttribute('data-closed', ''); }); it('does not close on item click when closeOnClick=false', async () => { @@ -178,7 +142,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); await user.click(screen.getByText('Toggle')); - expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', ''); + expect(screen.getByText('Actions')).toHaveAttribute('data-open', ''); }); it('calls onOpenChange', async () => { @@ -249,7 +213,7 @@ describe('Menu', () => { }); describe('disabled items', () => { - it('marks disabled item with data-cl-disabled', async () => { + it('marks disabled item with data-disabled', async () => { const user = userEvent.setup(); render( @@ -269,7 +233,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); - expect(document.querySelector('[data-cl-slot="menu-item"]')).toHaveAttribute('data-cl-disabled', ''); + expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveAttribute('data-disabled', ''); }); it('disabled item has aria-disabled', async () => { @@ -292,7 +256,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); - expect(document.querySelector('[data-cl-slot="menu-item"]')).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitem', { name: 'Cut' })).toHaveAttribute('aria-disabled', 'true'); }); it('does not close menu when clicking disabled item', async () => { @@ -316,7 +280,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); await user.click(screen.getByText('Cut')); - expect(screen.getByText('Actions')).toHaveAttribute('data-cl-open', ''); + expect(screen.getByText('Actions')).toHaveAttribute('data-open', ''); }); it('does not fire the consumer onClick when clicking a disabled item', async () => { @@ -365,7 +329,7 @@ describe('Menu', () => { await new Promise(r => requestAnimationFrame(r)); await user.keyboard('{ArrowDown}'); - expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', ''); + expect(screen.getByText('Cut')).toHaveAttribute('data-active', ''); }); it('navigates items with ArrowUp from last to first', async () => { @@ -386,7 +350,7 @@ describe('Menu', () => { await new Promise(r => requestAnimationFrame(r)); await user.keyboard('{ArrowDown}{ArrowUp}'); - expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', ''); + expect(screen.getByText('Cut')).toHaveAttribute('data-active', ''); }); it('Home moves to first item', async () => { @@ -408,7 +372,7 @@ describe('Menu', () => { await new Promise(r => requestAnimationFrame(r)); await user.keyboard('{ArrowDown}{ArrowDown}{Home}'); - expect(screen.getByText('Cut')).toHaveAttribute('data-cl-active', ''); + expect(screen.getByText('Cut')).toHaveAttribute('data-active', ''); }); it('End moves to last item', async () => { @@ -430,7 +394,7 @@ describe('Menu', () => { await new Promise(r => requestAnimationFrame(r)); await user.keyboard('{End}'); - expect(screen.getByText('Paste')).toHaveAttribute('data-cl-active', ''); + expect(screen.getByText('Paste')).toHaveAttribute('data-active', ''); }); }); @@ -593,7 +557,6 @@ describe('Menu', () => { const shareTrigger = screen.getByText('Share'); expect(shareTrigger).toHaveAttribute('role', 'menuitem'); - expect(shareTrigger).toHaveAttribute('data-cl-slot', 'menu-trigger'); }); it('opens submenu via controlled open prop', () => { @@ -658,7 +621,7 @@ describe('Menu', () => { render( Actions - + Cut @@ -666,15 +629,15 @@ describe('Menu', () => { , ); - expect(document.querySelector('[data-cl-slot="menu-positioner"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="menu-positioner"]')).not.toBeInTheDocument(); }); - it('has data-cl-side when open', async () => { + it('has data-side when open', async () => { const user = userEvent.setup(); render( Actions - + Cut @@ -684,7 +647,7 @@ describe('Menu', () => { await user.click(screen.getByText('Actions')); - expect(document.querySelector('[data-cl-slot="menu-positioner"]')).toHaveAttribute('data-cl-side'); + expect(document.querySelector('[data-testid="menu-positioner"]')).toHaveAttribute('data-side'); }); }); diff --git a/packages/headless/src/primitives/otp/README.md b/packages/headless/src/primitives/otp/README.md index 0b0ea6c066b..5e2d2f3f289 100644 --- a/packages/headless/src/primitives/otp/README.md +++ b/packages/headless/src/primitives/otp/README.md @@ -13,7 +13,7 @@ across the slots. Supports controlled/uncontrolled value, a character `pattern`, - Any fixed-length PIN or code split into per-character boxes. Each slot is a real, individually styleable `` — the primitive emits zero styles and injects -no global CSS. Everything is driven by `data-cl-*` attributes. +no global CSS. Everything is driven by `data-*` attributes. ## Usage @@ -148,14 +148,13 @@ and the `Ctrl`/`Cmd` boundary jumps stay logical (first / last-entered). ## Data Attributes -| Attribute | Applies To | Description | -| ------------------ | ----------- | --------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (`"otp-root"`, `"otp-input"`) | -| `data-cl-empty` | Root | Present when no character has been entered | -| `data-cl-complete` | Root | Present when every slot is filled | -| `data-cl-disabled` | Root, Input | Present when disabled | -| `data-cl-active` | Input | Present when the slot holds focus | -| `data-cl-filled` | Input | Present when the slot holds a character | +| Attribute | Applies To | Description | +| --------------- | ----------- | ------------------------------------------ | +| `data-empty` | Root | Present when no character has been entered | +| `data-complete` | Root | Present when every slot is filled | +| `data-disabled` | Root, Input | Present when disabled | +| `data-active` | Input | Present when the slot holds focus | +| `data-filled` | Input | Present when the slot holds a character | ## ARIA diff --git a/packages/headless/src/primitives/otp/otp-input.tsx b/packages/headless/src/primitives/otp/otp-input.tsx index 3486a9fb856..36e6b1441e0 100644 --- a/packages/headless/src/primitives/otp/otp-input.tsx +++ b/packages/headless/src/primitives/otp/otp-input.tsx @@ -43,7 +43,6 @@ export const OtpInput = React.forwardRef(functi const state = { active: activeIndex === index, filled: char !== '', disabled }; const defaultProps: Record = { - 'data-cl-slot': 'otp-input', value: char, type: mask ? 'password' : 'text', inputMode: inputModeForPattern(pattern), @@ -204,9 +203,9 @@ export const OtpInput = React.forwardRef(functi ref: [registerRef, forwardedRef], state, stateAttributesMapping: { - active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), - filled: (v: boolean) => (v ? { 'data-cl-filled': '' } : null), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + active: (v: boolean) => (v ? { 'data-active': '' } : null), + filled: (v: boolean) => (v ? { 'data-filled': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergeProps<'input'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/otp/otp-root.tsx b/packages/headless/src/primitives/otp/otp-root.tsx index 5d55aa99c35..f303c2a7102 100644 --- a/packages/headless/src/primitives/otp/otp-root.tsx +++ b/packages/headless/src/primitives/otp/otp-root.tsx @@ -175,7 +175,6 @@ export function OtpRoot(props: OtpProps) { const state = { disabled, complete, empty: value.length === 0 }; const defaultProps: Record = { - 'data-cl-slot': 'otp-root', role: 'group', children: ( <> @@ -190,7 +189,6 @@ export function OtpRoot(props: OtpProps) { tabIndex={-1} autoComplete='one-time-code' inputMode={inputModeForPattern(pattern)} - data-cl-slot='otp-hidden-input' style={visuallyHiddenInputStyle} /> ) : null} @@ -205,9 +203,9 @@ export function OtpRoot(props: OtpProps) { render, state, stateAttributesMapping: { - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), - complete: (v: boolean) => (v ? { 'data-cl-complete': '' } : null), - empty: (v: boolean) => (v ? { 'data-cl-empty': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + complete: (v: boolean) => (v ? { 'data-complete': '' } : null), + empty: (v: boolean) => (v ? { 'data-empty': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), })} diff --git a/packages/headless/src/primitives/otp/otp.test.tsx b/packages/headless/src/primitives/otp/otp.test.tsx index ba87227a936..cb1ffe8f486 100644 --- a/packages/headless/src/primitives/otp/otp.test.tsx +++ b/packages/headless/src/primitives/otp/otp.test.tsx @@ -14,6 +14,7 @@ function Harness(props: Partial> & { lengt return ( @@ -29,6 +30,7 @@ function Slots() { ))} @@ -36,29 +38,29 @@ function Slots() { } function inputs() { - return Array.from(document.querySelectorAll('[data-cl-slot="otp-input"]')); + return Array.from(document.querySelectorAll('[data-testid="otp-input"]')); } describe('Otp', () => { describe('slot attributes', () => { it('renders the root and one input per slot', () => { render(); - expect(document.querySelector('[data-cl-slot="otp-root"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="otp-root"]')).toBeInTheDocument(); expect(inputs()).toHaveLength(4); }); it('marks the root empty until a character is entered, then complete when full', async () => { const user = userEvent.setup(); render(); - const root = document.querySelector('[data-cl-slot="otp-root"]'); - expect(root).toHaveAttribute('data-cl-empty', ''); + const root = document.querySelector('[data-testid="otp-root"]'); + expect(root).toHaveAttribute('data-empty', ''); await user.type(inputs()[0], '1'); - expect(root).not.toHaveAttribute('data-cl-empty'); - expect(root).not.toHaveAttribute('data-cl-complete'); + expect(root).not.toHaveAttribute('data-empty'); + expect(root).not.toHaveAttribute('data-complete'); await user.type(inputs()[1], '2'); - expect(root).toHaveAttribute('data-cl-complete', ''); + expect(root).toHaveAttribute('data-complete', ''); }); it('marks a filled input and the active input', async () => { @@ -69,11 +71,11 @@ describe('Otp', () => { defaultValue='1' />, ); - expect(inputs()[0]).toHaveAttribute('data-cl-filled', ''); - expect(inputs()[1]).not.toHaveAttribute('data-cl-filled'); + expect(inputs()[0]).toHaveAttribute('data-filled', ''); + expect(inputs()[1]).not.toHaveAttribute('data-filled'); await user.click(inputs()[1]); - expect(inputs()[1]).toHaveAttribute('data-cl-active', ''); + expect(inputs()[1]).toHaveAttribute('data-active', ''); }); }); @@ -408,7 +410,7 @@ describe('Otp', () => { for (const input of inputs()) { expect(input).toBeDisabled(); } - expect(document.querySelector('[data-cl-slot="otp-root"]')).toHaveAttribute('data-cl-disabled', ''); + expect(document.querySelector('[data-testid="otp-root"]')).toHaveAttribute('data-disabled', ''); await user.type(inputs()[0], '1'); expect(onValueChange).not.toHaveBeenCalled(); @@ -424,14 +426,14 @@ describe('Otp', () => { defaultValue='1234' />, ); - const hidden = document.querySelector('[data-cl-slot="otp-hidden-input"]'); + const hidden = document.querySelector('input[aria-hidden="true"]'); expect(hidden).toHaveAttribute('name', 'code'); expect(hidden).toHaveValue('1234'); }); it('omits the hidden input when no name is given', () => { render(); - expect(document.querySelector('[data-cl-slot="otp-hidden-input"]')).not.toBeInTheDocument(); + expect(document.querySelector('input[aria-hidden="true"]')).not.toBeInTheDocument(); }); }); @@ -495,7 +497,7 @@ describe('Otp', () => { , ); const custom = screen.getByTestId('custom'); - expect(custom).toHaveAttribute('data-cl-slot', 'otp-input'); + expect(custom).toBeInTheDocument(); }); }); @@ -507,6 +509,7 @@ describe('Otp', () => { , ); diff --git a/packages/headless/src/primitives/popover/README.md b/packages/headless/src/primitives/popover/README.md index d72481fa719..58ceb1d53aa 100644 --- a/packages/headless/src/primitives/popover/README.md +++ b/packages/headless/src/primitives/popover/README.md @@ -89,12 +89,11 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall ## Data Attributes -| Attribute | Applies To | Description | -| ------------------------------------------------- | ----------------- | --------------------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"popover-popup"`) | -| `data-cl-open` / `data-cl-closed` | Trigger, Popup | Open/closed state | -| `data-cl-starting-style` / `data-cl-ending-style` | Popup | Transition state — set during enter/exit animation frames | -| `data-cl-side` | Positioner, Arrow | Resolved placement side | +| Attribute | Applies To | Description | +| ------------------------------------------- | ----------------- | --------------------------------------------------------- | +| `data-open` / `data-closed` | Trigger, Popup | Open/closed state | +| `data-starting-style` / `data-ending-style` | Popup | Transition state — set during enter/exit animation frames | +| `data-side` | Positioner, Arrow | Resolved placement side | ## Positioning diff --git a/packages/headless/src/primitives/popover/popover-arrow.tsx b/packages/headless/src/primitives/popover/popover-arrow.tsx index 438c514b26d..6c5de144080 100644 --- a/packages/headless/src/primitives/popover/popover-arrow.tsx +++ b/packages/headless/src/primitives/popover/popover-arrow.tsx @@ -16,8 +16,7 @@ export const PopoverArrow = React.forwardRef(f return ( ( const { popupRef, transitionProps } = usePopoverContext(); const defaultProps = { - 'data-cl-slot': 'popover-popup', ...transitionProps, }; diff --git a/packages/headless/src/primitives/popover/popover-positioner.tsx b/packages/headless/src/primitives/popover/popover-positioner.tsx index 5b65eea3bac..777168f0081 100644 --- a/packages/headless/src/primitives/popover/popover-positioner.tsx +++ b/packages/headless/src/primitives/popover/popover-positioner.tsx @@ -28,8 +28,7 @@ export const PopoverPositioner = React.forwardRef; const defaultProps = { ...ownProps, ...getReferenceProps() }; @@ -31,7 +30,7 @@ export const PopoverTrigger = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: mergeProps<'button'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/popover/popover.test.tsx b/packages/headless/src/primitives/popover/popover.test.tsx index d27ed3e8ad7..862de9cc0b6 100644 --- a/packages/headless/src/primitives/popover/popover.test.tsx +++ b/packages/headless/src/primitives/popover/popover.test.tsx @@ -12,12 +12,12 @@ function renderPopover(props: Partial> return render( Open popover - - - Popover Title - Some description + + + Popover Title + Some description

    Popover content

    - Close + Close
    , @@ -25,24 +25,6 @@ function renderPopover(props: Partial> } describe('Popover', () => { - describe('slot attributes', () => { - it('renders trigger with data-cl-slot', () => { - renderPopover(); - const trigger = screen.getByRole('button', { name: 'Open popover' }); - expect(trigger).toHaveAttribute('data-cl-slot', 'popover-trigger'); - }); - - it('renders all parts with correct slot attributes when open', () => { - renderPopover({ defaultOpen: true }); - - expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="popover-title"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="popover-description"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="popover-close"]')).toBeInTheDocument(); - }); - }); - describe('open/close', () => { it('opens on trigger click', async () => { const user = userEvent.setup(); @@ -51,8 +33,8 @@ describe('Popover', () => { const trigger = screen.getByRole('button', { name: 'Open popover' }); await user.click(trigger); - expect(trigger).toHaveAttribute('data-cl-open', ''); - expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + expect(trigger).toHaveAttribute('data-open', ''); + expect(document.querySelector('[data-testid="popover-popup"]')).toBeInTheDocument(); }); it('closes on trigger click when open', async () => { @@ -63,7 +45,7 @@ describe('Popover', () => { await user.click(trigger); await user.click(trigger); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); it('closes on Escape', async () => { @@ -73,7 +55,7 @@ describe('Popover', () => { await user.keyboard('{Escape}'); const trigger = screen.getByRole('button', { name: 'Open popover' }); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); it('closes via Close button', async () => { @@ -84,7 +66,7 @@ describe('Popover', () => { await user.click(closeBtn); const trigger = screen.getByRole('button', { name: 'Open popover' }); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); it('calls onOpenChange when toggled', async () => { @@ -102,11 +84,11 @@ describe('Popover', () => { const user = userEvent.setup(); renderPopover({ defaultOpen: true }); - expect(document.querySelector('[data-cl-slot="popover-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="popover-popup"]')).toBeInTheDocument(); await user.click(document.body); - expect(document.querySelector('[data-cl-slot="popover-popup"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="popover-popup"]')).not.toBeInTheDocument(); }); }); @@ -114,7 +96,7 @@ describe('Popover', () => { it('respects controlled open prop', () => { renderPopover({ open: true }); - expect(document.querySelector('[data-cl-slot="popover-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="popover-positioner"]')).toBeInTheDocument(); }); it('does not open when controlled open is false', async () => { @@ -123,7 +105,7 @@ describe('Popover', () => { await user.click(screen.getByRole('button', { name: 'Open popover' })); - expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="popover-positioner"]')).not.toBeInTheDocument(); }); }); @@ -131,8 +113,8 @@ describe('Popover', () => { it('positioner has aria-labelledby linked to title', () => { renderPopover({ defaultOpen: true }); - const title = document.querySelector('[data-cl-slot="popover-title"]'); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + const title = document.querySelector('[data-testid="popover-title"]'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); expect(title).toHaveAttribute('id'); expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); @@ -141,8 +123,8 @@ describe('Popover', () => { it('positioner has aria-describedby linked to description', () => { renderPopover({ defaultOpen: true }); - const desc = document.querySelector('[data-cl-slot="popover-description"]'); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + const desc = document.querySelector('[data-testid="popover-description"]'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); expect(desc).toHaveAttribute('id'); expect(positioner).toHaveAttribute('aria-describedby', desc?.getAttribute('id')); @@ -158,9 +140,9 @@ describe('Popover', () => { // their public props) — the aria pairing must always resolve correctly. renderPopover({ defaultOpen: true }); - const title = document.querySelector('[data-cl-slot="popover-title"]'); - const desc = document.querySelector('[data-cl-slot="popover-description"]'); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + const title = document.querySelector('[data-testid="popover-title"]'); + const desc = document.querySelector('[data-testid="popover-description"]'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); expect(positioner).toHaveAttribute('aria-describedby', desc?.getAttribute('id')); @@ -174,7 +156,7 @@ describe('Popover', () => { render( Open popover - +

    Popover content

    @@ -182,7 +164,7 @@ describe('Popover', () => {
    , ); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); expect(positioner).not.toHaveAttribute('aria-labelledby'); expect(positioner).not.toHaveAttribute('aria-describedby'); }); @@ -191,17 +173,17 @@ describe('Popover', () => { render( Open popover - + - Popover Title + Popover Title

    Popover content

    , ); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); - const title = document.querySelector('[data-cl-slot="popover-title"]'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); + const title = document.querySelector('[data-testid="popover-title"]'); expect(positioner).toHaveAttribute('aria-labelledby', title?.getAttribute('id')); expect(positioner).not.toHaveAttribute('aria-describedby'); }); @@ -210,27 +192,27 @@ describe('Popover', () => { describe('animation lifecycle', () => { it('positioner is not rendered when closed', () => { renderPopover(); - expect(document.querySelector('[data-cl-slot="popover-positioner"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="popover-positioner"]')).not.toBeInTheDocument(); }); - it('applies data-cl-open on popup when open', async () => { + it('applies data-open on popup when open', async () => { const user = userEvent.setup(); renderPopover(); await user.click(screen.getByRole('button', { name: 'Open popover' })); - const popup = document.querySelector('[data-cl-slot="popover-popup"]'); - expect(popup).toHaveAttribute('data-cl-open', ''); + const popup = document.querySelector('[data-testid="popover-popup"]'); + expect(popup).toHaveAttribute('data-open', ''); }); - it('positioner has data-cl-side', async () => { + it('positioner has data-side', async () => { const user = userEvent.setup(); renderPopover(); await user.click(screen.getByRole('button', { name: 'Open popover' })); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); - expect(positioner).toHaveAttribute('data-cl-side'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-side'); }); }); @@ -251,15 +233,15 @@ describe('Popover', () => { it('accepts custom placement', () => { renderPopover({ defaultOpen: true, placement: 'top-start' }); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); - expect(positioner).toHaveAttribute('data-cl-side', 'top'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-side', 'top'); }); it('defaults to bottom placement', () => { renderPopover({ defaultOpen: true }); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); - expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); + expect(positioner).toHaveAttribute('data-side', 'bottom'); }); }); @@ -272,7 +254,7 @@ describe('Popover', () => { // FloatingFocusManager schedules focus via requestAnimationFrame await new Promise(r => requestAnimationFrame(r)); - const positioner = document.querySelector('[data-cl-slot="popover-positioner"]'); + const positioner = document.querySelector('[data-testid="popover-positioner"]'); expect(positioner?.contains(document.activeElement)).toBe(true); }); @@ -322,21 +304,6 @@ describe('Popover', () => { ); expect(ref.current).toBeInstanceOf(HTMLButtonElement); - expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-trigger'); - }); - - it('forwards a consumer ref on Positioner (FloatingFocusManager wrapper shape)', () => { - const ref = createRef(); - render( - - Open popover - - content - - , - ); - - expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-positioner'); }); }); @@ -355,7 +322,7 @@ describe('Popover', () => { ); expect(ref.current).not.toBeNull(); - expect(ref.current).toHaveAttribute('data-cl-slot', 'popover-arrow'); + expect(ref.current).toHaveAttribute('data-side'); }); }); }); diff --git a/packages/headless/src/primitives/select/README.md b/packages/headless/src/primitives/select/README.md index 47bd2ef3feb..0da68184076 100644 --- a/packages/headless/src/primitives/select/README.md +++ b/packages/headless/src/primitives/select/README.md @@ -141,14 +141,13 @@ Typeahead is active only while the popup is open. It highlights the matching opt ## Data Attributes -| Attribute | Applies To | Description | -| --------------------------------- | ---------- | ---------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"select-option"`) | -| `data-cl-open` / `data-cl-closed` | Trigger | Popup open state | -| `data-cl-selected` | Option | The currently selected option | -| `data-cl-active` | Option | The keyboard-highlighted option | -| `data-cl-disabled` | Option | Disabled option | -| `data-cl-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 | ## Important Notes diff --git a/packages/headless/src/primitives/select/select-arrow.tsx b/packages/headless/src/primitives/select/select-arrow.tsx index 948dadcdf75..ab18020cd27 100644 --- a/packages/headless/src/primitives/select/select-arrow.tsx +++ b/packages/headless/src/primitives/select/select-arrow.tsx @@ -13,8 +13,7 @@ export function SelectArrow(props: SelectArrowProps) { return ( (v ? { 'data-cl-selected': '' } : null), - active: (v: boolean) => (v ? { 'data-cl-active': '' } : null), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + selected: (v: boolean) => (v ? { 'data-selected': '' } : null), + active: (v: boolean) => (v ? { 'data-active': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, ref: [itemRef, isSelected ? selectedItemRef : null], props: mergeProps<'button'>(defaultProps, otherProps), diff --git a/packages/headless/src/primitives/select/select-popup.tsx b/packages/headless/src/primitives/select/select-popup.tsx index 10fb3f0dd09..f9d1d8016ea 100644 --- a/packages/headless/src/primitives/select/select-popup.tsx +++ b/packages/headless/src/primitives/select/select-popup.tsx @@ -12,7 +12,6 @@ export const SelectPopup = React.forwardRef(fu const { popupRef, transitionProps } = useSelectContext(); const defaultProps = { - 'data-cl-slot': 'select-popup', ...transitionProps, }; diff --git a/packages/headless/src/primitives/select/select-positioner.tsx b/packages/headless/src/primitives/select/select-positioner.tsx index 0586c987b3f..eceae876a31 100644 --- a/packages/headless/src/primitives/select/select-positioner.tsx +++ b/packages/headless/src/primitives/select/select-positioner.tsx @@ -49,8 +49,7 @@ export const SelectPositioner = React.forwardRef; diff --git a/packages/headless/src/primitives/select/select-trigger.tsx b/packages/headless/src/primitives/select/select-trigger.tsx index 8b5ada7ce89..f73b599211d 100644 --- a/packages/headless/src/primitives/select/select-trigger.tsx +++ b/packages/headless/src/primitives/select/select-trigger.tsx @@ -16,7 +16,6 @@ export const SelectTrigger = React.forwardRef; const defaultProps = { ...ownProps, ...getReferenceProps() }; @@ -26,7 +25,7 @@ export const SelectTrigger = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, // floating-ui types `setReference` as a method signature, but at runtime it's // a stable callback that doesn't use `this`, so the unbound-method check is a diff --git a/packages/headless/src/primitives/select/select-value.tsx b/packages/headless/src/primitives/select/select-value.tsx index dc95462efe1..8bde01636fe 100644 --- a/packages/headless/src/primitives/select/select-value.tsx +++ b/packages/headless/src/primitives/select/select-value.tsx @@ -39,7 +39,6 @@ export function SelectValue(props: SelectValueProps) { selectedValue !== undefined ? (selectedLabel ?? resolveLabel(selectedValue, items, valueToLabelRef)) : placeholder; const defaultProps = { - 'data-cl-slot': 'select-value', children: displayText, }; diff --git a/packages/headless/src/primitives/select/select.test.tsx b/packages/headless/src/primitives/select/select.test.tsx index 0ba59900c5b..fd08306caf4 100644 --- a/packages/headless/src/primitives/select/select.test.tsx +++ b/packages/headless/src/primitives/select/select.test.tsx @@ -22,10 +22,13 @@ function renderSelect(props: Partial> = {...rest} > - + - - + + {fruits.map(({ label, value }) => ( > = } describe('Select', () => { - describe('slot attributes', () => { - it('renders trigger with data-cl-slot', () => { - renderSelect(); - const trigger = screen.getByRole('combobox'); - expect(trigger).toHaveAttribute('data-cl-slot', 'select-trigger'); - }); - - it('renders value with data-cl-slot', () => { - renderSelect(); - const value = document.querySelector('[data-cl-slot="select-value"]'); - expect(value).toBeInTheDocument(); - }); - - it('renders all parts with correct slot attributes when open', () => { - renderSelect({ defaultOpen: true }); - - const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); - const popup = document.querySelector('[data-cl-slot="select-popup"]'); - const options = document.querySelectorAll('[data-cl-slot="select-option"]'); - - expect(positioner).toBeInTheDocument(); - expect(popup).toBeInTheDocument(); - expect(options).toHaveLength(3); - }); - }); - describe('ARIA attributes', () => { it('keeps the trigger/listbox aria-controls pairing intact when a custom id is passed to the positioner', () => { render( @@ -79,8 +56,11 @@ describe('Select', () => { - - + + {fruits.map(({ label, value }) => ( { ); const trigger = screen.getByRole('combobox'); - const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + const positioner = document.querySelector('[data-testid="select-positioner"]'); // The listbox id is owned by floating-ui: a consumer-supplied id must not // override it, or the trigger's aria-controls pairing would silently break. @@ -108,19 +88,19 @@ describe('Select', () => { describe('items prop and label resolution', () => { it('shows placeholder when no value is selected', () => { renderSelect(); - const value = document.querySelector('[data-cl-slot="select-value"]'); + const value = document.querySelector('[data-testid="select-value"]'); expect(value?.textContent).toBe('Pick a fruit...'); }); it('resolves label from items before options mount', () => { renderSelect({ defaultValue: 'banana' }); - const value = document.querySelector('[data-cl-slot="select-value"]'); + const value = document.querySelector('[data-testid="select-value"]'); expect(value?.textContent).toBe('Banana'); }); it('resolves label for controlled value from items', () => { renderSelect({ value: 'cherry' }); - const value = document.querySelector('[data-cl-slot="select-value"]'); + const value = document.querySelector('[data-testid="select-value"]'); expect(value?.textContent).toBe('Cherry'); }); @@ -131,10 +111,13 @@ describe('Select', () => { alignItemWithTrigger={false} > - + - - + + { , ); - const value = document.querySelector('[data-cl-slot="select-value"]'); + const value = document.querySelector('[data-testid="select-value"]'); expect(value?.textContent).toBe('banana'); }); @@ -155,10 +138,13 @@ describe('Select', () => { render( - + - - + + { await user.click(screen.getByRole('combobox')); await user.click(screen.getByText('Special Item')); - const value = document.querySelector('[data-cl-slot="select-value"]'); + const value = document.querySelector('[data-testid="select-value"]'); expect(value?.textContent).toBe('Special Item'); }); }); @@ -186,8 +172,8 @@ describe('Select', () => { const trigger = screen.getByRole('combobox'); await user.click(trigger); - expect(trigger).toHaveAttribute('data-cl-open', ''); - expect(document.querySelector('[data-cl-slot="select-popup"]')).toBeInTheDocument(); + expect(trigger).toHaveAttribute('data-open', ''); + expect(document.querySelector('[data-testid="select-popup"]')).toBeInTheDocument(); }); it('closes on Escape', async () => { @@ -197,7 +183,7 @@ describe('Select', () => { await user.keyboard('{Escape}'); const trigger = screen.getByRole('combobox'); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); it('calls onOpenChange when toggled', async () => { @@ -234,7 +220,7 @@ describe('Select', () => { await user.click(screen.getByRole('combobox')); await user.click(screen.getByText('Cherry')); - const value = document.querySelector('[data-cl-slot="select-value"]'); + const value = document.querySelector('[data-testid="select-value"]'); expect(value?.textContent).toContain('Cherry'); }); }); @@ -252,10 +238,13 @@ describe('Select', () => { onValueChange={() => {}} > - + - - + + {fruits.map(({ label, value }) => ( { await user.click(screen.getByRole('combobox')); await user.click(screen.getByText('Banana')); - const value = document.querySelector('[data-cl-slot="select-value"]'); + const value = document.querySelector('[data-testid="select-value"]'); expect(value?.textContent).toBe('Apple'); }); }); @@ -287,7 +276,7 @@ describe('Select', () => { await user.click(trigger); await user.keyboard('{ArrowDown}'); - const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + const activeOption = document.querySelector('[role="option"][data-active]'); expect(activeOption).toBeInTheDocument(); }); @@ -303,9 +292,9 @@ describe('Select', () => { await user.keyboard(key); const options = screen.getAllByRole('option'); - expect(options[1]).toHaveAttribute('data-cl-selected', ''); + expect(options[1]).toHaveAttribute('data-selected', ''); expect(document.activeElement).toBe(options[1]); - expect(options[1]).toHaveAttribute('data-cl-active', ''); + expect(options[1]).toHaveAttribute('data-active', ''); }, ); @@ -322,10 +311,13 @@ describe('Select', () => { alignItemWithTrigger={false} > - + - - + +
    {manyItems.map(({ label, value }) => ( { await user.keyboard('{ArrowDown}'); } - const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + const activeOption = document.querySelector('[role="option"][data-active]'); expect(activeOption).toBeInTheDocument(); // The active item should be visible within its scroll container @@ -374,10 +366,13 @@ describe('Select', () => { alignItemWithTrigger={false} > - + - - + +
    {manyItems.map(({ label, value }) => ( { // Reopen — the selected item should be scrolled into view await user.click(trigger); - const selectedOption = document.querySelector('[data-cl-slot="select-option"][data-cl-selected]'); + const selectedOption = document.querySelector('[role="option"][data-selected]'); expect(selectedOption).toBeInTheDocument(); const scrollContainer = selectedOption!.closest('div[style]') as HTMLElement; @@ -419,35 +414,38 @@ describe('Select', () => { }); describe('option state attributes', () => { - it('marks selected option with data-cl-selected', () => { + it('marks selected option with data-selected', () => { renderSelect({ defaultValue: 'banana', defaultOpen: true }); - const options = document.querySelectorAll('[data-cl-slot="select-option"]'); - expect(options[1]).toHaveAttribute('data-cl-selected', ''); + const options = document.querySelectorAll('[role="option"]'); + expect(options[1]).toHaveAttribute('data-selected', ''); }); - it('marks active option with data-cl-active', async () => { + it('marks active option with data-active', async () => { const user = userEvent.setup(); renderSelect(); await user.click(screen.getByRole('combobox')); await user.keyboard('{ArrowDown}'); - const activeOption = document.querySelector('[data-cl-slot="select-option"][data-cl-active]'); + const activeOption = document.querySelector('[role="option"][data-active]'); expect(activeOption).toBeInTheDocument(); }); }); describe('disabled option', () => { - it('renders disabled option with data-cl-disabled', async () => { + it('renders disabled option with data-disabled', async () => { const user = userEvent.setup(); render( - + - - + + { await user.click(screen.getByRole('combobox')); - const disabledOption = screen.getByText('Banana').closest("[data-cl-slot='select-option']"); - expect(disabledOption).toHaveAttribute('data-cl-disabled', ''); + const disabledOption = screen.getByText('Banana').closest("[role='option']"); + expect(disabledOption).toHaveAttribute('data-disabled', ''); expect(disabledOption).toHaveAttribute('aria-disabled', 'true'); }); @@ -482,10 +480,13 @@ describe('Select', () => { alignItemWithTrigger={false} > - + - - + + { describe('animation lifecycle', () => { it('positioner is not rendered when closed', () => { renderSelect(); - const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + const positioner = document.querySelector('[data-testid="select-positioner"]'); expect(positioner).not.toBeInTheDocument(); }); - it('applies data-cl-open on popup when open', async () => { + it('applies data-open on popup when open', async () => { const user = userEvent.setup(); renderSelect(); await user.click(screen.getByRole('combobox')); - const popup = document.querySelector('[data-cl-slot="select-popup"]'); - expect(popup).toHaveAttribute('data-cl-open', ''); + const popup = document.querySelector('[data-testid="select-popup"]'); + expect(popup).toHaveAttribute('data-open', ''); }); - it('positioner has data-cl-side', async () => { + it('positioner has data-side', async () => { const user = userEvent.setup(); renderSelect(); await user.click(screen.getByRole('combobox')); - const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); - expect(positioner).toHaveAttribute('data-cl-side'); + const positioner = document.querySelector('[data-testid="select-positioner"]'); + expect(positioner).toHaveAttribute('data-side'); }); }); @@ -560,7 +561,7 @@ describe('Select', () => { it('respects controlled open prop', () => { renderSelect({ open: true }); - const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + const positioner = document.querySelector('[data-testid="select-positioner"]'); expect(positioner).toBeInTheDocument(); }); @@ -570,7 +571,7 @@ describe('Select', () => { await user.click(screen.getByRole('combobox')); - const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + const positioner = document.querySelector('[data-testid="select-positioner"]'); expect(positioner).not.toBeInTheDocument(); }); }); @@ -585,10 +586,13 @@ describe('Select', () => { defaultValue='banana' > - + - - + + {fruits.map(({ label, value }) => ( { ); // The positioner should render (alignItemWithTrigger doesn't prevent rendering) - const positioner = document.querySelector('[data-cl-slot="select-positioner"]'); + const positioner = document.querySelector('[data-testid="select-positioner"]'); expect(positioner).toBeInTheDocument(); }); @@ -614,7 +618,7 @@ describe('Select', () => { await user.click(screen.getByRole('combobox')); - const positioner = document.querySelector('[data-cl-slot="select-positioner"]') as HTMLElement; + const positioner = document.querySelector('[data-testid="select-positioner"]') as HTMLElement; // Standard Floating UI positioning uses position: absolute with transform expect(positioner.style.position).toBe('absolute'); }); @@ -632,10 +636,13 @@ describe('Select', () => { defaultValue='item-10' > - + - - + + {manyItems.map(({ label, value }) => ( { ); const trigger = screen.getByRole('combobox'); - const selectedOption = document.querySelector('[data-cl-slot="select-option"][data-cl-selected]'); + const selectedOption = document.querySelector('[role="option"][data-selected]'); expect(selectedOption).toBeInTheDocument(); const triggerRect = trigger.getBoundingClientRect(); @@ -674,10 +681,13 @@ describe('Select', () => { defaultValue='item-5' > - + - - + + {manyItems.map(({ label, value }) => ( { await user.click(screen.getByRole('combobox')); - const positioner = document.querySelector('[data-cl-slot="select-positioner"]') as HTMLElement; + const positioner = document.querySelector('[data-testid="select-positioner"]') as HTMLElement; const initialTop = positioner.getBoundingClientRect().top; // Scroll the container @@ -817,10 +827,13 @@ describe('Select', () => { alignItemWithTrigger={false} > - + - - + + { const options = screen.getAllByRole('option'); expect(options[0]).toHaveAttribute('aria-disabled', 'false'); - expect(options[0]).toHaveAttribute('data-cl-active'); + expect(options[0]).toHaveAttribute('data-active'); }); }); }); diff --git a/packages/headless/src/primitives/tabs/README.md b/packages/headless/src/primitives/tabs/README.md index b51d40f3723..b26d47651e0 100644 --- a/packages/headless/src/primitives/tabs/README.md +++ b/packages/headless/src/primitives/tabs/README.md @@ -115,16 +115,15 @@ No additional props beyond standard HTML attributes and the `render` prop. ## Data Attributes -| Attribute | Applies To | Description | -| ------------------------ | ------------ | ----------------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"tabs-tab"`, `"tabs-trigger"`) | -| `data-cl-selected` | Tab, Trigger | Active tab | -| `data-cl-disabled` | Tab, Trigger | Disabled tab | -| `data-cl-hidden` | Panel | Inactive panel | -| `data-cl-open` | Panel | Selected panel (when `shouldForceMount`) | -| `data-cl-closed` | Panel | Deselected panel (when `shouldForceMount`) | -| `data-cl-starting-style` | Panel | Enter animation frame (when `shouldForceMount`) | -| `data-cl-ending-style` | Panel | Exit animation frame (when `shouldForceMount`) | +| Attribute | Applies To | Description | +| --------------------- | ------------ | ----------------------------------------------- | +| `data-selected` | Tab, Trigger | Active tab | +| `data-disabled` | Tab, Trigger | Disabled tab | +| `data-hidden` | Panel | Inactive panel | +| `data-open` | Panel | Selected panel (when `shouldForceMount`) | +| `data-closed` | Panel | Deselected panel (when `shouldForceMount`) | +| `data-starting-style` | Panel | Enter animation frame (when `shouldForceMount`) | +| `data-ending-style` | Panel | Exit animation frame (when `shouldForceMount`) | ## CSS Variables @@ -142,7 +141,7 @@ No additional props beyond standard HTML attributes and the `render` prop. Use these to animate the indicator: ```css -[data-cl-slot='tabs-indicator'] { +.cl-tabs-indicator { position: absolute; left: var(--cl-tab-left); width: var(--cl-tab-width); @@ -165,20 +164,20 @@ When `shouldForceMount` is set, panels expose a direction variable for direction Use this to drive directional slide animations: ```css -[data-cl-slot='tabs-panel'] { +.cl-tabs-panel { --_direction: var(--cl-tab-transition-direction, 1); transition: opacity 200ms, translate 200ms; } -[data-cl-slot='tabs-panel'][data-cl-starting-style], -[data-cl-slot='tabs-panel'][data-cl-ending-style] { +.cl-tabs-panel[data-starting-style], +.cl-tabs-panel[data-ending-style] { opacity: 0; } -[data-cl-slot='tabs-panel'][data-cl-starting-style] { +.cl-tabs-panel[data-starting-style] { translate: calc(var(--_direction) * 8px) 0; } -[data-cl-slot='tabs-panel'][data-cl-ending-style] { +.cl-tabs-panel[data-ending-style] { translate: calc(var(--_direction) * -8px) 0; } ``` diff --git a/packages/headless/src/primitives/tabs/tabs-indicator.tsx b/packages/headless/src/primitives/tabs/tabs-indicator.tsx index 13aabfc5d9d..38738cd67e3 100644 --- a/packages/headless/src/primitives/tabs/tabs-indicator.tsx +++ b/packages/headless/src/primitives/tabs/tabs-indicator.tsx @@ -69,7 +69,6 @@ export function TabsIndicator(props: TabsIndicatorProps) { }, [value, getTabElement, orientation, listElement]); const defaultProps = { - 'data-cl-slot': 'tabs-indicator', 'aria-hidden': true as const, style, }; diff --git a/packages/headless/src/primitives/tabs/tabs-list.tsx b/packages/headless/src/primitives/tabs/tabs-list.tsx index 93e4dd2467c..010efba45e7 100644 --- a/packages/headless/src/primitives/tabs/tabs-list.tsx +++ b/packages/headless/src/primitives/tabs/tabs-list.tsx @@ -18,7 +18,6 @@ export function TabsList(props: TabsListProps) { orientation={orientation} render={(compositeProps: React.HTMLAttributes) => { const defaultProps: Record = { - 'data-cl-slot': 'tabs-list', role: 'tablist' as const, onKeyDown: (event: React.KeyboardEvent) => { if (event.key !== 'Home' && event.key !== 'End') { diff --git a/packages/headless/src/primitives/tabs/tabs-panel.tsx b/packages/headless/src/primitives/tabs/tabs-panel.tsx index bd5593e3a28..4a5cb28c0eb 100644 --- a/packages/headless/src/primitives/tabs/tabs-panel.tsx +++ b/packages/headless/src/primitives/tabs/tabs-panel.tsx @@ -37,13 +37,12 @@ export const TabsPanel = React.forwardRef(functi const effectiveTransitionProps = shouldForceMount && !hasBeenDeselected.current - ? { ...transitionProps, 'data-cl-starting-style': undefined, style: undefined } + ? { ...transitionProps, 'data-starting-style': undefined, style: undefined } : transitionProps; const state = { hidden: !isSelected }; const defaultProps = { - 'data-cl-slot': 'tabs-panel', id: panelId, role: 'tabpanel' as const, 'aria-labelledby': tabId, @@ -69,7 +68,7 @@ export const TabsPanel = React.forwardRef(functi ref: [panelRef, ref], state, stateAttributesMapping: { - hidden: (v: boolean) => (v ? { 'data-cl-hidden': '' } : null), + hidden: (v: boolean) => (v ? { 'data-hidden': '' } : null), }, props: merged, }); diff --git a/packages/headless/src/primitives/tabs/tabs-tab.tsx b/packages/headless/src/primitives/tabs/tabs-tab.tsx index 3a3bc7fb5be..96edd29a5c7 100644 --- a/packages/headless/src/primitives/tabs/tabs-tab.tsx +++ b/packages/headless/src/primitives/tabs/tabs-tab.tsx @@ -36,7 +36,6 @@ export const TabsTab = React.forwardRef(functio disabled={disabled} render={(compositeProps: React.HTMLAttributes) => { const defaultProps: Record = { - 'data-cl-slot': 'tabs-tab', id: tabId, role: 'tab' as const, type: 'button' as const, @@ -93,8 +92,8 @@ export const TabsTab = React.forwardRef(functio ref: [internalRef, compositeRef as React.Ref, ref], state, stateAttributesMapping: { - selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + selected: (v: boolean) => (v ? { 'data-selected': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: mergedProps, }); diff --git a/packages/headless/src/primitives/tabs/tabs-trigger.tsx b/packages/headless/src/primitives/tabs/tabs-trigger.tsx index e7fbbb96aac..3cbd54d2a78 100644 --- a/packages/headless/src/primitives/tabs/tabs-trigger.tsx +++ b/packages/headless/src/primitives/tabs/tabs-trigger.tsx @@ -30,7 +30,6 @@ export const TabsTrigger = React.forwardRef }; const defaultProps = { - 'data-cl-slot': 'tabs-trigger', id: tabId, role: 'tab' as const, type: 'button' as const, @@ -55,8 +54,8 @@ export const TabsTrigger = React.forwardRef ref: [triggerRef, ref], state, stateAttributesMapping: { - selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null), - disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null), + selected: (v: boolean) => (v ? { 'data-selected': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, props: merged, }); diff --git a/packages/headless/src/primitives/tabs/tabs.test.tsx b/packages/headless/src/primitives/tabs/tabs.test.tsx index 6f6cd4d5c92..2f1217cc8d4 100644 --- a/packages/headless/src/primitives/tabs/tabs.test.tsx +++ b/packages/headless/src/primitives/tabs/tabs.test.tsx @@ -27,25 +27,6 @@ function renderTabs(props: Partial> = {}) } describe('Tabs', () => { - describe('slot attributes', () => { - it('renders list with data-cl-slot', () => { - renderTabs(); - expect(document.querySelector('[data-cl-slot="tabs-list"]')).toBeInTheDocument(); - }); - - it('renders tabs with data-cl-slot', () => { - renderTabs(); - const tabs = document.querySelectorAll('[data-cl-slot="tabs-tab"]'); - expect(tabs).toHaveLength(3); - }); - - it('renders panels with data-cl-slot', () => { - renderTabs(); - const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); - expect(panels).toHaveLength(3); - }); - }); - describe('ARIA attributes', () => { it('list has role=tablist', () => { renderTabs(); @@ -136,10 +117,10 @@ describe('Tabs', () => { expect(screen.getByText('Settings content')).toBeVisible(); }); - it('marks selected tab with data-cl-selected', () => { + it('marks selected tab with data-selected', () => { renderTabs(); - expect(screen.getByText('Account')).toHaveAttribute('data-cl-selected', ''); - expect(screen.getByText('Settings').hasAttribute('data-cl-selected')).toBe(false); + expect(screen.getByText('Account')).toHaveAttribute('data-selected', ''); + expect(screen.getByText('Settings').hasAttribute('data-selected')).toBe(false); }); it('calls onValueChange on selection', async () => { @@ -338,9 +319,9 @@ describe('Tabs', () => { ); } - it('disabled tab has data-cl-disabled', () => { + it('disabled tab has data-disabled', () => { renderWithDisabled(); - expect(screen.getByText('Settings')).toHaveAttribute('data-cl-disabled', ''); + expect(screen.getByText('Settings')).toHaveAttribute('data-disabled', ''); }); it('disabled tab has aria-disabled', () => { @@ -384,11 +365,6 @@ describe('Tabs', () => { ); } - it('renders with data-cl-slot', () => { - renderWithIndicator(); - expect(document.querySelector('[data-cl-slot="tabs-indicator"]')).toBeInTheDocument(); - }); - it('has position absolute', () => { renderWithIndicator(); const indicator = screen.getByTestId('indicator'); @@ -462,7 +438,7 @@ describe('Tabs', () => { ); const indicator = screen.getByTestId('indicator'); - const list = document.querySelector('[data-cl-slot="tabs-list"]') as HTMLElement; + const list = screen.getByRole('tablist'); // The indicator should have registered an observer for its active tab. expect(observers.length).toBeGreaterThan(0); @@ -472,8 +448,8 @@ describe('Tabs', () => { const rect = (left: number, width: number) => ({ left, top: 0, right: left + width, bottom: 20, width, height: 20, x: left, y: 0, toJSON() {} }) as DOMRect; list.getBoundingClientRect = () => rect(0, 400); - for (const tab of document.querySelectorAll('[data-cl-slot="tabs-tab"]')) { - (tab as HTMLElement).getBoundingClientRect = () => rect(0, 250); + for (const tab of screen.getAllByRole('tab')) { + tab.getBoundingClientRect = () => rect(0, 250); } // Fire every observer the tree created. @@ -493,7 +469,7 @@ describe('Tabs', () => { describe('panel visibility', () => { it('hides non-selected panels with hidden attribute', () => { renderTabs(); - const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + const panels = screen.queryAllByRole('tabpanel', { hidden: true }); const visible = Array.from(panels).filter(p => !p.hasAttribute('hidden')); const hidden = Array.from(panels).filter(p => p.hasAttribute('hidden')); @@ -501,15 +477,15 @@ describe('Tabs', () => { expect(hidden).toHaveLength(2); }); - it('non-selected panels have data-cl-hidden', () => { + it('non-selected panels have data-hidden', () => { renderTabs(); - const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"][data-cl-hidden]'); + const panels = screen.queryAllByRole('tabpanel', { hidden: true }).filter(p => p.hasAttribute('data-hidden')); expect(panels).toHaveLength(2); }); it('non-selected panels have inert attribute, selected panel does not', () => { renderTabs(); - const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + const panels = screen.queryAllByRole('tabpanel', { hidden: true }); const inert = Array.from(panels).filter(p => p.hasAttribute('inert')); const notInert = Array.from(panels).filter(p => !p.hasAttribute('inert')); // Presence check only — `inertProps` emits the value each React major reflects @@ -524,7 +500,7 @@ describe('Tabs', () => { await user.click(screen.getByText('Settings')); - const panels = document.querySelectorAll('[data-cl-slot="tabs-panel"]'); + const panels = screen.queryAllByRole('tabpanel', { hidden: true }); const [account, settings, billing] = Array.from(panels); expect(account).toHaveAttribute('inert'); expect(settings).not.toHaveAttribute('inert'); @@ -581,7 +557,7 @@ describe('Tabs', () => { ); expect(ref.current).toBeInstanceOf(HTMLButtonElement); - expect(ref.current).toHaveAttribute('data-cl-slot', 'tabs-tab'); + expect(ref.current).toHaveAttribute('role', 'tab'); }); }); diff --git a/packages/headless/src/primitives/tooltip/README.md b/packages/headless/src/primitives/tooltip/README.md index be3f710a2eb..947d5ba68e0 100644 --- a/packages/headless/src/primitives/tooltip/README.md +++ b/packages/headless/src/primitives/tooltip/README.md @@ -93,12 +93,11 @@ Accepts all `FloatingArrow` props. `ref` and `context` are injected automaticall ## Data Attributes -| Attribute | Applies To | Description | -| ------------------------------------------------- | ----------------- | --------------------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"tooltip-popup"`) | -| `data-cl-open` / `data-cl-closed` | Trigger, Popup | Open/closed state | -| `data-cl-starting-style` / `data-cl-ending-style` | Popup | Transition state — set during enter/exit animation frames | -| `data-cl-side` | Positioner, Arrow | Resolved placement side | +| Attribute | Applies To | Description | +| ------------------------------------------- | ----------------- | --------------------------------------------------------- | +| `data-open` / `data-closed` | Trigger, Popup | Open/closed state | +| `data-starting-style` / `data-ending-style` | Popup | Transition state — set during enter/exit animation frames | +| `data-side` | Positioner, Arrow | Resolved placement side | ## Positioning diff --git a/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx b/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx index 5b9662f412a..1dc6d9847ca 100644 --- a/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx +++ b/packages/headless/src/primitives/tooltip/tooltip-arrow.tsx @@ -16,8 +16,7 @@ export const TooltipArrow = React.forwardRef(f return ( ( const { popupRef, transitionProps } = useTooltipContext(); const defaultProps = { - 'data-cl-slot': 'tooltip-popup', ...transitionProps, }; diff --git a/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx index 963e5856c29..916e928c881 100644 --- a/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx +++ b/packages/headless/src/primitives/tooltip/tooltip-positioner.tsx @@ -17,8 +17,7 @@ export const TooltipPositioner = React.forwardRef; diff --git a/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx b/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx index 19d3a3b05ee..26ee68ec3d0 100644 --- a/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx +++ b/packages/headless/src/primitives/tooltip/tooltip-trigger.tsx @@ -16,7 +16,6 @@ export const TooltipTrigger = React.forwardRef; const defaultProps = { ...ownProps, ...getReferenceProps() }; @@ -26,7 +25,7 @@ export const TooltipTrigger = React.forwardRef | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, // floating-ui types `setReference` as a method signature, but at runtime it's // a stable callback that doesn't use `this`, so the unbound-method check is a diff --git a/packages/headless/src/primitives/tooltip/tooltip.test.tsx b/packages/headless/src/primitives/tooltip/tooltip.test.tsx index 0a6fd637ffa..27c7831fd18 100644 --- a/packages/headless/src/primitives/tooltip/tooltip.test.tsx +++ b/packages/headless/src/primitives/tooltip/tooltip.test.tsx @@ -15,29 +15,14 @@ function renderTooltip(props: Partial> {...props} > Hover me - - Tooltip content + + Tooltip content , ); } describe('Tooltip', () => { - describe('slot attributes', () => { - it('renders trigger with data-cl-slot', () => { - renderTooltip(); - const trigger = screen.getByRole('button', { name: 'Hover me' }); - expect(trigger).toHaveAttribute('data-cl-slot', 'tooltip-trigger'); - }); - - it('renders all parts with correct slot attributes when open', () => { - renderTooltip({ defaultOpen: true }); - - expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).toBeInTheDocument(); - expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); - }); - }); - describe('open/close', () => { it('opens on hover', async () => { const user = userEvent.setup(); @@ -46,7 +31,7 @@ describe('Tooltip', () => { const trigger = screen.getByRole('button', { name: 'Hover me' }); await user.hover(trigger); - expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="tooltip-popup"]')).toBeInTheDocument(); }); it('closes on unhover', async () => { @@ -56,12 +41,12 @@ describe('Tooltip', () => { const trigger = screen.getByRole('button', { name: 'Hover me' }); await user.hover(trigger); - expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="tooltip-popup"]')).toBeInTheDocument(); await user.unhover(trigger); const triggerEl = screen.getByRole('button', { name: 'Hover me' }); - expect(triggerEl).toHaveAttribute('data-cl-closed', ''); + expect(triggerEl).toHaveAttribute('data-closed', ''); }); it('opens on focus', async () => { @@ -72,7 +57,7 @@ describe('Tooltip', () => { trigger.focus(); }); - expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="tooltip-popup"]')).toBeInTheDocument(); }); it('closes on Escape', async () => { @@ -82,7 +67,7 @@ describe('Tooltip', () => { await user.keyboard('{Escape}'); const trigger = screen.getByRole('button', { name: 'Hover me' }); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); it('calls onOpenChange when toggled', async () => { @@ -101,7 +86,7 @@ describe('Tooltip', () => { it('respects controlled open prop', () => { renderTooltip({ open: true }); - expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="tooltip-positioner"]')).toBeInTheDocument(); }); it('does not open when controlled open is false', async () => { @@ -110,7 +95,7 @@ describe('Tooltip', () => { await user.hover(screen.getByRole('button', { name: 'Hover me' })); - expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="tooltip-positioner"]')).not.toBeInTheDocument(); }); }); @@ -123,7 +108,7 @@ describe('Tooltip', () => { await user.hover(trigger); // aria-describedby must reference the tooltip positioner's id - const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + const positioner = document.querySelector('[data-testid="tooltip-positioner"]'); expect(positioner).toBeInTheDocument(); const positionerId = positioner!.getAttribute('id'); expect(positionerId).toBeTruthy(); @@ -146,9 +131,9 @@ describe('Tooltip', () => { const trigger = screen.getByRole('button', { name: 'Hover me' }); await user.hover(trigger); - expect(document.querySelector('[data-cl-slot="tooltip-popup"]')).toBeInTheDocument(); + expect(document.querySelector('[data-testid="tooltip-popup"]')).toBeInTheDocument(); // Tooltip must not steal focus — active element should not be inside the tooltip - const popup = document.querySelector('[data-cl-slot="tooltip-popup"]'); + const popup = document.querySelector('[data-testid="tooltip-popup"]'); expect(popup).not.toContainElement(document.activeElement as HTMLElement); }); @@ -157,8 +142,11 @@ describe('Tooltip', () => { render( Hover me - - Tooltip content + + Tooltip content , ); @@ -166,7 +154,7 @@ describe('Tooltip', () => { const trigger = screen.getByRole('button', { name: 'Hover me' }); await user.hover(trigger); - const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); + const positioner = document.querySelector('[data-testid="tooltip-positioner"]'); expect(positioner).toBeInTheDocument(); // The wired id is owned by floating-ui: a consumer-supplied id must not // silently break the aria-describedby pairing between trigger and positioner. @@ -179,27 +167,27 @@ describe('Tooltip', () => { describe('animation lifecycle', () => { it('positioner is not rendered when closed', () => { renderTooltip(); - expect(document.querySelector('[data-cl-slot="tooltip-positioner"]')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="tooltip-positioner"]')).not.toBeInTheDocument(); }); - it('applies data-cl-open on popup when open', async () => { + it('applies data-open on popup when open', async () => { const user = userEvent.setup(); renderTooltip(); await user.hover(screen.getByRole('button', { name: 'Hover me' })); - const popup = document.querySelector('[data-cl-slot="tooltip-popup"]'); - expect(popup).toHaveAttribute('data-cl-open', ''); + const popup = document.querySelector('[data-testid="tooltip-popup"]'); + expect(popup).toHaveAttribute('data-open', ''); }); - it('positioner has data-cl-side', async () => { + it('positioner has data-side', async () => { const user = userEvent.setup(); renderTooltip(); await user.hover(screen.getByRole('button', { name: 'Hover me' })); - const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); - expect(positioner).toHaveAttribute('data-cl-side'); + const positioner = document.querySelector('[data-testid="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-side'); }); }); @@ -207,34 +195,34 @@ describe('Tooltip', () => { it('accepts custom placement', () => { renderTooltip({ defaultOpen: true, placement: 'bottom-end' }); - const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); - expect(positioner).toHaveAttribute('data-cl-side', 'bottom'); + const positioner = document.querySelector('[data-testid="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-side', 'bottom'); }); it('defaults to top placement', () => { renderTooltip({ defaultOpen: true }); - const positioner = document.querySelector('[data-cl-slot="tooltip-positioner"]'); - expect(positioner).toHaveAttribute('data-cl-side', 'top'); + const positioner = document.querySelector('[data-testid="tooltip-positioner"]'); + expect(positioner).toHaveAttribute('data-side', 'top'); }); }); describe('trigger state attributes', () => { - it('trigger has data-cl-open when tooltip is visible', async () => { + it('trigger has data-open when tooltip is visible', async () => { const user = userEvent.setup(); renderTooltip(); const trigger = screen.getByRole('button', { name: 'Hover me' }); await user.hover(trigger); - expect(trigger).toHaveAttribute('data-cl-open', ''); + expect(trigger).toHaveAttribute('data-open', ''); }); - it('trigger has data-cl-closed when tooltip is hidden', () => { + it('trigger has data-closed when tooltip is hidden', () => { renderTooltip(); const trigger = screen.getByRole('button', { name: 'Hover me' }); - expect(trigger).toHaveAttribute('data-cl-closed', ''); + expect(trigger).toHaveAttribute('data-closed', ''); }); }); @@ -347,7 +335,7 @@ describe('Tooltip', () => { ); expect(ref.current).not.toBeNull(); - expect(ref.current).toHaveAttribute('data-cl-slot', 'tooltip-arrow'); + expect(ref.current).toHaveAttribute('data-side'); }); }); }); diff --git a/packages/headless/src/utils/css-vars.ts b/packages/headless/src/utils/css-vars.ts index 67c973bbfd2..200184cba61 100644 --- a/packages/headless/src/utils/css-vars.ts +++ b/packages/headless/src/utils/css-vars.ts @@ -45,7 +45,8 @@ export function cssVars(opts?: { sideOffset?: number }): Middleware { style.setProperty('--cl-available-height', `${availableHeight}px`); // Transform origin — points back toward the anchor - const arrowEl = elements.floating.querySelector("[data-cl-slot$='-arrow']"); + // The arrow is the only FloatingArrow descendant carrying data-side. + const arrowEl = elements.floating.querySelector('svg[data-side]'); let transformX: number; let transformY: number; diff --git a/packages/headless/src/utils/use-render.test.tsx b/packages/headless/src/utils/use-render.test.tsx index a26c3cdb0cb..da1411caad9 100644 --- a/packages/headless/src/utils/use-render.test.tsx +++ b/packages/headless/src/utils/use-render.test.tsx @@ -110,7 +110,7 @@ describe('useRender', () => { defaultTagName: 'button', state: { open: true }, stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }), + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, props: { 'data-testid': 'test' }, }); @@ -118,8 +118,8 @@ describe('useRender', () => { render(); const el = screen.getByTestId('test'); - expect(el).toHaveAttribute('data-cl-open', ''); - expect(el).not.toHaveAttribute('data-cl-closed'); + expect(el).toHaveAttribute('data-open', ''); + expect(el).not.toHaveAttribute('data-closed'); }); it('merges a single ref onto the rendered element', () => { @@ -219,7 +219,7 @@ describe('useRender', () => { ref, state: { open: true }, stateAttributesMapping: { - open: (v: boolean) => (v ? { 'data-cl-open': '' } : null), + open: (v: boolean) => (v ? { 'data-open': '' } : null), }, props: { id: 'test' }, }); @@ -229,7 +229,7 @@ describe('useRender', () => { expect(renderFn).toHaveBeenCalledWith( expect.objectContaining({ id: 'test', - 'data-cl-open': '', + 'data-open': '', ref: expect.any(Function), }), ); diff --git a/packages/headless/src/utils/use-render.tsx b/packages/headless/src/utils/use-render.tsx index bf1197e7ee4..2c7ba5293c8 100644 --- a/packages/headless/src/utils/use-render.tsx +++ b/packages/headless/src/utils/use-render.tsx @@ -21,7 +21,7 @@ export type ComponentProps = Reac /** * The props a primitive part applies to its own rendered element. Extends the * native props for `Tag` and additionally permits internal `data-*` attributes - * (e.g. `data-cl-slot`), which `@types/react` intentionally omits from its + * (e.g. `data-open`), which `@types/react` intentionally omits from its * element prop types. * * Use with `satisfies` to type-check authored default props — this validates diff --git a/packages/swingset/CLAUDE.md b/packages/swingset/CLAUDE.md index 6e1d253eef5..88a57bc4414 100644 --- a/packages/swingset/CLAUDE.md +++ b/packages/swingset/CLAUDE.md @@ -199,7 +199,7 @@ No styles, so there's no knob canvas. The single demo renders the primitive **ra # Accordion + that you target its `data-*` state attributes for appearance. --> ## Example @@ -222,7 +222,7 @@ No styles, so there's no knob canvas. The single demo renders the primitive **ra ## Styling - ``` diff --git a/packages/swingset/src/stories/accordion.mdx b/packages/swingset/src/stories/accordion.mdx index ba7bae68de8..73bf28aaf1f 100644 --- a/packages/swingset/src/stories/accordion.mdx +++ b/packages/swingset/src/stories/accordion.mdx @@ -6,7 +6,7 @@ A set of stacked sections where each is toggled by its own heading button, from `@clerk/headless`. It is a **headless** primitive: it supplies behavior, single/multiple open state, roving-focus keyboard navigation, ARIA wiring, and the expand/collapse animation lifecycle, but ships **no styles** — you bring your own CSS by targeting the -`data-cl-*` attributes each part emits. +`data-*` attributes each part emits. ## Example @@ -97,27 +97,26 @@ props beyond standard HTML attributes for their default element. ## Styling -Each part emits `data-cl-*` attributes you can target with any CSS solution: +Each part emits `data-*` attributes you can target with any CSS solution: -| Attribute | Applies To | Description | -| ------------------------ | -------------------- | --------------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier | -| `data-cl-open` | Item, Trigger, Panel | Present when the item is open | -| `data-cl-closed` | Item, Trigger, Panel | Present when the item is closed | -| `data-cl-disabled` | Item, Trigger | Present when the item is disabled | -| `data-cl-starting-style` | Panel | Present on the entering frame of the open animation | -| `data-cl-ending-style` | Panel | Present during the exit animation before unmount | +| Attribute | Applies To | Description | +| --------------------- | -------------------- | --------------------------------------------------- | +| `data-open` | Item, Trigger, Panel | Present when the item is open | +| `data-closed` | Item, Trigger, Panel | Present when the item is closed | +| `data-disabled` | Item, Trigger | Present when the item is disabled | +| `data-starting-style` | Panel | Present on the entering frame of the open animation | +| `data-ending-style` | Panel | Present during the exit animation before unmount | `Accordion.Panel` exposes `--cl-accordion-panel-height` (its measured content height) for height-based animations: ```css -[data-cl-slot='accordion-panel'] { +.cl-accordion-panel { overflow: hidden; height: var(--cl-accordion-panel-height); transition: height 200ms ease; } -[data-cl-slot='accordion-panel'][data-cl-closed] { +.cl-accordion-panel[data-closed] { height: 0; } ``` diff --git a/packages/swingset/src/stories/accordion.stories.tsx b/packages/swingset/src/stories/accordion.stories.tsx index b0d115db890..617544a506c 100644 --- a/packages/swingset/src/stories/accordion.stories.tsx +++ b/packages/swingset/src/stories/accordion.stories.tsx @@ -4,7 +4,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state, -// and ARIA wiring via the `data-cl-*` attributes each part emits, with zero appearance. +// and ARIA wiring via the `data-*` attributes each part emits, with zero appearance. // It is embedded once into the overview via `` in the MDX (the one thing prose // can't convey: that items actually expand/collapse). There is no interactive knob canvas // for headless primitives. @@ -27,7 +27,7 @@ export function Default() { A headless component provides behavior, state management, and accessibility without imposing any styles — you - bring your own CSS via the data-cl-slot attributes it emits. + bring your own classNames and target the data-* state attributes it emits. diff --git a/packages/swingset/src/stories/autocomplete.mdx b/packages/swingset/src/stories/autocomplete.mdx index f1878d35093..4edc103f6f8 100644 --- a/packages/swingset/src/stories/autocomplete.mdx +++ b/packages/swingset/src/stories/autocomplete.mdx @@ -5,7 +5,7 @@ import * as AutocompleteStories from './autocomplete.stories'; A text input paired with a filtered list of suggestions, from `@clerk/headless`. It is a **headless** primitive: it supplies input text, selected value, and open state, portalling, floating-ui positioning, virtual-focus keyboard navigation, and ARIA `combobox` wiring, but -ships **no styles** — you bring your own CSS by targeting the `data-cl-*` attributes each +ships **no styles** — you bring your own CSS by targeting the `data-*` attributes each part emits. It does **no filtering of its own**: you read `inputValue` and render the surviving options. @@ -103,17 +103,16 @@ default element. `Autocomplete.Portal` accepts `root`. ## Styling -Each part emits `data-cl-*` attributes you can target with any CSS solution: - -| Attribute | Applies To | Description | -| ------------------ | ----------------- | -------------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier | -| `data-cl-open` | Input | Present when the popup is open | -| `data-cl-closed` | Input | Present when the popup is closed | -| `data-cl-selected` | Option | Present on the option matching the selected value | -| `data-cl-active` | Option | Present on the keyboard-highlighted option | -| `data-cl-disabled` | Option | Present on a disabled option | -| `data-cl-side` | Positioner, Arrow | Resolved side: `top` / `bottom` / `left` / `right` | +Each part emits `data-*` attributes you can target with any CSS solution: + +| Attribute | Applies To | Description | +| --------------- | ----------------- | -------------------------------------------------- | +| `data-open` | Input | Present when the popup is open | +| `data-closed` | Input | Present when the popup is closed | +| `data-selected` | Option | Present on the option matching the selected value | +| `data-active` | Option | Present on the keyboard-highlighted option | +| `data-disabled` | Option | Present on a disabled option | +| `data-side` | Positioner, Arrow | Resolved side: `top` / `bottom` / `left` / `right` | The positioner exposes anchor/viewport geometry as CSS variables (and sets an inline `width` matching the input and a `max-height` of the available viewport height): diff --git a/packages/swingset/src/stories/autocomplete.stories.tsx b/packages/swingset/src/stories/autocomplete.stories.tsx index e5c67fac24b..4fce174e6ef 100644 --- a/packages/swingset/src/stories/autocomplete.stories.tsx +++ b/packages/swingset/src/stories/autocomplete.stories.tsx @@ -7,7 +7,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state, -// positioning, keyboard navigation, and ARIA wiring via the `data-cl-*` attributes each +// positioning, keyboard navigation, and ARIA wiring via the `data-*` attributes each // part emits, with zero appearance. It is embedded once into the overview via `` // in the MDX (the one thing prose can't convey: that typing filters the list the consumer // supplies). There is no interactive knob canvas for headless primitives. diff --git a/packages/swingset/src/stories/collapsible.mdx b/packages/swingset/src/stories/collapsible.mdx index a51dde4e225..f5ec2204d6b 100644 --- a/packages/swingset/src/stories/collapsible.mdx +++ b/packages/swingset/src/stories/collapsible.mdx @@ -5,7 +5,7 @@ import * as CollapsibleStories from './collapsible.stories'; A single show/hide panel toggled by a button, from `@clerk/headless`. It is a **headless** primitive: it supplies behavior, state, ARIA wiring, and the expand/collapse animation lifecycle, but ships **no styles** — you bring your own -CSS by targeting the `data-cl-*` attributes each part emits. +CSS by targeting the `data-*` attributes each part emits. ## Example @@ -69,26 +69,25 @@ standard HTML attributes for their default element. ## Styling -Each part emits `data-cl-*` attributes you can target with any CSS solution: +Each part emits `data-*` attributes you can target with any CSS solution: -| Attribute | Applies To | Description | -| ------------------ | -------------------- | -------------------------------- | -| `data-cl-slot` | All parts | Part identifier | -| `data-cl-open` | Root, Trigger, Panel | Present when the panel is open | -| `data-cl-closed` | Root, Trigger, Panel | Present when the panel is closed | -| `data-cl-disabled` | Root, Trigger | Present when disabled | +| Attribute | Applies To | Description | +| --------------- | -------------------- | -------------------------------- | +| `data-open` | Root, Trigger, Panel | Present when the panel is open | +| `data-closed` | Root, Trigger, Panel | Present when the panel is closed | +| `data-disabled` | Root, Trigger | Present when disabled | `Collapsible.Panel` also exposes `--collapsible-panel-height` / `--collapsible-panel-width` (its measured dimensions) for height/width-based animations: ```css -[data-cl-slot='collapsible-panel'] { +.cl-collapsible-panel { overflow: hidden; height: var(--collapsible-panel-height); transition: height 200ms ease; } -[data-cl-slot='collapsible-panel'][data-cl-closed] { +.cl-collapsible-panel[data-closed] { height: 0; } ``` diff --git a/packages/swingset/src/stories/collapsible.stories.tsx b/packages/swingset/src/stories/collapsible.stories.tsx index 9afdc99c872..36ee4e0cdc4 100644 --- a/packages/swingset/src/stories/collapsible.stories.tsx +++ b/packages/swingset/src/stories/collapsible.stories.tsx @@ -4,7 +4,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state, -// and ARIA wiring via the `data-cl-*` attributes each part emits, with zero appearance. +// and ARIA wiring via the `data-*` attributes each part emits, with zero appearance. // It is embedded once into the overview via `` in the MDX (the one thing prose // can't convey: that it actually expands/collapses). There is no interactive knob canvas // for headless primitives. @@ -21,7 +21,7 @@ export function Default() { What is a headless component? A headless component provides behavior, state management, and accessibility without imposing any styles — you - bring your own CSS via the data-cl-slot attributes it emits. + bring your own classNames and target the data-* state attributes it emits. ); diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 94c9927ffb4..f94c16b1a3a 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -99,10 +99,9 @@ The Mosaic dialog exposes the following slots that can be styled via `appearance Override per slot through `appearance.elements` — e.g. `{ 'dialog-popup': { borderRadius: 24 } }`. State attributes from the headless layer are also available for CSS targeting: -| Attribute | Applies To | Description | -| ------------------------ | ---------------------------------- | --------------------------------- | -| `data-cl-slot` | All parts | Part identifier | -| `data-cl-open` | Trigger, Backdrop, Viewport, Popup | Present when the dialog is open | -| `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Present when closed (during exit) | -| `data-cl-starting-style` | Backdrop, Viewport, Popup | Present on the entering frame | -| `data-cl-ending-style` | Backdrop, Viewport, Popup | Present during the exit animation | +| Attribute | Applies To | Description | +| --------------------- | ---------------------------------- | --------------------------------- | +| `data-open` | Trigger, Backdrop, Viewport, Popup | Present when the dialog is open | +| `data-closed` | Trigger, Backdrop, Viewport, Popup | Present when closed (during exit) | +| `data-starting-style` | Backdrop, Viewport, Popup | Present on the entering frame | +| `data-ending-style` | Backdrop, Viewport, Popup | Present during the exit animation | diff --git a/packages/swingset/src/stories/dialog.mdx b/packages/swingset/src/stories/dialog.mdx index a3dae7e9d74..23db48b9821 100644 --- a/packages/swingset/src/stories/dialog.mdx +++ b/packages/swingset/src/stories/dialog.mdx @@ -6,7 +6,7 @@ A modal window that overlays the page and traps focus until dismissed, from `@clerk/headless`. It is a **headless** primitive: it supplies open state, portalling, an overlay surface, a centering viewport with optional body scroll lock, focus management, dismissal (outside press / Escape), and ARIA wiring, but ships **no styles** — you bring -your own CSS by targeting the `data-cl-*` state attributes each part emits. +your own CSS by targeting the `data-*` state attributes each part emits. ## Example @@ -105,16 +105,15 @@ default element. ## Styling -The headless parts don't emit `data-cl-slot` — slot identity is applied by the styled -(Mosaic) layer. Target a part with your own class (or `render` prop) and combine it with -the `data-cl-*` state attributes each part emits: +The headless parts are unstyled. Target a part with your own class (or `render` prop) and +combine it with the `data-*` state attributes each part emits: -| Attribute | Applies To | Description | -| ------------------------ | ---------------------------------- | ------------------------------------------------- | -| `data-cl-open` | Trigger, Backdrop, Viewport, Popup | Present when the dialog is open | -| `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Present when closed (still mounted while exiting) | -| `data-cl-starting-style` | Backdrop, Viewport, Popup | Present on the entering frame | -| `data-cl-ending-style` | Backdrop, Viewport, Popup | Present during the exit animation | +| Attribute | Applies To | Description | +| --------------------- | ---------------------------------- | ------------------------------------------------- | +| `data-open` | Trigger, Backdrop, Viewport, Popup | Present when the dialog is open | +| `data-closed` | Trigger, Backdrop, Viewport, Popup | Present when closed (still mounted while exiting) | +| `data-starting-style` | Backdrop, Viewport, Popup | Present on the entering frame | +| `data-ending-style` | Backdrop, Viewport, Popup | Present during the exit animation | The backdrop, viewport, and popup stay mounted through the exit animation, so enter/exit transitions are CSS-driven: @@ -124,8 +123,8 @@ transitions are CSS-driven: opacity: 1; transition: opacity 150ms ease; } -.dialog-popup[data-cl-starting-style], -.dialog-popup[data-cl-ending-style] { +.dialog-popup[data-starting-style], +.dialog-popup[data-ending-style] { opacity: 0; } ``` diff --git a/packages/swingset/src/stories/dialog.stories.tsx b/packages/swingset/src/stories/dialog.stories.tsx index e77e71147f5..c1b07583877 100644 --- a/packages/swingset/src/stories/dialog.stories.tsx +++ b/packages/swingset/src/stories/dialog.stories.tsx @@ -4,7 +4,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state, -// and ARIA wiring via the `data-cl-*` attributes each part emits, with zero appearance. +// and ARIA wiring via the `data-*` attributes each part emits, with zero appearance. // It is embedded once into the overview via `` in the MDX (the one thing prose // can't convey: that it opens, traps focus, and dismisses). There is no interactive knob // canvas for headless primitives. diff --git a/packages/swingset/src/stories/drawer.mdx b/packages/swingset/src/stories/drawer.mdx index 76b6cfc9730..33a87ab6c6d 100644 --- a/packages/swingset/src/stories/drawer.mdx +++ b/packages/swingset/src/stories/drawer.mdx @@ -7,7 +7,7 @@ down, from `@clerk/headless`. It is a **headless** primitive: it supplies open s portalling, an overlay surface, a scroll-locked viewport, focus management, dismissal (drag / outside press / Escape), optional snap points, virtual-keyboard awareness, nesting, and ARIA wiring, but ships **no styles** — you bring your own CSS by targeting the -`data-cl-*` state attributes each part emits and composing the raw `--cl-drawer-*` custom +`data-*` state attributes each part emits and composing the raw `--cl-drawer-*` custom properties it writes. It reuses the same Floating UI infrastructure as `Dialog` with a hand-rolled drag engine layered on top; prefer `Dialog` for centered modals with no drag. @@ -156,21 +156,20 @@ additional props beyond standard HTML attributes for their default element. ## Styling -The headless parts don't emit `data-cl-slot` — slot identity is applied by the styled -(Mosaic) layer. Target a part with your own class (or `render` prop) and combine it with the -`data-cl-*` state attributes each part emits: - -| Attribute | Applies To | Description | -| ------------------------------------------------- | ---------------------------------- | --------------------------------------------------- | -| `data-cl-open` / `data-cl-closed` | Trigger, Backdrop, Viewport, Popup | Present while open / closed (still mounted exiting) | -| `data-cl-starting-style` / `data-cl-ending-style` | Backdrop, Viewport, Popup | Present on the entering / exiting frame | -| `data-cl-swiping` | Popup, Backdrop | Present while a drag is in progress | -| `data-cl-snap` | Popup | The active snap index | -| `data-cl-expanded` | Popup | Present when resting at the full-height snap | -| `data-cl-nested` | Popup | This drawer is itself nested | -| `data-cl-nested-drawer-open` | Popup | A nested child drawer is open | -| `data-cl-drawer-handle` | Handle | Grip / `handleOnly` hit-test target | -| `data-cl-drawer-no-drag` | (consumer-set) | Opt a subtree out of the drag gesture | +The headless parts are unstyled. Target a part with your own class (or `render` prop) and +combine it with the `data-*` state attributes each part emits: + +| Attribute | Applies To | Description | +| ------------------------------------------- | ---------------------------------- | --------------------------------------------------- | +| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Present while open / closed (still mounted exiting) | +| `data-starting-style` / `data-ending-style` | Backdrop, Viewport, Popup | Present on the entering / exiting frame | +| `data-swiping` | Popup, Backdrop | Present while a drag is in progress | +| `data-snap` | Popup | The active snap index | +| `data-expanded` | Popup | Present when resting at the full-height snap | +| `data-nested` | Popup | This drawer is itself nested | +| `data-nested-drawer-open` | Popup | A nested child drawer is open | +| `data-drawer-handle` | Handle | Grip / `handleOnly` hit-test target | +| `data-drawer-no-drag` | (consumer-set) | Opt a subtree out of the drag gesture | The drag engine and snap layer write raw inputs as CSS custom properties on `Drawer.Popup`; the styled layer composes them into the actual `transform` / `opacity` chains (the headless @@ -187,18 +186,18 @@ via `registerDrawerCssVars()` (a no-op where `CSS.registerProperty` is unavailab The backdrop, viewport, and popup stay mounted through the exit animation, so enter/exit transitions are CSS-driven. Sum the resting snap offset with the live drag delta so the sheet -follows the finger, and drop the transition while `data-cl-swiping` is present: +follows the finger, and drop the transition while `data-swiping` is present: ```css .drawer-popup { transform: translateY(calc(var(--cl-drawer-snap-point-offset, 0px) + var(--cl-drawer-swipe-movement-y, 0px))); transition: transform 450ms cubic-bezier(0.32, 0.72, 0, 1); } -.drawer-popup[data-cl-starting-style], -.drawer-popup[data-cl-ending-style] { +.drawer-popup[data-starting-style], +.drawer-popup[data-ending-style] { transform: translateY(100%); } -.drawer-popup[data-cl-swiping] { +.drawer-popup[data-swiping] { transition-duration: 0ms; } .drawer-backdrop { diff --git a/packages/swingset/src/stories/drawer.stories.tsx b/packages/swingset/src/stories/drawer.stories.tsx index cd32ebc8486..c6dfff97928 100644 --- a/packages/swingset/src/stories/drawer.stories.tsx +++ b/packages/swingset/src/stories/drawer.stories.tsx @@ -4,7 +4,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state, -// the drag-to-dismiss gesture, and ARIA wiring via the `data-cl-*` attributes each part +// the drag-to-dismiss gesture, and ARIA wiring via the `data-*` attributes each part // emits, with zero appearance. It is embedded once into the overview via `` in the // MDX (the one thing prose can't convey: that it opens, traps focus, and dismisses on drag // / Escape / outside press). There is no interactive knob canvas for headless primitives. diff --git a/packages/swingset/src/stories/file-upload.mdx b/packages/swingset/src/stories/file-upload.mdx index adb1ec101cf..7242e0a46ea 100644 --- a/packages/swingset/src/stories/file-upload.mdx +++ b/packages/swingset/src/stories/file-upload.mdx @@ -5,7 +5,7 @@ import * as FileUploadStories from './file-upload.stories'; A file picker that accepts input from a trigger button or drag-and-drop, from `@clerk/headless`. It is a **headless** primitive: it owns the selected-file state, a hidden ``, `accept` filtering (applied to both the picker and dropped files), single/multiple modes, and image -preview thumbnails, but ships **no styles** — you bring your own CSS by targeting the `data-cl-*` +preview thumbnails, but ships **no styles** — you bring your own CSS by targeting the `data-*` attributes each part emits. ## Example @@ -133,23 +133,22 @@ take no additional props beyond standard HTML attributes for their default eleme ## Styling -Each part emits `data-cl-*` attributes you can target with any CSS solution: +Each part emits `data-*` attributes you can target with any CSS solution: -| Attribute | Applies To | Description | -| ------------------ | ----------------------------------- | ----------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (e.g. `"file-upload-dropzone"`) | -| `data-cl-empty` | Root | Present when no files are selected | -| `data-cl-dragging` | Dropzone | Present while a valid drag is over the zone | -| `data-cl-image` | Item | Present when the item's file is an image | -| `data-cl-disabled` | Root, Trigger, Dropzone, ItemDelete | Present when disabled | +| Attribute | Applies To | Description | +| --------------- | ----------------------------------- | ------------------------------------------- | +| `data-empty` | Root | Present when no files are selected | +| `data-dragging` | Dropzone | Present while a valid drag is over the zone | +| `data-image` | Item | Present when the item's file is an image | +| `data-disabled` | Root, Trigger, Dropzone, ItemDelete | Present when disabled | ```css -[data-cl-slot='file-upload-dropzone'] { +.cl-file-upload-dropzone { border: 2px dashed var(--color-border); border-radius: 8px; padding: 24px; } -[data-cl-slot='file-upload-dropzone'][data-cl-dragging] { +.cl-file-upload-dropzone[data-dragging] { border-color: var(--color-accent); background: var(--color-accent-subtle); } diff --git a/packages/swingset/src/stories/file-upload.stories.tsx b/packages/swingset/src/stories/file-upload.stories.tsx index 9c39865a4a3..701412cedbe 100644 --- a/packages/swingset/src/stories/file-upload.stories.tsx +++ b/packages/swingset/src/stories/file-upload.stories.tsx @@ -4,7 +4,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: file selection via -// a trigger or drag-and-drop, live image previews, and the `data-cl-*` attributes each part +// a trigger or drag-and-drop, live image previews, and the `data-*` attributes each part // emits, with zero appearance. The only inline styles below constrain the preview thumbnail // so a full-resolution image can't blow out the demo; they are not part of the primitive. // It is embedded once into the overview via `` in the MDX (the one thing prose can't diff --git a/packages/swingset/src/stories/menu.mdx b/packages/swingset/src/stories/menu.mdx index 82d81fd047f..1160502ad58 100644 --- a/packages/swingset/src/stories/menu.mdx +++ b/packages/swingset/src/stories/menu.mdx @@ -5,7 +5,7 @@ import * as MenuStories from './menu.stories'; A floating list of actions opened from a trigger, from `@clerk/headless`. It is a **headless** primitive: it supplies open state, portalling, floating-ui positioning, roving-focus keyboard navigation with typeahead, submenu coordination, and ARIA wiring, but -ships **no styles** — you bring your own CSS by targeting the `data-cl-*` attributes each +ships **no styles** — you bring your own CSS by targeting the `data-*` attributes each part emits. ## Example @@ -109,18 +109,17 @@ props beyond standard HTML attributes for their default element. ## Styling -Each part emits `data-cl-*` attributes you can target with any CSS solution: +Each part emits `data-*` attributes you can target with any CSS solution: -| Attribute | Applies To | Description | -| ------------------------ | ----------------- | -------------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier | -| `data-cl-open` | Trigger, Popup | Present when the menu is open | -| `data-cl-closed` | Trigger, Popup | Present when closed (Popup stays mounted to exit) | -| `data-cl-active` | Item | Present on the keyboard-highlighted item | -| `data-cl-disabled` | Item | Present when the item is disabled | -| `data-cl-side` | Positioner, Arrow | Resolved side: `top` / `bottom` / `left` / `right` | -| `data-cl-starting-style` | Popup | Present on the entering frame | -| `data-cl-ending-style` | Popup | Present during the exit animation | +| Attribute | Applies To | Description | +| --------------------- | ----------------- | -------------------------------------------------- | +| `data-open` | Trigger, Popup | Present when the menu is open | +| `data-closed` | Trigger, Popup | Present when closed (Popup stays mounted to exit) | +| `data-active` | Item | Present on the keyboard-highlighted item | +| `data-disabled` | Item | Present when the item is disabled | +| `data-side` | Positioner, Arrow | Resolved side: `top` / `bottom` / `left` / `right` | +| `data-starting-style` | Popup | Present on the entering frame | +| `data-ending-style` | Popup | Present during the exit animation | The positioner exposes anchor/viewport geometry as CSS variables: @@ -133,7 +132,7 @@ The positioner exposes anchor/viewport geometry as CSS variables: | `--cl-transform-origin` | `transform-origin` pointing back toward the anchor | ```css -[data-cl-slot='menu-item'][data-cl-active] { +.cl-menu-item[data-active] { background: rgba(0, 0, 0, 0.06); } ``` diff --git a/packages/swingset/src/stories/menu.stories.tsx b/packages/swingset/src/stories/menu.stories.tsx index d917c2b4817..aa58ba8ba55 100644 --- a/packages/swingset/src/stories/menu.stories.tsx +++ b/packages/swingset/src/stories/menu.stories.tsx @@ -4,7 +4,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state, -// positioning, keyboard navigation, and ARIA wiring via the `data-cl-*` attributes each +// positioning, keyboard navigation, and ARIA wiring via the `data-*` attributes each // part emits, with zero appearance. It is embedded once into the overview via `` // in the MDX (the one thing prose can't convey: that it opens and items are keyboard // navigable). There is no interactive knob canvas for headless primitives. diff --git a/packages/swingset/src/stories/otp.mdx b/packages/swingset/src/stories/otp.mdx index 07e02199c36..3a1c5108c6e 100644 --- a/packages/swingset/src/stories/otp.mdx +++ b/packages/swingset/src/stories/otp.mdx @@ -5,7 +5,7 @@ import * as OtpStories from './otp.stories'; A one-time-password / PIN input from `@clerk/headless`. It is a **headless** primitive: it owns the value (a single string), splits it across per-character `Input` slots, and handles focus movement, keyboard editing, and paste distribution, but ships **no styles** — you bring your own CSS by -targeting the `data-cl-*` attributes each part emits. +targeting the `data-*` attributes each part emits. ## Example @@ -131,30 +131,29 @@ inside `Otp.Root`. ## Styling -Each part emits `data-cl-*` attributes you can target with any CSS solution: +Each part emits `data-*` attributes you can target with any CSS solution: -| Attribute | Applies To | Description | -| ------------------ | ----------- | --------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier (`"otp-root"`, `"otp-input"`) | -| `data-cl-empty` | Root | Present when no character has been entered | -| `data-cl-complete` | Root | Present when every slot is filled | -| `data-cl-disabled` | Root, Input | Present when disabled | -| `data-cl-active` | Input | Present when the slot holds focus | -| `data-cl-filled` | Input | Present when the slot holds a character | +| Attribute | Applies To | Description | +| --------------- | ----------- | ------------------------------------------ | +| `data-empty` | Root | Present when no character has been entered | +| `data-complete` | Root | Present when every slot is filled | +| `data-disabled` | Root, Input | Present when disabled | +| `data-active` | Input | Present when the slot holds focus | +| `data-filled` | Input | Present when the slot holds a character | ```css -[data-cl-slot='otp-root'] { +.cl-otp-root { display: flex; gap: 8px; } -[data-cl-slot='otp-input'] { +.cl-otp-input { width: 40px; height: 48px; text-align: center; border: 1px solid var(--color-border); border-radius: 8px; } -[data-cl-slot='otp-input'][data-cl-active] { +.cl-otp-input[data-active] { border-color: var(--color-accent); } ``` diff --git a/packages/swingset/src/stories/otp.stories.tsx b/packages/swingset/src/stories/otp.stories.tsx index 23cc5ef36b8..3514b134d37 100644 --- a/packages/swingset/src/stories/otp.stories.tsx +++ b/packages/swingset/src/stories/otp.stories.tsx @@ -4,7 +4,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: per-character -// slots, focus advancing as you type, paste distribution, and the `data-cl-*` attributes +// slots, focus advancing as you type, paste distribution, and the `data-*` attributes // each part emits, with zero appearance. It is embedded once into the overview via // `` in the MDX (the one thing prose can't convey: that typing, deleting, and // pasting actually move focus across the slots). There is no interactive knob canvas for diff --git a/packages/swingset/src/stories/popover.mdx b/packages/swingset/src/stories/popover.mdx index 08beb148efc..4470e848ebf 100644 --- a/packages/swingset/src/stories/popover.mdx +++ b/packages/swingset/src/stories/popover.mdx @@ -5,7 +5,7 @@ import * as PopoverStories from './popover.stories'; A floating panel anchored to a trigger, holding interactive content, from `@clerk/headless`. It is a **headless** primitive: it supplies open state, portalling, floating-ui positioning (flip/shift/arrow), focus management, dismissal (outside press / Escape), and ARIA wiring, -but ships **no styles** — you bring your own CSS by targeting the `data-cl-*` attributes +but ships **no styles** — you bring your own CSS by targeting the `data-*` attributes each part emits. ## Example @@ -96,16 +96,15 @@ Alignment and collision padding are encoded in the single `placement` prop. ## Styling -Each part emits `data-cl-*` attributes you can target with any CSS solution: +Each part emits `data-*` attributes you can target with any CSS solution: -| Attribute | Applies To | Description | -| ------------------------ | ----------------- | -------------------------------------------------- | -| `data-cl-slot` | All parts | Part identifier | -| `data-cl-open` | Trigger, Popup | Present when the popover is open | -| `data-cl-closed` | Trigger, Popup | Present when closed (Popup stays mounted to exit) | -| `data-cl-side` | Positioner, Arrow | Resolved side: `top` / `bottom` / `left` / `right` | -| `data-cl-starting-style` | Popup | Present on the entering frame | -| `data-cl-ending-style` | Popup | Present during the exit animation | +| Attribute | Applies To | Description | +| --------------------- | ----------------- | -------------------------------------------------- | +| `data-open` | Trigger, Popup | Present when the popover is open | +| `data-closed` | Trigger, Popup | Present when closed (Popup stays mounted to exit) | +| `data-side` | Positioner, Arrow | Resolved side: `top` / `bottom` / `left` / `right` | +| `data-starting-style` | Popup | Present on the entering frame | +| `data-ending-style` | Popup | Present during the exit animation | The positioner exposes anchor/viewport geometry as CSS variables for sizing and transform-origin-based animations: @@ -119,13 +118,13 @@ transform-origin-based animations: | `--cl-transform-origin` | `transform-origin` pointing back toward the anchor | ```css -[data-cl-slot='popover-popup'] { +.cl-popover-popup { transform-origin: var(--cl-transform-origin); opacity: 1; transition: opacity 120ms ease, transform 120ms ease; } -[data-cl-slot='popover-popup'][data-cl-starting-style], -[data-cl-slot='popover-popup'][data-cl-ending-style] { +.cl-popover-popup[data-starting-style], +.cl-popover-popup[data-ending-style] { opacity: 0; transform: scale(0.95); } diff --git a/packages/swingset/src/stories/popover.stories.tsx b/packages/swingset/src/stories/popover.stories.tsx index c946d23acb9..8706c2cfd7a 100644 --- a/packages/swingset/src/stories/popover.stories.tsx +++ b/packages/swingset/src/stories/popover.stories.tsx @@ -4,7 +4,7 @@ import type { StoryMeta } from '@/lib/types'; // Headless primitives ship no styles. This single demo renders the primitive raw — // unstyled — so it faithfully reflects what `@clerk/headless` provides: behavior, state, -// positioning, and ARIA wiring via the `data-cl-*` attributes each part emits, with zero +// positioning, and ARIA wiring via the `data-*` attributes each part emits, with zero // appearance. It is embedded once into the overview via `` in the MDX (the one // thing prose can't convey: that it anchors to the trigger and dismisses). There is no // interactive knob canvas for headless primitives. diff --git a/packages/swingset/src/stories/select.mdx b/packages/swingset/src/stories/select.mdx index a0b944a0802..7ea01b005e2 100644 --- a/packages/swingset/src/stories/select.mdx +++ b/packages/swingset/src/stories/select.mdx @@ -6,7 +6,7 @@ A custom dropdown for picking one value from a list, from `@clerk/headless`. It **headless** primitive: it supplies value and open state, portalling, floating-ui positioning (with optional native-`