wings-plugin-sdk 0.2.0 → 0.4.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.
Files changed (5) hide show
  1. package/README.md +42 -20
  2. package/package.json +9 -2
  3. package/ui-css.js +166 -4
  4. package/ui.d.ts +98 -12
  5. package/ui.js +322 -19
package/README.md CHANGED
@@ -106,7 +106,8 @@ the page out edge to edge with no padding, for a page that scrolls on its own, l
106
106
  You don't write the page's HTML. For `src/sidebar.tsx` that calls one of these, `wings-plugin build` writes
107
107
  `sidebar.html` with a mount point, its CSS and its script, titled like the manifest's page. A `sidebar.html` you
108
108
  write yourself wins: the build only writes a page that's missing or that it wrote before (it carries a
109
- `Generated by wings-plugin build` line). A page that builds its DOM itself, without React, gets the same shell
109
+ `Generated by wings-plugin build` line). A popover's page takes its sidebar's title, the name Wings gives the
110
+ popover too. A page that builds its DOM itself, without React, gets the same shell
110
111
  from `mountShell(place, options)` in `wings-plugin-sdk/shell`, which returns the element to fill.
111
112
 
112
113
  ### Talking to main
@@ -119,28 +120,41 @@ component mounts, so main answers with what it has. `useBroadcast(listener)` get
119
120
 
120
121
  | Component | Props | Renders |
121
122
  |---|---|---|
122
- | `Row`, `Stack` | `gap` (`none`, `xs`, `s`, `m`, `l`), `grow`; `Row` also `align`, `justify`, `wrap`; `as` | Children side by side or one under the other |
123
+ | `Row`, `Stack` | `gap` (`none`, `xs`, `s`, `m`, `l`), `grow`; `Row` also `align`, `justify`, `wrap`; `as` (`form`, `ul`, `ol` too) | Children side by side or one under the other; as a `ul` or `ol`, a list with no bullets. With `as`, the props and handlers are the element's: `<Row as="form" onSubmit={(e) => e.currentTarget.requestSubmit()}>` has a `HTMLFormElement` |
123
124
  | `Text` | `tone` (`muted`, `info`, `success`, `warn`, `danger`), `size` (`small`, `tiny`), `weight` (`medium`, `strong`), `mono`, `numeric`, `truncate`, `clamp`, `grow`, `as` | A span, or the element in `as` |
124
- | `Heading` | `actions` | A section heading, `h2`, with chips or a button on its right |
125
- | `Card` | `compact` | A card |
125
+ | `Title` | `as` (`h1`, `h2`, `h3`), `size` (`large`), `truncate` | A page's or a card's title, like a sprint's name or a setup step's question |
126
+ | `Heading` | `level` (2, 3, 4), `actions` | A small section heading, `h2` by default, with chips or a button on its right |
127
+ | `Card` | `compact`, `as` (`form`, `ul`, `ol` too) | A card, a card that's a form, or a list. Typed for its `as` element like `Row` and `Stack` |
126
128
  | `Gauge`, `Meter` | `label`, `percent`, `note`, `tone` or `levels` | A labelled bar with its percentage, or the bar alone, with the meter role |
127
129
  | `Stats` | `items: { label, value, tone? }[]` | Small figures side by side, label over value |
128
130
  | `Properties` | `items: { label, value }[]` | Label and value pairs |
129
131
  | `List`, `ListItem`, `ListButton` | `ordered` and `compact` on `List`, `align` on the rows | A list of rows; a `ListButton` in a `ListItem` makes the row a button |
130
- | `Dot`, `Spinner` | `tone`, `size`, `label` | A status dot or a spinner, announced only with a `label` |
132
+ | `Dot`, `Spinner` | `tone`, `size`, `label`; `Dot` also `color` | A status dot or a spinner, announced only with a `label`. A dot's `color` comes from your data, like a work item type's `#cc293d` |
131
133
  | `Pill`, `Tag` | `tone` | A filled pill, or an outlined tag like a branch |
132
134
  | `Loading`, `Empty` | `Empty`: `title`, `detail` | Before the first data, and nothing to show |
133
- | `Button` | `variant` (`primary`, `ghost`, `danger`), `size` (`small`) | A button, `type="button"` |
134
- | `Chip`, `Chips` | `pressed`; `Chips`: `label`, `options`, `value`, `onChange` | A small quiet button, and a group of them that picks one, with `aria-pressed` |
135
+ | `Button` | `variant` (`primary`, `ghost`, `danger`), `size` (`small`), `busy` | A button, `type="button"`; `busy` shows a spinner and ignores clicks, keeping focus |
136
+ | `Chip`, `Chips` | `pressed`; `Chips`: `label`, `options`, `value`, `onChange` | A small quiet button, and a group of them that picks one, with `aria-pressed`. An option's other props, like `id`, go to its chip |
135
137
  | `Tabs` | `label`, `items`, `value`, `onChange`, `children` | Tabs with arrow keys, Home and End, over their panel |
136
- | `Field` | `label`, `hint`, `error` | A labelled field that names and describes the input in it |
138
+ | `Field` | `label`, `hint`, `error`, `id`, `inline` | A labelled field that names and describes the input in it. `id` becomes the input's, for a page that puts focus back by id; `inline` puts the label beside it |
139
+ | `FieldGroup` | `label`, `hint`, `error` | Several controls under one label, like checkboxes: a `fieldset` with its `legend`, described by the hint; an error marks each checkbox invalid |
137
140
  | `TextInput`, `SearchField`, `Select` | `label`; `Select`: `options`, `value`, `onChange` | Inputs Wings styles; outside a `Field`, `label` names them |
138
141
  | `Checkbox` | `checked`, `onChange`, `children` | A checkbox with its label |
