Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@
"import": "./dist/primitives/popover/index.js",
"types": "./dist/primitives/popover/index.d.ts"
},
"./select": {
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
163 changes: 163 additions & 0 deletions packages/headless/src/primitives/select/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
# Select

A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.

## When to Use

- Picking a single value from a predefined list of options.
- When you need typeahead, keyboard navigation, and full styling control.
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.

## Usage

```tsx
import { Select } from '@/primitives/select';

<Select.Root>
<Select.Trigger>
<Select.Value placeholder='Choose a fruit...' />
</Select.Trigger>
<Select.Positioner>
<Select.Popup>
<Select.Option
value='apple'
label='Apple'
/>
<Select.Option
value='banana'
label='Banana'
/>
<Select.Option
value='cherry'
label='Cherry'
/>
</Select.Popup>
</Select.Positioner>
</Select.Root>;
```

### Controlled

```tsx
const [value, setValue] = useState('apple');

<Select.Root
value={value}
onValueChange={setValue}
>
{/* ... */}
</Select.Root>;
```

### With `items` for SSR label resolution

The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):

```tsx
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];

<Select.Root
items={items}
defaultValue='apple'
>
{/* Select.Value will display "Apple" even before Options mount */}
</Select.Root>;
```

### Disable item-to-trigger alignment

By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:

```tsx
<Select.Root alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ------------------------------------------ |
| `Select.Root` | — | Root context provider |
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
| `Select.Portal` | — | Portals children (accepts `root` prop) |
| `Select.Positioner` | `<div>` | Floating positioned container |
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
| `Select.Option` | `<button>` | A selectable option |
| `Select.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Select.Root`

| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount |
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |

### `Select.Value`

| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ------------------------------- |
| `placeholder` | `ReactNode` | — | Shown when no value is selected |

### `Select.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | -------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
| `disabled` | `boolean` | — | Prevents selection |

### `Select.Trigger`, `Select.Positioner`, `Select.Popup`

No additional props beyond standard HTML attributes and the `render` prop.

### `Select.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` / `Space` | Select the active option, close popup |
| `Escape` | Close the popup |
| Type a character | Jump to matching option (typeahead) |

Typeahead also works while the popup is closed — it changes the selected value directly.

## 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 |

## Important Notes

- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
- **Disabled options** can still receive keyboard focus but cannot be selected.

## ARIA

- Popup: `role="listbox"`
- Option: `role="option"`, `aria-selected`, `aria-disabled`
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
13 changes: 13 additions & 0 deletions packages/headless/src/primitives/select/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export * as Select from './parts';

export type {
SelectArrowProps,
SelectItem,
SelectOptionProps,
SelectPopupProps,
SelectPortalProps,
SelectPositionerProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from './parts';
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/select/parts.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root';
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
export { type SelectValueProps, SelectValue as Value } from './select-value';
export { type SelectPortalProps, SelectPortal as Portal } from './select-portal';
export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner';
export { type SelectPopupProps, SelectPopup as Popup } from './select-popup';
export { type SelectOptionProps, SelectOption as Option } from './select-option';
export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow';
22 changes: 22 additions & 0 deletions packages/headless/src/primitives/select/select-arrow.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useSelectContext } from './select-context';

export interface SelectArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function SelectArrow(props: SelectArrowProps) {
const { floatingContext, arrowRef, placement } = useSelectContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='select-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
51 changes: 51 additions & 0 deletions packages/headless/src/primitives/select/select-context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, type RefObject, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface SelectItem {
label: string;
value: string;
}

export interface SelectContextValue {
open: boolean;
items: SelectItem[] | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
selectedIndex: number | null;
selectedValue: string | undefined;
selectedLabel: string | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: RefObject<HTMLDivElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valueToLabelRef: React.MutableRefObject<Map<string, string>>;
selectedItemRef: React.MutableRefObject<HTMLElement | null>;
alignItemWithTrigger: boolean;
handleSelect: (value: string, index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select compound components must be used within <Select.Root>');
}
return ctx;
}
65 changes: 65 additions & 0 deletions packages/headless/src/primitives/select/select-option.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectOptionProps extends ComponentProps<'button'> {
value: string;
label?: string;
disabled?: boolean;
}

export function SelectOption(props: SelectOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } =
useSelectContext();

const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valueToLabelRef.current.set(value, displayLabel);
return () => {
valueToLabelRef.current.delete(value);
};
}, [value, displayLabel, valueToLabelRef]);

const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'select-option',
ref: combinedRef,
role: 'option' as const,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
tabIndex: isActive ? 0 : -1,
...(getItemProps({
onClick() {
if (!disabled) handleSelect(value, index);
},
}) as React.ComponentPropsWithRef<'button'>),
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
23 changes: 23 additions & 0 deletions packages/headless/src/primitives/select/select-popup.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectPopupProps extends ComponentProps<'div'> {}

