@mythicalos/ui-core 0.2.2 → 0.3.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.
@@ -0,0 +1,242 @@
1
+ /** Trigger caret (design card: `<span class="car">▾</span>`). */
2
+ export declare const POPOVER_CARET = "\u25BE";
3
+ /** Selected-item marker (design card: "✓ marks current"). */
4
+ export declare const POPOVER_CHECK = "\u2713";
5
+ /** Anchor gap in px — the design card's `top: calc(100% + 6px)`. Mirrored in styles.css. */
6
+ export declare const POPOVER_GAP_PX = 6;
7
+ /** Extra breathing room the flip test demands beyond the gap (mythical-select uses `height + 8`
8
+ * for a 6px gap — i.e. gap + 2). */
9
+ export declare const POPOVER_BREATHING_PX = 2;
10
+ /** Trigger value shown when nothing is selected (the em-dash placeholder, as in the product's
11
+ * hand-rolled selector) — never a fabricated item label. */
12
+ export declare const POPOVER_EMPTY_VALUE = "\u2014";
13
+ /** One row of the popover's single-select menu. */
14
+ export interface PopoverItem {
15
+ /** Stable identity handed back to `onSelect` — never the array index. */
16
+ key: string;
17
+ label: string;
18
+ /** Marks the current choice: gets the ✓ and `aria-checked="true"`. */
19
+ selected?: boolean;
20
+ /** Rendered, focusable-by-arrow? No — skipped by roving focus and inert on click. */
21
+ disabled?: boolean;
22
+ }
23
+ export type PopoverPlacement = "below" | "above";
24
+ export type PopoverAlign = "start" | "end";
25
+ export interface PopoverPosition {
26
+ placement: PopoverPlacement;
27
+ align: PopoverAlign;
28
+ }
29
+ /** The pre-measurement position: the design card's own (anchored below, left-aligned). A binding
30
+ * renders this on the first open frame, then corrects it once the panel can be measured. */
31
+ export declare const POPOVER_DEFAULT_POSITION: PopoverPosition;
32
+ /** The subset of a DOMRect this module needs (so callers can pass a real rect or a plain object). */
33
+ export interface PopoverRect {
34
+ top: number;
35
+ bottom: number;
36
+ left: number;
37
+ right: number;
38
+ }
39
+ export interface PopoverSize {
40
+ width: number;
41
+ height: number;
42
+ }
43
+ export interface PopoverViewport {
44
+ width: number;
45
+ height: number;
46
+ }
47
+ /**
48
+ * Viewport-aware vertical placement. Flips UP only when the panel would clip off the bottom AND it
49
+ * genuinely fits above — a panel taller than both gaps stays `below` (scrolling down to it beats
50
+ * clipping it against the top edge, and it matches <mythical-select>'s behaviour exactly).
51
+ */
52
+ export declare function resolvePopoverPlacement(anchor: PopoverRect, panelHeight: number, viewport: PopoverViewport, gap?: number): PopoverPlacement;
53
+ /**
54
+ * Viewport-aware horizontal alignment. The panel is start-aligned (design card: `left: 0`) and
55
+ * switches to end-aligned only when start does NOT fit and end does.
56
+ *
57
+ * "Fits" means BOTH resulting edges are inside the viewport, on both branches — an alignment pins
58
+ * one panel edge to the matching anchor edge, so an anchor that is itself partly off-screen drags
59
+ * the panel off with it. Testing only the far edge is wrong in each direction symmetrically:
60
+ * start-aligning an anchor hanging off the LEFT clips the panel's left edge even though its right
61
+ * edge is comfortable, and end-aligning an anchor hanging off the RIGHT clips its right edge even
62
+ * though `right - panelWidth >= 0`. A panel that fits neither way stays `start` — flipping would
63
+ * only trade one overflow for the other.
64
+ */
65
+ export declare function resolvePopoverAlign(anchor: PopoverRect, panelWidth: number, viewport: PopoverViewport): PopoverAlign;
66
+ /** Both axes at once — what a binding calls from its post-open measurement effect. */
67
+ export declare function resolvePopoverPosition(anchor: PopoverRect, panel: PopoverSize, viewport: PopoverViewport, gap?: number): PopoverPosition;
68
+ /** True when two positions describe the same placement — lets a binding skip a redundant
69
+ * re-render after re-measuring (and, more importantly, not loop measure→setState→measure). */
70
+ export declare function samePopoverPosition(a: PopoverPosition, b: PopoverPosition): boolean;
71
+ /**
72
+ * EVERY class name this component family renders — the derived ones the functions below compose
73
+ * AND the purely structural ones a binding puts on a wrapper/span. One source, so a binding cannot
74
+ * invent, misspell, or quietly re-style a class, and the Preact and React siblings cannot drift on
75
+ * markup either. (The rest of this package's atoms inline their structural classes in both
76
+ * bindings; the popover has more of them than any other atom, so it earns the map.)
77
+ */
78
+ export declare const POPOVER_CLASS: {
79
+ readonly anchor: "my-pop-anchor";
80
+ readonly trigger: "my-pop-trigger";
81
+ readonly triggerLabel: "my-pop-trigger__label";
82
+ readonly triggerValue: "my-pop-trigger__value";
83
+ readonly triggerCaret: "my-pop-trigger__caret";
84
+ readonly panel: "my-pop";
85
+ readonly panelAbove: "my-pop--above";
86
+ readonly panelEnd: "my-pop--end";
87
+ readonly head: "my-pop__head";
88
+ readonly title: "my-pop__title";
89
+ readonly caption: "my-pop__caption";
90
+ readonly menu: "my-pop__menu";
91
+ readonly item: "my-pop__item";
92
+ readonly itemLabel: "my-pop__label";
93
+ readonly itemCheck: "my-pop__check";
94
+ readonly divider: "my-pop__div";
95
+ readonly foot: "my-pop__foot";
96
+ readonly isOpen: "is-open";
97
+ readonly isSelected: "is-selected";
98
+ readonly isDisabled: "is-disabled";
99
+ };
100
+ export interface PopoverTriggerState {
101
+ open?: boolean;
102
+ disabled?: boolean;
103
+ }
104
+ /** Trigger class list. Shape rule 10: the trigger is clickable, so styles.css gives it
105
+ * `--my-r-control` — never the pill radius. */
106
+ export declare function popoverTriggerClass(s?: PopoverTriggerState): string;
107
+ /** Panel class list; the flip/align modifiers are the only thing position changes. */
108
+ export declare function popoverPanelClass(pos?: PopoverPosition): string;
109
+ /** Menu-row class list. */
110
+ export declare function popoverItemClass(item?: Pick<PopoverItem, "selected" | "disabled">): string;
111
+ export interface PopoverIds {
112
+ trigger: string;
113
+ /** The visual panel — the surface that carries the border/shadow and the optional head/footer. */
114
+ panel: string;
115
+ /** The `role="menu"` element NESTED in the panel. A menu may only own menuitem-family, group and
116
+ * separator children, so the heading and the caller's footer are siblings of this element, not
117
+ * children of it — putting them inside would make the menu's owned content invalid and let
118
+ * assistive tech drop or mis-announce them. */
119
+ menu: string;
120
+ /** The heading, when there is one — it names the menu in place of the trigger. */
121
+ title: string;
122
+ }
123
+ /** Stable, collision-free id set derived from one caller-supplied base id. */
124
+ export declare function popoverIds(baseId: string): PopoverIds;
125
+ export interface PopoverTriggerAria {
126
+ id: string;
127
+ "aria-haspopup": "menu";
128
+ "aria-expanded": "true" | "false";
129
+ /** The MENU (not the panel wrapper) it controls, and only while open — the element does not
130
+ * exist when closed, and pointing at a missing id is an authoring error assistive tech reports. */
131
+ "aria-controls": string | undefined;
132
+ }
133
+ export declare function popoverTriggerAria(open: boolean, ids: PopoverIds): PopoverTriggerAria;
134
+ export interface PopoverPanelAria {
135
+ id: string;
136
+ }
137
+ /** The visual panel: the surface, deliberately WITHOUT a role. It holds the optional heading and
138
+ * the caller's footer, which a `menu` may not own. */
139
+ export declare function popoverPanelAria(ids: PopoverIds): PopoverPanelAria;
140
+ export interface PopoverMenuAria {
141
+ id: string;
142
+ role: "menu";
143
+ "aria-labelledby": string;
144
+ }
145
+ /** The nested menu, which owns ONLY the `menuitemradio` rows. Named by the heading when there is
146
+ * one (the more specific label), otherwise by the trigger. */
147
+ export declare function popoverMenuAria(ids: PopoverIds, titled: boolean): PopoverMenuAria;
148
+ /**
149
+ * The caret and the ✓ are pure decoration: the trigger's `aria-expanded` and the row's
150
+ * `aria-checked` already convey both states, so reading the glyphs would duplicate them. Lives here
151
+ * rather than inline in each binding so neither can quietly un-hide one.
152
+ */
153
+ export declare const POPOVER_DECORATIVE_ARIA: {
154
+ readonly "aria-hidden": "true";
155
+ };
156
+ /** The rule between the menu and the caller's footer — a real separator, not a styled `<div>`. */
157
+ export declare const POPOVER_SEPARATOR_ARIA: {
158
+ readonly role: "separator";
159
+ };
160
+ export interface PopoverItemAria {
161
+ /** Single-select menu ⇒ radio semantics, so a screen reader announces "1 of N, checked". */
162
+ role: "menuitemradio";
163
+ "aria-checked": "true" | "false";
164
+ "aria-disabled": "true" | undefined;
165
+ }
166
+ export declare function popoverItemAria(item: Pick<PopoverItem, "selected" | "disabled">): PopoverItemAria;
167
+ /** What a key pressed on the CLOSED trigger means. Enter/Space are deliberately absent: the
168
+ * trigger is a real `<button>`, so the browser already synthesises a click from them — handling
169
+ * them here too would toggle twice. */
170
+ export type PopoverTriggerKeyAction = "open-first" | "open-last" | null;
171
+ export declare function popoverTriggerKeyAction(key: string): PopoverTriggerKeyAction;
172
+ /**
173
+ * What a key pressed INSIDE the open panel means.
174
+ * - `close` — Escape: close and pull focus back to the trigger (never strand it on <body>).
175
+ * - `dismiss` — Tab: close, but let the browser move focus onward normally.
176
+ * - the rest — roving focus.
177
+ */
178
+ export type PopoverPanelKeyAction = "close" | "dismiss" | "next" | "prev" | "first" | "last" | null;
179
+ export declare function popoverPanelKeyAction(key: string): PopoverPanelKeyAction;
180
+ /** Should the binding call `preventDefault()`? Everything we act on, except `dismiss` — Tab MUST
181
+ * keep its native behaviour or the popover becomes a focus trap. */
182
+ export declare function popoverKeyHandled(action: PopoverPanelKeyAction): boolean;
183
+ /**
184
+ * Does this action apply to the element that currently has focus?
185
+ *
186
+ * Dismissal (Escape / Tab) applies from ANYWHERE inside the popover — including the caller's
187
+ * footer content, where Escape must still close. Roving (arrows / Home / End) applies ONLY when
188
+ * focus is on a menu row or on the panel fallback. That distinction is load-bearing, not tidiness:
189
+ * a footer control is arbitrary caller markup, so swallowing Home/End there would steal a text
190
+ * input's caret keys, and swallowing an arrow would yank focus out of the footer and into the menu.
191
+ * The binding supplies the one thing this module cannot compute — whether the focused element is a
192
+ * roving surface.
193
+ */
194
+ export declare function popoverAppliesToFocus(action: PopoverPanelKeyAction, focusOnRovingSurface: boolean): boolean;
195
+ /** Index of the first enabled item scanning forward (`dir` 1) or backward (`dir` -1); -1 if every
196
+ * item is disabled (or the list is empty). */
197
+ export declare function edgePopoverIndex(items: readonly PopoverItem[], dir?: 1 | -1): number;
198
+ /** The next enabled index from `from` in `dir`, wrapping. Returns -1 when nothing is enabled and
199
+ * `from` itself when it is the ONLY enabled row (a wrap that lands back where it started). */
200
+ export declare function stepPopoverIndex(items: readonly PopoverItem[], from: number, dir: 1 | -1): number;
201
+ /**
202
+ * Where focus lands when the panel opens: the selected row if it is focusable, else the first
203
+ * enabled one. A selected-but-disabled row does not swallow focus.
204
+ */
205
+ export declare function initialPopoverIndex(items: readonly PopoverItem[]): number;
206
+ /**
207
+ * The single entry point a binding's keydown handler calls: maps a panel key action + the current
208
+ * focused index to the next focused index. Non-navigation actions (and a null action) leave the
209
+ * index untouched, so the caller never has to branch on the action twice.
210
+ *
211
+ * `current < 0` means "focus is in the panel but not on a row" (an all-disabled or empty list, or
212
+ * a click that landed on the panel's padding). From there Down means the FIRST enabled row and Up
213
+ * means the LAST — not a wrap arithmetic accident off index -1.
214
+ */
215
+ export declare function resolvePopoverIndex(action: PopoverPanelKeyAction, items: readonly PopoverItem[], current: number): number;
216
+ /**
217
+ * Is an optional slot (heading, caption, footer) worth rendering its chrome?
218
+ *
219
+ * `undefined`, `null`, `false` and `""` are all values BOTH frameworks render as nothing, and the
220
+ * idiomatic conditional slot is `footer={enabled && <Action />}` — which passes `false`, not
221
+ * `undefined`, when disabled. Testing `!== undefined` would leave an orphan separator and an empty
222
+ * box behind it, and an empty heading would leave the menu's `aria-labelledby` pointing at an
223
+ * element with no text. Shared here so both bindings apply the same rule.
224
+ */
225
+ export declare function popoverHasSlotContent(slot: unknown): boolean;
226
+ export interface PopoverTriggerText {
227
+ /** The static prefix ("review lane:") — null when the caller supplies none. */
228
+ label: string | null;
229
+ /** The current selection's label, or the em-dash placeholder. Never invented. */
230
+ value: string;
231
+ }
232
+ export interface PopoverTriggerTextOptions {
233
+ label?: string;
234
+ /** Overrides {@link POPOVER_EMPTY_VALUE} for the no-selection case. */
235
+ placeholder?: string;
236
+ }
237
+ /**
238
+ * The trigger face: prefix + the CURRENTLY selected item's label. Honest by construction — with no
239
+ * selection (or a selection that is not in the list) it shows the placeholder rather than naming an
240
+ * item that is not actually chosen.
241
+ */
242
+ export declare function popoverTriggerText(items: readonly PopoverItem[], opts?: PopoverTriggerTextOptions): PopoverTriggerText;
@@ -0,0 +1,303 @@
1
+ // @mythicalos/ui-core — PURE, DOM-free dropdown-popover logic (ds/components-popover, the
2
+ // component registry's `popover` v1 row). The design card is a chip trigger ("review lane:
3
+ // **codex cross-model** ▾") over an anchored panel — `--my-shadow-modal`, high z-index, ✓ marking
4
+ // the current item, and "ancestors must never clip it".
5
+ //
6
+ // EVERY decision the popover makes lives here so the Preact and React bindings can only ever
7
+ // render, never decide: class derivation, the viewport-aware flip/align geometry, the keyboard
8
+ // grammar (which key does what), roving-focus index arithmetic that skips disabled rows, the
9
+ // trigger's text composition, and the ARIA attribute maps. The bindings own exactly two things
10
+ // this module cannot: touching the DOM (measuring, focusing, listening) and rendering.
11
+ //
12
+ // Geometry provenance: `resolvePopoverPlacement` reproduces the established approach in
13
+ // `src/select/mythical-select.js`'s `data-flip` handling — flip up only when the panel would clip
14
+ // below AND genuinely fits above, measured as `panelHeight + gap + breathing` against the
15
+ // viewport. Keeping one rule means <mythical-select> and <Popover> never disagree about which way
16
+ // a list opens on the same screen.
17
+ /** Trigger caret (design card: `<span class="car">▾</span>`). */
18
+ export const POPOVER_CARET = "▾";
19
+ /** Selected-item marker (design card: "✓ marks current"). */
20
+ export const POPOVER_CHECK = "✓";
21
+ /** Anchor gap in px — the design card's `top: calc(100% + 6px)`. Mirrored in styles.css. */
22
+ export const POPOVER_GAP_PX = 6;
23
+ /** Extra breathing room the flip test demands beyond the gap (mythical-select uses `height + 8`
24
+ * for a 6px gap — i.e. gap + 2). */
25
+ export const POPOVER_BREATHING_PX = 2;
26
+ /** Trigger value shown when nothing is selected (the em-dash placeholder, as in the product's
27
+ * hand-rolled selector) — never a fabricated item label. */
28
+ export const POPOVER_EMPTY_VALUE = "—";
29
+ /** The pre-measurement position: the design card's own (anchored below, left-aligned). A binding
30
+ * renders this on the first open frame, then corrects it once the panel can be measured. */
31
+ export const POPOVER_DEFAULT_POSITION = { placement: "below", align: "start" };
32
+ /**
33
+ * Viewport-aware vertical placement. Flips UP only when the panel would clip off the bottom AND it
34
+ * genuinely fits above — a panel taller than both gaps stays `below` (scrolling down to it beats
35
+ * clipping it against the top edge, and it matches <mythical-select>'s behaviour exactly).
36
+ */
37
+ export function resolvePopoverPlacement(anchor, panelHeight, viewport, gap = POPOVER_GAP_PX) {
38
+ const need = panelHeight + gap + POPOVER_BREATHING_PX;
39
+ return anchor.bottom + need > viewport.height && anchor.top - need > 0 ? "above" : "below";
40
+ }
41
+ /**
42
+ * Viewport-aware horizontal alignment. The panel is start-aligned (design card: `left: 0`) and
43
+ * switches to end-aligned only when start does NOT fit and end does.
44
+ *
45
+ * "Fits" means BOTH resulting edges are inside the viewport, on both branches — an alignment pins
46
+ * one panel edge to the matching anchor edge, so an anchor that is itself partly off-screen drags
47
+ * the panel off with it. Testing only the far edge is wrong in each direction symmetrically:
48
+ * start-aligning an anchor hanging off the LEFT clips the panel's left edge even though its right
49
+ * edge is comfortable, and end-aligning an anchor hanging off the RIGHT clips its right edge even
50
+ * though `right - panelWidth >= 0`. A panel that fits neither way stays `start` — flipping would
51
+ * only trade one overflow for the other.
52
+ */
53
+ export function resolvePopoverAlign(anchor, panelWidth, viewport) {
54
+ const startFits = anchor.left >= 0 && anchor.left + panelWidth <= viewport.width;
55
+ const endFits = anchor.right <= viewport.width && anchor.right - panelWidth >= 0;
56
+ return !startFits && endFits ? "end" : "start";
57
+ }
58
+ /** Both axes at once — what a binding calls from its post-open measurement effect. */
59
+ export function resolvePopoverPosition(anchor, panel, viewport, gap = POPOVER_GAP_PX) {
60
+ return {
61
+ placement: resolvePopoverPlacement(anchor, panel.height, viewport, gap),
62
+ align: resolvePopoverAlign(anchor, panel.width, viewport),
63
+ };
64
+ }
65
+ /** True when two positions describe the same placement — lets a binding skip a redundant
66
+ * re-render after re-measuring (and, more importantly, not loop measure→setState→measure). */
67
+ export function samePopoverPosition(a, b) {
68
+ return a.placement === b.placement && a.align === b.align;
69
+ }
70
+ /* ── class derivation ─────────────────────────────────────────────────── */
71
+ /**
72
+ * EVERY class name this component family renders — the derived ones the functions below compose
73
+ * AND the purely structural ones a binding puts on a wrapper/span. One source, so a binding cannot
74
+ * invent, misspell, or quietly re-style a class, and the Preact and React siblings cannot drift on
75
+ * markup either. (The rest of this package's atoms inline their structural classes in both
76
+ * bindings; the popover has more of them than any other atom, so it earns the map.)
77
+ */
78
+ export const POPOVER_CLASS = {
79
+ anchor: "my-pop-anchor",
80
+ trigger: "my-pop-trigger",
81
+ triggerLabel: "my-pop-trigger__label",
82
+ triggerValue: "my-pop-trigger__value",
83
+ triggerCaret: "my-pop-trigger__caret",
84
+ panel: "my-pop",
85
+ panelAbove: "my-pop--above",
86
+ panelEnd: "my-pop--end",
87
+ head: "my-pop__head",
88
+ title: "my-pop__title",
89
+ caption: "my-pop__caption",
90
+ menu: "my-pop__menu",
91
+ item: "my-pop__item",
92
+ itemLabel: "my-pop__label",
93
+ itemCheck: "my-pop__check",
94
+ divider: "my-pop__div",
95
+ foot: "my-pop__foot",
96
+ isOpen: "is-open",
97
+ isSelected: "is-selected",
98
+ isDisabled: "is-disabled",
99
+ };
100
+ /** Trigger class list. Shape rule 10: the trigger is clickable, so styles.css gives it
101
+ * `--my-r-control` — never the pill radius. */
102
+ export function popoverTriggerClass(s = {}) {
103
+ const cls = [POPOVER_CLASS.trigger];
104
+ if (s.open)
105
+ cls.push(POPOVER_CLASS.isOpen);
106
+ if (s.disabled)
107
+ cls.push(POPOVER_CLASS.isDisabled);
108
+ return cls.join(" ");
109
+ }
110
+ /** Panel class list; the flip/align modifiers are the only thing position changes. */
111
+ export function popoverPanelClass(pos = POPOVER_DEFAULT_POSITION) {
112
+ const cls = [POPOVER_CLASS.panel];
113
+ if (pos.placement === "above")
114
+ cls.push(POPOVER_CLASS.panelAbove);
115
+ if (pos.align === "end")
116
+ cls.push(POPOVER_CLASS.panelEnd);
117
+ return cls.join(" ");
118
+ }
119
+ /** Menu-row class list. */
120
+ export function popoverItemClass(item = {}) {
121
+ const cls = [POPOVER_CLASS.item];
122
+ if (item.selected)
123
+ cls.push(POPOVER_CLASS.isSelected);
124
+ if (item.disabled)
125
+ cls.push(POPOVER_CLASS.isDisabled);
126
+ return cls.join(" ");
127
+ }
128
+ /** Stable, collision-free id set derived from one caller-supplied base id. */
129
+ export function popoverIds(baseId) {
130
+ return {
131
+ trigger: `${baseId}-trigger`,
132
+ panel: `${baseId}-panel`,
133
+ menu: `${baseId}-menu`,
134
+ title: `${baseId}-title`,
135
+ };
136
+ }
137
+ export function popoverTriggerAria(open, ids) {
138
+ return {
139
+ id: ids.trigger,
140
+ "aria-haspopup": "menu",
141
+ "aria-expanded": open ? "true" : "false",
142
+ "aria-controls": open ? ids.menu : undefined,
143
+ };
144
+ }
145
+ /** The visual panel: the surface, deliberately WITHOUT a role. It holds the optional heading and
146
+ * the caller's footer, which a `menu` may not own. */
147
+ export function popoverPanelAria(ids) {
148
+ return { id: ids.panel };
149
+ }
150
+ /** The nested menu, which owns ONLY the `menuitemradio` rows. Named by the heading when there is
151
+ * one (the more specific label), otherwise by the trigger. */
152
+ export function popoverMenuAria(ids, titled) {
153
+ return { id: ids.menu, role: "menu", "aria-labelledby": titled ? ids.title : ids.trigger };
154
+ }
155
+ /**
156
+ * The caret and the ✓ are pure decoration: the trigger's `aria-expanded` and the row's
157
+ * `aria-checked` already convey both states, so reading the glyphs would duplicate them. Lives here
158
+ * rather than inline in each binding so neither can quietly un-hide one.
159
+ */
160
+ export const POPOVER_DECORATIVE_ARIA = { "aria-hidden": "true" };
161
+ /** The rule between the menu and the caller's footer — a real separator, not a styled `<div>`. */
162
+ export const POPOVER_SEPARATOR_ARIA = { role: "separator" };
163
+ export function popoverItemAria(item) {
164
+ return {
165
+ role: "menuitemradio",
166
+ "aria-checked": item.selected ? "true" : "false",
167
+ "aria-disabled": item.disabled ? "true" : undefined,
168
+ };
169
+ }
170
+ export function popoverTriggerKeyAction(key) {
171
+ if (key === "ArrowDown")
172
+ return "open-first";
173
+ if (key === "ArrowUp")
174
+ return "open-last";
175
+ return null;
176
+ }
177
+ export function popoverPanelKeyAction(key) {
178
+ switch (key) {
179
+ case "Escape":
180
+ return "close";
181
+ case "Tab":
182
+ return "dismiss";
183
+ case "ArrowDown":
184
+ return "next";
185
+ case "ArrowUp":
186
+ return "prev";
187
+ case "Home":
188
+ return "first";
189
+ case "End":
190
+ return "last";
191
+ default:
192
+ return null;
193
+ }
194
+ }
195
+ /** Should the binding call `preventDefault()`? Everything we act on, except `dismiss` — Tab MUST
196
+ * keep its native behaviour or the popover becomes a focus trap. */
197
+ export function popoverKeyHandled(action) {
198
+ return action !== null && action !== "dismiss";
199
+ }
200
+ /**
201
+ * Does this action apply to the element that currently has focus?
202
+ *
203
+ * Dismissal (Escape / Tab) applies from ANYWHERE inside the popover — including the caller's
204
+ * footer content, where Escape must still close. Roving (arrows / Home / End) applies ONLY when
205
+ * focus is on a menu row or on the panel fallback. That distinction is load-bearing, not tidiness:
206
+ * a footer control is arbitrary caller markup, so swallowing Home/End there would steal a text
207
+ * input's caret keys, and swallowing an arrow would yank focus out of the footer and into the menu.
208
+ * The binding supplies the one thing this module cannot compute — whether the focused element is a
209
+ * roving surface.
210
+ */
211
+ export function popoverAppliesToFocus(action, focusOnRovingSurface) {
212
+ if (action === null)
213
+ return false;
214
+ if (action === "close" || action === "dismiss")
215
+ return true;
216
+ return focusOnRovingSurface;
217
+ }
218
+ /* ── roving-focus index arithmetic (disabled rows are skipped, ends wrap) ── */
219
+ /** Index of the first enabled item scanning forward (`dir` 1) or backward (`dir` -1); -1 if every
220
+ * item is disabled (or the list is empty). */
221
+ export function edgePopoverIndex(items, dir = 1) {
222
+ const n = items.length;
223
+ for (let step = 0; step < n; step++) {
224
+ const i = dir === 1 ? step : n - 1 - step;
225
+ if (!items[i]?.disabled)
226
+ return i;
227
+ }
228
+ return -1;
229
+ }
230
+ /** The next enabled index from `from` in `dir`, wrapping. Returns -1 when nothing is enabled and
231
+ * `from` itself when it is the ONLY enabled row (a wrap that lands back where it started). */
232
+ export function stepPopoverIndex(items, from, dir) {
233
+ const n = items.length;
234
+ if (n === 0)
235
+ return -1;
236
+ for (let step = 1; step <= n; step++) {
237
+ const i = (((from + dir * step) % n) + n) % n;
238
+ if (!items[i]?.disabled)
239
+ return i;
240
+ }
241
+ return -1;
242
+ }
243
+ /**
244
+ * Where focus lands when the panel opens: the selected row if it is focusable, else the first
245
+ * enabled one. A selected-but-disabled row does not swallow focus.
246
+ */
247
+ export function initialPopoverIndex(items) {
248
+ const sel = items.findIndex((it) => it.selected && !it.disabled);
249
+ return sel >= 0 ? sel : edgePopoverIndex(items, 1);
250
+ }
251
+ /**
252
+ * The single entry point a binding's keydown handler calls: maps a panel key action + the current
253
+ * focused index to the next focused index. Non-navigation actions (and a null action) leave the
254
+ * index untouched, so the caller never has to branch on the action twice.
255
+ *
256
+ * `current < 0` means "focus is in the panel but not on a row" (an all-disabled or empty list, or
257
+ * a click that landed on the panel's padding). From there Down means the FIRST enabled row and Up
258
+ * means the LAST — not a wrap arithmetic accident off index -1.
259
+ */
260
+ export function resolvePopoverIndex(action, items, current) {
261
+ switch (action) {
262
+ case "next":
263
+ return current < 0 ? edgePopoverIndex(items, 1) : stepPopoverIndex(items, current, 1);
264
+ case "prev":
265
+ return current < 0 ? edgePopoverIndex(items, -1) : stepPopoverIndex(items, current, -1);
266
+ case "first":
267
+ return edgePopoverIndex(items, 1);
268
+ case "last":
269
+ return edgePopoverIndex(items, -1);
270
+ default:
271
+ return current;
272
+ }
273
+ }
274
+ /* ── optional slots ───────────────────────────────────────────────────── */
275
+ /**
276
+ * Is an optional slot (heading, caption, footer) worth rendering its chrome?
277
+ *
278
+ * `undefined`, `null`, `false` and `""` are all values BOTH frameworks render as nothing, and the
279
+ * idiomatic conditional slot is `footer={enabled && <Action />}` — which passes `false`, not
280
+ * `undefined`, when disabled. Testing `!== undefined` would leave an orphan separator and an empty
281
+ * box behind it, and an empty heading would leave the menu's `aria-labelledby` pointing at an
282
+ * element with no text. Shared here so both bindings apply the same rule.
283
+ */
284
+ export function popoverHasSlotContent(slot) {
285
+ return slot !== undefined && slot !== null && slot !== false && slot !== "";
286
+ }
287
+ /**
288
+ * The trigger face: prefix + the CURRENTLY selected item's label. Honest by construction — with no
289
+ * selection (or a selection that is not in the list) it shows the placeholder rather than naming an
290
+ * item that is not actually chosen.
291
+ */
292
+ export function popoverTriggerText(items, opts = {}) {
293
+ const sel = items.find((it) => it.selected);
294
+ return {
295
+ label: opts.label ?? null,
296
+ value: sel ? sel.label : (opts.placeholder ?? POPOVER_EMPTY_VALUE),
297
+ };
298
+ }
299
+ // Deliberately NOT exported: an `aria-label` for the trigger. The trigger renders its label and
300
+ // value as real text (only the caret is aria-hidden), so its accessible name is already computed
301
+ // from exactly what the eye reads. Adding an aria-label would override that visible text — the
302
+ // classic "label in name" failure — so the panel points at the trigger with `aria-labelledby`
303
+ // instead of anyone re-deriving a string.