@mythicalos/ui-core 0.2.2 → 0.3.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/dist/index.d.ts +6 -0
- package/dist/index.js +23 -0
- package/dist/logic/file-explorer.d.ts +374 -0
- package/dist/logic/file-explorer.js +624 -0
- package/dist/logic/popover.d.ts +242 -0
- package/dist/logic/popover.js +303 -0
- package/dist/logic/queue.d.ts +142 -0
- package/dist/logic/queue.js +143 -0
- package/dist/logic/sendbar.d.ts +105 -0
- package/dist/logic/sendbar.js +136 -0
- package/dist/logic/session-card.d.ts +263 -0
- package/dist/logic/session-card.js +443 -0
- package/dist/logic/terminal.d.ts +222 -0
- package/dist/logic/terminal.js +264 -0
- package/package.json +1 -1
- package/src/select/mythical-select.js +3 -2
- package/styles.css +652 -0
|
@@ -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.
|