export function SelectPopup(props: SelectPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useSelectContext();

const defaultProps = {
'data-cl-slot': 'select-popup',
ref: popupRef,
...transitionProps,
};

return renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
16 changes: 16 additions & 0 deletions packages/headless/src/primitives/select/select-portal.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
'use client';

import { FloatingPortal } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { useSelectContext } from './select-context';

export interface SelectPortalProps {
children: ReactNode;
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
}

export function SelectPortal(props: SelectPortalProps) {
const { mounted } = useSelectContext();
if (!mounted) return null;
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(headless): add Select primitive by alexcarpenter · Pull Request #8479 · clerk/javascript · GitHub
Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@
"import": "./dist/primitives/popover/index.js",
"types": "./dist/primitives/popover/index.d.ts"
},
"./select": {
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
163 changes: 163 additions & 0 deletions packages/headless/src/primitives/select/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
# Select

A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.

## When to Use

- Picking a single value from a predefined list of options.
- When you need typeahead, keyboard navigation, and full styling control.
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.

## Usage

```tsx
import { Select } from '@/primitives/select';

<Select.Root>
<Select.Trigger>
<Select.Value placeholder='Choose a fruit...' />
</Select.Trigger>
<Select.Positioner>
<Select.Popup>
<Select.Option
value='apple'
label='Apple'
/>
<Select.Option
value='banana'
label='Banana'
/>
<Select.Option
value='cherry'
label='Cherry'
/>
</Select.Popup>
</Select.Positioner>
</Select.Root>;
```

### Controlled

```tsx
const [value, setValue] = useState('apple');

<Select.Root
value={value}
onValueChange={setValue}
>
{/* ... */}
</Select.Root>;
```

### With `items` for SSR label resolution

The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):

```tsx
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];

<Select.Root
items={items}
defaultValue='apple'
>
{/* Select.Value will display "Apple" even before Options mount */}
</Select.Root>;
```

### Disable item-to-trigger alignment

By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:

```tsx
<Select.Root alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ------------------------------------------ |
| `Select.Root` | — | Root context provider |
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
| `Select.Portal` | — | Portals children (accepts `root` prop) |
| `Select.Positioner` | `<div>` | Floating positioned container |
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
| `Select.Option` | `<button>` | A selectable option |
| `Select.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Select.Root`

| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount |
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |

### `Select.Value`

| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ------------------------------- |
| `placeholder` | `ReactNode` | — | Shown when no value is selected |

### `Select.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | -------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
| `disabled` | `boolean` | — | Prevents selection |

### `Select.Trigger`, `Select.Positioner`, `Select.Popup`

No additional props beyond standard HTML attributes and the `render` prop.

### `Select.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` / `Space` | Select the active option, close popup |
| `Escape` | Close the popup |
| Type a character | Jump to matching option (typeahead) |

Typeahead also works while the popup is closed — it changes the selected value directly.

## 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 |

## Important Notes

- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
- **Disabled options** can still receive keyboard focus but cannot be selected.

## ARIA

- Popup: `role="listbox"`
- Option: `role="option"`, `aria-selected`, `aria-disabled`
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
13 changes: 13 additions & 0 deletions packages/headless/src/primitives/select/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export * as Select from './parts';

export type {
SelectArrowProps,
SelectItem,
SelectOptionProps,
SelectPopupProps,
SelectPortalProps,
SelectPositionerProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from './parts';
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/select/parts.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root';
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
export { type SelectValueProps, SelectValue as Value } from './select-value';
export { type SelectPortalProps, SelectPortal as Portal } from './select-portal';
export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner';
export { type SelectPopupProps, SelectPopup as Popup } from './select-popup';
export { type SelectOptionProps, SelectOption as Option } from './select-option';
export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow';
22 changes: 22 additions & 0 deletions packages/headless/src/primitives/select/select-arrow.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useSelectContext } from './select-context';

export interface SelectArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function SelectArrow(props: SelectArrowProps) {
const { floatingContext, arrowRef, placement } = useSelectContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='select-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
51 changes: 51 additions & 0 deletions packages/headless/src/primitives/select/select-context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, type RefObject, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface SelectItem {
label: string;
value: string;
}

export interface SelectContextValue {
open: boolean;
items: SelectItem[] | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
selectedIndex: number | null;
selectedValue: string | undefined;
selectedLabel: string | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: RefObject<HTMLDivElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valueToLabelRef: React.MutableRefObject<Map<string, string>>;
selectedItemRef: React.MutableRefObject<HTMLElement | null>;
alignItemWithTrigger: boolean;
handleSelect: (value: string, index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select compound components must be used within <Select.Root>');
}
return ctx;
}
65 changes: 65 additions & 0 deletions packages/headless/src/primitives/select/select-option.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectOptionProps extends ComponentProps<'button'> {
value: string;
label?: string;
disabled?: boolean;
}

export function SelectOption(props: SelectOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } =
useSelectContext();

const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valueToLabelRef.current.set(value, displayLabel);
return () => {
valueToLabelRef.current.delete(value);
};
}, [value, displayLabel, valueToLabelRef]);

const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'select-option',
ref: combinedRef,
role: 'option' as const,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
tabIndex: isActive ? 0 : -1,
...(getItemProps({
onClick() {
if (!disabled) handleSelect(value, index);
},
}) as React.ComponentPropsWithRef<'button'>),
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
23 changes: 23 additions & 0 deletions packages/headless/src/primitives/select/select-popup.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectPopupProps extends ComponentProps<'div'> {}