142
+ | `Combobox` | `label`, `value`, `onInput`, `options: { value, label, detail? }[]`, `onPick`, `onEnter`, `onKeyDown`, `freeText`, `openOnFocus`, `filter`, `empty` | A text field with suggestions under it: arrows, Enter, Escape and Tab, and a press outside closes it. With `freeText` it keeps what you type; otherwise it picks one of `options`. `openOnFocus={false}` for a page that puts focus back into it after a render. Without `freeText` the picked label is selected when the field gets focus and after a pick, so typing replaces it. `onEnter(text)` runs on Enter when no option is highlighted, with the typed text, and stops the Enter from submitting a form around it. An `onKeyDown` you pass runs first; `e.preventDefault()` in it skips the combobox's own keys. ArrowDown on a closed combobox opens it and highlights the first option, as in the [WAI-ARIA APG combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) |
143
+ | `Menu` | `label`, `items: { id, label, onSelect, tone? }[]`, `align`, `variant`, `size` | A button that opens a menu: arrows, Home, End, Enter, Escape |
139
144
  | `Disclosure` | `summary` | More behind a line that opens it |
140
145
 
141
146
  Every component passes other props, like `id`, `title`, `className`, `onClick` and `aria-*`, to its element.
142
147
  `TextInput` and `SearchField` pass `ref` to the input on React 19.
143
148
 
149
+ A button that's working on something is `busy`: it shows a spinner, ignores clicks and says so with `aria-busy`
150
+ and `aria-disabled`, and unlike `disabled` it keeps the keyboard focus:
151
+
152
+ ```tsx
153
+ <Button variant="primary" busy={saving} onClick={save}>
154
+ Save
155
+ </Button>
156
+ ```
157
+
144
158
  ### Ours or your own
145
159
 
146
160
  The components are there for a page that looks like the rest of Wings without effort, not a rule. Write your
@@ -157,6 +171,7 @@ page has:
157
171
  | `--wings-primary`, `--wings-primary-fg` | The primary button, and its text |
158
172
  | `--wings-info`, `--wings-success`, `--wings-warning`, `--wings-danger` | Tones |
159
173
  | `--wings-radius` | A card's corners |
174
+ | `--wings-menu-bg`, `--wings-menu-ring`, `--wings-menu-active`, `--wings-shadow` | A raised layer like a menu or a list of suggestions, as Wings' own menus draw it: its opaque background, its edge, the highlighted option and its shadow |
160
175
  | `--wings-font`, `--wings-mono` | Fonts |
161
176
 
162
177
  ```tsx
@@ -179,33 +194,40 @@ function Banner({ children }: { children: string }) {
179
194
  ```
180
195
 
181
196
  Wings' base styles cover plain elements (buttons, inputs, selects and the page's font and colours), the `w-*`
182
- classes and two older helpers, `.muted` and `.card`; nothing else, so your other classes are yours. Your CSS loads after Wings', so it wins where you
183
- style a component, like `.my-list .w-list-button { height: 24px; }`.
197
+ classes and two older helpers, `.muted` and `.card`; nothing else, so your other classes are yours. Your CSS
198
+ loads after Wings', so it wins where you style a component, like `.my-list .w-list-button { height: 24px; }`.
184
199
 
185
200
  ### The class contract
186
201
 
187
202
  The components render plain elements with `w-*` class names and data attributes, like
188
203
  `<div class="w-card" data-size="compact">`, and no styles of their own. Wings styles them in its
189
204
  `plugin-ui.css`, which ships with the app, so a Wings update restyles every plugin. The names are a versioned
190
- public contract: `--wings-ui` on the page's root is its version, `1` now, and a version only adds classes.
191
- `Meter` sets `--w-value`, how full it is, which is data rather than a style.
205
+ public contract: `--wings-ui` on the page's root is its version, `2` now, and a version only adds classes and
206
+ rules. `Meter` sets `--w-value`, how full it is, and a `Dot` with a `color` sets `--w-color`; both are data
207
+ rather than styles.
192
208
 
193
209
  `w-shell` with `data-place` (`sidebar`, `popover`, `view`, `tab`, `settings`) and `data-fill`; `w-row`,
194
210
  `w-stack` with `data-gap`, `data-align`, `data-justify`, `data-wrap`, `data-grow`; `w-text` with `data-tone`,
195
211
  `data-size`, `data-weight`, `data-mono`, `data-numeric`, `data-truncate`, `data-clamp`, `data-grow`;
196
- `w-heading`, `w-heading-row`; `w-card` with `data-size`; `w-gauge`, `w-gauge-label`, `w-gauge-value`,
212
+ `w-title` with `data-size`; `w-heading`, `w-heading-row`; `w-card` with `data-size`; `w-gauge`, `w-gauge-label`, `w-gauge-value`,
197
213
  `w-gauge-note`, `w-meter` with `data-tone`, `w-meter-fill`; `w-stats`, `w-stat-label`, `w-stat-value`;
198
214
  `w-props`, `w-prop`, `w-prop-label`, `w-prop-value`; `w-list` with `data-density`, `w-list-item`,
199
- `w-list-button` with `data-align`; `w-dot`, `w-pill`, `w-tag` with `data-tone`; `w-spinner` with `data-size`;
200
- `w-loading`, `w-empty`, `w-empty-detail`; `w-button` with `data-variant`, `data-size`; `w-chips`, `w-chip`;
201
- `w-tabs`, `w-tab`; `w-field`, `w-field-label`, `w-field-hint`, `w-input`, `w-search`, `w-select`, `w-check`;
202
- `w-disclosure`.
215
+ `w-list-button` with `data-align`; `w-dot` with `data-tone` or `data-color`; `w-pill`, `w-tag` with `data-tone`; `w-spinner` with `data-size`;
216
+ `w-loading`, `w-empty`, `w-empty-detail`; `w-button` with `data-variant`, `data-size`, `aria-disabled`; `w-chips`, `w-chip`;
217
+ `w-tabs`, `w-tab`; `w-field` with `data-layout`, `w-field-row`, `w-field-label`, `w-field-hint`,
218
+ `w-field-group`, `w-input`, `w-search`, `w-select`, `w-check`; `w-combobox`, `w-listbox`, `w-option`,
219
+ `w-option-label`, `w-option-detail`, `w-option-empty`; `w-menu-root`, `w-menu` with `data-align`, `w-menu-item`
220
+ with `data-tone`; `w-disclosure`. A `ul` or `ol` with `w-row`, `w-stack` or `w-card` loses its bullets.
221
+
222
+ Version 2 added `w-title`, `Heading`'s levels, `w-field-group`, `w-field-row`, the combobox and menu classes,
223
+ `data-color` on `w-dot`, busy buttons (`aria-disabled` on `w-button`), lists as rows, stacks and cards, and the four raised-layer variables.
203
224
 
