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.
- package/README.md +7 -1
- package/dist/components/AppShell/appShell.theme.js +17 -2
- package/dist/components/Form/Form/form.state.svelte.d.ts +4 -0
- package/dist/components/Form/Form/visibility.d.ts +2 -0
- package/dist/components/Form/MultiStepForm/multiStepForm.state.svelte.d.ts +4 -0
- package/dist/components/Form/Select/Select.svelte +28 -2
- package/dist/components/Form/Select/select.align.d.ts +55 -0
- package/dist/components/Form/Select/select.align.js +41 -0
- package/dist/components/Form/Select/select.mcp.d.ts +1 -1
- package/dist/components/Form/Select/select.mcp.js +7 -3
- package/dist/components/Form/Select/select.props.d.ts +8 -0
- package/dist/components/Form/Select/select.state.svelte.d.ts +24 -0
- package/dist/components/Form/Select/select.state.svelte.js +111 -1
- package/dist/components/PageShell/pageShell.theme.js +2 -2
- package/dist/components/Popover/Popover.svelte +4 -0
- package/dist/components/Popover/popover.mcp.d.ts +1 -1
- package/dist/components/Popover/popover.mcp.js +1 -0
- package/dist/components/Popover/popover.props.d.ts +16 -0
- package/dist/components/Popover/popover.state.svelte.d.ts +1 -1
- package/dist/components/Popover/popover.state.svelte.js +13 -0
- package/dist/components/Sidebar/Sidebar.svelte +6 -1
- package/dist/components/Sidebar/sidebar.mcp.d.ts +1 -1
- package/dist/components/Sidebar/sidebar.mcp.js +1 -1
- package/dist/components/Sidebar/sidebar.theme.d.ts +12 -0
- package/dist/components/Sidebar/sidebar.theme.js +27 -3
- package/dist/generated/componentMcpRegistry.d.ts +3 -3
- 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
|
|
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-[
|
|
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-[
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
5
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
},
|