export function SelectPopup(props: SelectPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useSelectContext();

const defaultProps = {
'data-cl-slot': 'select-popup',
ref: popupRef,
...transitionProps,
};

return renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
16 changes: 16 additions & 0 deletions packages/headless/src/primitives/select/select-portal.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
'use client';

import { FloatingPortal } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { useSelectContext } from './select-context';

export interface SelectPortalProps {
children: ReactNode;
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
}

export function SelectPortal(props: SelectPortalProps) {
const { mounted } = useSelectContext();
if (!mounted) return null;
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(headless): add Select primitive by alexcarpenter · Pull Request #8479 · clerk/javascript · GitHub
Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@
"import": "./dist/primitives/popover/index.js",
"types": "./dist/primitives/popover/index.d.ts"
},
"./select": {
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
163 changes: 163 additions & 0 deletions packages/headless/src/primitives/select/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
# Select

A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.

## When to Use

- Picking a single value from a predefined list of options.
- When you need typeahead, keyboard navigation, and full styling control.
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.

## Usage

```tsx
import { Select } from '@/primitives/select';

<Select.Root>
<Select.Trigger>
<Select.Value placeholder='Choose a fruit...' />
</Select.Trigger>
<Select.Positioner>
<Select.Popup>
<Select.Option
value='apple'
label='Apple'
/>
<Select.Option
value='banana'
label='Banana'
/>
<Select.Option
value='cherry'
label='Cherry'
/>
</Select.Popup>
</Select.Positioner>
</Select.Root>;
```

### Controlled

```tsx
const [value, setValue] = useState('apple');

<Select.Root
value={value}
onValueChange={setValue}
>
{/* ... */}
</Select.Root>;
```

### With `items` for SSR label resolution

The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):

```tsx
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];

<Select.Root
items={items}
defaultValue='apple'
>
{/* Select.Value will display "Apple" even before Options mount */}
</Select.Root>;
```

### Disable item-to-trigger alignment

By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:

```tsx
<Select.Root alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ------------------------------------------ |
| `Select.Root` | — | Root context provider |
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
| `Select.Portal` | — | Portals children (accepts `root` prop) |
| `Select.Positioner` | `<div>` | Floating positioned container |
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
| `Select.Option` | `<button>` | A selectable option |
| `Select.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Select.Root`

| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount |
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |

### `Select.Value`

| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ------------------------------- |
| `placeholder` | `ReactNode` | — | Shown when no value is selected |

### `Select.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | -------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
| `disabled` | `boolean` | — | Prevents selection |

### `Select.Trigger`, `Select.Positioner`, `Select.Popup`

No additional props beyond standard HTML attributes and the `render` prop.

### `Select.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` / `Space` | Select the active option, close popup |
| `Escape` | Close the popup |
| Type a character | Jump to matching option (typeahead) |

Typeahead also works while the popup is closed — it changes the selected value directly.

## 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 |

## Important Notes

- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
- **Disabled options** can still receive keyboard focus but cannot be selected.

## ARIA

- Popup: `role="listbox"`
- Option: `role="option"`, `aria-selected`, `aria-disabled`
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
13 changes: 13 additions & 0 deletions packages/headless/src/primitives/select/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export * as Select from './parts';

export type {
SelectArrowProps,
SelectItem,
SelectOptionProps,
SelectPopupProps,
SelectPortalProps,
SelectPositionerProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from './parts';
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/select/parts.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root';
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
export { type SelectValueProps, SelectValue as Value } from './select-value';
export { type SelectPortalProps, SelectPortal as Portal } from './select-portal';
export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner';
export { type SelectPopupProps, SelectPopup as Popup } from './select-popup';
export { type SelectOptionProps, SelectOption as Option } from './select-option';
export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow';
22 changes: 22 additions & 0 deletions packages/headless/src/primitives/select/select-arrow.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useSelectContext } from './select-context';

export interface SelectArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function SelectArrow(props: SelectArrowProps) {
const { floatingContext, arrowRef, placement } = useSelectContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='select-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
51 changes: 51 additions & 0 deletions packages/headless/src/primitives/select/select-context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, type RefObject, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface SelectItem {
label: string;
value: string;
}

export interface SelectContextValue {
open: boolean;
items: SelectItem[] | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
selectedIndex: number | null;
selectedValue: string | undefined;
selectedLabel: string | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: RefObject<HTMLDivElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valueToLabelRef: React.MutableRefObject<Map<string, string>>;
selectedItemRef: React.MutableRefObject<HTMLElement | null>;
alignItemWithTrigger: boolean;
handleSelect: (value: string, index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select compound components must be used within <Select.Root>');
}
return ctx;
}
65 changes: 65 additions & 0 deletions packages/headless/src/primitives/select/select-option.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectOptionProps extends ComponentProps<'button'> {
value: string;
label?: string;
disabled?: boolean;
}

export function SelectOption(props: SelectOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } =
useSelectContext();

const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valueToLabelRef.current.set(value, displayLabel);
return () => {
valueToLabelRef.current.delete(value);
};
}, [value, displayLabel, valueToLabelRef]);

const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'select-option',
ref: combinedRef,
role: 'option' as const,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
tabIndex: isActive ? 0 : -1,
...(getItemProps({
onClick() {
if (!disabled) handleSelect(value, index);
},
}) as React.ComponentPropsWithRef<'button'>),
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
23 changes: 23 additions & 0 deletions packages/headless/src/primitives/select/select-popup.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectPopupProps extends ComponentProps<'div'> {}

