wings-plugin-ui 0.0.0-stage → 0.1.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.
Files changed (6) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +179 -2
  3. package/package.json +44 -4
  4. package/plugin-ui.css +1292 -0
  5. package/ui.d.ts +277 -0
  6. package/ui.js +583 -0
package/ui.js ADDED
@@ -0,0 +1,583 @@
1
+ // wings-plugin-ui: a page says what to show and where, and Wings decides how it looks. Every component renders
2
+ // `w-*` class names and data attributes that Wings' plugin-ui.css styles, never a style of its own, so a Wings
3
+ // update restyles every plugin. The interactive ones are shadcn/ui's choice, Base UI's components, so a page behaves
4
+ // like Wings itself. Your own components and CSS work alongside them.
5
+ import { Button as BaseButton } from "@base-ui/react/button";
6
+ import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";
7
+ import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible";
8
+ import { Combobox as BaseCombobox } from "@base-ui/react/combobox";
9
+ import { Field as BaseField } from "@base-ui/react/field";
10
+ import { Fieldset as BaseFieldset } from "@base-ui/react/fieldset";
11
+ import { Input as BaseInput } from "@base-ui/react/input";
12
+ import { Menu as BaseMenu } from "@base-ui/react/menu";
13
+ import { Select as BaseSelect } from "@base-ui/react/select";
14
+ import { Tabs as BaseTabs } from "@base-ui/react/tabs";
15
+ import { Toggle as BaseToggle } from "@base-ui/react/toggle";
16
+ import { ToggleGroup as BaseToggleGroup } from "@base-ui/react/toggle-group";
17
+ import { createContext, createElement as h, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
18
+ import { createRoot } from "react-dom/client";
19
+
20
+ import { mountShell } from "wings-plugin-sdk/shell";
21
+
22
+ // ---------- places ----------
23
+
24
+ let root = null;
25
+
26
+ function show(place, element, options) {
27
+ const shell = mountShell(place, options);
28
+ root ??= createRoot(shell);
29
+ root.render(element);
30
+ }
31
+
32
+ /** Shows `element` as the page of a sidebar. One call per page; calling again renders the new element. */
33
+ export const showSidebar = (element, options) => show("sidebar", element, options);
34
+ /** Shows `element` as a view's page, in a modal or a window of its own. */
35
+ export const showView = (element, options) => show("view", element, options);
36
+ /** Shows `element` as a view's page in a tab next to the terminals. */
37
+ export const showTab = (element, options) => show("tab", element, options);
38
+ /** Shows `element` as a sidebar's popover. */
39
+ export const showPopover = (element, options) => show("popover", element, options);
40
+ /** Shows `element` as the plugin's page in Wings Settings. */
41
+ export const showSettings = (element, options) => show("settings", element, options);
42
+
43
+ // ---------- talking to main ----------
44
+
45
+ const HELLO = { type: "hello" };
46
+
47
+ /**
48
+ * The newest message of `type` the plugin's other pages broadcast, or null until one comes. Sends `hello` once
49
+ * when the component mounts, so main answers with its state; pass null to send nothing.
50
+ */
51
+ export function useMainState(type, hello = HELLO) {
52
+ const [state, setState] = useState(null);
53
+ useEffect(() => {
54
+ const off = wings.onBroadcast((message) => {
55
+ if (message?.type === type) setState(message);
56
+ });
57
+ if (hello != null) void wings.broadcast(hello);
58
+ return off;
59
+ // `hello` is only sent when the component mounts.
60
+ }, [type]);
61
+ return state;
62
+ }
63
+
64
+ // ---------- helpers ----------
65
+
66
+ const cx = (base, extra) => (extra ? `${base} ${extra}` : base);
67
+ /** A data attribute that's there or not. */
68
+ const flag = (on) => (on ? "" : undefined);
69
+
70
+ // ---------- layout and text ----------
71
+
72
+ /** Children side by side, centred. `gap` is none, xs, s, m (default) or l. */
73
+ export function Row({ as = "div", gap, align, justify, wrap, grow, className, ...rest }) {
74
+ 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) });
75
+ }
76
+
77
+ /** Children one under the other. */
78
+ export function Stack({ as = "div", gap, grow, className, ...rest }) {
79
+ return h(as, { ...rest, className: cx("w-stack", className), "data-gap": gap, "data-grow": flag(grow) });
80
+ }
81
+
82
+ /** Text with a tone and size, that can grow to fill a row or truncate with an ellipsis. */
83
+ export function Text({ as = "span", tone, size, weight, mono, numeric, truncate, clamp, grow, className, ...rest }) {
84
+ return h(as, {
85
+ ...rest,
86
+ className: cx("w-text", className),
87
+ "data-tone": tone,
88
+ "data-size": size,
89
+ "data-weight": weight,
90
+ "data-mono": flag(mono),
91
+ "data-numeric": flag(numeric),
92
+ "data-truncate": flag(truncate),
93
+ "data-clamp": flag(clamp),
94
+ "data-grow": flag(grow),
95
+ });
96
+ }
97
+
98
+ /** A page's or a card's title: an `h1` unless `as` says otherwise. `size` large for a bigger one. `truncate` for one line. */
99
+ export function Title({ as = "h1", size, truncate, className, ...rest }) {
100
+ return h(as, { ...rest, className: cx("w-title", className), "data-size": size, "data-truncate": flag(truncate) });
101
+ }
102
+
103
+ /** A small section heading, `h2` unless `level` says 3 or 4, with `actions` like chips or a button on its right. */
104
+ export function Heading({ level = 2, actions, className, ...rest }) {
105
+ const heading = h(`h${level}`, { ...rest, className: cx("w-heading", className) });
106
+ return actions ? h("div", { className: "w-heading-row" }, heading, actions) : heading;
107
+ }
108
+
109
+ /** A card. `compact` for a summary line. */
110
+ export function Card({ as = "div", compact, className, ...rest }) {
111
+ return h(as, { ...rest, className: cx("w-card", className), "data-size": compact ? "compact" : undefined });
112
+ }
113
+
114
+ // ---------- figures ----------
115
+
116
+ const levelTone = (percent, levels) => (!levels ? undefined : percent >= levels[1] ? "danger" : percent >= levels[0] ? "warn" : undefined);
117
+
118
+ /** A bar from 0 to 100. `tone` is warn or danger. */
119
+ export function Meter({ percent, label, tone, className, ...rest }) {
120
+ return h(
121
+ "div",
122
+ { ...rest, className: cx("w-meter", className), role: "meter", "aria-label": label, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "data-tone": tone },
123
+ // How full it is is data, not a style: plugin-ui.css sizes the bar from it.
124
+ h("span", { className: "w-meter-fill", style: { "--w-value": `${Math.max(0, Math.min(100, percent))}%` } }),
125
+ );
126
+ }
127
+
128
+ /** A labelled bar with its percentage and a note under it. `levels` are where it turns warn and danger. */
129
+ export function Gauge({ label, percent, note, tone, levels, className, ...rest }) {
130
+ return h(
131
+ "div",
132
+ { ...rest, className: cx("w-gauge", className) },
133
+ h("span", { className: "w-gauge-label" }, label),
134
+ h("span", { className: "w-gauge-value" }, `${percent}%`),
135
+ h(Meter, { percent, label, tone: tone ?? levelTone(percent, levels) }),
136
+ note ? h("span", { className: "w-gauge-note" }, note) : null,
137
+ );
138
+ }
139
+
140
+ /** Small figures side by side, label over value. */
141
+ export function Stats({ items, className, ...rest }) {
142
+ return h(
143
+ "dl",
144
+ { ...rest, className: cx("w-stats", className) },
145
+ 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))),
146
+ );
147
+ }
148
+
149
+ /** Label and value pairs, one per line. */
150
+ export function Properties({ items, className, ...rest }) {
151
+ return h(
152
+ "dl",
153
+ { ...rest, className: cx("w-props", className) },
154
+ 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))),
155
+ );
156
+ }
157
+
158
+ // ---------- lists ----------
159
+
160
+ /** A list of rows. `ordered` for an `ol`, `compact` for rows with no space between them. */
161
+ export function List({ ordered, compact, className, ...rest }) {
162
+ return h(ordered ? "ol" : "ul", { ...rest, className: cx("w-list", className), "data-density": compact ? "compact" : undefined });
163
+ }
164
+
165
+ /** A row in a `List`. Holding a `ListButton`, the button is the row. */
166
+ export function ListItem({ align, className, ...rest }) {
167
+ return h("li", { ...rest, className: cx("w-list-item", className), "data-align": align });
168
+ }
169
+
170
+ /** A row that's a button, inside a `ListItem`. */
171
+ export function ListButton({ align, type = "button", className, ...rest }) {
172
+ return h("button", { ...rest, type, className: cx("w-list-button", className), "data-align": align });
173
+ }
174
+
175
+
176
+ // ---------- status ----------
177
+
178
+ /**
179
+ * A status dot. With a `label` it's announced; otherwise it's decoration next to text that says the same.
180
+ * `color` is a colour from your data, like a work item type's; otherwise `tone` colours it.
181
+ */
182
+ export function Dot({ tone, color, label, className, style, ...rest }) {
183
+ return h("span", {
184
+ ...rest,
185
+ className: cx("w-dot", className),
186
+ // A colour that comes with the data, not a style: plugin-ui.css paints the dot with it, over any tone.
187
+ style: color ? { ...style, "--w-color": color } : style,
188
+ "data-color": color ? "" : undefined,
189
+ "data-tone": color ? undefined : tone,
190
+ ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }),
191
+ });
192
+ }
193
+
194
+ /** A spinner. With a `label` it's announced. */
195
+ export function Spinner({ size, label, className, ...rest }) {
196
+ return h("span", { ...rest, className: cx("w-spinner", className), "data-size": size, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
197
+ }
198
+
199
+ /**
200
+ * A badge: `variant` is default, secondary, destructive, outline, ghost or link, like shadcn/ui's. `tone` is Wings'
201
+ * own addition, a colour that says something.
202
+ */
203
+ export function Badge({ variant = "default", tone, className, ...rest }) {
204
+ return h("span", { ...rest, className: cx("w-badge", className), "data-variant": variant, "data-tone": tone });
205
+ }
206
+
207
+ /** What shows before the first data comes. */
208
+ export function Loading({ children = "Loading…", className, ...rest }) {
209
+ return h("p", { ...rest, className: cx("w-loading", className) }, children);
210
+ }
211
+
212
+ /** Nothing to show: a `title`, a `description` on what to do about it, and `children`, like a button, under them. */
213
+ export function Empty({ title, description, children, className, ...rest }) {
214
+ return h(
215
+ "div",
216
+ { ...rest, className: cx("w-empty", className) },
217
+ h("div", { className: "w-empty-header" }, h("p", { className: "w-empty-title" }, title), description === undefined ? null : h("p", { className: "w-empty-description" }, description)),
218
+ children,
219
+ );
220
+ }
221
+
222
+ // ---------- buttons ----------
223
+
224
+ /**
225
+ * A button on Base UI's Button. `variant` is default (filled), secondary, outline, ghost, destructive or link; `size`
226
+ * default, xs, sm, lg, icon, icon-xs, icon-sm or icon-lg, like shadcn/ui's. `busy` shows a spinner and ignores clicks while the
227
+ * button keeps the keyboard focus.
228
+ */
229
+ export function Button({ variant = "default", size = "default", busy, disabled, type = "button", children, className, ...rest }) {
230
+ return h(
231
+ BaseButton,
232
+ {
233
+ ...rest,
234
+ type,
235
+ className: cx("w-button", className),
236
+ "data-variant": variant,
237
+ "data-size": size,
238
+ // Busy, not disabled: a disabled button would drop the keyboard focus on the page. Base UI's disabled button that
239
+ // stays focusable is aria-disabled and ignores clicks.
240
+ disabled: busy || disabled,
241
+ focusableWhenDisabled: busy,
242
+ "aria-busy": busy || undefined,
243
+ },
244
+ busy ? h(Spinner) : null,
245
+ children,
246
+ );
247
+ }
248
+
249
+ /** A small quiet button. With `pressed` it's a toggle. */
250
+ export function Chip({ pressed, type = "button", className, ...rest }) {
251
+ return pressed === undefined
252
+ ? h(BaseButton, { ...rest, type, className: cx("w-chip", className) })
253
+ : h(BaseToggle, { ...rest, type, pressed, className: cx("w-chip", className) });
254
+ }
255
+
256
+ /** Chips that pick one of `options`, like a filter. `onChange` gets each click, the chip that's on too. */
257
+ export function Chips({ label, options, value, onChange, className, ...rest }) {
258
+ return h(
259
+ BaseToggleGroup,
260
+ {
261
+ ...rest,
262
+ className: cx("w-chips", className),
263
+ "aria-label": label,
264
+ value: [value],
265
+ // A group of one: pressing the chip that's on turns it off, which is the click on `value` again.
266
+ onValueChange: (next) => onChange(next[0] ?? value),
267
+ },
268
+ options.map(({ value: v, label: text, ...chip }) => h(BaseToggle, { ...chip, key: v, value: v, className: "w-chip" }, text)),
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Tabs over the panel they show, `children`, on Base UI's Tabs. Arrow keys, Home and End move between tabs and
274
+ * show the tab they land on. `variant` is default (a pill) or line (an underline), like shadcn/ui's.
275
+ */
276
+ export function Tabs({ label, items, value, onChange, variant = "default", children, className, ...rest }) {
277
+ return h(
278
+ BaseTabs.Root,
279
+ { ...rest, className: cx("w-tabs-root", className), value, onValueChange: onChange },
280
+ h(
281
+ BaseTabs.List,
282
+ { className: "w-tabs", "aria-label": label, "data-variant": variant, activateOnFocus: true },
283
+ items.map((t) => h(BaseTabs.Tab, { key: t.value, value: t.value, className: "w-tab", title: t.title }, t.label)),
284
+ ),
285
+ h(BaseTabs.Panel, { value, className: "w-tab-panel" }, children),
286
+ );
287
+ }
288
+
289
+ // ---------- fields ----------
290
+
291
+ /** One of Lucide's icons, as the app's own components draw them. */
292
+ const icon = (path, className) => h("svg", { className, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, h("path", { d: path }));
293
+ const CHEVRON_DOWN = "m6 9 6 6 6-6";
294
+ const CHECK = "M20 6 9 17l-5-5";
295
+
296
+ const FieldIdContext = createContext(undefined);
297
+ const GroupContext = createContext(null);
298
+
299
+ /** A hint or an error under a group. */
300
+ const fieldNote = (id, hint, error) => (error ?? hint ? h("p", { id, className: "w-field-hint", "data-tone": error ? "danger" : undefined }, error ?? hint) : null);
301
+
302
+ /**
303
+ * A labelled field with a `hint` or an `error` under it, for the input in `children`, on Base UI's Field: the label,
304
+ * the hint and the invalid state reach the input by themselves. `id` is the input's id, for a page that puts focus
305
+ * back by id; `inline` puts the label and the input on one line.
306
+ */
307
+ export function Field({ id: given, label, hint, error, inline, children, className, ...rest }) {
308
+ const generated = useId();
309
+ // The label is tied to the input by Base UI: the input registers its id, the one you gave or one it makes.
310
+ const labelled = h(BaseField.Label, { className: "w-field-label" }, label);
311
+ const control = h(FieldIdContext.Provider, { value: given }, children);
312
+ const hintId = `${given ?? generated}-hint`;
313
+ return h(
314
+ BaseField.Root,
315
+ { ...rest, className: cx("w-field", className), invalid: Boolean(error), "data-layout": inline ? "inline" : undefined },
316
+ inline ? h("div", { className: "w-field-row" }, labelled, control) : labelled,
317
+ inline ? null : control,
318
+ error ? h(BaseField.Error, { id: hintId, match: true, className: "w-field-hint", "data-tone": "danger" }, error) : hint ? h(BaseField.Description, { id: hintId, className: "w-field-hint" }, hint) : null,
319
+ );
320
+ }
321
+
322
+ /** Several controls under one label, like a list of checkboxes, with a `hint` or an `error` under them. */
323
+ export function FieldGroup({ label, hint, error, children, className, ...rest }) {
324
+ const id = useId();
325
+ const hintId = error ?? hint ? `${id}-hint` : undefined;
326
+ return h(
327
+ BaseFieldset.Root,
328
+ { ...rest, className: cx("w-field-group", className), "aria-describedby": hintId },
329
+ h(BaseFieldset.Legend, { className: "w-field-label" }, label),
330
+ h(GroupContext.Provider, { value: { invalid: Boolean(error) } }, children),
331
+ fieldNote(hintId, hint, error),
332
+ );
333
+ }
334
+
335
+ /** The id a `Field` gives its input, unless the input has its own. */
336
+ function useFieldId(id) {
337
+ const field = useContext(FieldIdContext);
338
+ return id ?? field;
339
+ }
340
+
341
+ /** A text input. Outside a `Field`, give it a `label`. */
342
+ export function TextInput({ label, type = "text", id, className, ...rest }) {
343
+ return h(BaseInput, { "aria-label": label, ...rest, id: useFieldId(id), type, className: cx("w-input", className) });
344
+ }
345
+
346
+ /** A search box. `label` names it for screen readers. */
347
+ export function SearchField({ label, id, className, ...rest }) {
348
+ return h(BaseInput, { "aria-label": label, ...rest, id: useFieldId(id), type: "search", className: cx("w-input w-search", className) });
349
+ }
350
+
351
+ /** A select of `options` on Base UI's Select, its list a popup in the page. `onChange` gets the value picked. */
352
+ export function Select({ label, options, value, defaultValue, onChange, disabled, name, required, form, id, className, ...rest }) {
353
+ return h(
354
+ BaseSelect.Root,
355
+ {
356
+ items: options,
357
+ // Like a native select, one without a `value` starts on its first option.
358
+ ...(value === undefined ? { defaultValue: defaultValue ?? options[0]?.value ?? null } : { value }),
359
+ onValueChange: (next, details) => {
360
+ // Base UI clears a pick whose option left the list, but checks against the value from before the render, so
361
+ // a page that picks and drops an option (a "Choose…" row) at once would get null for a value still listed.
362
+ if (next === null && details.reason === "none" && options.some((o) => o.value === value)) return;
363
+ onChange?.(next);
364
+ },
365
+ disabled,
366
+ name,
367
+ required,
368
+ form,
369
+ },
370
+ h(
371
+ BaseSelect.Trigger,
372
+ { "aria-label": label, ...rest, id: useFieldId(id), className: cx("w-select", className) },
373
+ h(BaseSelect.Value, { className: "w-select-value" }),
374
+ h(BaseSelect.Icon, { render: icon(CHEVRON_DOWN, "w-select-icon") }),
375
+ ),
376
+ h(
377
+ BaseSelect.Portal,
378
+ null,
379
+ h(
380
+ BaseSelect.Positioner,
381
+ { className: "w-positioner", sideOffset: 4 },
382
+ h(
383
+ BaseSelect.Popup,
384
+ { className: "w-popup w-select-popup" },
385
+ h(
386
+ BaseSelect.List,
387
+ null,
388
+ options.map((o) =>
389
+ h(BaseSelect.Item, { key: o.value, value: o.value, label: o.label, disabled: o.disabled, className: "w-select-item" }, h(BaseSelect.ItemText, null, o.label), h(BaseSelect.ItemIndicator, { className: "w-item-check", render: h("span") }, icon(CHECK))),
390
+ ),
391
+ ),
392
+ ),
393
+ ),
394
+ ),
395
+ );
396
+ }
397
+
398
+ /** A checkbox with its label, `children`, on Base UI's Checkbox. `onChange` gets whether it's checked. */
399
+ export function Checkbox({ onChange, children, className, id, ...rest }) {
400
+ // In a FieldGroup with an error, each box says so: a group can't be marked invalid itself.
401
+ const group = useContext(GroupContext);
402
+ return h(
403
+ "label",
404
+ { className: cx("w-check", className) },
405
+ // A button, so `id` is the one you can focus: on Base UI's default span it names the hidden input.
406
+ h(BaseCheckbox.Root, { "aria-invalid": group?.invalid || undefined, ...rest, id: useFieldId(id), nativeButton: true, render: h("button", { type: "button" }), onCheckedChange: onChange && ((checked) => onChange(checked)), className: "w-checkbox" }, h(BaseCheckbox.Indicator, { className: "w-checkbox-indicator", render: h("span") }, icon(CHECK))),
407
+ children,
408
+ );
409
+ }
410
+
411
+ const contains = (option, query) => {
412
+ const q = query.trim().toLowerCase();
413
+ return !q || option.label.toLowerCase().includes(q) || Boolean(option.detail?.toLowerCase().includes(q));
414
+ };
415
+
416
+ /**
417
+ * A text field with `options` under it, on Base UI's Combobox. Arrows move, Enter picks, Escape and Tab close. With
418
+ * `freeText` the field keeps what you type (`value`, `onInput`) and starts with nothing highlighted; otherwise you
419
+ * type to find an option, the field shows `value` once you've picked, and the list starts on the first option.
420
+ * `open="whileText"` keeps the list shown while the field has text and something matches, which Escape, Tab and a
421
+ * press outside don't close: they only drop the highlight.
422
+ */
423
+ export function Combobox({ label, value, onInput, options, onPick, onEnter, onKeyDown: onKeyDownProp, freeText = false, openOnFocus = true, open: openMode = "auto", filter = contains, empty = "No matches.", className, id: given, ...rest }) {
424
+ const id = useFieldId(given);
425
+ const [opened, setOpened] = useState(false);
426
+ const [query, setQuery] = useState("");
427
+ // Whether the list shuts for a moment to let go of its highlight, see `dismiss`.
428
+ const [letGo, setLetGo] = useState(false);
429
+ const input = useRef(null);
430
+ const highlighted = useRef(false);
431
+ const text = freeText ? value : query;
432
+ // "whileText": the list is open exactly while there is text and something matches it, whatever has focus.
433
+ const whileText = openMode === "whileText";
434
+ const open = whileText ? text !== "" : opened;
435
+ const shown = open ? options.filter((o) => filter(o, text)) : [];
436
+ const visible = whileText ? shown.length > 0 : open;
437
+
438
+ const close = () => {
439
+ setOpened(false);
440
+ setQuery("");
441
+ };
442
+ // Base UI keeps its highlight until the list closes, and offers no other way to drop it. A list that stays shown
443
+ // closes and opens again within one render, with no exit animation in between, which is how it lets go.
444
+ const dismiss = () => whileText && setLetGo(true);
445
+ useLayoutEffect(() => {
446
+ if (letGo) setLetGo(false);
447
+ }, [letGo]);
448
+ // A picker shows what was picked while it's closed, so select that text: typing replaces it, as it does in a
449
+ // native select-and-type field, instead of landing after it. MUI's Autocomplete does the same (`selectOnFocus`).
450
+ useLayoutEffect(() => {
451
+ if (!freeText && !open && document.activeElement === input.current) input.current.select();
452
+ }, [freeText, open, value]);
453
+
454
+ const onKeyDown = (e) => {
455
+ onKeyDownProp?.(e);
456
+ // The consumer's key, handled: Base UI's own handlers for it don't run.
457
+ if (e.defaultPrevented) return e.preventBaseUIHandler();
458
+ if (e.key === "Enter" && !highlighted.current && onEnter) {
459
+ // Nothing highlighted: the text is the answer, and Enter doesn't submit a form around it.
460
+ e.preventDefault();
461
+ e.preventBaseUIHandler();
462
+ onEnter(e.currentTarget.value);
463
+ } else if (whileText && e.key === "Escape" && highlighted.current) {
464
+ // The list stays, but an Escape that drops the highlight is taken, so a form or dialog around closes on the next one.
465
+ e.preventDefault();
466
+ }
467
+ };
468
+
469
+ return h(
470
+ "div",
471
+ { className: cx("w-combobox", className) },
472
+ h(
473
+ BaseCombobox.Root,
474
+ {
475
+ items: shown,
476
+ // The text is filtered here, by `filter`.
477
+ filter: null,
478
+ // Nothing is selected in Base UI's terms: a pick goes to `onPick`, and `value` is the field's text.
479
+ value: null,
480
+ onValueChange: (option) => {
481
+ if (!option) return;
482
+ onPick(option);
483
+ close();
484
+ },
485
+ inputValue: freeText || !open ? value : query,
486
+ onInputValueChange: (next, details) => {
487
+ if (details.reason !== "input-change") return;
488
+ if (freeText) onInput?.(next);
489
+ else setQuery(next);
490
+ },
491
+ open: visible && !letGo,
492
+ onOpenChange: (next, details) => {
493
+ if (whileText) {
494
+ // Escape, Tab and a press outside: the list stays, and it's left to the page when nothing was highlighted.
495
+ details.cancel();
496
+ if (!next) dismiss();
497
+ return;
498
+ }
499
+ if (next && !freeText && details.reason !== "input-change") setQuery("");
500
+ if (next) setOpened(true);
501
+ else close();
502
+ },
503
+ onItemHighlighted: (option) => (highlighted.current = option !== undefined),
504
+ itemToStringLabel: (option) => option.label,
505
+ autoHighlight: freeText ? false : "always",
506
+ },
507
+ h(BaseCombobox.Input, {
508
+ "aria-label": label,
509
+ ...rest,
510
+ id,
511
+ ref: input,
512
+ className: "w-input",
513
+ spellCheck: false,
514
+ onFocus: (e) => {
515
+ if (!freeText) e.currentTarget.select();
516
+ if (!openOnFocus || whileText) return;
517
+ if (!freeText) setQuery("");
518
+ setOpened(true);
519
+ },
520
+ onKeyDown,
521
+ }),
522
+ h(
523
+ BaseCombobox.Portal,
524
+ null,
525
+ h(
526
+ BaseCombobox.Positioner,
527
+ { className: "w-positioner", sideOffset: 4 },
528
+ h(
529
+ BaseCombobox.Popup,
530
+ { className: "w-popup w-combobox-popup", "aria-label": label },
531
+ h(BaseCombobox.Empty, { className: "w-option-empty" }, empty),
532
+ h(
533
+ BaseCombobox.List,
534
+ null,
535
+ (o) =>
536
+ h(BaseCombobox.Item, { key: o.value, value: o, className: "w-option" }, h("span", { className: "w-option-label" }, o.label), o.detail ? h("span", { className: "w-option-detail" }, o.detail) : null),
537
+ ),
538
+ ),
539
+ ),
540
+ ),
541
+ ),
542
+ );
543
+ }
544
+
545
+ /**
546
+ * A button that opens a menu of `items` under it on Base UI's Menu, each `{ id, label, onSelect, variant? }`. Arrows,
547
+ * Home and End move, Enter or Space picks, Escape closes and puts focus back on the button. `align` end lines it up
548
+ * on the right.
549
+ */
550
+ export function Menu({ label, items, align, variant = "default", size = "default", className, ...rest }) {
551
+ return h(
552
+ "div",
553
+ { className: cx("w-menu-root", className) },
554
+ h(
555
+ BaseMenu.Root,
556
+ null,
557
+ h(BaseMenu.Trigger, { ...rest, className: "w-button", "data-variant": variant, "data-size": size }, label),
558
+ h(
559
+ BaseMenu.Portal,
560
+ null,
561
+ h(
562
+ BaseMenu.Positioner,
563
+ { className: "w-positioner", align: align === "end" ? "end" : "start", sideOffset: 4 },
564
+ h(
565
+ BaseMenu.Popup,
566
+ { className: "w-popup w-menu-popup" },
567
+ items.map((item) => h(BaseMenu.Item, { key: item.id, className: "w-menu-item", "data-variant": item.variant ?? "default", onClick: item.onSelect }, item.label)),
568
+ ),
569
+ ),
570
+ ),
571
+ ),
572
+ );
573
+ }
574
+
575
+ /** More behind a `summary` line that opens it, on Base UI's Collapsible. */
576
+ export function Disclosure({ summary, open, defaultOpen, onOpenChange, children, className, ...rest }) {
577
+ return h(
578
+ BaseCollapsible.Root,
579
+ { ...rest, className: cx("w-disclosure", className), open, defaultOpen, onOpenChange: onOpenChange && ((next) => onOpenChange(next)) },
580
+ h(BaseCollapsible.Trigger, { className: "w-disclosure-trigger" }, summary),
581
+ h(BaseCollapsible.Panel, { className: "w-disclosure-panel" }, children),
582
+ );
583
+ }