entasis 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +7 -1
  2. package/dist/components/AppShell/appShell.theme.js +17 -2
  3. package/dist/components/Form/Form/form.state.svelte.d.ts +4 -0
  4. package/dist/components/Form/Form/visibility.d.ts +2 -0
  5. package/dist/components/Form/MultiStepForm/multiStepForm.state.svelte.d.ts +4 -0
  6. package/dist/components/Form/Select/Select.svelte +28 -2
  7. package/dist/components/Form/Select/select.align.d.ts +55 -0
  8. package/dist/components/Form/Select/select.align.js +41 -0
  9. package/dist/components/Form/Select/select.mcp.d.ts +1 -1
  10. package/dist/components/Form/Select/select.mcp.js +7 -3
  11. package/dist/components/Form/Select/select.props.d.ts +8 -0
  12. package/dist/components/Form/Select/select.state.svelte.d.ts +24 -0
  13. package/dist/components/Form/Select/select.state.svelte.js +111 -1
  14. package/dist/components/PageShell/pageShell.theme.js +2 -2
  15. package/dist/components/Popover/Popover.svelte +4 -0
  16. package/dist/components/Popover/popover.mcp.d.ts +1 -1
  17. package/dist/components/Popover/popover.mcp.js +1 -0
  18. package/dist/components/Popover/popover.props.d.ts +16 -0
  19. package/dist/components/Popover/popover.state.svelte.d.ts +1 -1
  20. package/dist/components/Popover/popover.state.svelte.js +13 -0
  21. package/dist/components/Sidebar/Sidebar.svelte +6 -1
  22. package/dist/components/Sidebar/sidebar.mcp.d.ts +1 -1
  23. package/dist/components/Sidebar/sidebar.mcp.js +1 -1
  24. package/dist/components/Sidebar/sidebar.theme.d.ts +12 -0
  25. package/dist/components/Sidebar/sidebar.theme.js +27 -3
  26. package/dist/generated/componentMcpRegistry.d.ts +3 -3
  27. package/package.json +3 -3