export function SelectPopup(props: SelectPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useSelectContext();

const defaultProps = {
'data-cl-slot': 'select-popup',
ref: popupRef,
...transitionProps,
};

return renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
16 changes: 16 additions & 0 deletions packages/headless/src/primitives/select/select-portal.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
'use client';

import { FloatingPortal } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { useSelectContext } from './select-context';

export interface SelectPortalProps {
children: ReactNode;
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
}

export function SelectPortal(props: SelectPortalProps) {
const { mounted } = useSelectContext();
if (!mounted) return null;
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(headless): add Select primitive by alexcarpenter · Pull Request #8479 · clerk/javascript · GitHub
Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@
"import": "./dist/primitives/popover/index.js",
"types": "./dist/primitives/popover/index.d.ts"
},
"./select": {
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
163 changes: 163 additions & 0 deletions packages/headless/src/primitives/select/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
# Select

A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.

## When to Use

- Picking a single value from a predefined list of options.
- When you need typeahead, keyboard navigation, and full styling control.
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.

## Usage

```tsx
import { Select } from '@/primitives/select';

<Select.Root>
<Select.Trigger>
<Select.Value placeholder='Choose a fruit...' />
</Select.Trigger>
<Select.Positioner>
<Select.Popup>
<Select.Option
value='apple'
label='Apple'
/>
<Select.Option
value='banana'
label='Banana'
/>
<Select.Option
value='cherry'
label='Cherry'
/>
</Select.Popup>
</Select.Positioner>
</Select.Root>;
```

### Controlled

```tsx
const [value, setValue] = useState('apple');

<Select.Root
value={value}
onValueChange={setValue}
>
{/* ... */}
</Select.Root>;
```

### With `items` for SSR label resolution

The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):

```tsx
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];

<Select.Root
items={items}
defaultValue='apple'
>
{/* Select.Value will display "Apple" even before Options mount */}
</Select.Root>;
```

### Disable item-to-trigger alignment

By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:

```tsx
<Select.Root alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ------------------------------------------ |
| `Select.Root` | — | Root context provider |
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
| `Select.Portal` | — | Portals children (accepts `root` prop) |
| `Select.Positioner` | `<div>` | Floating positioned container |
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
| `Select.Option` | `<button>` | A selectable option |
| `Select.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Select.Root`

| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount |
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |

### `Select.Value`

| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ------------------------------- |
| `placeholder` | `ReactNode` | — | Shown when no value is selected |

### `Select.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | -------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
| `disabled` | `boolean` | — | Prevents selection |

### `Select.Trigger`, `Select.Positioner`, `Select.Popup`

No additional props beyond standard HTML attributes and the `render` prop.

### `Select.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` / `Space` | Select the active option, close popup |
| `Escape` | Close the popup |
| Type a character | Jump to matching option (typeahead) |

Typeahead also works while the popup is closed — it changes the selected value directly.

## 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 |

## Important Notes

- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
- **Disabled options** can still receive keyboard focus but cannot be selected.

## ARIA

- Popup: `role="listbox"`
- Option: `role="option"`, `aria-selected`, `aria-disabled`
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
13 changes: 13 additions & 0 deletions packages/headless/src/primitives/select/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export * as Select from './parts';

export type {
SelectArrowProps,
SelectItem,
SelectOptionProps,
SelectPopupProps,
SelectPortalProps,
SelectPositionerProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from './parts';
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/select/parts.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root';
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
export { type SelectValueProps, SelectValue as Value } from './select-value';
export { type SelectPortalProps, SelectPortal as Portal } from './select-portal';
export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner';
export { type SelectPopupProps, SelectPopup as Popup } from './select-popup';
export { type SelectOptionProps, SelectOption as Option } from './select-option';
export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow';
22 changes: 22 additions & 0 deletions packages/headless/src/primitives/select/select-arrow.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useSelectContext } from './select-context';

export interface SelectArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function SelectArrow(props: SelectArrowProps) {
const { floatingContext, arrowRef, placement } = useSelectContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='select-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
51 changes: 51 additions & 0 deletions packages/headless/src/primitives/select/select-context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, type RefObject, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface SelectItem {
label: string;
value: string;
}

export interface SelectContextValue {
open: boolean;
items: SelectItem[] | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
selectedIndex: number | null;
selectedValue: string | undefined;
selectedLabel: string | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: RefObject<HTMLDivElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valueToLabelRef: React.MutableRefObject<Map<string, string>>;
selectedItemRef: React.MutableRefObject<HTMLElement | null>;
alignItemWithTrigger: boolean;
handleSelect: (value: string, index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select compound components must be used within <Select.Root>');
}
return ctx;
}
65 changes: 65 additions & 0 deletions packages/headless/src/primitives/select/select-option.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectOptionProps extends ComponentProps<'button'> {
value: string;
label?: string;
disabled?: boolean;
}

export function SelectOption(props: SelectOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } =
useSelectContext();

const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valueToLabelRef.current.set(value, displayLabel);
return () => {
valueToLabelRef.current.delete(value);
};
}, [value, displayLabel, valueToLabelRef]);

const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'select-option',
ref: combinedRef,
role: 'option' as const,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
tabIndex: isActive ? 0 : -1,
...(getItemProps({
onClick() {
if (!disabled) handleSelect(value, index);
},
}) as React.ComponentPropsWithRef<'button'>),
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
23 changes: 23 additions & 0 deletions packages/headless/src/primitives/select/select-popup.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectPopupProps extends ComponentProps<'div'> {}

export function SelectPopup(props: SelectPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useSelectContext();

const defaultProps = {
'data-cl-slot': 'select-popup',
ref: popupRef,
...transitionProps,
};

return renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
16 changes: 16 additions & 0 deletions packages/headless/src/primitives/select/select-portal.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
'use client';

import { FloatingPortal } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { useSelectContext } from './select-context';

export interface SelectPortalProps {
children: ReactNode;
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
}

export function SelectPortal(props: SelectPortalProps) {
const { mounted } = useSelectContext();
if (!mounted) return null;
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(headless): add Select primitive by alexcarpenter · Pull Request #8479 · clerk/javascript · GitHub
Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@
"import": "./dist/primitives/popover/index.js",
"types": "./dist/primitives/popover/index.d.ts"
},
"./select": {
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
163 changes: 163 additions & 0 deletions packages/headless/src/primitives/select/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
# Select

A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.

## When to Use

- Picking a single value from a predefined list of options.
- When you need typeahead, keyboard navigation, and full styling control.
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.

## Usage

```tsx
import { Select } from '@/primitives/select';

<Select.Root>
<Select.Trigger>
<Select.Value placeholder='Choose a fruit...' />
</Select.Trigger>
<Select.Positioner>
<Select.Popup>
<Select.Option
value='apple'
label='Apple'
/>
<Select.Option
value='banana'
label='Banana'
/>
<Select.Option
value='cherry'
label='Cherry'
/>
</Select.Popup>
</Select.Positioner>
</Select.Root>;
```

### Controlled

```tsx
const [value, setValue] = useState('apple');

<Select.Root
value={value}
onValueChange={setValue}
>
{/* ... */}
</Select.Root>;
```

### With `items` for SSR label resolution

The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):

```tsx
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];

<Select.Root
items={items}
defaultValue='apple'
>
{/* Select.Value will display "Apple" even before Options mount */}
</Select.Root>;
```

### Disable item-to-trigger alignment

By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:

```tsx
<Select.Root alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ------------------------------------------ |
| `Select.Root` | — | Root context provider |
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
| `Select.Portal` | — | Portals children (accepts `root` prop) |
| `Select.Positioner` | `<div>` | Floating positioned container |
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
| `Select.Option` | `<button>` | A selectable option |
| `Select.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Select.Root`

| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount |
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |

### `Select.Value`

| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ------------------------------- |
| `placeholder` | `ReactNode` | — | Shown when no value is selected |

### `Select.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | -------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
| `disabled` | `boolean` | — | Prevents selection |

### `Select.Trigger`, `Select.Positioner`, `Select.Popup`

No additional props beyond standard HTML attributes and the `render` prop.

### `Select.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` / `Space` | Select the active option, close popup |
| `Escape` | Close the popup |
| Type a character | Jump to matching option (typeahead) |

Typeahead also works while the popup is closed — it changes the selected value directly.

## 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 |

## Important Notes

- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
- **Disabled options** can still receive keyboard focus but cannot be selected.

## ARIA

- Popup: `role="listbox"`
- Option: `role="option"`, `aria-selected`, `aria-disabled`
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
13 changes: 13 additions & 0 deletions packages/headless/src/primitives/select/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export * as Select from './parts';

export type {
SelectArrowProps,
SelectItem,
SelectOptionProps,
SelectPopupProps,
SelectPortalProps,
SelectPositionerProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from './parts';
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/select/parts.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root';
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
export { type SelectValueProps, SelectValue as Value } from './select-value';
export { type SelectPortalProps, SelectPortal as Portal } from './select-portal';
export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner';
export { type SelectPopupProps, SelectPopup as Popup } from './select-popup';
export { type SelectOptionProps, SelectOption as Option } from './select-option';
export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow';
22 changes: 22 additions & 0 deletions packages/headless/src/primitives/select/select-arrow.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useSelectContext } from './select-context';

export interface SelectArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function SelectArrow(props: SelectArrowProps) {
const { floatingContext, arrowRef, placement } = useSelectContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='select-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
51 changes: 51 additions & 0 deletions packages/headless/src/primitives/select/select-context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, type RefObject, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface SelectItem {
label: string;
value: string;
}

export interface SelectContextValue {
open: boolean;
items: SelectItem[] | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
selectedIndex: number | null;
selectedValue: string | undefined;
selectedLabel: string | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: RefObject<HTMLDivElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valueToLabelRef: React.MutableRefObject<Map<string, string>>;
selectedItemRef: React.MutableRefObject<HTMLElement | null>;
alignItemWithTrigger: boolean;
handleSelect: (value: string, index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select compound components must be used within <Select.Root>');
}
return ctx;
}
65 changes: 65 additions & 0 deletions packages/headless/src/primitives/select/select-option.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectOptionProps extends ComponentProps<'button'> {
value: string;
label?: string;
disabled?: boolean;
}

export function SelectOption(props: SelectOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } =
useSelectContext();

const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valueToLabelRef.current.set(value, displayLabel);
return () => {
valueToLabelRef.current.delete(value);
};
}, [value, displayLabel, valueToLabelRef]);