204
225
  ### Older Wings
205
226
 
206
- Wings before 0.1.15 has no `w-*` styles. A page built with `wings-plugin-sdk/ui` checks `--wings-ui` when it
207
- mounts, and on a Wings without it, or with an older version, adds the same styles from the package, before
208
- your CSS. So a plugin works on older Wings without a `minWingsVersion`, and on a current Wings it always gets
227
+ Wings before 0.1.15 has no `w-*` styles, and a Wings with `--wings-ui: 1` lacks version 2's. A page built with
228
+ `wings-plugin-sdk/ui` checks `--wings-ui` when it mounts, and on a Wings without it, or with an older version,
229
+ adds the package's own copy of the styles, before Wings' and your CSS. Since a version only adds rules, the old
230
+ ones Wings has and the new ones from the package fit together. So a plugin works on older Wings without a `minWingsVersion`, and on a current Wings it always gets
209
231
  Wings' own styles.
210
232
 
211
233
  ### A linked SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wings-plugin-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Types, React hooks, UI components and a build command for Wings plugins.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  }
28
28
  },
29
29
  "scripts": {
30
- "test": "node --test"
30
+ "test": "tsc -p tsconfig.json && node --test"
31
31
  },
32
32
  "bin": {
33
33
  "wings-plugin": "bin.mjs"
@@ -68,5 +68,12 @@
68
68
  "react-dom": {
69
69
  "optional": true
70
70
  }
71
+ },
72
+ "devDependencies": {
73
+ "@types/react": "^19.1.8",
74
+ "jsdom": "^30.1.2",
75
+ "react": "^19.1.0",
76
+ "react-dom": "^19.1.0",
77
+ "typescript": "~6.0.3"
71
78
  }
72
79
  }
package/ui-css.js CHANGED
@@ -2,12 +2,21 @@
2
2
  // in plugin-ui.css wins whenever it's there, so a Wings update restyles every plugin. Keep both the same.
3
3
 
4
4
  /** The version of the classes this package renders. Wings' plugin-ui.css sets it as `--wings-ui`. */
5
- export const UI_VERSION = 1;
5
+ export const UI_VERSION = 2;
6
6
 
7
- export const fallbackCss = `/* wings-ui 1 start
7
+ export const fallbackCss = `/* wings-ui 2 start
8
8
  The classes wings-plugin-sdk/ui renders, a public contract: the names and data attributes stay, and Wings may
9
9
  restyle them. wings-plugin-sdk carries a copy of this block for older Wings without --wings-ui, so keep the
10
- two the same (apps/desktop/src/lib/plugin-ui.test.ts checks). Bump --wings-ui when a class is added. */
10
+ two the same (apps/desktop/src/lib/plugin-ui.test.ts checks). Bump --wings-ui when a class is added, and only add
11
+ rules: on an older Wings the SDK's copy loads before Wings' own, so a changed rule would lose to the old one. */
12
+
13
+ /* Raised layers, like a list of suggestions or a menu, as Wings' own menus draw them. */
14
+ :root {
15
+ --wings-menu-bg: rgb(32 29 35);
16
+ --wings-menu-ring: rgb(236 233 228 / 0.1);
17
+ --wings-menu-active: rgb(255 255 255 / 0.08);
18
+ --wings-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
19
+ }
11
20
 
12
21
  /* The page's place: a sidebar, popover, view (modal, window or tab) or settings page. */
