wings-plugin-sdk 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui.js DELETED
@@ -1,601 +0,0 @@
1
- // wings-plugin-sdk/ui: a page says what to show and where, and Wings decides how it looks. Every component renders
2
- // plain elements with `w-*` class names and data attributes that Wings' plugin-ui.css styles, never a style of its
3
- // own, so a Wings update restyles every plugin. Your own components and CSS work alongside them.
4
- import { createContext, createElement as h, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
5
- import { createRoot } from "react-dom/client";
6
-
7
- import { mountShell } from "./shell.js";
8
-
9
- export { mountShell } from "./shell.js";
10
- export { useBroadcast } from "./react.js";
11
-
12
- // ---------- places ----------
13
-
14
- let root = null;
15
-
16
- function show(place, element, options) {
17
- const shell = mountShell(place, options);
18
- root ??= createRoot(shell);
19
- root.render(element);
20
- }
21
-
22
- /** Shows `element` as the page of a sidebar. One call per page; calling again renders the new element. */
23
- export const showSidebar = (element, options) => show("sidebar", element, options);
24
- /** Shows `element` as a view's page, in a modal or a window of its own. */
25
- export const showView = (element, options) => show("view", element, options);
26
- /** Shows `element` as a view's page in a tab next to the terminals. */
27
- export const showTab = (element, options) => show("tab", element, options);
28
- /** Shows `element` as a sidebar's popover. */
29
- export const showPopover = (element, options) => show("popover", element, options);
30
- /** Shows `element` as the plugin's page in Wings Settings. */
31
- export const showSettings = (element, options) => show("settings", element, options);
32
-
33
- // ---------- talking to main ----------
34
-
35
- const HELLO = { type: "hello" };
36
-
37
- /**
38
- * The newest message of `type` the plugin's other pages broadcast, or null until one comes. Sends `hello` once
39
- * when the component mounts, so main answers with its state; pass null to send nothing.
40
- */
41
- export function useMainState(type, hello = HELLO) {
42
- const [state, setState] = useState(null);
43
- useEffect(() => {
44
- const off = wings.onBroadcast((message) => {
45
- if (message?.type === type) setState(message);
46
- });
47
- if (hello != null) void wings.broadcast(hello);
48
- return off;
49
- // `hello` is only sent when the component mounts.
50
- }, [type]);
51
- return state;
52
- }
53
-
54
- // ---------- helpers ----------
55
-
56
- const cx = (base, extra) => (extra ? `${base} ${extra}` : base);
57
- /** A data attribute that's there or not. */
58
- const flag = (on) => (on ? "" : undefined);
59
-
60
- // ---------- layout and text ----------
61
-
62
- /** Children side by side, centred. `gap` is none, xs, s, m (default) or l. */
63
- export function Row({ as = "div", gap, align, justify, wrap, grow, className, ...rest }) {
64
- return h(as, { ...rest, className: cx("w-row", className), "data-gap": gap, "data-align": align, "data-justify": justify, "data-wrap": flag(wrap), "data-grow": flag(grow) });
65
- }
66
-
67
- /** Children one under the other. */
68
- export function Stack({ as = "div", gap, grow, className, ...rest }) {
69
- return h(as, { ...rest, className: cx("w-stack", className), "data-gap": gap, "data-grow": flag(grow) });
70
- }
71
-
72
- /** Text with a tone and size, that can grow to fill a row or truncate with an ellipsis. */
73
- export function Text({ as = "span", tone, size, weight, mono, numeric, truncate, clamp, grow, className, ...rest }) {
74
- return h(as, {
75
- ...rest,
76
- className: cx("w-text", className),
77
- "data-tone": tone,
78
- "data-size": size,
79
- "data-weight": weight,
80
- "data-mono": flag(mono),
81
- "data-numeric": flag(numeric),
82
- "data-truncate": flag(truncate),
83
- "data-clamp": flag(clamp),
84
- "data-grow": flag(grow),
85
- });
86
- }
87
-
88
- /** A page's or a card's title: an `h1` unless `as` says otherwise. `size` large for a bigger one. `truncate` for one line. */
89
- export function Title({ as = "h1", size, truncate, className, ...rest }) {
90
- return h(as, { ...rest, className: cx("w-title", className), "data-size": size, "data-truncate": flag(truncate) });
91
- }
92
-
93
- /** A small section heading, `h2` unless `level` says 3 or 4, with `actions` like chips or a button on its right. */
94
- export function Heading({ level = 2, actions, className, ...rest }) {
95
- const heading = h(`h${level}`, { ...rest, className: cx("w-heading", className) });
96
- return actions ? h("div", { className: "w-heading-row" }, heading, actions) : heading;
97
- }
98
-
99
- /** A card. `compact` for a summary line. */
100
- export function Card({ as = "div", compact, className, ...rest }) {
101
- return h(as, { ...rest, className: cx("w-card", className), "data-size": compact ? "compact" : undefined });
102
- }
103
-
104
- // ---------- figures ----------
105
-
106
- const levelTone = (percent, levels) => (!levels ? undefined : percent >= levels[1] ? "danger" : percent >= levels[0] ? "warn" : undefined);
107
-
108
- /** A bar from 0 to 100. `tone` is warn or danger. */
109
- export function Meter({ percent, label, tone, className, ...rest }) {
110
- return h(
111
- "div",
112
- { ...rest, className: cx("w-meter", className), role: "meter", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "data-tone": tone },
113
- // How full it is is data, not a style: plugin-ui.css sizes the bar from it.
114
- h("span", { className: "w-meter-fill", style: { "--w-value": `${Math.max(0, Math.min(100, percent))}%` } }),
115
- );
116
- }
117
-
118
- /** A labelled bar with its percentage and a note under it. `levels` are where it turns warn and danger. */
119
- export function Gauge({ label, percent, note, tone, levels, className, ...rest }) {
120
- return h(
121
- "div",
122
- { ...rest, className: cx("w-gauge", className) },
123
- h("span", { className: "w-gauge-label" }, label),
124
- h("span", { className: "w-gauge-value" }, `${percent}%`),
125
- h(Meter, { percent, label, tone: tone ?? levelTone(percent, levels) }),
126
- note ? h("span", { className: "w-gauge-note" }, note) : null,
127
- );
128
- }
129
-
130
- /** Small figures side by side, label over value. */
131
- export function Stats({ items, className, ...rest }) {
132
- return h(
133
- "dl",
134
- { ...rest, className: cx("w-stats", className) },
135
- items.map((item) => h("div", { key: item.label }, h("dt", { className: "w-stat-label" }, item.label), h("dd", { className: "w-stat-value", "data-tone": item.tone, title: item.title }, item.value))),
136
- );
137
- }
138
-
139
- /** Label and value pairs, one per line. */
140
- export function Properties({ items, className, ...rest }) {
141
- return h(
142
- "dl",
143
- { ...rest, className: cx("w-props", className) },
144
- items.map((item) => h("div", { key: item.label, className: "w-prop" }, h("dt", { className: "w-prop-label" }, item.label), h("dd", { className: "w-prop-value" }, item.value))),
145
- );
146
- }
147
-
148
- // ---------- lists ----------
149
-
150
- /** A list of rows. `ordered` for an `ol`, `compact` for rows with no space between them. */
151
- export function List({ ordered, compact, className, ...rest }) {
152
- return h(ordered ? "ol" : "ul", { ...rest, className: cx("w-list", className), "data-density": compact ? "compact" : undefined });
153
- }
154
-
155
- /** A row in a `List`. Holding a `ListButton`, the button is the row. */
156
- export function ListItem({ align, className, ...rest }) {
157
- return h("li", { ...rest, className: cx("w-list-item", className), "data-align": align });
158
- }
159
-
160
- /** A row that's a button, inside a `ListItem`. */
161
- export function ListButton({ align, type = "button", className, ...rest }) {
162
- return h("button", { ...rest, type, className: cx("w-list-button", className), "data-align": align });
163
- }
164
-
165
- // ---------- status ----------
166
-
167
- /**
168
- * A status dot. With a `label` it's announced; otherwise it's decoration next to text that says the same.
169
- * `color` is a colour from your data, like a work item type's; otherwise `tone` colours it.
170
- */
171
- export function Dot({ tone, color, label, className, style, ...rest }) {
172
- return h("span", {
173
- ...rest,
174
- className: cx("w-dot", className),
175
- // A colour that comes with the data, not a style: plugin-ui.css paints the dot with it, over any tone.
176
- style: color ? { ...style, "--w-color": color } : style,
177
- "data-color": color ? "" : undefined,
178
- "data-tone": color ? undefined : tone,
179
- ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }),
180
- });
181
- }
182
-
183
- /** A spinner. With a `label` it's announced. */
184
- export function Spinner({ size, label, className, ...rest }) {
185
- return h("span", { ...rest, className: cx("w-spinner", className), "data-size": size, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
186
- }
187
-
188
- /** A filled pill, like a label on a command. */
189
- export function Pill({ tone, className, ...rest }) {
190
- return h("span", { ...rest, className: cx("w-pill", className), "data-tone": tone });
191
- }
192
-
193
- /** An outlined tag, like a branch name. */
194
- export function Tag({ tone, className, ...rest }) {
195
- return h("span", { ...rest, className: cx("w-tag", className), "data-tone": tone });
196
- }
197
-
198
- /** What shows before the first data comes. */
199
- export function Loading({ children = "Loading…", className, ...rest }) {
200
- return h("p", { ...rest, className: cx("w-loading", className) }, children);
201
- }
202
-
203
- /** Nothing to show: what's missing, and a `detail` line on what to do about it. */
204
- export function Empty({ title, detail, className, ...rest }) {
205
- return h("div", { ...rest, className: cx("w-empty", className) }, h("p", null, title), detail === undefined ? null : h("p", { className: "w-empty-detail" }, detail));
206
- }
207
-
208
- // ---------- buttons ----------
209
-
210
- /** A button. `variant` is primary, ghost or danger; `size` small. */
211
- export function Button({ variant, size, busy, type = "button", onClick, children, className, ...rest }) {
212
- return h(
213
- "button",
214
- {
215
- ...rest,
216
- type,
217
- className: cx("w-button", className),
218
- "data-variant": variant,
219
- "data-size": size,
220
- // Busy, not disabled: a disabled button would drop the keyboard focus on the page.
221
- "aria-busy": busy || undefined,
222
- "aria-disabled": busy || rest["aria-disabled"],
223
- onClick: busy ? (e) => e.preventDefault() : onClick,
224
- },
225
- busy ? h(Spinner) : null,
226
- children,
227
- );
228
- }
229
-
230
- /** A small quiet button. With `pressed` it's a toggle. */
231
- export function Chip({ pressed, type = "button", className, ...rest }) {
232
- return h("button", { ...rest, type, className: cx("w-chip", className), "aria-pressed": pressed });
233
- }
234
-
235
- /** Chips that pick one of `options`, like a filter. `onChange` gets each click, the chip that's on too. */
236
- export function Chips({ label, options, value, onChange, className, ...rest }) {
237
- return h(
238
- "div",
239
- { ...rest, className: cx("w-chips", className), role: "group", "aria-label": label },
240
- options.map(({ value: v, label: text, ...chip }) => h(Chip, { ...chip, key: v, pressed: v === value, onClick: () => onChange(v) }, text)),
241
- );
242
- }
243
-
244
- /** Tabs over the panel they show, `children`. Arrow keys, Home and End move between tabs. */
245
- export function Tabs({ label, items, value, onChange, children, className, ...rest }) {
246
- const id = useId();
247
- const tabId = (v) => `${id}-tab-${v}`;
248
- const onKeyDown = (e) => {
249
- const at = items.findIndex((t) => t.value === value);
250
- const next = { ArrowRight: at + 1, ArrowLeft: at - 1, Home: 0, End: items.length - 1 }[e.key];
251
- if (next === undefined) return;
252
- e.preventDefault();
253
- const tab = items[(next + items.length) % items.length];
254
- onChange(tab.value);
255
- document.getElementById(tabId(tab.value))?.focus();
256
- };
257
- return h(
258
- "div",
259
- { ...rest, className },
260
- h(
261
- "div",
262
- { className: "w-tabs", role: "tablist", "aria-label": label, onKeyDown },
263
- items.map((t) =>
264
- h("button", { key: t.value, type: "button", id: tabId(t.value), className: "w-tab", role: "tab", "aria-selected": t.value === value, "aria-controls": `${id}-panel`, tabIndex: t.value === value ? 0 : -1, onClick: () => onChange(t.value) }, t.label),
265
- ),
266
- ),
267
- h("div", { id: `${id}-panel`, role: "tabpanel", "aria-labelledby": tabId(value) }, children),
268
- );
269
- }
270
-
271
- // ---------- fields ----------
272
-
273
- const FieldContext = createContext(null);
274
-
275
- /** Gives an input in a `Field` its label and hint. */
276
- function useFieldProps(rest) {
277
- const field = useContext(FieldContext);
278
- return field ? { id: field.id, "aria-describedby": field.hint, "aria-invalid": field.invalid || undefined, ...rest } : rest;
279
- }
280
-
281
- /** A hint or an error under a field or a group. */
282
- const fieldNote = (id, hint, error) => (error ?? hint ? h("p", { id, className: "w-field-hint", "data-tone": error ? "danger" : undefined }, error ?? hint) : null);
283
-
284
- /**
285
- * A labelled field with a `hint` or an `error` under it, for the input in `children`. `id` is the input's id, for
286
- * a page that puts focus back by id; `inline` puts the label and the input on one line.
287
- */
288
- export function Field({ id: given, label, hint, error, inline, children, className, ...rest }) {
289
- const generated = useId();
290
- const id = given ?? generated;
291
- const hintId = error ?? hint ? `${id}-hint` : undefined;
292
- const labelled = h("label", { className: "w-field-label", htmlFor: id }, label);
293
- const control = h(FieldContext.Provider, { value: { id, hint: hintId, invalid: Boolean(error) } }, children);
294
- return h(
295
- "div",
296
- { ...rest, className: cx("w-field", className), "data-layout": inline ? "inline" : undefined },
297
- inline ? h("div", { className: "w-field-row" }, labelled, control) : labelled,
298
- inline ? null : control,
299
- fieldNote(hintId, hint, error),
300
- );
301
- }
302
-
303
- const GroupContext = createContext(null);
304
-
305
- /** Several controls under one label, like a list of checkboxes, with a `hint` or an `error` under them. */
306
- export function FieldGroup({ label, hint, error, children, className, ...rest }) {
307
- const id = useId();
308
- const hintId = error ?? hint ? `${id}-hint` : undefined;
309
- return h(
310
- "fieldset",
311
- { ...rest, className: cx("w-field-group", className), "aria-describedby": hintId },
312
- h("legend", { className: "w-field-label" }, label),
313
- h(GroupContext.Provider, { value: { invalid: Boolean(error) } }, children),
314
- fieldNote(hintId, hint, error),
315
- );
316
- }
317
-
318
- /** A text input. Outside a `Field`, give it a `label`. */
319
- export function TextInput({ label, type = "text", className, ...rest }) {
320
- return h("input", { "aria-label": label, ...useFieldProps(rest), type, className: cx("w-input", className) });
321
- }
322
-
323
- /** A search box. `label` names it for screen readers. */
324
- export function SearchField({ label, className, ...rest }) {
325
- return h("input", { "aria-label": label, ...useFieldProps(rest), type: "search", className: cx("w-search", className) });
326
- }
327
-
328
- /** A select of `options`. `onChange` gets the value picked. */
329
- export function Select({ label, options, onChange, className, ...rest }) {
330
- return h(
331
- "select",
332
- { "aria-label": label, ...useFieldProps(rest), className: cx("w-select", className), onChange: (e) => onChange?.(e.currentTarget.value) },
333
- options.map((o) => h("option", { key: o.value, value: o.value, disabled: o.disabled }, o.label)),
334
- );
335
- }
336
-
337
- /** A checkbox with its label, `children`. `onChange` gets whether it's checked. */
338
- export function Checkbox({ onChange, children, className, ...rest }) {
339
- // In a FieldGroup with an error, each box says so: a group can't be marked invalid itself.
340
- const group = useContext(GroupContext);
341
- return h("label", { className: cx("w-check", className) }, h("input", { "aria-invalid": group?.invalid || undefined, ...rest, type: "checkbox", onChange: (e) => onChange?.(e.currentTarget.checked) }), children);
342
- }
343
-
344
- const contains = (option, query) => {
345
- const q = query.trim().toLowerCase();
346
- return !q || option.label.toLowerCase().includes(q) || Boolean(option.detail?.toLowerCase().includes(q));
347
- };
348
-
349
- /** Closes a raised list when you press the mouse outside `root`, while it's open. */
350
- function useCloseOutside(open, root, close) {
351
- const latest = useRef(close);
352
- latest.current = close;
353
- useEffect(() => {
354
- if (!open) return;
355
- const away = (e) => {
356
- if (!root.current?.contains(e.target)) latest.current();
357
- };
358
- document.addEventListener("mousedown", away);
359
- return () => document.removeEventListener("mousedown", away);
360
- }, [open, root]);
361
- }
362
-
363
- /**
364
- * A text field with `options` under it. Arrows move, Enter picks, Escape and Tab close. With `freeText` the field
365
- * keeps what you type (`value`, `onInput`) and starts with nothing highlighted; otherwise you type to find an
366
- * option, the field shows `value` once you've picked, and the list starts on the first option. `open="whileText"`
367
- * keeps the list shown while the field has text and something matches, which Escape, Tab and a press outside don't close.
368
- */
369
- export function Combobox({ label, value, onInput, options, onPick, onEnter, onKeyDown: onKeyDownProp, freeText = false, openOnFocus = true, open: openMode = "auto", filter = contains, empty = "No matches.", className, ...rest }) {
370
- const field = useFieldProps(rest);
371
- const generated = useId();
372
- const id = field.id ?? generated;
373
- const [opened, setOpened] = useState(false);
374
- const [query, setQuery] = useState("");
375
- const [active, setActive] = useState(-1);
376
- const root = useRef(null);
377
- const list = useRef(null);
378
- const input = useRef(null);
379
- const first = freeText ? -1 : 0;
380
- const text = freeText ? value : query;
381
- // "whileText": the list is open exactly while there is text and something matches it, whatever has focus.
382
- const whileText = openMode === "whileText";
383
- const open = whileText ? text !== "" : opened;
384
- const shown = open ? options.filter((o) => filter(o, text)) : [];
385
- const visible = whileText ? shown.length > 0 : open;
386
- const current = Math.min(active, shown.length - 1);
387
- const optionId = (i) => `${id}-option-${i}`;
388
-
389
- const close = () => {
390
- setOpened(false);
391
- setQuery("");
392
- setActive(-1);
393
- };
394
- // Escape, Tab and a press outside: a "whileText" list stays, so only the highlight goes.
395
- const dismiss = whileText ? () => setActive(-1) : close;
396
- const openAt = (i) => {
397
- setOpened(true);
398
- setActive(i);
399
- };
400
- const pick = (option) => {
401
- onPick(option);
402
- close();
403
- };
404
- useCloseOutside(open && !whileText, root, dismiss);
405
- useLayoutEffect(() => {
406
- if (current >= 0) list.current?.children[current]?.scrollIntoView({ block: "nearest" });
407
- }, [current]);
408
- // A picker shows what was picked while it's closed, so select that text: typing replaces it, as it does in a
409
- // native select-and-type field, instead of landing after it. MUI's Autocomplete does the same (`selectOnFocus`).
410
- useLayoutEffect(() => {
411
- if (!freeText && !open && document.activeElement === input.current) input.current.select();
412
- }, [freeText, open, value]);
413
-
414
- const onKeyDown = (e) => {
415
- onKeyDownProp?.(e);
416
- if (e.defaultPrevented) return;
417
- if (e.key === "ArrowDown") {
418
- e.preventDefault();
419
- // A closed free-text field moves to the first option at once; a closed picker opens on its first.
420
- if (!open) openAt(0);
421
- else setActive(Math.min(current + 1, shown.length - 1));
422
- } else if (e.key === "ArrowUp") {
423
- e.preventDefault();
424
- setActive(Math.max(current - 1, first));
425
- } else if (e.key === "Enter" && open && current >= 0) {
426
- // Picking from the list doesn't also submit a form around it.
427
- e.preventDefault();
428
- pick(shown[current]);
429
- } else if (e.key === "Enter" && onEnter) {
430
- // Nothing highlighted: the text is the answer, and Enter doesn't submit a form around it.
431
- e.preventDefault();
432
- onEnter(e.currentTarget.value);
433
- } else if (e.key === "Escape" || e.key === "Tab") {
434
- // An open list takes the Escape, so a form or dialog around it closes on the next one.
435
- if (e.key === "Escape" && (whileText ? current >= 0 : open)) e.preventDefault();
436
- dismiss();
437
- }
438
- };
439
-
440
- return h(
441
- "div",
442
- { className: cx("w-combobox", className), ref: root },
443
- h("input", {
444
- "aria-label": label,
445
- ...field,
446
- id,
447
- ref: input,
448
- className: "w-input",
449
- role: "combobox",
450
- "aria-autocomplete": "list",
451
- "aria-controls": `${id}-list`,
452
- "aria-expanded": visible,
453
- "aria-activedescendant": current >= 0 ? optionId(current) : undefined,
454
- autoComplete: "off",
455
- spellCheck: false,
456
- value: freeText || !open ? value : query,
457
- onFocus: (e) => {
458
- if (!freeText) e.currentTarget.select();
459
- if (!openOnFocus) return;
460
- if (!freeText) setQuery("");
461
- openAt(first);
462
- },
463
- // A field that kept focus after a pick or Escape opens again when you click it.
464
- onClick: () => {
465
- if (open) return;
466
- if (!freeText) setQuery("");
467
- openAt(first);
468
- },
469
- onChange: (e) => {
470
- if (freeText) onInput?.(e.currentTarget.value);
471
- else setQuery(e.currentTarget.value);
472
- openAt(first);
473
- },
474
- onBlur: (e) => {
475
- if (e.relatedTarget && !root.current.contains(e.relatedTarget)) dismiss();
476
- },
477
- onKeyDown,
478
- }),
479
- h(
480
- "div",
481
- { className: "w-listbox", hidden: !visible },
482
- h(
483
- "ul",
484
- { id: `${id}-list`, role: "listbox", "aria-label": label, ref: list },
485
- shown.map((o, i) =>
486
- h(
487
- "li",
488
- {
489
- key: o.value,
490
- id: optionId(i),
491
- role: "option",
492
- className: "w-option",
493
- "aria-selected": i === current,
494
- // Before the field loses focus, so the pick lands.
495
- onMouseDown: (e) => {
496
- e.preventDefault();
497
- pick(o);
498
- },
499
- onMouseMove: () => i !== current && setActive(i),
500
- },
501
- h("span", { className: "w-option-label" }, o.label),
502
- o.detail ? h("span", { className: "w-option-detail" }, o.detail) : null,
503
- ),
504
- ),
505
- ),
506
- open && !whileText && !shown.length ? h("p", { className: "w-option-empty", role: "status" }, empty) : null,
507
- ),
508
- );
509
- }
510
-
511
- /**
512
- * A button that opens a menu of `items` under it, each `{ id, label, onSelect, tone? }`. Arrows, Home and End move,
513
- * Enter or Space picks, Escape closes and puts focus back on the button. `align` end lines it up on the right.
514
- */
515
- export function Menu({ label, items, align, variant, size, className, ...rest }) {
516
- const id = useId();
517
- const [open, setOpen] = useState(false);
518
- const root = useRef(null);
519
- const button = useRef(null);
520
- const entries = useRef([]);
521
- const close = (refocus) => {
522
- setOpen(false);
523
- if (refocus) button.current?.focus();
524
- };
525
- useCloseOutside(open, root, () => close(false));
526
- useEffect(() => {
527
- if (open) entries.current[0]?.focus();
528
- }, [open]);
529
- const select = (item) => {
530
- close(true);
531
- item.onSelect();
532
- };
533
- const onKeyDown = (e) => {
534
- const at = entries.current.indexOf(document.activeElement);
535
- const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: items.length - 1 }[e.key];
536
- if (next !== undefined) {
537
- e.preventDefault();
538
- entries.current[(next + items.length) % items.length]?.focus();
539
- } else if (e.key === "Enter" || e.key === " ") {
540
- e.preventDefault();
541
- if (at >= 0) select(items[at]);
542
- } else if (e.key === "Escape") {
543
- e.preventDefault();
544
- close(true);
545
- } else if (e.key === "Tab") close(false);
546
- };
547
- return h(
548
- "div",
549
- { className: cx("w-menu-root", className), ref: root },
550
- h(
551
- "button",
552
- {
553
- ...rest,
554
- type: "button",
555
- id: `${id}-button`,
556
- ref: button,
557
- className: "w-button",
558
- "data-variant": variant,
559
- "data-size": size,
560
- "aria-haspopup": "menu",
561
- "aria-expanded": open,
562
- "aria-controls": open ? `${id}-menu` : undefined,
563
- onClick: () => setOpen(!open),
564
- onKeyDown: (e) => {
565
- if (e.key === "ArrowDown" && !open) {
566
- e.preventDefault();
567
- setOpen(true);
568
- }
569
- },
570
- },
571
- label,
572
- ),
573
- open
574
- ? h(
575
- "ul",
576
- { id: `${id}-menu`, role: "menu", className: "w-menu", "aria-labelledby": `${id}-button`, "data-align": align, onKeyDown },
577
- items.map((item, i) =>
578
- h(
579
- "li",
580
- {
581
- key: item.id,
582
- role: "menuitem",
583
- tabIndex: -1,
584
- className: "w-menu-item",
585
- "data-tone": item.tone,
586
- ref: (el) => (entries.current[i] = el),
587
- onClick: () => select(item),
588
- onMouseMove: (e) => e.currentTarget.focus(),
589
- },
590
- item.label,
591
- ),
592
- ),
593
- )
594
- : null,
595
- );
596
- }
597
-
598
- /** More behind a `summary` line that opens it. */
599
- export function Disclosure({ summary, children, className, ...rest }) {
600
- return h("details", { ...rest, className: cx("w-disclosure", className) }, h("summary", null, summary), children);
601
- }