@antadesign/anta 0.3.5 → 0.3.6

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.
@@ -1,6 +1,7 @@
1
1
  import { Fragment, jsx, jsxs } from "@antadesign/anta/jsx-runtime";
2
2
  import { useState, useMemo } from "../jsx-runtime";
3
- import { nativeStateChange } from "../anta_helpers";
3
+ import { nativeStateChange, ISOLATE_HINT } from "../anta_helpers";
4
+ import { normalizeOpt, matchQueryRegex, matchesQuery } from "./select-options";
4
5
  import { Button } from "./Button";
5
6
  import { Menu } from "./Menu";
6
7
  import { MenuItem } from "./MenuItem";
@@ -8,8 +9,8 @@ import { MenuGroup } from "./MenuGroup";
8
9
  import { MenuSeparator } from "./MenuSeparator";
9
10
  import { Tag } from "./Tag";
10
11
  import { Input } from "./Input";
12
+ import { Tooltip } from "./Tooltip";
11
13
  import styles from "./SelectFaceted.module.css";
12
- const normalizeOpt = (o) => typeof o === "string" ? { value: o, label: o } : o;
13
14
  const leavesOf = (items) => {
14
15
  const out = [];
15
16
  for (const it of items) {
@@ -22,13 +23,7 @@ const leavesOf = (items) => {
22
23
  return out;
23
24
  };
24
25
  const isEmpty = (v) => v == null || v === "" || Array.isArray(v) && v.length === 0;
25
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
- const defaultMatch = (o, query) => {
27
- const q = query.trim();
28
- if (!q) return true;
29
- const re = new RegExp(q.split(/\s+/).map(escapeRe).join("\\s+"), "i");
30
- return [o.value, o.label, o.hint].some((v) => typeof v === "string" && re.test(v));
31
- };
26
+ const defaultMatch = (o, query) => matchesQuery(o, matchQueryRegex(query));
32
27
  const SelectFaceted = (props) => {
33
28
  const {
34
29
  facets,
@@ -40,6 +35,7 @@ const SelectFaceted = (props) => {
40
35
  size,
41
36
  priority = "secondary",
42
37
  disabled,
38
+ toneSelected,
43
39
  searchable,
44
40
  searchPlaceholder = "Filter\u2026",
45
41
  clearable = true,
@@ -125,6 +121,7 @@ const SelectFaceted = (props) => {
125
121
  label: opt.label ?? opt.value,
126
122
  hint: opt.hint,
127
123
  tone: opt.tone,
124
+ toneSelected,
128
125
  disabled: opt.disabled,
129
126
  "data-menu-open": ""
130
127
  };
@@ -142,13 +139,17 @@ const SelectFaceted = (props) => {
142
139
  );
143
140
  }
144
141
  const arr = current[facet.key] ?? [];
142
+ const isolable = facet.selectAll !== false && !opt.disabled;
143
+ const tip = opt.tooltip ?? (isolable ? ISOLATE_HINT : void 0);
144
+ const hintOnly = opt.tooltip == null && isolable;
145
145
  return /* @__PURE__ */ jsx(
146
146
  MenuItem,
147
147
  {
148
148
  ...shared,
149
149
  selectionIndicator: "checkbox",
150
150
  selected: arr.includes(opt.value),
151
- onSelect: () => setFacet(facet, arr.includes(opt.value) ? arr.filter((v) => v !== opt.value) : [...arr, opt.value])
151
+ onSelect: (e) => isolable && e?.altKey ? setFacet(facet, [opt.value]) : setFacet(facet, arr.includes(opt.value) ? arr.filter((v) => v !== opt.value) : [...arr, opt.value]),
152
+ children: tip && /* @__PURE__ */ jsx(Tooltip, { ...hintOnly ? { follow: true, delay: 700 } : {}, children: tip })
152
153
  },
153
154
  `${keyPrefix}${opt.value}`
154
155
  );
@@ -161,7 +162,7 @@ const SelectFaceted = (props) => {
161
162
  const allOn = enabled.length > 0 && enabled.every((v) => arr.includes(v));
162
163
  const someOn = enabled.some((v) => arr.includes(v));
163
164
  return /* @__PURE__ */ jsxs(Fragment, { children: [
164
- facet.selectAll && leaves.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
165
+ facet.selectAll !== false && leaves.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
165
166
  /* @__PURE__ */ jsx(
166
167
  MenuItem,
167
168
  {
@@ -0,0 +1,14 @@
1
+ import type { SelectOption } from './Select';
2
+ /** Coerce a bare string option to `{ value, label }`; pass an object through. */
3
+ export declare const normalizeOpt: (o: string | SelectOption) => SelectOption;
4
+ /** The built-in filter regex: case-insensitive, with each run of whitespace in the
5
+ * query matching any gap (`\s+`). Returns null for an empty query — the signal to
6
+ * match everything and to skip match-highlighting. */
7
+ export declare function matchQueryRegex(query: string): RegExp | null;
8
+ /** Whether an option matches the built-in query regex, testing value / label /
9
+ * hint. A null regex (empty query) matches everything. */
10
+ export declare const matchesQuery: (o: SelectOption, queryRe: RegExp | null) => boolean;
11
+ /** Bold the matched substring(s) of `text` for display (built-in matcher only).
12
+ * A null regex returns the text unchanged. The regex is re-created global so
13
+ * `exec` walks every match; the zero-width guard stops an empty match looping. */
14
+ export declare const highlight: (text: string, queryRe: RegExp | null) => React.ReactNode;
@@ -0,0 +1,31 @@
1
+ import { jsx } from "@antadesign/anta/jsx-runtime";
2
+ const normalizeOpt = (o) => typeof o === "string" ? { value: o, label: o } : o;
3
+ function matchQueryRegex(query) {
4
+ const q = query.trim();
5
+ if (!q) return null;
6
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ return new RegExp(q.split(/\s+/).map(escape).join("\\s+"), "i");
8
+ }
9
+ const matchesQuery = (o, queryRe) => !queryRe || [o.value, o.label ?? "", o.hint ?? ""].some((s) => queryRe.test(s));
10
+ const highlight = (text, queryRe) => {
11
+ if (!queryRe) return text;
12
+ const re = new RegExp(queryRe.source, "gi");
13
+ const out = [];
14
+ let last = 0;
15
+ let m;
16
+ while ((m = re.exec(text)) !== null) {
17
+ if (m.index > last) out.push(text.slice(last, m.index));
18
+ out.push(/* @__PURE__ */ jsx("b", { children: m[0] }, out.length));
19
+ last = m.index + m[0].length;
20
+ if (m[0].length === 0) re.lastIndex++;
21
+ }
22
+ if (out.length === 0) return text;
23
+ if (last < text.length) out.push(text.slice(last));
24
+ return out;
25
+ };
26
+ export {
27
+ highlight,
28
+ matchQueryRegex,
29
+ matchesQuery,
30
+ normalizeOpt
31
+ };
@@ -0,0 +1 @@
1
+ @layer anta{a-dialog{--dialog-bg: var(--bg-1);--dialog-text: var(--text-2);--dialog-border: color-mix(in oklch, black 9%, transparent);--dialog-radius: 10px;--dialog-shadow: inset 0 0 0 1px var(--dialog-border), 0 1px 3px color-mix(in oklch, black 10%, transparent), 0 12px 32px color-mix(in oklch, black 22%, transparent), 0 28px 64px color-mix(in oklch, black 14%, transparent);--dialog-overlay: color-mix(in oklch, black 42%, transparent);display:contents}a-dialog[round]{--dialog-round: attr(round type(<length>), var(--dialog-radius, 10px))}a-dialog:not(:defined){display:none}.dark a-dialog{--dialog-border: color-mix(in oklch, white 14%, transparent);--dialog-shadow: inset 0 0 0 1px var(--dialog-border), 0 1px 3px color-mix(in oklch, black 40%, transparent), 0 12px 32px color-mix(in oklch, black 55%, transparent), 0 28px 64px color-mix(in oklch, black 40%, transparent);--dialog-overlay: color-mix(in oklch, black 62%, transparent)}}
@@ -0,0 +1,26 @@
1
+ import { HTMLElementBase } from '../anta_helpers';
2
+ import './a-dialog.css';
3
+ export declare class ADialogElement extends HTMLElementBase {
4
+ #private;
5
+ static observedAttributes: string[];
6
+ private dialog;
7
+ private headerSlot;
8
+ private footerSlot;
9
+ private closeSlot;
10
+ constructor();
11
+ connectedCallback(): void;
12
+ disconnectedCallback(): void;
13
+ attributeChangedCallback(name: string): void;
14
+ /** Public imperative API — mirrors `<dialog>`. `show()` requests open,
15
+ * `close()` requests close; both honor the state contract. */
16
+ show(): void;
17
+ close(): void;
18
+ get open(): boolean;
19
+ /** Announce a requested OPEN (cancelable, before applying), then — uncontrolled
20
+ * and un-vetoed — apply it. Controlled: never self-apply. */
21
+ requestOpen(): void;
22
+ /** Announce a requested CLOSE (cancelable, before applying), then — uncontrolled
23
+ * and un-vetoed — apply it. Controlled: never self-apply. */
24
+ requestClose(): void;
25
+ }
26
+ export declare function register_a_dialog(): void;
@@ -0,0 +1,386 @@
1
+ import { HTMLElementBase, parseOpenState } from "../anta_helpers";
2
+ import "./a-dialog.css";
3
+ const CLOSE_TRIGGER = "closerequest";
4
+ const ANIM_MS = 200;
5
+ const SHADOW_STYLE = `
6
+ :host { display: contents; }
7
+
8
+ dialog {
9
+ --_dur: ${ANIM_MS}ms;
10
+ --_r: var(--dialog-round, var(--dialog-radius, 10px));
11
+
12
+ position: fixed;
13
+ box-sizing: border-box;
14
+ padding: 0;
15
+ border: none;
16
+ max-width: calc(100vw - 2 * var(--dialog-margin, 32px));
17
+ max-height: calc(100dvh - 2 * var(--dialog-margin, 32px));
18
+ width: var(--dialog-width, 480px);
19
+ height: var(--dialog-height, fit-content);
20
+ overflow: visible;
21
+ color: var(--dialog-text, inherit);
22
+ background: var(--dialog-bg, #fff);
23
+ border-radius: var(--_r);
24
+ box-shadow: var(--dialog-shadow, inset 0 0 0 1px color-mix(in oklch, black 8%, transparent), 0 10px 38px color-mix(in oklch, black 28%, transparent));
25
+
26
+ flex-direction: column;
27
+ }
28
+
29
+ dialog[open] {
30
+ display: flex;
31
+ }
32
+
33
+ dialog:focus, dialog:focus-visible { outline: none; }
34
+
35
+ dialog::backdrop {
36
+ background: var(--dialog-overlay, color-mix(in oklch, black 40%, transparent));
37
+ }
38
+
39
+ :host { --_enter-from: scale(0.97); }
40
+ :host([position="left"]) { --_enter-from: translateX(-100%); }
41
+ :host([position="right"]) { --_enter-from: translateX(100%); }
42
+ :host([position="top"]) { --_enter-from: translateY(-100%); }
43
+ :host([position="bottom"]) { --_enter-from: translateY(100%); }
44
+
45
+ @media (prefers-reduced-motion: no-preference) {
46
+ dialog {
47
+ opacity: 0;
48
+ transform: var(--_enter-from);
49
+ transition:
50
+ opacity var(--_dur) ease,
51
+ transform var(--_dur) ease,
52
+ overlay var(--_dur) ease allow-discrete,
53
+ display var(--_dur) ease allow-discrete;
54
+ }
55
+ dialog[open] {
56
+ opacity: 1;
57
+ transform: none;
58
+ }
59
+ @starting-style {
60
+ dialog[open] { opacity: 0; transform: var(--_enter-from); }
61
+ }
62
+
63
+ dialog::backdrop {
64
+ background: transparent;
65
+ transition:
66
+ background var(--_dur) ease,
67
+ overlay var(--_dur) ease allow-discrete,
68
+ display var(--_dur) ease allow-discrete;
69
+ }
70
+ dialog[open]::backdrop { background: var(--dialog-overlay, color-mix(in oklch, black 40%, transparent)); }
71
+ @starting-style {
72
+ dialog[open]::backdrop { background: transparent; }
73
+ }
74
+ }
75
+
76
+ :host([position="left"]) dialog,
77
+ :host([position="right"]) dialog {
78
+ width: var(--dialog-width, 380px);
79
+ max-width: calc(100vw - var(--dialog-margin, 32px));
80
+ height: 100dvh;
81
+ max-height: 100dvh;
82
+ border-radius: 0;
83
+ }
84
+ :host([position="left"]) dialog { margin: 0 auto 0 0; }
85
+ :host([position="right"]) dialog { margin: 0 0 0 auto; }
86
+
87
+ :host([position="top"]) dialog,
88
+ :host([position="bottom"]) dialog {
89
+ width: 100vw;
90
+ max-width: 100vw;
91
+ height: var(--dialog-height, fit-content);
92
+ max-height: calc(100dvh - var(--dialog-margin, 32px));
93
+ border-radius: 0;
94
+ }
95
+ :host([position="top"]) dialog { margin: 0 auto auto auto; }
96
+ :host([position="bottom"]) dialog { margin: auto auto 0 auto; }
97
+
98
+ :host([position="fullscreen"]) dialog {
99
+ width: 100vw;
100
+ max-width: 100vw;
101
+ height: 100dvh;
102
+ max-height: 100dvh;
103
+ margin: 0;
104
+ border-radius: 0;
105
+ }
106
+
107
+ :host([round]) dialog { border-radius: var(--_r); }
108
+ :host([round="top"]) dialog { border-radius: var(--_r) var(--_r) 0 0; }
109
+ :host([round="bottom"]) dialog { border-radius: 0 0 var(--_r) var(--_r); }
110
+ :host([round="left"]) dialog { border-radius: var(--_r) 0 0 var(--_r); }
111
+ :host([round="right"]) dialog { border-radius: 0 var(--_r) var(--_r) 0; }
112
+
113
+ slot[name="header"] {
114
+ display: none;
115
+ flex: 0 0 auto;
116
+ padding: var(--dialog-header-padding, 18px 20px 8px);
117
+ font-size: 17px;
118
+ font-weight: 560;
119
+ line-height: 24px;
120
+ }
121
+ slot[name="header"].has-content { display: block; }
122
+ dialog.has-close slot[name="header"] { padding-inline-end: 52px; }
123
+
124
+ slot[part="body"] {
125
+ display: block;
126
+ flex: 1 1 auto;
127
+ min-height: 0;
128
+ overflow: auto;
129
+ padding: var(--dialog-body-padding, 8px 20px 12px);
130
+ }
131
+ /* With a close button but no header, the body is the top zone, so it takes the
132
+ \u2715 clearance instead of the header (which is display:none when empty). */
133
+ dialog.has-close slot[name="header"]:not(.has-content) ~ slot[part="body"] {
134
+ padding-inline-end: 52px;
135
+ }
136
+
137
+ slot[name="footer"] {
138
+ display: none;
139
+ flex: 0 0 auto;
140
+ padding: var(--dialog-footer-padding, 8px 20px 18px);
141
+ }
142
+ slot[name="footer"].has-content {
143
+ display: flex;
144
+ gap: 8px;
145
+ justify-content: flex-end;
146
+ align-items: center;
147
+ flex-wrap: wrap;
148
+ }
149
+
150
+ slot[name="close"] { display: none; }
151
+ slot[name="close"].has-content {
152
+ display: block;
153
+ position: absolute;
154
+ inset-block-start: 10px;
155
+ inset-inline-end: 10px;
156
+ z-index: 1;
157
+ }
158
+ `;
159
+ class ADialogElement extends HTMLElementBase {
160
+ static observedAttributes = ["state", "name"];
161
+ dialog;
162
+ headerSlot;
163
+ footerSlot;
164
+ closeSlot;
165
+ // Counts `dialog.close()` calls WE initiate whose async `close` event is still
166
+ // pending, and is decremented as each arrives. The native `close` event fires
167
+ // from a queued task, so a synchronous guard can't span it, and a boolean
168
+ // can't span a BURST (two re-parents in one tick queue two closes; the first
169
+ // event would clear the flag and the second would be misread as out-of-band).
170
+ // The counter lets the `close` handler tell our own closes (ignore) from an
171
+ // out-of-band one (`<form method="dialog">`, a consumer calling the shadow
172
+ // dialog's close(), bfcache), which it must reconcile.
173
+ #programmaticCloses = 0;
174
+ // The intended (uncontrolled) open state, held in JS so it survives a
175
+ // disconnect → reconnect (a DOM move / re-parent). disconnectedCallback
176
+ // force-closes the native dialog to free the top layer, but must not lose
177
+ // whether the user had it open — otherwise a re-parented open dialog would
178
+ // vanish (trigger-opened) or a re-parented closed one would pop back (a stale
179
+ // `default-state="open"` re-seeding). Only ever reflects genuine open/close
180
+ // intent, never the disconnect teardown.
181
+ #wantOpen = false;
182
+ // default-state is read ONCE (the first connect), never again — re-seeding on
183
+ // every connect would reopen a dialog the user has since closed.
184
+ #seeded = false;
185
+ // Whether the current pointer press began on the backdrop (the dialog element
186
+ // itself), so a press-inside → release-on-backdrop drag (text selection) is
187
+ // NOT read as a backdrop dismiss.
188
+ #pressedBackdrop = false;
189
+ // The document click listener wired for the `name`-trigger convenience, kept so
190
+ // it can be torn down (and re-wired when `name` / controlled-ness changes).
191
+ #triggerListener;
192
+ constructor() {
193
+ super();
194
+ const shadow = this.attachShadow({ mode: "open" });
195
+ const style = document.createElement("style");
196
+ style.textContent = SHADOW_STYLE;
197
+ this.dialog = document.createElement("dialog");
198
+ this.dialog.setAttribute("part", "dialog");
199
+ this.dialog.tabIndex = -1;
200
+ this.closeSlot = document.createElement("slot");
201
+ this.closeSlot.name = "close";
202
+ this.closeSlot.setAttribute("part", "close");
203
+ this.headerSlot = document.createElement("slot");
204
+ this.headerSlot.name = "header";
205
+ this.headerSlot.setAttribute("part", "header");
206
+ const bodySlot = document.createElement("slot");
207
+ bodySlot.setAttribute("part", "body");
208
+ this.footerSlot = document.createElement("slot");
209
+ this.footerSlot.name = "footer";
210
+ this.footerSlot.setAttribute("part", "footer");
211
+ for (const slot of [this.headerSlot, this.footerSlot, this.closeSlot]) {
212
+ slot.addEventListener("slotchange", () => {
213
+ const has = slot.assignedNodes().length > 0;
214
+ slot.classList.toggle("has-content", has);
215
+ if (slot === this.closeSlot) this.dialog.classList.toggle("has-close", has);
216
+ });
217
+ }
218
+ this.headerSlot.addEventListener("slotchange", () => this.#syncAccessibleName());
219
+ this.dialog.append(this.headerSlot, bodySlot, this.footerSlot, this.closeSlot);
220
+ shadow.append(style, this.dialog);
221
+ this.dialog.addEventListener("cancel", (e) => {
222
+ e.preventDefault();
223
+ if (!this.#persistent) this.requestClose();
224
+ });
225
+ this.dialog.addEventListener("pointerdown", (e) => {
226
+ this.#pressedBackdrop = e.target === this.dialog;
227
+ });
228
+ this.dialog.addEventListener("click", (e) => {
229
+ if (this.#persistent) return;
230
+ if (this.#pressedBackdrop && e.target === this.dialog) this.requestClose();
231
+ });
232
+ this.dialog.addEventListener("close", () => {
233
+ if (this.#programmaticCloses > 0) {
234
+ this.#programmaticCloses--;
235
+ return;
236
+ }
237
+ if (this.#controlled) this.#reflect(parseOpenState(this.getAttribute("state")));
238
+ else this.#wantOpen = false;
239
+ });
240
+ this.addEventListener(CLOSE_TRIGGER, () => this.requestClose());
241
+ }
242
+ connectedCallback() {
243
+ if (this.#controlled) {
244
+ this.#reflect(parseOpenState(this.getAttribute("state")));
245
+ } else {
246
+ if (!this.#seeded) {
247
+ if (parseOpenState(this.getAttribute("default-state")) === "open") this.#wantOpen = true;
248
+ this.#seeded = true;
249
+ }
250
+ if (this.#wantOpen) this.#open();
251
+ }
252
+ this.#syncTriggerListener();
253
+ this.#syncAccessibleName();
254
+ }
255
+ disconnectedCallback() {
256
+ this.#teardownTriggerListener();
257
+ if (this.dialog.open) {
258
+ this.#programmaticCloses++;
259
+ this.dialog.close();
260
+ }
261
+ }
262
+ attributeChangedCallback(name) {
263
+ if (name === "state") {
264
+ if (this.#controlled) this.#reflect(parseOpenState(this.getAttribute("state")));
265
+ this.#syncTriggerListener();
266
+ } else if (name === "name") {
267
+ this.#syncTriggerListener();
268
+ }
269
+ }
270
+ /** Controlled mode: the `state` attribute is present and owns the open state. */
271
+ get #controlled() {
272
+ return this.hasAttribute("state");
273
+ }
274
+ /** Light-dismiss (Esc + backdrop) disabled — the dialog closes only via
275
+ * explicit controls (close button / programmatic). */
276
+ get #persistent() {
277
+ return this.hasAttribute("persistent");
278
+ }
279
+ /** The currently *applied* open state (read from the native dialog — the
280
+ * source of truth for what's painted). */
281
+ get #current() {
282
+ return this.dialog.open ? "open" : "closed";
283
+ }
284
+ /** Public imperative API — mirrors `<dialog>`. `show()` requests open,
285
+ * `close()` requests close; both honor the state contract. */
286
+ show() {
287
+ this.requestOpen();
288
+ }
289
+ close() {
290
+ this.requestClose();
291
+ }
292
+ get open() {
293
+ return this.dialog.open;
294
+ }
295
+ /** Announce a requested OPEN (cancelable, before applying), then — uncontrolled
296
+ * and un-vetoed — apply it. Controlled: never self-apply. */
297
+ requestOpen() {
298
+ if (this.#current === "open") return;
299
+ const ok = this.#announce("open");
300
+ if (this.#controlled) return;
301
+ if (ok) this.#open();
302
+ }
303
+ /** Announce a requested CLOSE (cancelable, before applying), then — uncontrolled
304
+ * and un-vetoed — apply it. Controlled: never self-apply. */
305
+ requestClose() {
306
+ if (this.#current === "closed") return;
307
+ const ok = this.#announce("closed");
308
+ if (this.#controlled) return;
309
+ if (ok) this.#close();
310
+ }
311
+ /** Dispatch the cancelable `statechange` for a requested transition. Returns
312
+ * false if a handler vetoed it. */
313
+ #announce(next) {
314
+ return this.dispatchEvent(
315
+ new CustomEvent("statechange", {
316
+ cancelable: true,
317
+ detail: { next, prev: this.#current }
318
+ })
319
+ );
320
+ }
321
+ /** Reflect a controlled `state` onto the native dialog (idempotent). No
322
+ * `statechange` — this is the consumer's own command, not a user gesture. */
323
+ #reflect(state) {
324
+ if (state === "open") this.#open();
325
+ else this.#close();
326
+ }
327
+ /** Raw open: show the modal dialog. Records the open intent (survives a
328
+ * disconnect). Guarded against a not-connected / already-open native element
329
+ * (both throw / no-op). */
330
+ #open() {
331
+ this.#wantOpen = true;
332
+ if (this.dialog.open || !this.isConnected) return;
333
+ this.dialog.showModal();
334
+ if (!this.querySelector("[autofocus]")) this.dialog.focus();
335
+ }
336
+ /** Raw close. Records the closed intent and counts the pending `close` event as
337
+ * ours so the listener doesn't reconcile it. (The disconnect teardown closes
338
+ * the native dialog directly, NOT through here, to preserve `#wantOpen`.) */
339
+ #close() {
340
+ this.#wantOpen = false;
341
+ if (!this.dialog.open) return;
342
+ this.#programmaticCloses++;
343
+ this.dialog.close();
344
+ }
345
+ /** Publish the header text as the shadow dialog's accessible name (a-input's
346
+ * label-mirror pattern). Shadow-internal — not a host / light-DOM write. */
347
+ #syncAccessibleName() {
348
+ const text = this.headerSlot.assignedNodes().map((n) => n.textContent ?? "").join(" ").trim();
349
+ if (text) this.dialog.setAttribute("aria-label", text);
350
+ else this.dialog.removeAttribute("aria-label");
351
+ }
352
+ // --- `name`-trigger convenience (uncontrolled only) ---
353
+ #syncTriggerListener() {
354
+ const wanted = !this.#controlled && !!this.getAttribute("name");
355
+ if (wanted && !this.#triggerListener) this.#wireTriggerListener();
356
+ else if (!wanted && this.#triggerListener) this.#teardownTriggerListener();
357
+ }
358
+ #wireTriggerListener() {
359
+ this.#triggerListener = (e) => {
360
+ const name = this.getAttribute("name");
361
+ if (!name) return;
362
+ const t = e.target;
363
+ if (!(t instanceof Element)) return;
364
+ if (t.closest(`[data-dialog-open="${CSS.escape(name)}"]`)) this.requestOpen();
365
+ else if (t.closest(`[data-dialog-close="${CSS.escape(name)}"]`)) this.requestClose();
366
+ };
367
+ this.doc.addEventListener("click", this.#triggerListener);
368
+ }
369
+ #teardownTriggerListener() {
370
+ if (this.#triggerListener) {
371
+ this.doc.removeEventListener("click", this.#triggerListener);
372
+ this.#triggerListener = void 0;
373
+ }
374
+ }
375
+ }
376
+ function register_a_dialog() {
377
+ if (typeof customElements === "undefined") return;
378
+ if (!customElements.get("a-dialog")) {
379
+ customElements.define("a-dialog", ADialogElement);
380
+ }
381
+ }
382
+ register_a_dialog();
383
+ export {
384
+ ADialogElement,
385
+ register_a_dialog
386
+ };
@@ -1,4 +1,4 @@
1
- import { HTMLElementBase } from "../anta_helpers";
1
+ import { HTMLElementBase, parseOpenState } from "../anta_helpers";
2
2
  import "./a-expander.css";
3
3
  const ANIM_MS = 200;
4
4
  const SUMMARY_TYPE_SCALE = {
@@ -139,7 +139,7 @@ const SHADOW_STYLE = `
139
139
  transition-behavior: allow-discrete;
140
140
  }
141
141
  `;
142
- const parseState = (v) => v === "open" ? "open" : "closed";
142
+ const parseState = parseOpenState;
143
143
  class AExpanderElement extends HTMLElementBase {
144
144
  static observedAttributes = ["state", "disabled", "round"];
145
145
  summary;
@@ -94,6 +94,7 @@ const SHADOW_STYLE = `
94
94
  padding-inline: 1px;
95
95
  border-radius: 2px;
96
96
  text-align: center;
97
+ white-space: nowrap;
97
98
  outline: none;
98
99
  cursor: default;
99
100
  font-variant-numeric: tabular-nums;
@@ -102,7 +103,7 @@ const SHADOW_STYLE = `
102
103
  .seg[inputmode="numeric"] {
103
104
  /* Fixed to two tabular digits so the box never reflows: neither the narrower
104
105
  placeholder dashes nor a mid-entry single digit changes the field width. */
105
- width: calc(2ch + 2px);
106
+ width: calc(2ch + 4px);
106
107
  }
107
108
  .seg:focus {
108
109
  background: var(--input-time-seg-focus-bg);
@@ -110,8 +110,11 @@ export declare class AMenuElement extends HTMLElementBase {
110
110
  * the seat target — the cursor lands on the first real option — but they stay
111
111
  * arrow-reachable. */
112
112
  private resetActive;
113
- /** Combobox arrow / Home / End / Enter handling; returns true if it consumed the
114
- * key (so `handleKey` stops). Any other key falls through to the input (typing). */
113
+ /** Combobox arrow / PageUp / PageDown / Enter handling; returns true if it
114
+ * consumed the key (so `handleKey` stops). Home / End are deliberately NOT
115
+ * handled: focus is in a text field, so they must reach it for caret movement
116
+ * (line start/end); PageUp / PageDown jump the option list to first / last
117
+ * instead. Any other key falls through to the input (typing). */
115
118
  private handleComboKey;
116
119
  /** Public imperative API. Routes through the same intent path as the
117
120
  * triggers, so it emits `statechange` and respects controlled mode. */
@@ -451,11 +451,22 @@ class AMenuElement extends HTMLElementBase {
451
451
  el.style.setProperty("--fade-bottom", bottom ? "14px" : "0px");
452
452
  }
453
453
  /* ------------------------------ combobox mode ------------------------------ */
454
- /** The filter field slotted into THIS menu (never a submenu's), or null. Its
454
+ /** Combobox-anchor mode: the trigger anchor ITSELF is the filter field (it
455
+ * carries `[data-menu-search]`), as in `InputAutocomplete` where an editable
456
+ * `<a-input>` is the always-visible field the menu hangs under. null for the
457
+ * normal case — `Select`/`SelectFaceted` slot the field as a child, so their
458
+ * anchor never carries the marker and this stays inert for them. */
459
+ get #comboAnchor() {
460
+ const a = this.triggerAnchor;
461
+ return a?.matches("[data-menu-search]") ? a : null;
462
+ }
463
+ /** The filter field of THIS menu, or null. A slotted child (never a submenu's)
464
+ * wins; else the anchor when it's the field (combobox-anchor mode). Its
455
465
  * presence switches the menu to the combobox keyboard. */
456
466
  get #searchField() {
457
467
  const el = this.querySelector("[data-menu-search]");
458
- return el && el.closest("a-menu") === this ? el : null;
468
+ if (el && el.closest("a-menu") === this) return el;
469
+ return this.#comboAnchor;
459
470
  }
460
471
  /** Move the combobox cursor. Sets the item's `active` **property** (off-DOM
461
472
  * `:state(active)`, no attribute churn) for the highlight, and REPORTS the
@@ -481,11 +492,15 @@ class AMenuElement extends HTMLElementBase {
481
492
  resetActive() {
482
493
  const q = this.#searchField?.value;
483
494
  if (!q || !q.trim()) return this.setActive(null);
495
+ if (this.#comboAnchor) return this.setActive(null);
484
496
  const items = this.focusableItems();
485
497
  this.setActive(items.find((it) => !it.hasAttribute("data-menu-skip-active")) ?? items[0] ?? null);
486
498
  }
487
- /** Combobox arrow / Home / End / Enter handling; returns true if it consumed the
488
- * key (so `handleKey` stops). Any other key falls through to the input (typing). */
499
+ /** Combobox arrow / PageUp / PageDown / Enter handling; returns true if it
500
+ * consumed the key (so `handleKey` stops). Home / End are deliberately NOT
501
+ * handled: focus is in a text field, so they must reach it for caret movement
502
+ * (line start/end); PageUp / PageDown jump the option list to first / last
503
+ * instead. Any other key falls through to the input (typing). */
489
504
  handleComboKey(e) {
490
505
  const items = this.focusableItems();
491
506
  const idx = this.activeItem ? items.indexOf(this.activeItem) : -1;
@@ -498,11 +513,11 @@ class AMenuElement extends HTMLElementBase {
498
513
  e.preventDefault();
499
514
  this.setActive(items[idx <= 0 ? items.length - 1 : idx - 1] ?? null);
500
515
  return true;
501
- case "Home":
516
+ case "PageUp":
502
517
  e.preventDefault();
503
518
  this.setActive(items[0] ?? null);
504
519
  return true;
505
- case "End":
520
+ case "PageDown":
506
521
  e.preventDefault();
507
522
  this.setActive(items[items.length - 1] ?? null);
508
523
  return true;
@@ -848,6 +863,10 @@ class AMenuElement extends HTMLElementBase {
848
863
  return;
849
864
  }
850
865
  if (e.key === "Tab") {
866
+ if (this.#comboAnchor) {
867
+ this.requestClose(e);
868
+ return;
869
+ }
851
870
  const f = this.focusables();
852
871
  if (!f.length) return;
853
872
  e.preventDefault();
@@ -969,6 +988,12 @@ class AMenuElement extends HTMLElementBase {
969
988
  anchor.removeEventListener("contextmenu", onContext);
970
989
  this.listening = false;
971
990
  };
991
+ } else if (this.#comboAnchor) {
992
+ this.teardown = () => {
993
+ this.listening = false;
994
+ };
995
+ this.listening = true;
996
+ return;
972
997
  } else {
973
998
  const onClick = (e) => {
974
999
  const viaKeyboard = e.detail === 0;