Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/headless-remove-data-cl-slot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
6 changes: 3 additions & 3 deletions packages/headless/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ import { Select } from '@clerk/headless/select';
</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`
Expand Down
4 changes: 2 additions & 2 deletions packages/headless/src/hooks/use-animations-finished.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null>;

const { result } = renderHook(() => useAnimationsFinished(ref, true));
Expand All @@ -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));
});
Expand Down
8 changes: 4 additions & 4 deletions packages/headless/src/hooks/use-animations-finished.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -75,16 +75,16 @@ export function useAnimationsFinished(ref: RefObject<HTMLElement | null>, 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;
Expand Down
18 changes: 9 additions & 9 deletions packages/headless/src/hooks/use-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '',
});
});

Expand All @@ -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
Expand Down Expand Up @@ -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[]);
Expand Down
24 changes: 12 additions & 12 deletions packages/headless/src/hooks/use-transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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);
Expand All @@ -54,15 +54,15 @@ export function useTransition({ open, ref }: UseTransitionOptions): UseTransitio
const transitionProps = useMemo<TransitionProps>(() => {
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]);
Expand Down
15 changes: 7 additions & 8 deletions packages/headless/src/primitives/accordion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
8 changes: 3 additions & 5 deletions packages/headless/src/primitives/accordion/accordion-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ export function AccordionItem(props: AccordionItemProps) {

const state = { open, disabled };

const defaultProps = {
'data-cl-slot': 'accordion-item',
};
const defaultProps = {};

return (
<AccordionItemContext.Provider value={itemContextValue}>
Expand All @@ -39,8 +37,8 @@ export function AccordionItem(props: AccordionItemProps) {
render,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
open: (v: boolean): Record<string, string> | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
},
props: mergeProps<'div'>(defaultProps, otherProps),
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,12 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const effectiveTransitionProps = !hasBeenClosed.current
? {
...transitionProps,
'data-cl-starting-style': undefined,
'data-starting-style': undefined,
style: undefined,
}
: transitionProps;

const defaultProps: Record<string, unknown> = {
'data-cl-slot': 'accordion-panel',
id: panelId,
role: 'region' as const,
'aria-labelledby': triggerId,
Expand All @@ -123,7 +122,7 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
ref: [panelRef, ref],
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' }),
open: (v: boolean): Record<string, string> | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
},
props: merged,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,13 @@ export function AccordionRoot(props: AccordionProps) {
const { 'aria-orientation': _ao, ...restCompositeProps } = compositeProps;

const defaultProps: Record<string, unknown> = {
'data-cl-slot': 'accordion-root',
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Home' && event.key !== 'End') {
return;
}
event.preventDefault();
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>('[data-cl-slot="accordion-trigger"]:not([disabled])'),
event.currentTarget.querySelectorAll<HTMLElement>('[aria-expanded]:not([disabled])'),
Comment on lines 60 to +66

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope Home/End navigation to this Accordion’s enabled triggers.

[aria-expanded]:not([disabled]) searches the entire subtree, so nested accordions or consumer controls with aria-expanded can be included. It also filters native disabled, while AccordionTrigger represents its disabled state with aria-disabled; disabled triggers may therefore remain in the navigation list. Use the Composite registry or another Accordion-specific selector, and exclude aria-disabled="true".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/accordion/accordion-root.tsx` around lines
60 - 66, Update the Home/End handling in the accordion root’s onKeyDown handler
to collect only this Accordion’s registered or uniquely identified enabled
triggers, excluding elements with aria-disabled="true". Do not use the broad
subtree selector that can include nested accordions or unrelated aria-expanded
controls, and preserve the existing Home/End navigation behavior for the
filtered trigger list.

);
if (items.length === 0) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
disabled={disabled}
render={(compositeProps: React.HTMLAttributes<HTMLElement>) => {
const defaultProps: Record<string, unknown> = {
'data-cl-slot': 'accordion-trigger',
id: triggerId,
type: 'button' as const,
'aria-expanded': open,
Expand Down Expand Up @@ -59,9 +58,8 @@ export function AccordionTrigger(props: AccordionTriggerProps) {
ref: compositeRef as React.Ref<unknown>,
state,
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null =>
v ? { 'data-cl-open': '' } : { 'data-cl-closed': '' },
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
open: (v: boolean): Record<string, string> | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
},
props: mergedProps,
});
Expand Down
Loading
Loading