package/README.md CHANGED
@@ -320,7 +320,13 @@ Snippet props: `size` (px number or CSS length, default `1lh`), `color` (a role
320
320
 
321
321
  - `pnpm dev` runs the documentation site with a page per component under `/components/<name>`.
322
322
  - `.claude/skills/entasis/` (mirrored in `.agents/skills/entasis/`) holds the coding-agent skill: import conventions, per-component references, theming notes.
323
- - Each component folder ships a `*.mcp.ts` description used by the MCP integration.
323
+ - Each component folder ships a `*.mcp.ts` description, served by the MCP server the docs site runs at
324
+ `https://entasis.beynar.workers.dev/mcp` (one `components` tool that returns a component's documentation).
325
+ Add it to a project for Claude Code with:
326
+
327
+ ```bash
328
+ claude mcp add --transport http --scope project entasis https://entasis.beynar.workers.dev/mcp
329
+ ```
324
330
 
325
331
  ## Releasing
326
332
 
@@ -42,8 +42,8 @@ const defaultPage = cva({
42
42
  variant: {
43
43
  admin: 'bg-surface [--page-shell-chrome:var(--color-surface-canvas)] [--page-shell-surface:var(--color-surface)]',
44
44
  floating: 'bg-surface-canvas [--page-shell-chrome:var(--color-surface)] [--page-shell-surface:var(--color-surface-canvas)] [--page-shell-chrome-inline-gap:0.5rem] [--page-shell-chrome-block-gap:0.5rem] [--page-shell-header-top-radius:var(--radius-lg)] [--page-shell-header-bottom-radius:var(--radius-lg)] [--page-shell-footer-top-radius:var(--radius-lg)] [--page-shell-footer-bottom-radius:var(--radius-lg)] [--page-shell-chrome-border:var(--color-neutral-muted)] [--page-shell-chrome-shadow:var(--elevation-1)]',
45
- inset: 'bg-surface [--page-shell-chrome:var(--color-surface)] [--page-shell-surface:var(--color-surface)] transition-[border-color,box-shadow] md:raised-1 md:group-data-[display-state=hidden]/sidebar-wrapper:border-transparent md:group-data-[display-state=hidden]/sidebar-wrapper:shadow-none',
46
- split: 'bg-surface [--page-shell-chrome:var(--color-surface)] [--page-shell-surface:var(--color-surface)] transition-[border-color,box-shadow] md:raised-1 md:group-data-[display-state=hidden]/sidebar-wrapper:border-transparent md:group-data-[display-state=hidden]/sidebar-wrapper:shadow-none',
45
+ inset: 'bg-surface [--page-shell-chrome:var(--color-surface)] [--page-shell-surface:var(--color-surface)] transition-[border-color,box-shadow] md:raised-1 md:group-data-[page-flush=true]/sidebar-wrapper:border-transparent md:group-data-[page-flush=true]/sidebar-wrapper:shadow-none',
46
+ split: 'bg-surface [--page-shell-chrome:var(--color-surface)] [--page-shell-surface:var(--color-surface)] transition-[border-color,box-shadow] md:raised-1 md:group-data-[page-flush=true]/sidebar-wrapper:border-transparent md:group-data-[page-flush=true]/sidebar-wrapper:shadow-none',
47
47
  // The frame already owns the radius, border and elevation, so the page inside it is flat,
48
48
  // and its header chrome is the same surface as the content: the white column is one
49
49
  // piece whose only corners are the frame's own.
@@ -54,6 +54,21 @@ const defaultPage = cva({
54
54
  right: ''
55
55
  }
56
56
  },
57
+ compoundVariants: [
58
+ // A floating panel column pads its page side, which already spaces the panel card from the
59
+ // page's header and footer cards: their own inset on that side would double the gap. A
60
+ // hidden panel leaves the rail (or the screen edge) beside the page, so the inset returns.
61
+ {
62
+ variant: 'floating',
63
+ side: 'left',
64
+ class: 'md:[--page-shell-chrome-left-gap:0px] md:group-data-[display-state=hidden]/sidebar-wrapper:[--page-shell-chrome-left-gap:0.5rem]'
65
+ },
66
+ {
67
+ variant: 'floating',
68
+ side: 'right',
69
+ class: 'md:[--page-shell-chrome-right-gap:0px] md:group-data-[display-state=hidden]/sidebar-wrapper:[--page-shell-chrome-right-gap:0.5rem]'
70
+ }
71
+ ],
57
72
  defaultVariants: {
58
73
  variant: 'admin',
59
74
  side: 'left'
@@ -634,6 +634,7 @@ export declare class FormState<I extends FormInputs = FormInputs> {
634
634
  items?: import("../Select/select.props.js").SelectItems;
635
635
  density?: import("../../../types/theme.js").Density;
636
636
  separators?: boolean;
637
+ alignItemWithTrigger?: boolean;
637
638
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes;
638
639
  i18n?: Partial<import("../../../i18n/en.js").Messages>;
639
640
  }, "visible"> & {
@@ -1405,6 +1406,7 @@ export declare class FormState<I extends FormInputs = FormInputs> {
1405
1406
  items?: import("../Select/select.props.js").SelectItems;
1406
1407
  density?: import("../../../types/theme.js").Density;
1407
1408
  separators?: boolean;
1409
+ alignItemWithTrigger?: boolean;
1408
1410
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes;
1409
1411
  i18n?: Partial<import("../../../i18n/en.js").Messages>;
1410
1412
  }, "visible"> & {
@@ -2210,6 +2212,7 @@ export declare class FormState<I extends FormInputs = FormInputs> {
2210
2212
  items?: import("../Select/select.props.js").SelectItems;
2211
2213
  density?: import("../../../types/theme.js").Density;
2212
2214
  separators?: boolean;
2215
+ alignItemWithTrigger?: boolean;
2213
2216
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes;
2214
2217
  i18n?: Partial<import("../../../i18n/en.js").Messages>;
2215
2218
  }, "visible"> & {
@@ -2981,6 +2984,7 @@ export declare class FormState<I extends FormInputs = FormInputs> {
2981
2984
  items?: import("../Select/select.props.js").SelectItems;
2982
2985
  density?: import("../../../types/theme.js").Density;
2983
2986
  separators?: boolean;
2987
+ alignItemWithTrigger?: boolean;
2984
2988
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes;
2985
2989
  i18n?: Partial<import("../../../i18n/en.js").Messages>;
2986
2990
  }, "visible"> & {
@@ -3349,6 +3349,7 @@ export declare function prepareInputProps(input: FormFieldInput, size: Sizes, la
3349
3349
  focused?: boolean | undefined;
3350
3350
  i18n?: Partial<import("../../../i18n/en.js").Messages> | undefined;
3351
3351
  separators?: boolean | undefined;
3352
+ alignItemWithTrigger?: boolean | undefined;
3352
3353
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes | undefined;
3353
3354
  display?: string;
3354
3355
  } | {
@@ -8972,6 +8973,7 @@ export declare function prepareInputProps(input: FormFieldInput, size: Sizes, la
8972
8973
  focused?: boolean | undefined;
8973
8974
  i18n?: Partial<import("../../../i18n/en.js").Messages> | undefined;
8974
8975
  separators?: boolean | undefined;
8976
+ alignItemWithTrigger?: boolean | undefined;
8975
8977
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes | undefined;
8976
8978
  display?: string;
8977
8979
  } | {
@@ -614,6 +614,7 @@ export declare class MultiStepFormState<I extends MultiStepFormItems = FormStep[
614
614
  items?: import("../Select/select.props.js").SelectItems;
615
615
  density?: import("../../../types/theme.js").Density;
616
616
  separators?: boolean;
617
+ alignItemWithTrigger?: boolean;
617
618
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes;
618
619
  i18n?: Partial<import("../../../i18n/en.js").Messages>;
619
620
  }, "visible"> & {
@@ -1385,6 +1386,7 @@ export declare class MultiStepFormState<I extends MultiStepFormItems = FormStep[
1385
1386
  items?: import("../Select/select.props.js").SelectItems;
1386
1387
  density?: import("../../../types/theme.js").Density;
1387
1388
  separators?: boolean;
1389
+ alignItemWithTrigger?: boolean;
1388
1390
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes;
1389
1391
  i18n?: Partial<import("../../../i18n/en.js").Messages>;
1390
1392
  }, "visible"> & {
@@ -2190,6 +2192,7 @@ export declare class MultiStepFormState<I extends MultiStepFormItems = FormStep[
2190
2192
  items?: import("../Select/select.props.js").SelectItems;
2191
2193
  density?: import("../../../types/theme.js").Density;
2192
2194
  separators?: boolean;
2195
+ alignItemWithTrigger?: boolean;
2193
2196
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes;
2194
2197
  i18n?: Partial<import("../../../i18n/en.js").Messages>;
2195
2198
  }, "visible"> & {
@@ -2961,6 +2964,7 @@ export declare class MultiStepFormState<I extends MultiStepFormItems = FormStep[
2961
2964
  items?: import("../Select/select.props.js").SelectItems;
2962
2965
  density?: import("../../../types/theme.js").Density;
2963
2966
  separators?: boolean;
2967
+ alignItemWithTrigger?: boolean;
2964
2968
  triggerAttrs?: import("../Select/select.props.js").SelectTriggerAttributes;
2965
2969
  i18n?: Partial<import("../../../i18n/en.js").Messages>;
2966
2970
  }, "visible"> & {
@@ -30,6 +30,7 @@
30
30
  visible,
31
31
  items,
32
32
  separators = true,
33
+ alignItemWithTrigger = true,
33
34
  triggerAttrs,
34
35
  label,
35
36
  ...rest
@@ -37,6 +38,9 @@
37
38
  if (value === undefined) value = untrack(() => defaultValue);
38
39
 
39
40
  const id = $props.id();
41
+ let valueEl = $state<HTMLElement | null>(null);
42
+ let listEl = $state<HTMLDivElement | null>(null);
43
+ let viewportEl = $state<HTMLDivElement | null>(null);
40
44
  const t = $derived(useI18n(i18n));
41
45
 
42
46
  const field = createFieldState({
@@ -105,6 +109,18 @@
105
109
  },
106
110
  set triggerEl(_) {
107
111
  // field.node is owned by the bind:this below.
112
+ },
113
+ get alignItemWithTrigger() {
114
+ return alignItemWithTrigger;
115
+ },
116
+ get valueEl() {
117
+ return valueEl;
118
+ },
119
+ get listEl() {
120
+ return listEl;
121
+ },
122
+ get viewportEl() {
123
+ return viewportEl;
108
124
  }
109
125
  });
110
126
 
@@ -121,6 +137,7 @@
121
137
  fitTrigger
122
138
  position="bottom"
123
139
  ref={field.node?.parentElement}
140
+ positionPanel={select.positionPanel}
124
141
  size="small"
125
142
  transition={{
126
143
  in: { scale: 1, opacity: 0 },
@@ -143,7 +160,13 @@
143
160
  event.preventDefault();
144
161
  }}
145
162
  >
146
- <ScrollArea scrollOnEdges type="auto" class="flex max-h-[240px] flex-col">
163
+ <ScrollArea
164
+ bind:ref={listEl}
165
+ bind:viewportRef={viewportEl}
166
+ scrollOnEdges
167
+ type="auto"
168
+ class="flex max-h-[240px] flex-col"
169
+ >
147
170
  {#each select.renderGroups as group, groupIndex (groupIndex)}
148
171
  {#if separators && groupIndex > 0}
149
172
  <div role="separator" class={classes.separator({ size })}></div>
@@ -230,7 +253,10 @@
230
253
  triggerAttrs?.onblur?.(event);
231
254
  }}
232
255
  >
233
- <span class={classes.value({ size, placeholder: !select.selectedOption })}>
256
+ <span
257
+ bind:this={valueEl}
258
+ class={classes.value({ size, placeholder: !select.selectedOption })}
259
+ >
234
260
  {select.selectedOption?.label ?? placeholder ?? t.selectOption}
235
261
  </span>
236
262
  {@render caretDownIcon({ class: classes.triggerIcon({ size }) })}
@@ -0,0 +1,55 @@
1
+ /** Distance kept between an item-aligned panel and the viewport edges, in px. */
2
+ export declare const SELECT_ALIGN_MARGIN = 8;
3
+ /**
4
+ * Viewport measurements of an open Select, taken with the option list at its natural height and
5
+ * scrolled to the top. `textStart` values are the inline-start edge of the text (left in LTR,
6
+ * right in RTL).
7
+ */
8
+ export type SelectAlignMetrics = {
9
+ viewport: {
10
+ width: number;
11
+ height: number;
12
+ };
13
+ trigger: {
14
+ top: number;
15
+ height: number;
16
+ };
17
+ /** The trigger's value (or placeholder) text. */
18
+ valueTextStart: number;
19
+ panel: {
20
+ left: number;
21
+ right: number;
22
+ top: number;
23
+ width: number;
24
+ height: number;
25
+ };
26
+ /** The scrolling option list inside the panel. */
27
+ list: {
28
+ top: number;
29
+ height: number;
30
+ };
31
+ /** The option to put on the trigger: the selected one, else the first. */
32
+ item: {
33
+ top: number;
34
+ height: number;
35
+ textStart: number;
36
+ };
37
+ rtl: boolean;
38
+ };
39
+ export type SelectAlignment = {
40
+ /** Panel position, viewport coordinates. */
41
+ x: number;
42
+ y: number;
43
+ /** Height to cap the option list at. */
44
+ listHeight: number;
45
+ /** Scroll offset that keeps the option on the trigger. */
46
+ scrollTop: number;
47
+ };
48
+ /**
49
+ * Item-aligned placement, the native select's and Radix's: the panel covers the trigger with the
50
+ * option's middle on the trigger's middle and its text on the value text. A panel that would cross
51
+ * the top edge is pinned there and its list scrolled by the overflow, so the option stays on the
52
+ * trigger; one that would cross the bottom edge is cut short and scrolls. Only when the trigger
53
+ * sits so low that fewer than four rows would fit does the panel rise and give up the alignment.
54
+ */
55
+ export declare function alignItemWithTrigger(m: SelectAlignMetrics): SelectAlignment;
@@ -0,0 +1,41 @@
1
+ /** Distance kept between an item-aligned panel and the viewport edges, in px. */
2
+ export const SELECT_ALIGN_MARGIN = 8;
3
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
4
+ /**
5
+ * Item-aligned placement, the native select's and Radix's: the panel covers the trigger with the
6
+ * option's middle on the trigger's middle and its text on the value text. A panel that would cross
7
+ * the top edge is pinned there and its list scrolled by the overflow, so the option stays on the
8
+ * trigger; one that would cross the bottom edge is cut short and scrolls. Only when the trigger
9
+ * sits so low that fewer than four rows would fit does the panel rise and give up the alignment.
10
+ */
11
+ export function alignItemWithTrigger(m) {
12
+ const top = SELECT_ALIGN_MARGIN;
13
+ const bottom = m.viewport.height - SELECT_ALIGN_MARGIN;
14
+ const chromeAbove = m.list.top - m.panel.top;
15
+ const chromeBelow = m.panel.top + m.panel.height - (m.list.top + m.list.height);
16
+ const itemMiddle = m.item.top + m.item.height / 2 - m.panel.top;
17
+ const triggerMiddle = m.trigger.top + m.trigger.height / 2;
18
+ let y = triggerMiddle - itemMiddle;
19
+ let scrollTop = 0;
20
+ if (y < top) {
21
+ scrollTop = top - y;
22
+ y = top;
23
+ }
24
+ let height = Math.min(m.panel.height - scrollTop, bottom - y);
25
+ const minHeight = Math.min(m.panel.height, chromeAbove + chromeBelow + m.item.height * 4);
26
+ if (height < minHeight) {
27
+ height = minHeight;
28
+ y = Math.max(top, bottom - height);
29
+ }
30
+ const listHeight = Math.max(0, height - chromeAbove - chromeBelow);
31
+ scrollTop = clamp(scrollTop, 0, m.list.height - listHeight);
32
+ // Line the option's text up with the value's: the panel moves by the gap between them.
33
+ const textOffset = m.rtl ? m.panel.right - m.item.textStart : m.item.textStart - m.panel.left;
34
+ const x = m.rtl ? m.valueTextStart + textOffset - m.panel.width : m.valueTextStart - textOffset;
35
+ return {
36
+ x: clamp(x, SELECT_ALIGN_MARGIN, m.viewport.width - SELECT_ALIGN_MARGIN - m.panel.width),
37
+ y,
38
+ listHeight,
39
+ scrollTop
40
+ };
41
+ }
@@ -1 +1 @@
1
- export declare const selectDescription = "\n# Select Component\n\nA custom (non-native) dropdown selection field: a combobox trigger opening a listbox popover,\nwith full keyboard navigation, grouped options, and Field/Form integration.\n\n## Basic Usage\n\n```svelte\n<Select\n\tlabel=\"Country\"\n\tbind:value={country}\n\titems={[\n\t\t{ value: 'us', label: 'United States' },\n\t\t{ value: 'uk', label: 'United Kingdom' },\n\t\t{ value: 'ca', label: 'Canada' }\n\t]}\n/>\n```\n\n## Grouped options\n\nFlat options and `{ label, items }` groups can be mixed freely; separators render between groups.\n\n```svelte\n<Select\n\tlabel=\"Timezone\"\n\tbind:value={tz}\n\titems={[\n\t\t{ label: 'Europe', items: [{ value: 'paris', label: 'Paris' }, { value: 'berlin', label: 'Berlin' }] },\n\t\t{ label: 'America', items: [{ value: 'nyc', label: 'New York' }, { value: 'la', label: 'Los Angeles' }] }\n\t]}\n/>\n```\n\n## Props\n\nExtends all Field component props plus:\n\n### Core Props\n- **value**: string (bindable) - Selected value\n- **items**: (SelectOption | SelectOptionGroup)[] - Flat `{ value, label, disabled? }` options and/or `{ label, items }` groups\n- **placeholder**: string (default: 'Select an option') - Trigger text when no selection\n- **separators**: boolean (default: true) - Render separators between consecutive groups\n\n### Field Props (inherited)\n- **label**: string | Snippet - Field label, and the trigger's accessible name; without it the trigger falls back to the placeholder\n- **description**: string | Snippet - Helper text\n- **required**: boolean - Mark as required\n- **disabled**: boolean - Disable the trigger\n- **size**: 'small' | 'normal' | 'large' - Trigger and dropdown size\n- **density**: 'compact' | 'normal' | 'comfortable' (default: 'normal') - Spacing density forwarded to the dropdown option rows (paddings, gaps, min-height)\n- **name**: string - Form field name; also renders a hidden input for native form posts\n\n### Bindable Props\n- **value**: string - Selected value\n- **errors**: string[] - Validation errors\n- **focused**: boolean - Trigger focus state\n\n### Callbacks\n- **onValueChange**: (value) => void - Fires when the selection changes\n- **onValidate**: (value) => string[] | boolean - Custom validation\n\n### Advanced Props\n- **theme**: SelectThemeProps - Theme overrides (input, inputContainer, value, triggerIcon, content, group, groupLabel, item, itemIndicator, separator)\n\n## Keyboard\n\n- Closed: ArrowDown / ArrowUp / Enter / Space open the dropdown, anchored on the selected option\n- Open: ArrowDown / ArrowUp move the highlight (wrap-around), Home / End jump, Enter / Space select, Escape closes, Tab closes and moves focus on\n- Type-ahead: typing letters while the trigger has focus moves the highlight to the next option whose label starts with the typed text\n- Disabled options are skipped by the highlight\n\n## Accessibility\n\nARIA 1.2 select-only combobox pattern: the trigger is a `role=\"combobox\"` button with\n`aria-haspopup=\"listbox\"`, `aria-expanded`, and `aria-controls`; DOM focus stays on the\ntrigger while `aria-activedescendant` tracks the highlighted `role=\"option\"` (virtual focus).\nThe selected option shows a check indicator and `aria-selected`.\n\nThe trigger always has an accessible name: `label` names it through the Field label (a string\nlabel as a `<label for>`, a snippet through `aria-labelledby`), and without one the trigger\nfalls back to `placeholder`, then to the catalog's \"Select an option\". Pass `label` whenever\nan unlabelled select sits in a toolbar or filter row, so the name says which control it is\nrather than repeating the placeholder.\n\n## Notes\n\n- Selection re-focuses the trigger (matches native select behavior)\n- Clicking outside closes via trigger blur; option rows prevent mousedown so the click can land\n- The dropdown scrolls beyond ~240px (ScrollArea); the highlight scrolls into view on keyboard nav\n\n## State contract\n\n- **value**: current bindable editable value.\n- **defaultValue**: initial value used only when `value` is omitted.\n- **onValueChange**: called with the new value when the component changes it.\n\n";
1
+ export declare const selectDescription = "\n# Select Component\n\nA custom (non-native) selection field: a combobox trigger opening a listbox popover, with full\nkeyboard navigation, grouped options, and Field/Form integration. Like a native select (and\nRadix's item-aligned or Base UI's `alignItemWithTrigger` position), the listbox opens over the\ntrigger with the selected option sitting on the value.\n\n## Basic Usage\n\n```svelte\n<Select\n\tlabel=\"Country\"\n\tbind:value={country}\n\titems={[\n\t\t{ value: 'us', label: 'United States' },\n\t\t{ value: 'uk', label: 'United Kingdom' },\n\t\t{ value: 'ca', label: 'Canada' }\n\t]}\n/>\n```\n\n## Grouped options\n\nFlat options and `{ label, items }` groups can be mixed freely; separators render between groups.\n\n```svelte\n<Select\n\tlabel=\"Timezone\"\n\tbind:value={tz}\n\titems={[\n\t\t{ label: 'Europe', items: [{ value: 'paris', label: 'Paris' }, { value: 'berlin', label: 'Berlin' }] },\n\t\t{ label: 'America', items: [{ value: 'nyc', label: 'New York' }, { value: 'la', label: 'Los Angeles' }] }\n\t]}\n/>\n```\n\n## Props\n\nExtends all Field component props plus:\n\n### Core Props\n- **value**: string (bindable) - Selected value\n- **items**: (SelectOption | SelectOptionGroup)[] - Flat `{ value, label, disabled? }` options and/or `{ label, items }` groups\n- **placeholder**: string (default: 'Select an option') - Trigger text when no selection\n- **separators**: boolean (default: true) - Render separators between consecutive groups\n- **alignItemWithTrigger**: boolean (default: true) - Open over the trigger with the selected option (the first enabled one when nothing is selected) on the trigger's middle and its text on the value text. A list taller than the viewport is capped 8px from the edges and pre-scrolled so the option stays on the trigger; a trigger too close to the bottom for four rows gives up the alignment and the panel rises into view. Wheel and touch scrolling outside the panel are blocked while it is open, so the trigger cannot move away from it. `false` opens a dropdown below the trigger instead, capped at ~240px\n\n### Field Props (inherited)\n- **label**: string | Snippet - Field label, and the trigger's accessible name; without it the trigger falls back to the placeholder\n- **description**: string | Snippet - Helper text\n- **required**: boolean - Mark as required\n- **disabled**: boolean - Disable the trigger\n- **size**: 'small' | 'normal' | 'large' - Trigger and dropdown size\n- **density**: 'compact' | 'normal' | 'comfortable' (default: 'normal') - Spacing density forwarded to the dropdown option rows (paddings, gaps, min-height)\n- **name**: string - Form field name; also renders a hidden input for native form posts\n\n### Bindable Props\n- **value**: string - Selected value\n- **errors**: string[] - Validation errors\n- **focused**: boolean - Trigger focus state\n\n### Callbacks\n- **onValueChange**: (value) => void - Fires when the selection changes\n- **onValidate**: (value) => string[] | boolean - Custom validation\n\n### Advanced Props\n- **theme**: SelectThemeProps - Theme overrides (input, inputContainer, value, triggerIcon, content, group, groupLabel, item, itemIndicator, separator)\n\n## Keyboard\n\n- Closed: ArrowDown / ArrowUp / Enter / Space open the dropdown, anchored on the selected option\n- Open: ArrowDown / ArrowUp move the highlight (wrap-around), Home / End jump, Enter / Space select, Escape closes, Tab closes and moves focus on\n- Type-ahead: typing letters while the trigger has focus moves the highlight to the next option whose label starts with the typed text\n- Disabled options are skipped by the highlight\n\n## Accessibility\n\nARIA 1.2 select-only combobox pattern: the trigger is a `role=\"combobox\"` button with\n`aria-haspopup=\"listbox\"`, `aria-expanded`, and `aria-controls`; DOM focus stays on the\ntrigger while `aria-activedescendant` tracks the highlighted `role=\"option\"` (virtual focus).\nThe selected option shows a check indicator and `aria-selected`.\n\nThe trigger always has an accessible name: `label` names it through the Field label (a string\nlabel as a `<label for>`, a snippet through `aria-labelledby`), and without one the trigger\nfalls back to `placeholder`, then to the catalog's \"Select an option\". Pass `label` whenever\nan unlabelled select sits in a toolbar or filter row, so the name says which control it is\nrather than repeating the placeholder.\n\n## Notes\n\n- Selection re-focuses the trigger (matches native select behavior)\n- Clicking outside closes via trigger blur; option rows prevent mousedown so the click can land\n- The list scrolls (ScrollArea) beyond the viewport when item-aligned, beyond ~240px as a dropdown; the highlight scrolls into view on keyboard nav\n- The panel covers the trigger while open, so clicking outside (not the trigger) closes it, as with a native select\n\n## State contract\n\n- **value**: current bindable editable value.\n- **defaultValue**: initial value used only when `value` is omitted.\n- **onValueChange**: called with the new value when the component changes it.\n\n";
@@ -1,8 +1,10 @@
1
1
  export const selectDescription = `
2
2
  # Select Component
3
3
 
4
- A custom (non-native) dropdown selection field: a combobox trigger opening a listbox popover,
5
- with full keyboard navigation, grouped options, and Field/Form integration.
4
+ A custom (non-native) selection field: a combobox trigger opening a listbox popover, with full
5
+ keyboard navigation, grouped options, and Field/Form integration. Like a native select (and
6
+ Radix's item-aligned or Base UI's \`alignItemWithTrigger\` position), the listbox opens over the
7
+ trigger with the selected option sitting on the value.
6
8
 
7
9
  ## Basic Usage
8
10
 
@@ -42,6 +44,7 @@ Extends all Field component props plus:
42
44
  - **items**: (SelectOption | SelectOptionGroup)[] - Flat \`{ value, label, disabled? }\` options and/or \`{ label, items }\` groups
43
45
  - **placeholder**: string (default: 'Select an option') - Trigger text when no selection
44
46
  - **separators**: boolean (default: true) - Render separators between consecutive groups
47
+ - **alignItemWithTrigger**: boolean (default: true) - Open over the trigger with the selected option (the first enabled one when nothing is selected) on the trigger's middle and its text on the value text. A list taller than the viewport is capped 8px from the edges and pre-scrolled so the option stays on the trigger; a trigger too close to the bottom for four rows gives up the alignment and the panel rises into view. Wheel and touch scrolling outside the panel are blocked while it is open, so the trigger cannot move away from it. \`false\` opens a dropdown below the trigger instead, capped at ~240px
45
48
 
46
49
  ### Field Props (inherited)
47
50
  - **label**: string | Snippet - Field label, and the trigger's accessible name; without it the trigger falls back to the placeholder
@@ -88,7 +91,8 @@ rather than repeating the placeholder.
88
91
 
89
92
  - Selection re-focuses the trigger (matches native select behavior)
90
93
  - Clicking outside closes via trigger blur; option rows prevent mousedown so the click can land
91
- - The dropdown scrolls beyond ~240px (ScrollArea); the highlight scrolls into view on keyboard nav
94
+ - The list scrolls (ScrollArea) beyond the viewport when item-aligned, beyond ~240px as a dropdown; the highlight scrolls into view on keyboard nav
95
+ - The panel covers the trigger while open, so clicking outside (not the trigger) closes it, as with a native select
92
96
 
93
97
  ## State contract
94
98
 
@@ -34,6 +34,14 @@ export type SelectProps = InputProps<'select'> & {
34
34
  density?: Density;
35
35
  /** Render separators between consecutive groups. */
36
36
  separators?: boolean;
37
+ /**
38
+ * Open over the trigger with the selected option (or the first, when nothing is selected) on
39
+ * the value, its text lined up with the value's, like a native select. A list taller than the
40
+ * viewport is capped and pre-scrolled to keep the option there. `false` opens a dropdown below
41
+ * the trigger instead.
42
+ * @default true
43
+ */
44
+ alignItemWithTrigger?: boolean;
37
45
  /** Native attributes applied to the combobox trigger button. */
38
46
  triggerAttrs?: SelectTriggerAttributes;
39
47
  /** Per-instance i18n overrides merged over the global catalog. */
@@ -8,13 +8,24 @@ interface SelectStateOptions {
8
8
  disabled: boolean | undefined;
9
9
  /** The trigger button — reactive getter bridged to `field.node`, refocused after selection. */
10
10
  triggerEl: HTMLElement | null;
11
+ /** Open over the trigger with the selected option on the value, like a native select. */
12
+ alignItemWithTrigger: boolean;
13
+ /** The trigger's value text, the option list root, and its scrolling viewport. */
14
+ valueEl: HTMLElement | null;
15
+ listEl: HTMLElement | null;
16
+ viewportEl: HTMLElement | null;
11
17
  }
12
18
  export declare class SelectState {
19
+ #private;
13
20
  id: string;
14
21
  items: SelectItems | undefined;
15
22
  value: string | null | undefined;
16
23
  disabled: boolean | undefined;
17
24
  triggerEl: HTMLElement | null;
25
+ alignItemWithTrigger: boolean;
26
+ valueEl: HTMLElement | null;
27
+ listEl: HTMLElement | null;
28
+ viewportEl: HTMLElement | null;
18
29
  isOpen: boolean;
19
30
  listboxId: string;
20
31
  /** All options, flattened across groups — render order. */
@@ -38,6 +49,19 @@ export declare class SelectState {
38
49
  };
39
50
  constructor(options: SelectStateOptions);
40
51
  optionId: (value: string) => string;
52
+ /**
53
+ * Popover `positionPanel`: places the open panel over the trigger with the selected option (or
54
+ * the first one) on the value, and sizes and scrolls the list to keep it there. `null` falls
55
+ * back to the dropdown below the trigger.
56
+ */
57
+ positionPanel: ({ panel, reference }: {
58
+ panel: HTMLElement;
59
+ reference: unknown;
60
+ }) => {
61
+ x: number;
62
+ y: number;
63
+ minWidth: number;
64
+ } | null;
41
65
  open: () => void;
42
66
  close: () => void;
43
67
  toggle: () => void;
@@ -1,8 +1,28 @@
1
1
  import { bind } from '../../../utils/state.svelte.js';
2
2
  import { useListNavigation } from '../../../utils/useListNavigation.svelte.js';
3
+ import { alignItemWithTrigger, SELECT_ALIGN_MARGIN } from './select.align.js';
4
+ /** Inline-start edge of an element's first text: what lines up between the value and an option. */
5
+ const textStart = (element, rtl) => {
6
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, {
7
+ acceptNode: (node) => node.textContent?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
8
+ });
9
+ const text = walker.nextNode();
10
+ let rect = element.getBoundingClientRect();
11
+ const range = text ? document.createRange() : null;
12
+ // Ranges without layout (jsdom) fall back to the element's own box.
13
+ if (range?.getBoundingClientRect) {
14
+ range.selectNodeContents(text);
15
+ rect = range.getBoundingClientRect();
16
+ }
17
+ return rtl ? rect.right : rect.left;
18
+ };
3
19
  const isGroup = (entry) => Array.isArray(entry.items);
4
20
  export class SelectState {
5
21
  isOpen = $state(false);
22
+ // Where this open session placed the panel. Measured once: later repositioning (the panel
23
+ // resizing, the list scrolling) must not re-scroll the list under the pointer. A new viewport
24
+ // size measures again.
25
+ #alignment = null;
6
26
  listboxId = $derived.by(() => `${this.id}-listbox`);
7
27
  /** All options, flattened across groups — render order. */
8
28
  flatOptions = $derived.by(() => (this.items ?? []).flatMap((entry) => (isGroup(entry) ? entry.items : [entry])));
@@ -35,12 +55,100 @@ export class SelectState {
35
55
  });
36
56
  constructor(options) {
37
57
  bind(this, options);
58
+ // An item-aligned panel sits on the trigger, so the page holds still while it is open, as
59
+ // it does for a native select. Popover's body lock does not reach an app's own scroll
60
+ // container (a scrolling <main>), so wheel and touch scrolling are blocked outside the
61
+ // panel. The panel is portaled under the locked body: its own scrolling cannot chain out.
62
+ $effect(() => {
63
+ if (!this.isOpen || !this.alignItemWithTrigger)
64
+ return;
65
+ const block = (event) => {
66
+ const panel = document.getElementById(this.listboxId)?.closest('dialog');
67
+ if (event.target instanceof Node && panel?.contains(event.target))
68
+ return;
69
+ event.preventDefault();
70
+ };
71
+ const listen = { capture: true, passive: false };
72
+ window.addEventListener('wheel', block, listen);
73
+ window.addEventListener('touchmove', block, listen);
74
+ return () => {
75
+ window.removeEventListener('wheel', block, listen);
76
+ window.removeEventListener('touchmove', block, listen);
77
+ };
78
+ });
38
79
  }
39
80
  // Index-based ids — sanitizing values into ids can collide ('a.b' and 'a_b' both → 'a_b').
40
81
  optionId = (value) => `${this.id}-option-${this.flatOptions.findIndex((o) => o.value === value)}`;
82
+ /**
83
+ * Popover `positionPanel`: places the open panel over the trigger with the selected option (or
84
+ * the first one) on the value, and sizes and scrolls the list to keep it there. `null` falls
85
+ * back to the dropdown below the trigger.
86
+ */
87
+ positionPanel = ({ panel, reference }) => {
88
+ const { valueEl, listEl, viewportEl } = this;
89
+ if (!this.alignItemWithTrigger || !valueEl || !listEl || !viewportEl)
90
+ return null;
91
+ if (!(reference instanceof HTMLElement))
92
+ return null;
93
+ const anchor = this.selectedOption && !this.selectedOption.disabled
94
+ ? this.selectedOption
95
+ : this.flatOptions.find((option) => !option.disabled);
96
+ const item = anchor && document.getElementById(this.optionId(anchor.value));
97
+ if (!item)
98
+ return null;
99
+ const width = window.innerWidth;
100
+ const height = window.innerHeight;
101
+ const kept = this.#alignment;
102
+ if (kept && kept.viewport.width === width && kept.viewport.height === height)
103
+ return kept.position;
104
+ // Measure the list at its natural height and scroll, the panel at least as wide as the
105
+ // trigger (what `fitTrigger` applies on the next render).
106
+ const trigger = reference.getBoundingClientRect();
107
+ const surface = panel.firstElementChild;
108
+ if (surface)
109
+ surface.style.minWidth = `${trigger.width}px`;
110
+ listEl.style.maxHeight = 'none';
111
+ viewportEl.scrollTop = 0;
112
+ const rtl = getComputedStyle(reference).direction === 'rtl';
113
+ const panelRect = panel.getBoundingClientRect();
114
+ const listRect = listEl.getBoundingClientRect();
115
+ const itemRect = item.getBoundingClientRect();
116
+ const placed = alignItemWithTrigger({
117
+ viewport: { width, height },
118
+ trigger: { top: trigger.top, height: trigger.height },
119
+ valueTextStart: textStart(valueEl, rtl),
120
+ panel: {
121
+ left: panelRect.left,
122
+ right: panelRect.right,
123
+ top: panelRect.top,
124
+ width: panelRect.width,
125
+ height: panelRect.height
126
+ },
127
+ list: { top: listRect.top, height: listRect.height },
128
+ item: { top: itemRect.top, height: itemRect.height, textStart: textStart(item, rtl) },
129
+ rtl
130
+ });
131
+ listEl.style.maxHeight = `${placed.listHeight}px`;
132
+ viewportEl.scrollTop = placed.scrollTop;
133
+ // Overhang the trigger on both sides. The text alignment fixes the start edge, so the panel
134
+ // widens at the end to match the start's overhang (at least 4px) and stays in the viewport.
135
+ const end = rtl ? placed.x + panelRect.width : placed.x;
136
+ const startOverhang = rtl ? end - trigger.right : trigger.left - placed.x;
137
+ const overhang = Math.max(4, startOverhang);
138
+ const minWidth = Math.max(panelRect.width, rtl ? end - (trigger.left - overhang) : trigger.right + overhang - placed.x);
139
+ const x = Math.min(Math.max(rtl ? end - minWidth : placed.x, SELECT_ALIGN_MARGIN), width - SELECT_ALIGN_MARGIN - minWidth);
140
+ // Popover applies `minWidth` through a style binding, which skips the write when the value
141
+ // matches the previous session's: put back what the measurement overwrote.
142
+ if (surface)
143
+ surface.style.minWidth = `${minWidth}px`;
144
+ const position = { x: Math.max(SELECT_ALIGN_MARGIN, x), y: placed.y, minWidth };
145
+ this.#alignment = { position, viewport: { width, height } };
146
+ return position;
147
+ };
41
148
  open = () => {
42
149
  if (this.disabled)
43
150
  return;
151
+ this.#alignment = null;
44
152
  this.isOpen = true;
45
153
  // Safari/Firefox-macOS don't focus buttons on click — grab focus explicitly so the
46
154
  // blur-to-close and trigger keydown paths work for mouse users everywhere.
@@ -51,8 +159,10 @@ export class SelectState {
51
159
  if (selected && !selected.disabled) {
52
160
  this.nav.setHighlighted(selected.value);
53
161
  // The dropdown mounts on the next flush — defer the scroll (setTimeout, not rAF:
54
- // rAF stalls in hidden tabs).
162
+ // rAF stalls in hidden tabs). An item-aligned panel has already scrolled its list.
55
163
  setTimeout(() => {
164
+ if (this.#alignment)
165
+ return;
56
166
  document
57
167
  .getElementById(this.optionId(selected.value))
58
168
  ?.scrollIntoView({ block: 'nearest' });
@@ -4,7 +4,7 @@ const defaultShell = cva({
4
4
  base: 'flex min-h-full w-full flex-col overflow-clip rounded-[inherit] bg-surface !bg-[var(--page-shell-surface,var(--color-surface))] text-neutral'
5
5
  });
6
6
  const defaultHeader = cva({
7
- base: 'sticky top-[calc(var(--page-shell-edge-inset,0px)_+_var(--page-shell-chrome-block-gap,0px))] z-20 mx-[var(--page-shell-chrome-inline-gap,0px)] mt-[var(--page-shell-chrome-block-gap,0px)] mb-[var(--page-shell-chrome-block-gap,0px)] shrink-0 rounded-tl-[var(--page-shell-header-top-radius,inherit)] rounded-tr-[var(--page-shell-header-top-radius,inherit)] rounded-br-[var(--page-shell-header-bottom-radius,0px)] rounded-bl-[var(--page-shell-header-bottom-radius,0px)] border-b [border-bottom-color:var(--page-shell-chrome-divider,var(--color-neutral-muted))] bg-[var(--page-shell-chrome,var(--color-surface))] shadow-[var(--page-shell-chrome-shadow,none)] ring-1 ring-inset ring-[var(--page-shell-chrome-border,transparent)] backdrop-blur'
7
+ base: 'sticky top-[calc(var(--page-shell-edge-inset,0px)_+_var(--page-shell-chrome-block-gap,0px))] z-20 ml-[var(--page-shell-chrome-left-gap,var(--page-shell-chrome-inline-gap,0px))] mr-[var(--page-shell-chrome-right-gap,var(--page-shell-chrome-inline-gap,0px))] mt-[var(--page-shell-chrome-block-gap,0px)] mb-[var(--page-shell-chrome-block-gap,0px)] shrink-0 rounded-tl-[var(--page-shell-header-top-radius,inherit)] rounded-tr-[var(--page-shell-header-top-radius,inherit)] rounded-br-[var(--page-shell-header-bottom-radius,0px)] rounded-bl-[var(--page-shell-header-bottom-radius,0px)] border-b [border-bottom-color:var(--page-shell-chrome-divider,var(--color-neutral-muted))] bg-[var(--page-shell-chrome,var(--color-surface))] shadow-[var(--page-shell-chrome-shadow,none)] ring-1 ring-inset ring-[var(--page-shell-chrome-border,transparent)] backdrop-blur'
8
8
  });
9
9
  // The row wraps: the title keeps its natural width and the actions drop under it when they do not
10
10
  // fit, instead of the title being squeezed to an ellipsis by a wide actions block.
@@ -63,7 +63,7 @@ const defaultContentInner = cva({
63
63
  }
64
64
  });
65
65
  const defaultFooter = cva({
66
- base: 'sticky bottom-[calc(var(--page-shell-edge-inset,0px)_+_var(--page-shell-chrome-block-gap,0px))] z-20 mx-[var(--page-shell-chrome-inline-gap,0px)] mt-[var(--page-shell-chrome-block-gap,0px)] mb-[var(--page-shell-chrome-block-gap,0px)] shrink-0 rounded-tl-[var(--page-shell-footer-top-radius,0px)] rounded-tr-[var(--page-shell-footer-top-radius,0px)] rounded-br-[var(--page-shell-footer-bottom-radius,inherit)] rounded-bl-[var(--page-shell-footer-bottom-radius,inherit)] border-t [border-top-color:var(--page-shell-chrome-divider,var(--color-neutral-muted))] bg-[var(--page-shell-chrome,var(--color-surface))] shadow-[var(--page-shell-chrome-shadow,none)] ring-1 ring-inset ring-[var(--page-shell-chrome-border,transparent)] backdrop-blur'
66
+ base: 'sticky bottom-[calc(var(--page-shell-edge-inset,0px)_+_var(--page-shell-chrome-block-gap,0px))] z-20 ml-[var(--page-shell-chrome-left-gap,var(--page-shell-chrome-inline-gap,0px))] mr-[var(--page-shell-chrome-right-gap,var(--page-shell-chrome-inline-gap,0px))] mt-[var(--page-shell-chrome-block-gap,0px)] mb-[var(--page-shell-chrome-block-gap,0px)] shrink-0 rounded-tl-[var(--page-shell-footer-top-radius,0px)] rounded-tr-[var(--page-shell-footer-top-radius,0px)] rounded-br-[var(--page-shell-footer-bottom-radius,inherit)] rounded-bl-[var(--page-shell-footer-bottom-radius,inherit)] border-t [border-top-color:var(--page-shell-chrome-divider,var(--color-neutral-muted))] bg-[var(--page-shell-chrome,var(--color-surface))] shadow-[var(--page-shell-chrome-shadow,none)] ring-1 ring-inset ring-[var(--page-shell-chrome-border,transparent)] backdrop-blur'
67
67
  });
68
68
  const defaultFooterInner = cva({
69
69
  base: 'flex min-h-row-lg items-center justify-between gap-lg px-xl py-md text-sm text-neutral/70'
@@ -34,6 +34,7 @@
34
34
  directedTransition = true,
35
35
  lockScroll = true,
36
36
  fitTrigger = false,
37
+ positionPanel,
37
38
  inline = false,
38
39
  mobileSheet = false,
39
40
  mobileSheetSizeTransition = true,
@@ -87,6 +88,9 @@
87
88
  get fitTrigger() {
88
89
  return fitTrigger;
89
90
  },
91
+ get positionPanel() {
92
+ return positionPanel;
93
+ },
90
94
  get mobileSheet() {
91
95
  return mobileSheet;
92
96
  },