entasis 0.7.1 → 0.9.0
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 +2 -2
- package/dist/components/FloatingWindow/FloatingWindow.svelte +26 -2
- package/dist/components/FloatingWindow/floatingWindow.dock.svelte.js +11 -2
- package/dist/components/FloatingWindow/floatingWindow.mcp.d.ts +1 -1
- package/dist/components/FloatingWindow/floatingWindow.mcp.js +7 -4
- package/dist/components/FloatingWindow/floatingWindow.props.d.ts +6 -0
- package/dist/components/FloatingWindow/floatingWindow.state.svelte.d.ts +3 -0
- package/dist/components/FloatingWindow/floatingWindow.state.svelte.js +18 -4
- package/dist/components/FloatingWindow/floatingWindow.theme.d.ts +3 -0
- package/dist/components/FloatingWindow/floatingWindow.theme.js +20 -7
- package/dist/components/Form/File/FileInput.svelte +6 -42
- 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/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 +58 -10
- package/dist/components/Sidebar/Sidebar.svelte.d.ts +1 -1
- package/dist/components/Sidebar/SidebarMenuItem.svelte +19 -2
- package/dist/components/Sidebar/SidebarMenuItem.svelte.d.ts +4 -0
- package/dist/components/Sidebar/SidebarPanel.svelte +199 -88
- package/dist/components/Sidebar/SidebarPanel.svelte.d.ts +3 -0
- package/dist/components/Sidebar/SidebarViewStage.svelte +103 -0
- package/dist/components/Sidebar/SidebarViewStage.svelte.d.ts +18 -0
- package/dist/components/Sidebar/index.d.ts +1 -1
- package/dist/components/Sidebar/sidebar.mcp.d.ts +1 -1
- package/dist/components/Sidebar/sidebar.mcp.js +19 -2
- package/dist/components/Sidebar/sidebar.props.d.ts +56 -1
- package/dist/components/Sidebar/sidebar.state.svelte.d.ts +4 -0
- package/dist/components/Sidebar/sidebar.state.svelte.js +9 -1
- package/dist/components/Sidebar/sidebar.theme.d.ts +128 -5
- package/dist/components/Sidebar/sidebar.theme.js +69 -3
- package/dist/components/Sidebar/sidebar.views.svelte.d.ts +138 -0
- package/dist/components/Sidebar/sidebar.views.svelte.js +303 -0
- package/dist/components/Theme/theme.floatingWindows.d.ts +6 -1
- package/dist/components/Theme/theme.floatingWindows.js +7 -2
- package/dist/components/Theme/theme.mcp.d.ts +1 -1
- package/dist/components/Theme/theme.mcp.js +1 -0
- package/dist/generated/componentContract.d.ts +1 -1
- package/dist/generated/componentContract.js +1 -0
- package/dist/generated/componentMcpRegistry.d.ts +6 -6
- package/dist/tailwind/index.mcp.d.ts +1 -1
- package/dist/tailwind/index.mcp.js +4 -0
- package/dist/tailwind/scales.js +11 -3
- package/dist/tailwind/spacing.js +17 -0
- package/dist/utils/cva/merge.d.ts +7 -0
- package/dist/utils/cva/merge.js +6 -1
- package/dist/utils/pointerDrag.js +7 -1
- package/package.json +1 -1
|
@@ -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' });
|
|
@@ -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
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const popoverDescription = "\n# Popover Component\n\nThe Popover component displays floating content positioned relative to a trigger element. It's ideal for tooltips, dropdown menus, and contextual information.\n\n## Basic Usage\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n\t\t\n</script>\n// By default Popover comes with a button that triggers them, no need to define a callback and a $state\n<Popover trigger={{ content: \"Toggle Popover\" }}>\n\tPopover content here\n</Popover>\n```\n\n## Props\n\n### Core Props\n- **open**: boolean (bindable) - Controls popover visibility (optional when using trigger prop)\n- **defaultOpen**: boolean (default: false) - Initial state when open is not provided\n- **ref**: HTMLElement | null - Reference element to position popover against (optional when using trigger prop)\n- **id**: string - Unique identifier\n\n### Layout Props\n- **position**: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' (default: 'bottom')\n - Determines where popover appears relative to trigger\n- **offset**: number - Distance in pixels from the reference element\n- **fitTrigger**: boolean (default: false) - Whether popover should match the width of the trigger element\n- **mobileSheet**: boolean (default: false) - On mobile viewports (<768px), render as a bottom sheet instead of an anchored floating panel\n- **inline**: boolean (default: false) - Render the panel in normal document flow where the component sits instead of portaling to the viewport-fixed layer: no floating-ui positioning, no scroll lock, no outside-press dismissal (Escape still closes it). Same panel classes and motion, and the trigger still toggles it. Wins over `mobileSheet`\n- **mobileSheetSizeTransition**: boolean (default: true) - Whether mobile-sheet panels animate intrinsic size changes\n\n### Event Props\n- **onOpenChange**: (open: boolean) => void - Called once for each library-requested state change\n- **onAfterOpen**: (payload: PopoverState) => void - Called after the open transition finishes\n- **onAfterClose**: (payload: PopoverState) => void - Called after the close transition finishes\n\n### Slot Props\n- **children**: Snippet<[PopoverState]> - Popover content\n- **trigger**: Snippet<[PopoverState]> | (ButtonProps & { content?: string }) | false - Trigger element\n - Pass a snippet function for custom trigger: `{#snippet trigger(popover)}...</snippet>`\n - Pass button props object for default button: `trigger={{ content: \"Click Me\", color: \"primary\" }}`\n - Pass `false` to disable trigger (use with external ref)\n\n### Interaction Props\n- **openOnHover**: boolean (default: false) - Open on mouse hover\n- **openOnClick**: boolean (default: true) - Open on click\n- **delay**: number (default: 100) - Delay in ms before opening on hover\n- **closeOnEscape**: boolean (default: true) - Close on Escape. Only the topmost open layer closes, so Escape inside a nested popover leaves its parent open\n- **closeOnClickOutside**: boolean (default: true) - Close on an outside press. A press dismisses this popover and every layer stacked above it, but never the layer that was pressed\n- **closeOnMouseLeave**: boolean (default: false) - Close when the pointer leaves the hover safe area. The safe area is the trigger, the panel, and a prediction cone toward the panel, so a diagonal move to the panel keeps it open.\n- **debugSafeArea**: boolean (default: false) - Show hover safe-area overlays. Trigger/panel rectangles render in blue; the prediction cone toward the panel renders in orange.\n\n### Focus & ARIA Props\n- **focusOnOpen**: 'first' | 'container' | false (default: false) - Where focus goes when the panel opens: `'first'` moves it to an `[autofocus]` / `[data-autofocus]` target or the first tabbable control, `'container'` focuses the panel itself, `false` keeps it on the trigger. Focus always returns to the trigger when the popover closes (Escape or outside press)\n- **haspopup**: 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' | true (default: 'dialog') - Value of `aria-haspopup` on the trigger, describing what the panel contains. `aria-expanded` and `aria-controls` are managed automatically alongside it, for the built-in Button trigger and for a snippet trigger using `{@attach popover.reference}`\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover focusOnOpen=\"first\" haspopup=\"listbox\" trigger={{ content: 'Pick one' }}>\n\t<ul role=\"listbox\"><li role=\"option\" tabindex=\"0\">First</li></ul>\n</Popover>\n```\n\n### Visual Props\n- **size**: 'small' | 'normal' | 'large' (default: 'normal')\n - small: Compact popover size\n - normal: Standard popover size\n - large: Larger popover size\n- **transition**: TransitionConfig - Custom transition animation\n- **directedTransition**: boolean (default: true) - Transition direction follows position\n\n### Behavior Props\n- **lockScroll**: boolean (default: true) - Lock body scroll when open\n\n### Styling Props\n- **class**: string - Additional CSS classes\n- **theme**: ComponentTheme - Custom theme overrides\n\n## Structure\n\n```\n<PopoverTrigger>\n\t<Trigger />\n</PopoverTrigger>\n\n<PopoverContent>\n\t<Children />\n</PopoverContent>\n```\n\n## Examples\n\n### More Examples\n\n### With a custom trigger snippet\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n\timport { Button } from 'entasis/button';\n</script>\n\n<!-- {@attach popover.reference} anchors the panel to the element and keeps\n aria-haspopup / aria-expanded / aria-controls in sync on it -->\n<Popover>\n\t{#snippet trigger(popover)}\n\t\t<Button onclick={() => popover.toggle()} {@attach popover.reference}>Open</Button>\n\t{/snippet}\n\t\n\t<p>This is a popover!</p>\n</Popover>\n```\n\n### With button props\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{\n\t\tcontent: \"Click Me\",\n\t\tcolor: \"secondary\",\n\t\tsize: \"small\"\n\t}}\n>\n\t<p>This is a popover!</p>\n</Popover>\n```\n\n### Different Positions\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<!-- Top -->\n<Popover position=\"top\" trigger={{ content: \"Top\" }}>\n\tTop popover\n</Popover>\n\n<!-- Bottom -->\n<Popover position=\"bottom\" trigger={{ content: \"Bottom\" }}>\n\tBottom popover\n</Popover>\n\n<!-- Left -->\n<Popover position=\"left\" trigger={{ content: \"Left\" }}>\n\tLeft popover\n</Popover>\n\n<!-- Right -->\n<Popover position=\"right\" trigger={{ content: \"Right\" }}>\n\tRight popover\n</Popover>\n```\n\n### Advanced Example\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n\timport { Button } from 'entasis/button';\n</script>\n\n<Popover position=\"bottom-start\" trigger={{ content: \"Menu\" }}>\n\t<div class=\"flex flex-col gap-1\">\n\t\t<Button variant=\"ghost\" fullWidth>Profile</Button>\n\t\t<Button variant=\"ghost\" fullWidth>Settings</Button>\n\t\t<Button variant=\"ghost\" fullWidth>Logout</Button>\n\t</div>\n</Popover>\n```\n\n### Open on Hover\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Hover Me\" }}\n\topenOnHover\n\topenOnClick={false}\n\tdelay={200}\n>\n\tHover content\n</Popover>\n```\n\n### With Custom Offset\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Trigger\" }}\n\tposition=\"bottom\"\n\toffset={20}\n>\n\t20px away from trigger\n</Popover>\n```\n\n### Fit Trigger Width\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Click Me\" }}\n\tfitTrigger\n>\n\tPopover matches trigger width\n</Popover>\n```\n\n### Inline (static, in flow)\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<!-- Open in place, no portal: useful for docs, visual tests, or an always-visible panel -->\n<Popover inline open trigger={false}>\n\t<p>Rendered where the component sits.</p>\n</Popover>\n\n<!-- The trigger still toggles an inline panel -->\n<Popover inline trigger={{ content: 'Toggle' }}>\n\t<p>Expands below the trigger, in the flow.</p>\n</Popover>\n```\n\n### Mobile Bottom Sheet\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover\n\ttrigger={{ content: \"Open filters\" }}\n\tposition=\"bottom\"\n\tmobileSheet\n>\n\tFilters content\n</Popover>\n```\n\n### Different Sizes\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<!-- Small -->\n<Popover size=\"small\" trigger={{ content: \"Small\" }}>\n\tSmall popover\n</Popover>\n\n<!-- Large -->\n<Popover size=\"large\" trigger={{ content: \"Large\" }}>\n\tLarge popover with more content\n</Popover>\n```\n\n### Close on Mouse Leave\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Hover Me\" }}\n\topenOnHover\n\tcloseOnMouseLeave\n>\n\tCloses when you move outside the rectangle tolerance\n</Popover>\n```\n\n### With Lifecycle Hooks\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Trigger\" }}\n\tonAfterOpen={(payload) => console.log('Popover opened', payload)}\n\tonAfterClose={(payload) => console.log('Popover closed', payload)}\n>\n\tWatch the console\n</Popover>\n```\n\n### User Card Popover with External Ref\n\n```svelte\n<script lang=\"ts\">\n\timport { Popover } from 'entasis/popover';\n\timport { Button } from 'entasis/button';\n\timport { Avatar } from 'entasis/avatar';\n\n\tlet avatarRef = $state<HTMLElement | null>(null);\n\tlet open = $state(false);\n\tconst user = { name: 'John Doe', email: 'john@example.com' };\n</script>\n\n<button type=\"button\" bind:this={avatarRef} onclick={() => (open = !open)}>\n\t<Avatar name={user.name} />\n</button>\n\n<Popover bind:open ref={avatarRef} position=\"bottom\">\n\t<div class=\"p-4\">\n\t\t<h3>{user.name}</h3>\n\t\t<p>{user.email}</p>\n\t\t<Button fullWidth>View Profile</Button>\n\t</div>\n</Popover>\n```\n\n## State Management\n\nThe Popover component uses a `PopoverState` instance that is passed to all slot snippets. This state object provides:\n\n- **id**: string - Popover identifier\n- **size**: Size - Current popover size\n- **position**: Placement - Current popover position\n- **offset**: number - Current offset value\n- **open()**: () => void - Method to open the popover\n- **close()**: () => void - Method to close the popover\n- **toggle()**: () => void - Method to toggle the popover\n- **reference**: attachment for a custom trigger element (`{@attach popover.reference}`); anchors the panel to it and keeps its `aria-haspopup`, `aria-expanded`, and `aria-controls` in sync\n\n## Accessibility\n\n- The trigger carries `aria-haspopup` (from `haspopup`), `aria-expanded`, and `aria-controls` \u2014 automatically for the built-in Button and for a snippet trigger using `{@attach popover.reference}`\n- `focusOnOpen` decides where focus lands on open; focus returns to the trigger on close, whether by Escape or an outside press\n- Non-modal: Tab is not contained and the page is not made inert (use Dialog for that)\n- Escape closes only the topmost open layer; an outside press dismisses every layer stacked above the one pressed. Popovers, menus, and dialogs share one layer stack\n- Keyboard navigation support\n\n## Notes\n\n- Popover is positioned using floating-ui, except with `inline`, where the document lays the panel out\n- Automatically adjusts position to stay in viewport\n- Multiple popovers can be stacked\n- Scroll locking prevents background scroll (when enabled)\n- Transitions animate based on position direction\n\n## Theme Customization\n\nThe Popover component uses a theme object that can be customized using the `theme` prop or by setting a global theme.\n\n### Theme Structure\n\nThe theme object contains the following parts:\n- **motion**: Open/close transition preset, keyed by `mode` (a floating panel scales, the mobile\n sheet slides up). Takes `in` / `out` FSO params plus a `duration` / `easing` motion token;\n the `transition` prop wins over it\n- **popover**: Main popover container styles\n\n### Theme Type Definition\n\n```typescript\nimport type { PopoverThemeProps } from 'entasis/popover';\n\n// Example theme customization\nconst customTheme: PopoverThemeProps = {\n popover: {\n base: 'z-[+50] fixed bg-surface-floating text-neutral w-fit rounded-xl raised isolate h-fit',\n size: {\n small: 'max-w-3xs w-full p-2',\n normal: 'max-w-xs w-full p-3',\n large: 'max-w-sm w-full p-4'\n }\n }\n};\n```\n\n### Available Variants\n\n**popover**:\n- base: Base classes applied to all popovers\n- Variants:\n - size: 'small' | 'normal' | 'large' - Controls max-width, width, and padding\n - mode: 'floating' | 'inline' | 'mobileSheet' - Set from `inline` / `mobileSheet`; `root` positions the wrapper (fixed, in flow, or full-screen sheet)\n\n### Usage Examples\n\n**Basic Theme Override**:\n```svelte\n<Popover \n trigger={{ content: \"Click Me\" }}\n theme={{\n popover: {\n base: 'rounded-xl lift-5 border-2 border-primary',\n size: {\n normal: 'max-w-md p-4'\n }\n }\n }}\n>\n Custom styled popover content\n</Popover>\n```\n\n**Size Customization**:\n```svelte\n<Popover \n size=\"large\"\n trigger={{ content: \"Large Popover\" }}\n theme={{\n popover: {\n size: {\n large: 'max-w-lg p-6'\n }\n }\n }}\n>\n Large popover with more padding\n</Popover>\n```\n\n**Global Theme Setting**:\n```svelte\n<script>\n import { setPopoverTheme } from './index.ts';\n \n setPopoverTheme({\n popover: {\n base: 'rounded-lg lift-5 backdrop-blur-sm bg-white/95',\n size: {\n normal: 'max-w-sm p-4'\n }\n }\n });\n</script>\n```\n";
|
|
1
|
+
export declare const popoverDescription = "\n# Popover Component\n\nThe Popover component displays floating content positioned relative to a trigger element. It's ideal for tooltips, dropdown menus, and contextual information.\n\n## Basic Usage\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n\t\t\n</script>\n// By default Popover comes with a button that triggers them, no need to define a callback and a $state\n<Popover trigger={{ content: \"Toggle Popover\" }}>\n\tPopover content here\n</Popover>\n```\n\n## Props\n\n### Core Props\n- **open**: boolean (bindable) - Controls popover visibility (optional when using trigger prop)\n- **defaultOpen**: boolean (default: false) - Initial state when open is not provided\n- **ref**: HTMLElement | null - Reference element to position popover against (optional when using trigger prop)\n- **id**: string - Unique identifier\n\n### Layout Props\n- **position**: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' (default: 'bottom')\n - Determines where popover appears relative to trigger\n- **offset**: number - Distance in pixels from the reference element\n- **fitTrigger**: boolean (default: false) - Whether popover should match the width of the trigger element\n- **positionPanel**: ({ panel, reference }) => { x: number; y: number; minWidth?: number } | null - Place the panel yourself instead of floating-ui: return its viewport coordinates (the panel is `position: fixed`) and optionally a `minWidth` in px that replaces `fitTrigger`'s, or `null` to fall back to `position`. Called when the panel mounts and whenever floating-ui would reposition it. Select uses it to open over its trigger\n- **mobileSheet**: boolean (default: false) - On mobile viewports (<768px), render as a bottom sheet instead of an anchored floating panel\n- **inline**: boolean (default: false) - Render the panel in normal document flow where the component sits instead of portaling to the viewport-fixed layer: no floating-ui positioning, no scroll lock, no outside-press dismissal (Escape still closes it). Same panel classes and motion, and the trigger still toggles it. Wins over `mobileSheet`\n- **mobileSheetSizeTransition**: boolean (default: true) - Whether mobile-sheet panels animate intrinsic size changes\n\n### Event Props\n- **onOpenChange**: (open: boolean) => void - Called once for each library-requested state change\n- **onAfterOpen**: (payload: PopoverState) => void - Called after the open transition finishes\n- **onAfterClose**: (payload: PopoverState) => void - Called after the close transition finishes\n\n### Slot Props\n- **children**: Snippet<[PopoverState]> - Popover content\n- **trigger**: Snippet<[PopoverState]> | (ButtonProps & { content?: string }) | false - Trigger element\n - Pass a snippet function for custom trigger: `{#snippet trigger(popover)}...</snippet>`\n - Pass button props object for default button: `trigger={{ content: \"Click Me\", color: \"primary\" }}`\n - Pass `false` to disable trigger (use with external ref)\n\n### Interaction Props\n- **openOnHover**: boolean (default: false) - Open on mouse hover\n- **openOnClick**: boolean (default: true) - Open on click\n- **delay**: number (default: 100) - Delay in ms before opening on hover\n- **closeOnEscape**: boolean (default: true) - Close on Escape. Only the topmost open layer closes, so Escape inside a nested popover leaves its parent open\n- **closeOnClickOutside**: boolean (default: true) - Close on an outside press. A press dismisses this popover and every layer stacked above it, but never the layer that was pressed\n- **closeOnMouseLeave**: boolean (default: false) - Close when the pointer leaves the hover safe area. The safe area is the trigger, the panel, and a prediction cone toward the panel, so a diagonal move to the panel keeps it open.\n- **debugSafeArea**: boolean (default: false) - Show hover safe-area overlays. Trigger/panel rectangles render in blue; the prediction cone toward the panel renders in orange.\n\n### Focus & ARIA Props\n- **focusOnOpen**: 'first' | 'container' | false (default: false) - Where focus goes when the panel opens: `'first'` moves it to an `[autofocus]` / `[data-autofocus]` target or the first tabbable control, `'container'` focuses the panel itself, `false` keeps it on the trigger. Focus always returns to the trigger when the popover closes (Escape or outside press)\n- **haspopup**: 'dialog' | 'menu' | 'listbox' | 'tree' | 'grid' | true (default: 'dialog') - Value of `aria-haspopup` on the trigger, describing what the panel contains. `aria-expanded` and `aria-controls` are managed automatically alongside it, for the built-in Button trigger and for a snippet trigger using `{@attach popover.reference}`\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover focusOnOpen=\"first\" haspopup=\"listbox\" trigger={{ content: 'Pick one' }}>\n\t<ul role=\"listbox\"><li role=\"option\" tabindex=\"0\">First</li></ul>\n</Popover>\n```\n\n### Visual Props\n- **size**: 'small' | 'normal' | 'large' (default: 'normal')\n - small: Compact popover size\n - normal: Standard popover size\n - large: Larger popover size\n- **transition**: TransitionConfig - Custom transition animation\n- **directedTransition**: boolean (default: true) - Transition direction follows position\n\n### Behavior Props\n- **lockScroll**: boolean (default: true) - Lock body scroll when open\n\n### Styling Props\n- **class**: string - Additional CSS classes\n- **theme**: ComponentTheme - Custom theme overrides\n\n## Structure\n\n```\n<PopoverTrigger>\n\t<Trigger />\n</PopoverTrigger>\n\n<PopoverContent>\n\t<Children />\n</PopoverContent>\n```\n\n## Examples\n\n### More Examples\n\n### With a custom trigger snippet\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n\timport { Button } from 'entasis/button';\n</script>\n\n<!-- {@attach popover.reference} anchors the panel to the element and keeps\n aria-haspopup / aria-expanded / aria-controls in sync on it -->\n<Popover>\n\t{#snippet trigger(popover)}\n\t\t<Button onclick={() => popover.toggle()} {@attach popover.reference}>Open</Button>\n\t{/snippet}\n\t\n\t<p>This is a popover!</p>\n</Popover>\n```\n\n### With button props\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{\n\t\tcontent: \"Click Me\",\n\t\tcolor: \"secondary\",\n\t\tsize: \"small\"\n\t}}\n>\n\t<p>This is a popover!</p>\n</Popover>\n```\n\n### Different Positions\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<!-- Top -->\n<Popover position=\"top\" trigger={{ content: \"Top\" }}>\n\tTop popover\n</Popover>\n\n<!-- Bottom -->\n<Popover position=\"bottom\" trigger={{ content: \"Bottom\" }}>\n\tBottom popover\n</Popover>\n\n<!-- Left -->\n<Popover position=\"left\" trigger={{ content: \"Left\" }}>\n\tLeft popover\n</Popover>\n\n<!-- Right -->\n<Popover position=\"right\" trigger={{ content: \"Right\" }}>\n\tRight popover\n</Popover>\n```\n\n### Advanced Example\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n\timport { Button } from 'entasis/button';\n</script>\n\n<Popover position=\"bottom-start\" trigger={{ content: \"Menu\" }}>\n\t<div class=\"flex flex-col gap-1\">\n\t\t<Button variant=\"ghost\" fullWidth>Profile</Button>\n\t\t<Button variant=\"ghost\" fullWidth>Settings</Button>\n\t\t<Button variant=\"ghost\" fullWidth>Logout</Button>\n\t</div>\n</Popover>\n```\n\n### Open on Hover\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Hover Me\" }}\n\topenOnHover\n\topenOnClick={false}\n\tdelay={200}\n>\n\tHover content\n</Popover>\n```\n\n### With Custom Offset\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Trigger\" }}\n\tposition=\"bottom\"\n\toffset={20}\n>\n\t20px away from trigger\n</Popover>\n```\n\n### Fit Trigger Width\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Click Me\" }}\n\tfitTrigger\n>\n\tPopover matches trigger width\n</Popover>\n```\n\n### Inline (static, in flow)\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<!-- Open in place, no portal: useful for docs, visual tests, or an always-visible panel -->\n<Popover inline open trigger={false}>\n\t<p>Rendered where the component sits.</p>\n</Popover>\n\n<!-- The trigger still toggles an inline panel -->\n<Popover inline trigger={{ content: 'Toggle' }}>\n\t<p>Expands below the trigger, in the flow.</p>\n</Popover>\n```\n\n### Mobile Bottom Sheet\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover\n\ttrigger={{ content: \"Open filters\" }}\n\tposition=\"bottom\"\n\tmobileSheet\n>\n\tFilters content\n</Popover>\n```\n\n### Different Sizes\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<!-- Small -->\n<Popover size=\"small\" trigger={{ content: \"Small\" }}>\n\tSmall popover\n</Popover>\n\n<!-- Large -->\n<Popover size=\"large\" trigger={{ content: \"Large\" }}>\n\tLarge popover with more content\n</Popover>\n```\n\n### Close on Mouse Leave\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Hover Me\" }}\n\topenOnHover\n\tcloseOnMouseLeave\n>\n\tCloses when you move outside the rectangle tolerance\n</Popover>\n```\n\n### With Lifecycle Hooks\n\n```svelte\n<script>\n\timport { Popover } from 'entasis/popover';\n</script>\n\n<Popover \n\ttrigger={{ content: \"Trigger\" }}\n\tonAfterOpen={(payload) => console.log('Popover opened', payload)}\n\tonAfterClose={(payload) => console.log('Popover closed', payload)}\n>\n\tWatch the console\n</Popover>\n```\n\n### User Card Popover with External Ref\n\n```svelte\n<script lang=\"ts\">\n\timport { Popover } from 'entasis/popover';\n\timport { Button } from 'entasis/button';\n\timport { Avatar } from 'entasis/avatar';\n\n\tlet avatarRef = $state<HTMLElement | null>(null);\n\tlet open = $state(false);\n\tconst user = { name: 'John Doe', email: 'john@example.com' };\n</script>\n\n<button type=\"button\" bind:this={avatarRef} onclick={() => (open = !open)}>\n\t<Avatar name={user.name} />\n</button>\n\n<Popover bind:open ref={avatarRef} position=\"bottom\">\n\t<div class=\"p-4\">\n\t\t<h3>{user.name}</h3>\n\t\t<p>{user.email}</p>\n\t\t<Button fullWidth>View Profile</Button>\n\t</div>\n</Popover>\n```\n\n## State Management\n\nThe Popover component uses a `PopoverState` instance that is passed to all slot snippets. This state object provides:\n\n- **id**: string - Popover identifier\n- **size**: Size - Current popover size\n- **position**: Placement - Current popover position\n- **offset**: number - Current offset value\n- **open()**: () => void - Method to open the popover\n- **close()**: () => void - Method to close the popover\n- **toggle()**: () => void - Method to toggle the popover\n- **reference**: attachment for a custom trigger element (`{@attach popover.reference}`); anchors the panel to it and keeps its `aria-haspopup`, `aria-expanded`, and `aria-controls` in sync\n\n## Accessibility\n\n- The trigger carries `aria-haspopup` (from `haspopup`), `aria-expanded`, and `aria-controls` \u2014 automatically for the built-in Button and for a snippet trigger using `{@attach popover.reference}`\n- `focusOnOpen` decides where focus lands on open; focus returns to the trigger on close, whether by Escape or an outside press\n- Non-modal: Tab is not contained and the page is not made inert (use Dialog for that)\n- Escape closes only the topmost open layer; an outside press dismisses every layer stacked above the one pressed. Popovers, menus, and dialogs share one layer stack\n- Keyboard navigation support\n\n## Notes\n\n- Popover is positioned using floating-ui, except with `inline`, where the document lays the panel out\n- Automatically adjusts position to stay in viewport\n- Multiple popovers can be stacked\n- Scroll locking prevents background scroll (when enabled)\n- Transitions animate based on position direction\n\n## Theme Customization\n\nThe Popover component uses a theme object that can be customized using the `theme` prop or by setting a global theme.\n\n### Theme Structure\n\nThe theme object contains the following parts:\n- **motion**: Open/close transition preset, keyed by `mode` (a floating panel scales, the mobile\n sheet slides up). Takes `in` / `out` FSO params plus a `duration` / `easing` motion token;\n the `transition` prop wins over it\n- **popover**: Main popover container styles\n\n### Theme Type Definition\n\n```typescript\nimport type { PopoverThemeProps } from 'entasis/popover';\n\n// Example theme customization\nconst customTheme: PopoverThemeProps = {\n popover: {\n base: 'z-[+50] fixed bg-surface-floating text-neutral w-fit rounded-xl raised isolate h-fit',\n size: {\n small: 'max-w-3xs w-full p-2',\n normal: 'max-w-xs w-full p-3',\n large: 'max-w-sm w-full p-4'\n }\n }\n};\n```\n\n### Available Variants\n\n**popover**:\n- base: Base classes applied to all popovers\n- Variants:\n - size: 'small' | 'normal' | 'large' - Controls max-width, width, and padding\n - mode: 'floating' | 'inline' | 'mobileSheet' - Set from `inline` / `mobileSheet`; `root` positions the wrapper (fixed, in flow, or full-screen sheet)\n\n### Usage Examples\n\n**Basic Theme Override**:\n```svelte\n<Popover \n trigger={{ content: \"Click Me\" }}\n theme={{\n popover: {\n base: 'rounded-xl lift-5 border-2 border-primary',\n size: {\n normal: 'max-w-md p-4'\n }\n }\n }}\n>\n Custom styled popover content\n</Popover>\n```\n\n**Size Customization**:\n```svelte\n<Popover \n size=\"large\"\n trigger={{ content: \"Large Popover\" }}\n theme={{\n popover: {\n size: {\n large: 'max-w-lg p-6'\n }\n }\n }}\n>\n Large popover with more padding\n</Popover>\n```\n\n**Global Theme Setting**:\n```svelte\n<script>\n import { setPopoverTheme } from './index.ts';\n \n setPopoverTheme({\n popover: {\n base: 'rounded-lg lift-5 backdrop-blur-sm bg-white/95',\n size: {\n normal: 'max-w-sm p-4'\n }\n }\n });\n</script>\n```\n";
|
|
@@ -29,6 +29,7 @@ The Popover component displays floating content positioned relative to a trigger
|
|
|
29
29
|
- Determines where popover appears relative to trigger
|
|
30
30
|
- **offset**: number - Distance in pixels from the reference element
|
|
31
31
|
- **fitTrigger**: boolean (default: false) - Whether popover should match the width of the trigger element
|
|
32
|
+
- **positionPanel**: ({ panel, reference }) => { x: number; y: number; minWidth?: number } | null - Place the panel yourself instead of floating-ui: return its viewport coordinates (the panel is \`position: fixed\`) and optionally a \`minWidth\` in px that replaces \`fitTrigger\`'s, or \`null\` to fall back to \`position\`. Called when the panel mounts and whenever floating-ui would reposition it. Select uses it to open over its trigger
|
|
32
33
|
- **mobileSheet**: boolean (default: false) - On mobile viewports (<768px), render as a bottom sheet instead of an anchored floating panel
|
|
33
34
|
- **inline**: boolean (default: false) - Render the panel in normal document flow where the component sits instead of portaling to the viewport-fixed layer: no floating-ui positioning, no scroll lock, no outside-press dismissal (Escape still closes it). Same panel classes and motion, and the trigger still toggles it. Wins over \`mobileSheet\`
|
|
34
35
|
- **mobileSheetSizeTransition**: boolean (default: true) - Whether mobile-sheet panels animate intrinsic size changes
|
|
@@ -33,6 +33,22 @@ export type PopoverProps = WithAttachments<{
|
|
|
33
33
|
ref?: HTMLElement | VirtualElement | null;
|
|
34
34
|
/** Preferred placement relative to the reference element; supports responsive values. */
|
|
35
35
|
position?: ResponsiveProps<Placement>;
|
|
36
|
+
/**
|
|
37
|
+
* Places the panel yourself instead of floating-ui: return its viewport coordinates (the
|
|
38
|
+
* panel is `position: fixed`) and optionally a `minWidth` in px that replaces `fitTrigger`'s,
|
|
39
|
+
* or `null` to fall back to `position`. Called when the panel mounts and whenever floating-ui
|
|
40
|
+
* would reposition it (scroll, resize, size changes), with the positioned panel wrapper and the
|
|
41
|
+
* reference element in one payload. Select uses it to open over its trigger with the selected
|
|
42
|
+
* option on the value.
|
|
43
|
+
*/
|
|
44
|
+
positionPanel?: (payload: {
|
|
45
|
+
panel: HTMLElement;
|
|
46
|
+
reference: HTMLElement | VirtualElement;
|
|
47
|
+
}) => {
|
|
48
|
+
x: number;
|
|
49
|
+
y: number;
|
|
50
|
+
minWidth?: number;
|
|
51
|
+
} | null;
|
|
36
52
|
/** When true, clicking the trigger toggles the popover open and closed. */
|
|
37
53
|
openOnClick?: boolean;
|
|
38
54
|
/** When true, hovering the trigger opens the popover after `delay`. */
|
|
@@ -2,7 +2,7 @@ import type { PopoverProps } from './popover.props.js';
|
|
|
2
2
|
import { type Placement, type VirtualElement } from '@floating-ui/dom';
|
|
3
3
|
import { type PopoverThemeProps } from './popover.theme.js';
|
|
4
4
|
type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>;
|
|
5
|
-
interface PopoverOptions extends MakeRequired<Pick<PopoverProps, 'id' | 'size' | 'position' | 'transition' | 'onOpenChange' | 'offset' | 'directedTransition' | 'closeOnEscape' | 'lockScroll' | 'closeOnMouseLeave' | 'debugSafeArea' | 'closeOnClickOutside' | 'openOnHover' | 'delay' | 'openOnClick' | 'mobileSheet' | 'inline' | 'focusOnOpen' | 'haspopup'>, 'directedTransition' | 'closeOnEscape' | 'lockScroll' | 'closeOnMouseLeave' | 'debugSafeArea' | 'closeOnClickOutside' | 'openOnHover' | 'delay' | 'openOnClick' | 'inline'> {
|
|
5
|
+
interface PopoverOptions extends MakeRequired<Pick<PopoverProps, 'id' | 'size' | 'position' | 'transition' | 'onOpenChange' | 'offset' | 'directedTransition' | 'closeOnEscape' | 'lockScroll' | 'closeOnMouseLeave' | 'debugSafeArea' | 'closeOnClickOutside' | 'openOnHover' | 'delay' | 'openOnClick' | 'mobileSheet' | 'inline' | 'focusOnOpen' | 'haspopup' | 'positionPanel'>, 'directedTransition' | 'closeOnEscape' | 'lockScroll' | 'closeOnMouseLeave' | 'debugSafeArea' | 'closeOnClickOutside' | 'openOnHover' | 'delay' | 'openOnClick' | 'inline'> {
|
|
6
6
|
isOpen: boolean;
|
|
7
7
|
externalRef?: HTMLElement | VirtualElement | null;
|
|
8
8
|
fitTrigger: boolean;
|
|
@@ -142,6 +142,19 @@ export class PopoverState extends PopoverOptionsBase {
|
|
|
142
142
|
if (this.fitTrigger) {
|
|
143
143
|
this.triggerWidth = this.referenceElement.getBoundingClientRect().width;
|
|
144
144
|
}
|
|
145
|
+
const custom = this.positionPanel?.({ panel: node, reference: this.referenceElement });
|
|
146
|
+
if (custom) {
|
|
147
|
+
if (custom.minWidth != null)
|
|
148
|
+
this.triggerWidth = custom.minWidth;
|
|
149
|
+
Object.assign(node.style, {
|
|
150
|
+
position: 'fixed',
|
|
151
|
+
left: `${custom.x}px`,
|
|
152
|
+
top: `${custom.y}px`,
|
|
153
|
+
visibility: ''
|
|
154
|
+
});
|
|
155
|
+
this.safeArea.updateAreas();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
145
158
|
const { x, y, strategy, placement } = await computePosition(this.referenceElement, node, {
|
|
146
159
|
strategy: 'fixed',
|
|
147
160
|
placement: this.computedPosition,
|