const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'select-option',
ref: combinedRef,
role: 'option' as const,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
tabIndex: isActive ? 0 : -1,
...(getItemProps({
onClick() {
if (!disabled) handleSelect(value, index);
},
}) as React.ComponentPropsWithRef<'button'>),
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
23 changes: 23 additions & 0 deletions packages/headless/src/primitives/select/select-popup.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectPopupProps extends ComponentProps<'div'> {}

export function SelectPopup(props: SelectPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useSelectContext();

const defaultProps = {
'data-cl-slot': 'select-popup',
ref: popupRef,
...transitionProps,
};

return renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
16 changes: 16 additions & 0 deletions packages/headless/src/primitives/select/select-portal.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
'use client';

import { FloatingPortal } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { useSelectContext } from './select-context';

export interface SelectPortalProps {
children: ReactNode;
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
}

export function SelectPortal(props: SelectPortalProps) {
const { mounted } = useSelectContext();
if (!mounted) return null;
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(headless): add Select primitive by alexcarpenter · Pull Request #8479 · clerk/javascript · GitHub
Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@
"import": "./dist/primitives/popover/index.js",
"types": "./dist/primitives/popover/index.d.ts"
},
"./select": {
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
163 changes: 163 additions & 0 deletions packages/headless/src/primitives/select/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
# Select

A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.

## When to Use

- Picking a single value from a predefined list of options.
- When you need typeahead, keyboard navigation, and full styling control.
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.

## Usage

```tsx
import { Select } from '@/primitives/select';

<Select.Root>
<Select.Trigger>
<Select.Value placeholder='Choose a fruit...' />
</Select.Trigger>
<Select.Positioner>
<Select.Popup>
<Select.Option
value='apple'
label='Apple'
/>
<Select.Option
value='banana'
label='Banana'
/>
<Select.Option
value='cherry'
label='Cherry'
/>
</Select.Popup>
</Select.Positioner>
</Select.Root>;
```

### Controlled

```tsx
const [value, setValue] = useState('apple');

<Select.Root
value={value}
onValueChange={setValue}
>
{/* ... */}
</Select.Root>;
```

### With `items` for SSR label resolution

The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):

```tsx
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];

<Select.Root
items={items}
defaultValue='apple'
>
{/* Select.Value will display "Apple" even before Options mount */}
</Select.Root>;
```

### Disable item-to-trigger alignment

By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:

```tsx
<Select.Root alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ------------------------------------------ |
| `Select.Root` | — | Root context provider |
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
| `Select.Portal` | — | Portals children (accepts `root` prop) |
| `Select.Positioner` | `<div>` | Floating positioned container |
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
| `Select.Option` | `<button>` | A selectable option |
| `Select.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Select.Root`

| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount |
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |

### `Select.Value`

| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ------------------------------- |
| `placeholder` | `ReactNode` | — | Shown when no value is selected |

### `Select.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | -------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
| `disabled` | `boolean` | — | Prevents selection |

### `Select.Trigger`, `Select.Positioner`, `Select.Popup`

No additional props beyond standard HTML attributes and the `render` prop.

### `Select.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` / `Space` | Select the active option, close popup |
| `Escape` | Close the popup |
| Type a character | Jump to matching option (typeahead) |

Typeahead also works while the popup is closed — it changes the selected value directly.

## 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 |

## Important Notes

- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
- **Disabled options** can still receive keyboard focus but cannot be selected.

## ARIA

- Popup: `role="listbox"`
- Option: `role="option"`, `aria-selected`, `aria-disabled`
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
13 changes: 13 additions & 0 deletions packages/headless/src/primitives/select/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export * as Select from './parts';

export type {
SelectArrowProps,
SelectItem,
SelectOptionProps,
SelectPopupProps,
SelectPortalProps,
SelectPositionerProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from './parts';
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/select/parts.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root';
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
export { type SelectValueProps, SelectValue as Value } from './select-value';
export { type SelectPortalProps, SelectPortal as Portal } from './select-portal';
export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner';
export { type SelectPopupProps, SelectPopup as Popup } from './select-popup';
export { type SelectOptionProps, SelectOption as Option } from './select-option';
export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow';
22 changes: 22 additions & 0 deletions packages/headless/src/primitives/select/select-arrow.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useSelectContext } from './select-context';

export interface SelectArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function SelectArrow(props: SelectArrowProps) {
const { floatingContext, arrowRef, placement } = useSelectContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='select-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
51 changes: 51 additions & 0 deletions packages/headless/src/primitives/select/select-context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, type RefObject, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface SelectItem {
label: string;
value: string;
}

export interface SelectContextValue {
open: boolean;
items: SelectItem[] | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
selectedIndex: number | null;
selectedValue: string | undefined;
selectedLabel: string | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: RefObject<HTMLDivElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valueToLabelRef: React.MutableRefObject<Map<string, string>>;
selectedItemRef: React.MutableRefObject<HTMLElement | null>;
alignItemWithTrigger: boolean;
handleSelect: (value: string, index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select compound components must be used within <Select.Root>');
}
return ctx;
}
65 changes: 65 additions & 0 deletions packages/headless/src/primitives/select/select-option.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectOptionProps extends ComponentProps<'button'> {
value: string;
label?: string;
disabled?: boolean;
}

export function SelectOption(props: SelectOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } =
useSelectContext();

const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valueToLabelRef.current.set(value, displayLabel);
return () => {
valueToLabelRef.current.delete(value);
};
}, [value, displayLabel, valueToLabelRef]);