13
22
  .w-shell {
@@ -44,6 +53,13 @@ export const fallbackCss = `/* wings-ui 1 start
44
53
  gap: 8px;
45
54
  min-width: 0;
46
55
  }
56
+ :is(ul, ol):is(.w-row, .w-stack, .w-card) {
57
+ margin: 0;
58
+ list-style: none;
59
+ }
60
+ :is(ul, ol):is(.w-row, .w-stack) {
61
+ padding: 0;
62
+ }
47
63
  :is(.w-row, .w-stack)[data-gap="none"] {
48
64
  gap: 0;
49
65
  }
@@ -126,6 +142,26 @@ export const fallbackCss = `/* wings-ui 1 start
126
142
  min-width: 0;
127
143
  }
128
144
 
145
+ /* A page's or a card's title. */
146
+ .w-title {
147
+ font-size: 14px;
148
+ font-weight: 600;
149
+ line-height: 1.35;
150
+ overflow-wrap: anywhere;
151
+ }
152
+ .w-title[data-size="large"] {
153
+ font-size: 15px;
154
+ }
155
+ /* A title cut to one line with an ellipsis, like Text truncate. */
156
+ .w-title[data-truncate] {
157
+ overflow: hidden;
158
+ text-overflow: ellipsis;
159
+ white-space: nowrap;
160
+ }
161
+ :is(.w-row, .w-list-item, .w-list-button) > .w-title[data-truncate] {
162
+ min-width: 0;
163
+ }
164
+
129
165
  /* A section heading, with its controls on the right. */
130
166
  .w-heading {
131
167
  margin-top: 6px;
@@ -330,6 +366,10 @@ export const fallbackCss = `/* wings-ui 1 start
330
366
  .w-dot[data-tone="danger"] {
331
367
  background: var(--wings-danger);
332
368
  }
369
+ /* A colour from the plugin's data, like a work item type's, in --w-color. */
370
+ .w-dot[data-color] {
371
+ background: var(--w-color);
372
+ }
333
373
  .w-spinner {
334
374
  flex: none;
335
375
  width: 10px;
@@ -440,11 +480,24 @@ export const fallbackCss = `/* wings-ui 1 start
440
480
  .w-button[data-variant="ghost"]:hover {
441
481
  background: var(--wings-hover);
442
482
  }
483
+ /* Busy: it keeps focus, unlike a disabled button, and looks like one. */
484
+ .w-button[aria-disabled="true"] {
485
+ opacity: 0.5;
486
+ cursor: default;
487
+ }
488
+ .w-button[aria-disabled="true"]:active {
489
+ transform: none;
490
+ }
443
491
  .w-button[data-size="small"] {
444
492
  height: 24px;
445
493
  padding: 0 8px;
446
494
  font-size: 12px;
447
495
  }
496
+ /* A busy button's spinner takes the button's text colour, so it shows on primary, danger, ghost and default alike. */
497
+ .w-button .w-spinner {
498
+ border-color: color-mix(in srgb, currentColor 25%, transparent);
499
+ border-top-color: currentColor;
500
+ }
448
501
 
449
502
  /* Chips: small quiet buttons, and a group of them that filters what shows. */
450
503
  .w-chips {
@@ -508,6 +561,28 @@ export const fallbackCss = `/* wings-ui 1 start
508
561
  font-size: 12px;
509
562
  font-weight: 500;
510
563
  }
564
+ .w-field[data-layout="inline"] > .w-field-row {
565
+ display: flex;
566
+ align-items: center;
567
+ gap: 8px;
568
+ min-width: 0;
569
+ }
570
+ .w-field-row > .w-field-label {
571
+ flex: none;
572
+ }
573
+ .w-field-group {
574
+ display: flex;
575
+ flex-direction: column;
576
+ gap: 4px;
577
+ min-width: 0;
578
+ margin: 0;
579
+ padding: 0;
580
+ border: 0;
581
+ }
582
+ .w-field-group > legend {
583
+ margin-bottom: 4px;
584
+ padding: 0;
585
+ }
511
586
  .w-field-hint {
512
587
  font-size: 12px;
513
588
  color: var(--wings-muted);
@@ -530,6 +605,93 @@ export const fallbackCss = `/* wings-ui 1 start
530
605
  margin: 0;
531
606
  }
532
607
 
608
+ /* A field with suggestions under it, and a menu under a button: raised lists of options. */
609
+ .w-combobox,
610
+ .w-menu-root {
611
+ position: relative;
612
+ min-width: 0;
613
+ }
614
+ .w-menu-root {
615
+ display: inline-block;
616
+ }
617
+ .w-listbox,
618
+ .w-menu {
619
+ position: absolute;
620
+ z-index: 10;
621
+ top: calc(100% + 4px);
622
+ left: 0;
623
+ max-height: 240px;
624
+ margin: 0;
625
+ padding: 4px;
626
+ overflow: auto;
627
+ list-style: none;
628
+ border-radius: var(--wings-radius);
629
+ background: var(--wings-menu-bg);
630
+ box-shadow:
631
+ 0 0 0 1px var(--wings-menu-ring),
632
+ var(--wings-shadow);
633
+ }
634
+ .w-listbox {
635
+ right: 0;
636
+ }
637
+ .w-listbox > ul {
638
+ margin: 0;
639
+ padding: 0;
640
+ list-style: none;
641
+ }
642
+ .w-menu {
643
+ min-width: 100%;
644
+ width: max-content;
645
+ }
646
+ .w-menu[data-align="end"] {
647
+ left: auto;
648
+ right: 0;
649
+ }
650
+ .w-listbox[hidden],
651
+ .w-menu[hidden] {
652
+ display: none;
653
+ }
654
+ .w-option,
655
+ .w-menu-item {
656
+ display: flex;
657
+ align-items: baseline;
658
+ gap: 8px;
659
+ padding: 4px 6px;
660
+ border-radius: 8px;
661
+ cursor: default;
662
+ }
663
+ .w-option[aria-selected="true"],
664
+ .w-menu-item:focus {
665
+ background: var(--wings-menu-active);
666
+ }
667
+ /* Like Wings' own menus, the highlight shows where focus is. */
668
+ .w-menu-item:focus-visible {
669
+ outline: none;
670
+ }
671
+ .w-menu-item[data-tone="danger"] {
672
+ color: var(--wings-danger);
673
+ }
674
+ .w-option-label {
675
+ flex: 1;
676
+ min-width: 0;
677
+ overflow: hidden;
678
+ text-overflow: ellipsis;
679
+ white-space: nowrap;
680
+ }
681
+ .w-option-detail {
682
+ flex: none;
683
+ max-width: 45%;
684
+ overflow: hidden;
685
+ text-overflow: ellipsis;
686
+ white-space: nowrap;
687
+ font-size: 12px;
688
+ color: var(--wings-muted);
689
+ }
690
+ .w-option-empty {
691
+ padding: 4px 6px;
692
+ color: var(--wings-muted);
693
+ }
694
+
533
695
  /* More behind a summary line. */
534
696
  .w-disclosure > summary {
535
697
  padding: 4px 6px;
@@ -543,5 +705,5 @@ export const fallbackCss = `/* wings-ui 1 start
543
705
  animation: none;
544
706
  }
545
707
  }
546
- /* wings-ui 1 end */
708
+ /* wings-ui 2 end */
547
709
  `;
package/ui.d.ts CHANGED
@@ -37,15 +37,24 @@ export function useMainState<T extends { type: string }>(type: T["type"], hello?
37
37
  export type Tone = "muted" | "info" | "success" | "warn" | "danger";
38
38
  type Props<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag>;
39
39
  type As = "div" | "span" | "p" | "section" | "header" | "footer" | "strong" | "time" | "label" | "li";
40
+ /** The elements `Row` and `Stack` can be. */
41
+ type BoxAs = As | "form" | "ul" | "ol";
42
+ /** The elements `Card` can be. */
43
+ type CardAs = As | "form" | "article" | "ul" | "ol";
44
+ /** The props of element `E`, with `own` on top: where they share a name, `own` wins. */
45
+ type Own<E extends keyof JSX.IntrinsicElements, own> = Omit<Props<E>, keyof own> & own;
40
46
  /** Spacing between children: none, xs (2px), s (4px), m (8px, the default) or l (12px), set by Wings. */
41
47
  export type Gap = "none" | "xs" | "s" | "m" | "l";
42
48
 
43
49
  // ---------- layout and text ----------
44
50
 
45
- /** Children side by side, centred. */
46
- export function Row(props: Props<"div"> & { as?: As; gap?: Gap; align?: "start" | "baseline"; justify?: "end" | "between"; wrap?: boolean; grow?: boolean }): ReactElement;
47
- /** Children one under the other. */
48
- export function Stack(props: Props<"div"> & { as?: As; gap?: Gap; grow?: boolean }): ReactElement;
51
+ /**
52
+ * Children side by side, centred. `as` picks the element, like a `form` or a `ul`; the props and handlers are the
53
+ * element's, so `<Row as="form" onSubmit={(e) => e.currentTarget.requestSubmit()}>` has a `HTMLFormElement`.
54
+ */
55
+ export function Row<E extends BoxAs = "div">(props: Own<E, { as?: E; gap?: Gap; align?: "start" | "baseline"; justify?: "end" | "between"; wrap?: boolean; grow?: boolean }>): ReactElement;
56
+ /** Children one under the other. As a `ul` or `ol` it's a list with no bullets, of `li` children. `as` types the props like `Row`'s. */
57
+ export function Stack<E extends BoxAs = "div">(props: Own<E, { as?: E; gap?: Gap; grow?: boolean }>): ReactElement;
49
58
  /** Text with a tone and size, that can `grow` to fill a row or `truncate` with an ellipsis. */
50
59
  export function Text(
51
60
  props: Props<"span"> & {
@@ -65,9 +74,12 @@ export function Text(
65
74
  },
66
75
  ): ReactElement;
67
76
  /** A section heading, an `h2`, with `actions` like chips or a button on its right. */
68
- export function Heading(props: Props<"h2"> & { actions?: ReactNode }): ReactElement;
69
- /** A card. `compact` for a summary line. */
70
- export function Card(props: Props<"div"> & { as?: As; compact?: boolean }): ReactElement;
77
+ export function Heading(props: Props<"h2"> & { level?: 2 | 3 | 4; actions?: ReactNode }): ReactElement;
78
+ /** A page's or a card's title, like a sprint's name or a setup step's question: an `h1` unless `as` says otherwise. */
79
+ /** `truncate` cuts a title that's too long for its place to one line, with an ellipsis. */
80
+ export function Title(props: Props<"h1"> & { as?: "h1" | "h2" | "h3"; size?: "large"; truncate?: boolean }): ReactElement;
81
+ /** A card. `compact` for a summary line. As a `form` it holds a form; as a `ul` or `ol`, a list of `li`. `as` types the props like `Row`'s. */
82
+ export function Card<E extends CardAs = "div">(props: Own<E, { as?: E; compact?: boolean }>): ReactElement;
71
83
 
72
84
  // ---------- figures ----------
73
85
 
@@ -98,7 +110,14 @@ export function ListButton(props: Props<"button"> & { align?: "baseline" }): Rea
98
110
  // ---------- status ----------
99
111
 
100
112
  /** A status dot, green unless `tone` says otherwise. With a `label` it's announced; otherwise it's decoration. */
101
- export function Dot(props: Props<"span"> & { tone?: Exclude<Tone, "success">; label?: string }): ReactElement;
113
+ export function Dot(
114
+ props: Props<"span"> & {
115
+ tone?: Exclude<Tone, "success">;
116
+ /** A colour from your data, like a work item type's `#cc293d`, set as `--w-color`. Wins over `tone`. */
117
+ color?: string;
118
+ label?: string;
119
+ },
120
+ ): ReactElement;
102
121
  /** A spinner. With a `label` it's announced; otherwise it's decoration. */
103
122
  export function Spinner(props: Props<"span"> & { size?: "large"; label?: string }): ReactElement;
104
123
  /** A filled pill, like a label on a command. */
@@ -112,8 +131,11 @@ export function Empty(props: Omit<Props<"div">, "title"> & { title: ReactNode; d
112
131
 
113
132
  // ---------- buttons ----------
114
133
 
115
- /** A button, `type="button"` unless you say otherwise. */
116
- export function Button(props: Props<"button"> & { variant?: "primary" | "ghost" | "danger"; size?: "small" }): ReactElement;
134
+ /**
135
+ * A button, `type="button"` unless you say otherwise. `busy` shows a spinner and ignores clicks while it keeps the
136
+ * keyboard focus, unlike `disabled`: it's `aria-busy` and `aria-disabled`.
137
+ */
138
+ export function Button(props: Props<"button"> & { variant?: "primary" | "ghost" | "danger"; size?: "small"; busy?: boolean }): ReactElement;
117
139
  /** A small quiet button. With `pressed` it's a toggle. */
118
140
  export function Chip(props: Props<"button"> & { pressed?: boolean }): ReactElement;
119
141
  export interface Option<V extends string> {
@@ -122,14 +144,34 @@ export interface Option<V extends string> {
122
144
  title?: string;
123
145
  }
124
146
  /** Chips that pick one of `options`, like a filter, in a group named `label`. `onChange` gets each click, the chip that's on too. */
125
- export function Chips<V extends string>(props: Omit<Props<"div">, "onChange"> & { label: string; options: Option<V>[]; value: V; onChange: (value: V) => void }): ReactElement;
147
+ export function Chips<V extends string>(
148
+ props: Omit<Props<"div">, "onChange"> & {
149
+ label: string;
150
+ /** Each chip's other props, like `id`, go to its button. */
151
+ options: (Option<V> & Omit<Props<"button">, "value" | "onClick" | "children">)[];
152
+ value: V;
153
+ onChange: (value: V) => void;
154
+ },
155
+ ): ReactElement;
126
156
  /** Tabs named `label` over the panel they show, `children`. Arrow keys, Home and End move between tabs. */
127
157
  export function Tabs<V extends string>(props: Omit<Props<"div">, "onChange"> & { label: string; items: Option<V>[]; value: V; onChange: (value: V) => void }): ReactElement;
128
158
 
129
159
  // ---------- fields ----------
130
160
 
131
161
  /** A labelled field with a `hint` or an `error` under it, for the `TextInput`, `SearchField` or `Select` in `children`. */
132
- export function Field(props: Props<"div"> & { label: ReactNode; hint?: ReactNode; error?: ReactNode }): ReactElement;
162
+ export function Field(
163
+ props: Props<"div"> & {
164
+ label: ReactNode;
165
+ hint?: ReactNode;
166
+ error?: ReactNode;
167
+ /** The input's id, and the label's `htmlFor`, for a page that puts focus back by id. Generated otherwise. */
168
+ id?: string;
169
+ /** The label and the input on one line. */
170
+ inline?: boolean;
171
+ },
172
+ ): ReactElement;
173
+ /** Several controls under one label (a `fieldset` and its `legend`), like checkboxes, with a `hint` or an `error` under them. */
174
+ export function FieldGroup(props: Props<"fieldset"> & { label: ReactNode; hint?: ReactNode; error?: ReactNode }): ReactElement;
133
175
  /** A text input. Outside a `Field`, give it a `label`. */
134
176
  export function TextInput(props: Props<"input"> & { label?: string; ref?: Ref<HTMLInputElement> }): ReactElement;
135
177
  /** A search box. `label` names it for screen readers. */
@@ -140,5 +182,49 @@ export function Select<V extends string>(
140
182
  ): ReactElement;
141
183
  /** A checkbox with its label, `children`. `onChange` gets whether it's checked. */
142
184
  export function Checkbox(props: Omit<Props<"input">, "onChange" | "type"> & { onChange?: (checked: boolean) => void }): ReactElement;
185
+ export interface ComboboxOption {
186
+ value: string;
187
+ label: string;
188
+ /** A second, quieter part, like a project's client. */
189
+ detail?: string;
190
+ }
191
+ /**
192
+ * A text field with `options` under it. Arrows move, Enter picks, Escape and Tab close, and pressing outside
193
+ * closes it. With `freeText` the field keeps what you type (`value`, `onInput`) and starts with nothing
194
+ * highlighted; otherwise you type to find an option and the field shows `value`, what you picked, once closed.
195
+ * `filter` decides what matches what's typed: the label or the detail containing it, by default. Without
196
+ * `freeText` the field's text is selected when it gets focus and after a pick, so typing replaces the label. An
197
+ * `onKeyDown` you pass runs first; `preventDefault()` in it keeps the Combobox's own keys from running.
198
+ */
199
+ export function Combobox(
200
+ props: Omit<Props<"input">, "value" | "onInput" | "type"> & {
201
+ label?: string;
202
+ value: string;
203
+ onInput?: (text: string) => void;
204
+ options: ComboboxOption[];
205
+ onPick: (option: ComboboxOption) => void;
206
+ /** Enter with no option highlighted, with the text typed. The Enter doesn't submit a form around it. */
207
+ onEnter?: (text: string) => void;
208
+ freeText?: boolean;
209
+ /** Open the list when the field gets focus, by default; false opens it on a click, a key or typing only, for a page that puts focus back by id after it renders. */
210
+ openOnFocus?: boolean;
211
+ filter?: (option: ComboboxOption, query: string) => boolean;
212
+ /** What shows when nothing matches. */
213
+ empty?: ReactNode;
214
+ },
215
+ ): ReactElement;
216
+ export interface MenuItem {
217
+ id: string;
218
+ label: ReactNode;
219
+ onSelect: () => void;
220
+ tone?: "danger";
221
+ }
222
+ /**
223
+ * A button, showing `label`, that opens a menu of `items` under it. Arrows, Home and End move, Enter or Space
224
+ * picks, Escape closes and puts focus back on the button. `align` end lines the menu up with the button's right.
225
+ */
226
+ export function Menu(
227
+ props: Omit<Props<"button">, "children"> & { label: ReactNode; items: MenuItem[]; align?: "end"; variant?: "primary" | "ghost" | "danger"; size?: "small" },
228
+ ): ReactElement;
143
229
  /** More behind a `summary` line that opens it. */
144
230
  export function Disclosure(props: Props<"details"> & { summary: ReactNode }): ReactElement;
package/ui.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // wings-plugin-sdk/ui: a page says what to show and where, and Wings decides how it looks. Every component renders
2
2
  // plain elements with `w-*` class names and data attributes that Wings' plugin-ui.css styles, never a style of its
3
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, useState } from "react";
4
+ import { createContext, createElement as h, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
5
5
  import { createRoot } from "react-dom/client";
6
6
 
7
7
  import { mountShell } from "./shell.js";
@@ -85,9 +85,14 @@ export function Text({ as = "span", tone, size, weight, mono, numeric, truncate,
85
85
  });
86
86
  }
87
87
 
88
- /** A section heading, with `actions` like chips or a button on its right. */
89
- export function Heading({ actions, className, ...rest }) {
90
- const heading = h("h2", { ...rest, className: cx("w-heading", className) });
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) });
91
96
  return actions ? h("div", { className: "w-heading-row" }, heading, actions) : heading;
92
97
  }
93
98
 
@@ -159,9 +164,20 @@ export function ListButton({ align, type = "button", className, ...rest }) {
159
164
 
160
165
  // ---------- status ----------
161
166
 
162
- /** A status dot. With a `label` it's announced; otherwise it's decoration next to text that says the same. */
163
- export function Dot({ tone, label, className, ...rest }) {
164
- return h("span", { ...rest, className: cx("w-dot", className), "data-tone": tone, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
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
+ });
165
181
  }
166
182
 
167
183
  /** A spinner. With a `label` it's announced. */
@@ -192,8 +208,23 @@ export function Empty({ title, detail, className, ...rest }) {
192
208
  // ---------- buttons ----------
193
209
 
194
210
  /** A button. `variant` is primary, ghost or danger; `size` small. */
195
- export function Button({ variant, size, type = "button", className, ...rest }) {
196
- return h("button", { ...rest, type, className: cx("w-button", className), "data-variant": variant, "data-size": size });
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
+ );
197
228
  }
198
229
 
199
230
  /** A small quiet button. With `pressed` it's a toggle. */
@@ -206,7 +237,7 @@ export function Chips({ label, options, value, onChange, className, ...rest }) {
206
237
  return h(
207
238
  "div",
208
239
  { ...rest, className: cx("w-chips", className), role: "group", "aria-label": label },
209
- options.map((o) => h(Chip, { key: o.value, pressed: o.value === value, title: o.title, onClick: () => onChange(o.value) }, o.label)),
240
+ options.map(({ value: v, label: text, ...chip }) => h(Chip, { ...chip, key: v, pressed: v === value, onClick: () => onChange(v) }, text)),
210
241
  );
211
242
  }
212
243
 
@@ -247,16 +278,40 @@ function useFieldProps(rest) {
247
278
  return field ? { id: field.id, "aria-describedby": field.hint, "aria-invalid": field.invalid || undefined, ...rest } : rest;
248
279
  }
249
280
 
250
- /** A labelled field with a `hint` or an `error` under it, for the input in `children`. */
251
- export function Field({ label, hint, error, children, className, ...rest }) {
252
- const id = useId();
253
- const note = error ?? hint;
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);
254
294
  return h(
255
295
  "div",
256
- { ...rest, className: cx("w-field", className) },
257
- h("label", { className: "w-field-label", htmlFor: id }, label),
258
- h(FieldContext.Provider, { value: { id, hint: note ? `${id}-hint` : undefined, invalid: Boolean(error) } }, children),
259
- note ? h("p", { id: `${id}-hint`, className: "w-field-hint", "data-tone": error ? "danger" : undefined }, note) : null,
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),
260
315
  );
261
316
  }
262
317
 
@@ -281,7 +336,255 @@ export function Select({ label, options, onChange, className, ...rest }) {
281
336
 
282
337
  /** A checkbox with its label, `children`. `onChange` gets whether it's checked. */
283
338
  export function Checkbox({ onChange, children, className, ...rest }) {
284
- return h("label", { className: cx("w-check", className) }, h("input", { ...rest, type: "checkbox", onChange: (e) => onChange?.(e.currentTarget.checked) }), children);
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.
367
+ */
368
+ export function Combobox({ label, value, onInput, options, onPick, onEnter, onKeyDown: onKeyDownProp, freeText = false, openOnFocus = true, filter = contains, empty = "No matches.", className, ...rest }) {
369
+ const field = useFieldProps(rest);
370
+ const generated = useId();
371
+ const id = field.id ?? generated;
372
+ const [open, setOpen] = useState(false);
373
+ const [query, setQuery] = useState("");
374
+ const [active, setActive] = useState(-1);
375
+ const root = useRef(null);
376
+ const list = useRef(null);
377
+ const input = useRef(null);
378
+ const first = freeText ? -1 : 0;
379
+ const shown = open ? options.filter((o) => filter(o, freeText ? value : query)) : [];
380
+ const current = Math.min(active, shown.length - 1);
381
+ const optionId = (i) => `${id}-option-${i}`;
382
+
383
+ const close = () => {
384
+ setOpen(false);
385
+ setQuery("");
386
+ setActive(-1);
387
+ };
388
+ const openAt = (i) => {
389
+ setOpen(true);
390
+ setActive(i);
391
+ };
392
+ const pick = (option) => {
393
+ onPick(option);
394
+ close();
395
+ };
396
+ useCloseOutside(open, root, close);
397
+ useLayoutEffect(() => {
398
+ if (current >= 0) list.current?.children[current]?.scrollIntoView({ block: "nearest" });
399
+ }, [current]);
400
+ // A picker shows what was picked while it's closed, so select that text: typing replaces it, as it does in a
401
+ // native select-and-type field, instead of landing after it. MUI's Autocomplete does the same (`selectOnFocus`).
402
+ useLayoutEffect(() => {
403
+ if (!freeText && !open && document.activeElement === input.current) input.current.select();
404
+ }, [freeText, open, value]);
405
+
406
+ const onKeyDown = (e) => {
407
+ onKeyDownProp?.(e);
408
+ if (e.defaultPrevented) return;
409
+ if (e.key === "ArrowDown") {
410
+ e.preventDefault();
411
+ // A closed free-text field moves to the first option at once; a closed picker opens on its first.
412
+ if (!open) openAt(0);
413
+ else setActive(Math.min(current + 1, shown.length - 1));
414
+ } else if (e.key === "ArrowUp") {
415
+ e.preventDefault();
416
+ setActive(Math.max(current - 1, first));
417
+ } else if (e.key === "Enter" && open && current >= 0) {
418
+ // Picking from the list doesn't also submit a form around it.
419
+ e.preventDefault();
420
+ pick(shown[current]);
421
+ } else if (e.key === "Enter" && onEnter) {
422
+ // Nothing highlighted: the text is the answer, and Enter doesn't submit a form around it.
423
+ e.preventDefault();
424
+ onEnter(e.currentTarget.value);
425
+ } else if (e.key === "Escape" || e.key === "Tab") {
426
+ // An open list takes the Escape, so a form or dialog around it closes on the next one.
427
+ if (e.key === "Escape" && open) e.preventDefault();
428
+ close();
429
+ }
430
+ };
431
+
432
+ return h(
433
+ "div",
434
+ { className: cx("w-combobox", className), ref: root },
435
+ h("input", {
436
+ "aria-label": label,
437
+ ...field,
438
+ id,
439
+ ref: input,
440
+ className: "w-input",
441
+ role: "combobox",
442
+ "aria-autocomplete": "list",
443
+ "aria-controls": `${id}-list`,
444
+ "aria-expanded": open,
445
+ "aria-activedescendant": current >= 0 ? optionId(current) : undefined,
446
+ autoComplete: "off",
447
+ spellCheck: false,
448
+ value: freeText || !open ? value : query,
449
+ onFocus: (e) => {
450
+ if (!freeText) e.currentTarget.select();
451
+ if (!openOnFocus) return;
452
+ if (!freeText) setQuery("");
453
+ openAt(first);
454
+ },
455
+ // A field that kept focus after a pick or Escape opens again when you click it.
456
+ onClick: () => {
457
+ if (open) return;
458
+ if (!freeText) setQuery("");
459
+ openAt(first);
460
+ },
461
+ onChange: (e) => {
462
+ if (freeText) onInput?.(e.currentTarget.value);
463
+ else setQuery(e.currentTarget.value);
464
+ openAt(first);
465
+ },
466
+ onBlur: (e) => {
467
+ if (e.relatedTarget && !root.current.contains(e.relatedTarget)) close();
468
+ },
469
+ onKeyDown,
470
+ }),
471
+ h(
472
+ "div",
473
+ { className: "w-listbox", hidden: !open },
474
+ h(
475
+ "ul",
476
+ { id: `${id}-list`, role: "listbox", "aria-label": label, ref: list },
477
+ shown.map((o, i) =>
478
+ h(
479
+ "li",
480
+ {
481
+ key: o.value,
482
+ id: optionId(i),
483
+ role: "option",
484
+ className: "w-option",
485
+ "aria-selected": i === current,
486
+ // Before the field loses focus, so the pick lands.
487
+ onMouseDown: (e) => {
488
+ e.preventDefault();
489
+ pick(o);
490
+ },
491
+ onMouseMove: () => i !== current && setActive(i),
492
+ },
493
+ h("span", { className: "w-option-label" }, o.label),
494
+ o.detail ? h("span", { className: "w-option-detail" }, o.detail) : null,
495
+ ),
496
+ ),
497
+ ),
498
+ open && !shown.length ? h("p", { className: "w-option-empty", role: "status" }, empty) : null,
499
+ ),
500
+ );
501
+ }
502
+
503
+ /**
504
+ * A button that opens a menu of `items` under it, each `{ id, label, onSelect, tone? }`. Arrows, Home and End move,
505
+ * Enter or Space picks, Escape closes and puts focus back on the button. `align` end lines it up on the right.
506
+ */
507
+ export function Menu({ label, items, align, variant, size, className, ...rest }) {
508
+ const id = useId();
509
+ const [open, setOpen] = useState(false);
510
+ const root = useRef(null);
511
+ const button = useRef(null);
512
+ const entries = useRef([]);
513
+ const close = (refocus) => {
514
+ setOpen(false);
515
+ if (refocus) button.current?.focus();
516
+ };
517
+ useCloseOutside(open, root, () => close(false));
518
+ useEffect(() => {
519
+ if (open) entries.current[0]?.focus();
520
+ }, [open]);
521
+ const select = (item) => {
522
+ close(true);
523
+ item.onSelect();
524
+ };
525
+ const onKeyDown = (e) => {
526
+ const at = entries.current.indexOf(document.activeElement);
527
+ const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: items.length - 1 }[e.key];
528
+ if (next !== undefined) {
529
+ e.preventDefault();
530
+ entries.current[(next + items.length) % items.length]?.focus();
531
+ } else if (e.key === "Enter" || e.key === " ") {
532
+ e.preventDefault();
533
+ if (at >= 0) select(items[at]);
534
+ } else if (e.key === "Escape") {
535
+ e.preventDefault();
536
+ close(true);
537
+ } else if (e.key === "Tab") close(false);
538
+ };
539
+ return h(
540
+ "div",
541
+ { className: cx("w-menu-root", className), ref: root },
542
+ h(
543
+ "button",
544
+ {
545
+ ...rest,
546
+ type: "button",
547
+ id: `${id}-button`,
548
+ ref: button,
549
+ className: "w-button",
550
+ "data-variant": variant,
551
+ "data-size": size,
552
+ "aria-haspopup": "menu",
553
+ "aria-expanded": open,
554
+ "aria-controls": open ? `${id}-menu` : undefined,
555
+ onClick: () => setOpen(!open),
556
+ onKeyDown: (e) => {
557
+ if (e.key === "ArrowDown" && !open) {
558
+ e.preventDefault();
559
+ setOpen(true);
560
+ }
561
+ },
562
+ },
563
+ label,
564
+ ),
565
+ open
566
+ ? h(
567
+ "ul",
568
+ { id: `${id}-menu`, role: "menu", className: "w-menu", "aria-labelledby": `${id}-button`, "data-align": align, onKeyDown },
569
+ items.map((item, i) =>
570
+ h(
571
+ "li",
572
+ {
573
+ key: item.id,
574
+ role: "menuitem",
575
+ tabIndex: -1,
576
+ className: "w-menu-item",
577
+ "data-tone": item.tone,
578
+ ref: (el) => (entries.current[i] = el),
579
+ onClick: () => select(item),
580
+ onMouseMove: (e) => e.currentTarget.focus(),
581
+ },
582
+ item.label,
583
+ ),
584
+ ),
585
+ )
586
+ : null,
587
+ );
285
588
  }
286
589
 
287
590
  /** More behind a `summary` line that opens it. */