const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'select-option',
ref: combinedRef,
role: 'option' as const,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
tabIndex: isActive ? 0 : -1,
...(getItemProps({
onClick() {
if (!disabled) handleSelect(value, index);
},
}) as React.ComponentPropsWithRef<'button'>),
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
23 changes: 23 additions & 0 deletions packages/headless/src/primitives/select/select-popup.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectPopupProps extends ComponentProps<'div'> {}

export function SelectPopup(props: SelectPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useSelectContext();

const defaultProps = {
'data-cl-slot': 'select-popup',
ref: popupRef,
...transitionProps,
};

return renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
16 changes: 16 additions & 0 deletions packages/headless/src/primitives/select/select-portal.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
'use client';

import { FloatingPortal } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { useSelectContext } from './select-context';

export interface SelectPortalProps {
children: ReactNode;
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
}

export function SelectPortal(props: SelectPortalProps) {
const { mounted } = useSelectContext();
if (!mounted) return null;
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(headless): add Select primitive by alexcarpenter · Pull Request #8479 · clerk/javascript · GitHub
Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@
"import": "./dist/primitives/popover/index.js",
"types": "./dist/primitives/popover/index.d.ts"
},
"./select": {
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
163 changes: 163 additions & 0 deletions packages/headless/src/primitives/select/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
# Select

A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.

## When to Use

- Picking a single value from a predefined list of options.
- When you need typeahead, keyboard navigation, and full styling control.
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.

## Usage

```tsx
import { Select } from '@/primitives/select';

<Select.Root>
<Select.Trigger>
<Select.Value placeholder='Choose a fruit...' />
</Select.Trigger>
<Select.Positioner>
<Select.Popup>
<Select.Option
value='apple'
label='Apple'
/>
<Select.Option
value='banana'
label='Banana'
/>
<Select.Option
value='cherry'
label='Cherry'
/>
</Select.Popup>
</Select.Positioner>
</Select.Root>;
```

### Controlled

```tsx
const [value, setValue] = useState('apple');

<Select.Root
value={value}
onValueChange={setValue}
>
{/* ... */}
</Select.Root>;
```

### With `items` for SSR label resolution

The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):

```tsx
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];

<Select.Root
items={items}
defaultValue='apple'
>
{/* Select.Value will display "Apple" even before Options mount */}
</Select.Root>;
```

### Disable item-to-trigger alignment

By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:

```tsx
<Select.Root alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ------------------------------------------ |
| `Select.Root` | — | Root context provider |
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
| `Select.Portal` | — | Portals children (accepts `root` prop) |
| `Select.Positioner` | `<div>` | Floating positioned container |
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
| `Select.Option` | `<button>` | A selectable option |
| `Select.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Select.Root`

| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount |
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |

### `Select.Value`

| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ------------------------------- |
| `placeholder` | `ReactNode` | — | Shown when no value is selected |

### `Select.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | -------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
| `disabled` | `boolean` | — | Prevents selection |

### `Select.Trigger`, `Select.Positioner`, `Select.Popup`

No additional props beyond standard HTML attributes and the `render` prop.

### `Select.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` / `Space` | Select the active option, close popup |
| `Escape` | Close the popup |
| Type a character | Jump to matching option (typeahead) |

Typeahead also works while the popup is closed — it changes the selected value directly.

## 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 |

## Important Notes

- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
- **Disabled options** can still receive keyboard focus but cannot be selected.

## ARIA

- Popup: `role="listbox"`
- Option: `role="option"`, `aria-selected`, `aria-disabled`
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
13 changes: 13 additions & 0 deletions packages/headless/src/primitives/select/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export * as Select from './parts';

export type {
SelectArrowProps,
SelectItem,
SelectOptionProps,
SelectPopupProps,
SelectPortalProps,
SelectPositionerProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from './parts';
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/select/parts.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root';
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
export { type SelectValueProps, SelectValue as Value } from './select-value';
export { type SelectPortalProps, SelectPortal as Portal } from './select-portal';
export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner';
export { type SelectPopupProps, SelectPopup as Popup } from './select-popup';
export { type SelectOptionProps, SelectOption as Option } from './select-option';
export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow';
22 changes: 22 additions & 0 deletions packages/headless/src/primitives/select/select-arrow.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useSelectContext } from './select-context';

export interface SelectArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function SelectArrow(props: SelectArrowProps) {
const { floatingContext, arrowRef, placement } = useSelectContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='select-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
51 changes: 51 additions & 0 deletions packages/headless/src/primitives/select/select-context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, type RefObject, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface SelectItem {
label: string;
value: string;
}

export interface SelectContextValue {
open: boolean;
items: SelectItem[] | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
selectedIndex: number | null;
selectedValue: string | undefined;
selectedLabel: string | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: RefObject<HTMLDivElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valueToLabelRef: React.MutableRefObject<Map<string, string>>;
selectedItemRef: React.MutableRefObject<HTMLElement | null>;
alignItemWithTrigger: boolean;
handleSelect: (value: string, index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select compound components must be used within <Select.Root>');
}
return ctx;
}
65 changes: 65 additions & 0 deletions packages/headless/src/primitives/select/select-option.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectOptionProps extends ComponentProps<'button'> {
value: string;
label?: string;
disabled?: boolean;
}

export function SelectOption(props: SelectOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } =
useSelectContext();

const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valueToLabelRef.current.set(value, displayLabel);
return () => {
valueToLabelRef.current.delete(value);
};
}, [value, displayLabel, valueToLabelRef]);

const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'select-option',
ref: combinedRef,
role: 'option' as const,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
tabIndex: isActive ? 0 : -1,
...(getItemProps({
onClick() {
if (!disabled) handleSelect(value, index);
},
}) as React.ComponentPropsWithRef<'button'>),
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
23 changes: 23 additions & 0 deletions packages/headless/src/primitives/select/select-popup.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectPopupProps extends ComponentProps<'div'> {}

export function SelectPopup(props: SelectPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useSelectContext();

const defaultProps = {
'data-cl-slot': 'select-popup',
ref: popupRef,
...transitionProps,
};

return renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
16 changes: 16 additions & 0 deletions packages/headless/src/primitives/select/select-portal.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
'use client';

import { FloatingPortal } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { useSelectContext } from './select-context';

export interface SelectPortalProps {
children: ReactNode;
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
}

export function SelectPortal(props: SelectPortalProps) {
const { mounted } = useSelectContext();
if (!mounted) return null;
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
}
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(headless): add Select primitive by alexcarpenter · Pull Request #8479 · clerk/javascript · GitHub
Skip to content
Closed
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
4 changes: 4 additions & 0 deletions packages/headless/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,10 @@
"import": "./dist/primitives/popover/index.js",
"types": "./dist/primitives/popover/index.d.ts"
},
"./select": {
"import": "./dist/primitives/select/index.js",
"types": "./dist/primitives/select/index.d.ts"
},
"./dialog": {
"import": "./dist/primitives/dialog/index.js",
"types": "./dist/primitives/dialog/index.d.ts"
Expand Down
163 changes: 163 additions & 0 deletions packages/headless/src/primitives/select/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
# Select

A dropdown select component with keyboard navigation, typeahead, and optional item-to-trigger alignment. Replaces native `<select>` with a fully styled, accessible alternative.

## When to Use

- Picking a single value from a predefined list of options.
- When you need typeahead, keyboard navigation, and full styling control.
- Prefer Select over Autocomplete when the user should choose from a fixed list without typing to filter.

## Usage

```tsx
import { Select } from '@/primitives/select';

<Select.Root>
<Select.Trigger>
<Select.Value placeholder='Choose a fruit...' />
</Select.Trigger>
<Select.Positioner>
<Select.Popup>
<Select.Option
value='apple'
label='Apple'
/>
<Select.Option
value='banana'
label='Banana'
/>
<Select.Option
value='cherry'
label='Cherry'
/>
</Select.Popup>
</Select.Positioner>
</Select.Root>;
```

### Controlled

```tsx
const [value, setValue] = useState('apple');

<Select.Root
value={value}
onValueChange={setValue}
>
{/* ... */}
</Select.Root>;
```

### With `items` for SSR label resolution

The `items` prop allows label resolution before options mount (useful for server rendering or deferred lists):

```tsx
const items = [
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
];

<Select.Root
items={items}
defaultValue='apple'
>
{/* Select.Value will display "Apple" even before Options mount */}
</Select.Root>;
```

### Disable item-to-trigger alignment

By default, the selected option visually aligns with the trigger. Disable this for standard dropdown positioning:

```tsx
<Select.Root alignItemWithTrigger={false}>{/* Uses standard Floating UI positioning */}</Select.Root>
```

## Parts

| Part | Default Element | Description |
| ------------------- | --------------- | ------------------------------------------ |
| `Select.Root` | — | Root context provider |
| `Select.Trigger` | `<button>` | Toggles the dropdown on click |
| `Select.Value` | `<span>` | Displays the selected label or placeholder |
| `Select.Portal` | — | Portals children (accepts `root` prop) |
| `Select.Positioner` | `<div>` | Floating positioned container |
| `Select.Popup` | `<div>` | Visual wrapper for the option list |
| `Select.Option` | `<button>` | A selectable option |
| `Select.Arrow` | `<svg>` | Optional floating arrow |

## Props

### `Select.Root`

| Prop | Type | Default | Description |
| ---------------------- | ------------------------- | ---------------- | ------------------------------------------------------------------ |
| `value` | `string` | — | Controlled selected value |
| `defaultValue` | `string` | — | Initial selected value (uncontrolled) |
| `onValueChange` | `(value: string) => void` | — | Called when selection changes |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `items` | `SelectItem[]` | — | `{ label, value }` pairs for label resolution before options mount |
| `alignItemWithTrigger` | `boolean` | `true` | Visually align selected option over the trigger |
| `placement` | `Placement` | `"bottom-start"` | Floating UI placement |
| `sideOffset` | `number` | `4` | Gap between trigger and popup (px) |

### `Select.Value`

| Prop | Type | Default | Description |
| ------------- | ----------- | ------- | ------------------------------- |
| `placeholder` | `ReactNode` | — | Shown when no value is selected |

### `Select.Option`

| Prop | Type | Default | Description |
| ---------- | --------- | --------------------- | -------------------------------------- |
| `value` | `string` | **required** | The option's value |
| `label` | `string` | falls back to `value` | Display label, also used for typeahead |
| `disabled` | `boolean` | — | Prevents selection |

### `Select.Trigger`, `Select.Positioner`, `Select.Popup`

No additional props beyond standard HTML attributes and the `render` prop.

### `Select.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.

## Keyboard Navigation

| Key | Action |
| ----------------- | ------------------------------------- |
| `ArrowDown` | Move to next option |
| `ArrowUp` | Move to previous option |
| `Enter` / `Space` | Select the active option, close popup |
| `Escape` | Close the popup |
| Type a character | Jump to matching option (typeahead) |

Typeahead also works while the popup is closed — it changes the selected value directly.

## 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 |

## Important Notes

- **`label` on `Select.Option`** drives both display in `Select.Value` and typeahead matching. If omitted, `value` is used for both.
- **`items` prop** is only for label resolution — it does not control which options render. You still render `Select.Option` children yourself.
- **Disabled options** can still receive keyboard focus but cannot be selected.

## ARIA

- Popup: `role="listbox"`
- Option: `role="option"`, `aria-selected`, `aria-disabled`
- Trigger: `aria-expanded`, `aria-haspopup="listbox"`, `aria-controls`
13 changes: 13 additions & 0 deletions packages/headless/src/primitives/select/index.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
export * as Select from './parts';

export type {
SelectArrowProps,
SelectItem,
SelectOptionProps,
SelectPopupProps,
SelectPortalProps,
SelectPositionerProps,
SelectProps,
SelectTriggerProps,
SelectValueProps,
} from './parts';
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/select/parts.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
export { type SelectItem, type SelectProps, SelectRoot as Root } from './select-root';
export { type SelectTriggerProps, SelectTrigger as Trigger } from './select-trigger';
export { type SelectValueProps, SelectValue as Value } from './select-value';
export { type SelectPortalProps, SelectPortal as Portal } from './select-portal';
export { type SelectPositionerProps, SelectPositioner as Positioner } from './select-positioner';
export { type SelectPopupProps, SelectPopup as Popup } from './select-popup';
export { type SelectOptionProps, SelectOption as Option } from './select-option';
export { type SelectArrowProps, SelectArrow as Arrow } from './select-arrow';
22 changes: 22 additions & 0 deletions packages/headless/src/primitives/select/select-arrow.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import React from 'react';
import { useSelectContext } from './select-context';

export interface SelectArrowProps extends React.ComponentPropsWithRef<typeof FloatingArrow> {}

export function SelectArrow(props: SelectArrowProps) {
const { floatingContext, arrowRef, placement } = useSelectContext();
const side = placement.split('-')[0];

return (
<FloatingArrow
data-cl-slot='select-arrow'
data-cl-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
51 changes: 51 additions & 0 deletions packages/headless/src/primitives/select/select-context.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
import type {
ExtendedRefs,
FloatingContext,
Placement,
ReferenceType,
UseInteractionsReturn,
} from '@floating-ui/react';
import { type CSSProperties, createContext, type RefObject, useContext } from 'react';
import type { TransitionProps } from '../../hooks/use-transition';

export interface SelectItem {
label: string;
value: string;
}

export interface SelectContextValue {
open: boolean;
items: SelectItem[] | undefined;
floatingContext: FloatingContext;
refs: ExtendedRefs<ReferenceType>;
floatingStyles: CSSProperties;
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
selectedIndex: number | null;
selectedValue: string | undefined;
selectedLabel: string | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
labelsRef: React.MutableRefObject<Array<string | null>>;
popupRef: RefObject<HTMLDivElement | null>;
arrowRef: React.MutableRefObject<SVGSVGElement | null>;
valueToLabelRef: React.MutableRefObject<Map<string, string>>;
selectedItemRef: React.MutableRefObject<HTMLElement | null>;
alignItemWithTrigger: boolean;
handleSelect: (value: string, index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}

export const SelectContext = createContext<SelectContextValue | null>(null);

export function useSelectContext() {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select compound components must be used within <Select.Root>');
}
return ctx;
}
65 changes: 65 additions & 0 deletions packages/headless/src/primitives/select/select-option.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
'use client';

import { useListItem, useMergeRefs } from '@floating-ui/react';
import React, { useEffect } from 'react';
import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectOptionProps extends ComponentProps<'button'> {
value: string;
label?: string;
disabled?: boolean;
}

export function SelectOption(props: SelectOptionProps) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } =
useSelectContext();

const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });

const isSelected = selectedValue === value;
const isActive = activeIndex === index;

useEffect(() => {
valueToLabelRef.current.set(value, displayLabel);
return () => {
valueToLabelRef.current.delete(value);
};
}, [value, displayLabel, valueToLabelRef]);

const combinedRef = useMergeRefs([itemRef, isSelected ? selectedItemRef : null]);

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
'data-cl-slot': 'select-option',
ref: combinedRef,
role: 'option' as const,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
tabIndex: isActive ? 0 : -1,
...(getItemProps({
onClick() {
if (!disabled) handleSelect(value, index);
},
}) as React.ComponentPropsWithRef<'button'>),
};

return renderElement({
defaultTagName: 'button',
render,
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-cl-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-cl-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-cl-disabled': '' } : null),
},
props: mergeProps<'button'>(defaultProps, otherProps),
});
}
23 changes: 23 additions & 0 deletions packages/headless/src/primitives/select/select-popup.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
'use client';

import { type ComponentProps, mergeProps, renderElement } from '../../utils/render-element';
import { useSelectContext } from './select-context';

export interface SelectPopupProps extends ComponentProps<'div'> {}

export function SelectPopup(props: SelectPopupProps) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useSelectContext();

const defaultProps = {
'data-cl-slot': 'select-popup',
ref: popupRef,
...transitionProps,
};

return renderElement({
defaultTagName: 'div',
render,
props: mergeProps<'div'>(defaultProps, otherProps),
});
}
16 changes: 16 additions & 0 deletions packages/headless/src/primitives/select/select-portal.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
'use client';

import { FloatingPortal } from '@floating-ui/react';
import type { ReactNode } from 'react';
import { useSelectContext } from './select-context';

export interface SelectPortalProps {
children: ReactNode;
root?: HTMLElement | null | React.RefObject<HTMLElement | null>;
}

export function SelectPortal(props: SelectPortalProps) {
const { mounted } = useSelectContext();
if (!mounted) return null;
return <FloatingPortal root={props.root}>{props.children}</FloatingPortal>;
}
Loading
Loading