wings-plugin-sdk 0.2.0 → 0.3.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/README.md +42 -20
- package/package.json +6 -1
- package/ui-css.js +152 -4
- package/ui.d.ts +83 -11
- package/ui.js +308 -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
|
|
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` | Children side by side or one under the other; as a `ul` or `ol`, a list with no bullets |
|
|
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
|
-
| `
|
|
125
|
-
| `
|
|
125
|
+
| `Title` | `as` (`h1`, `h2`, `h3`), `size` (`large`) | 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 |
|
|
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`, `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 |
|
|
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
|
|
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, `
|
|
191
|
-
`Meter` sets `--w-value`, how full it is,
|
|
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
|
|
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
|
|
202
|
-
`w-
|
|
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
|
|
207
|
-
mounts, and on a Wings without it, or with an older version,
|
|
208
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Types, React hooks, UI components and a build command for Wings plugins.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -68,5 +68,10 @@
|
|
|
68
68
|
"react-dom": {
|
|
69
69
|
"optional": true
|
|
70
70
|
}
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"jsdom": "^30.1.2",
|
|
74
|
+
"react": "^19.1.0",
|
|
75
|
+
"react-dom": "^19.1.0"
|
|
71
76
|
}
|
|
72
77
|
}
|
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 =
|
|
5
|
+
export const UI_VERSION = 2;
|
|
6
6
|
|
|
7
|
-
export const fallbackCss = `/* wings-ui
|
|
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,17 @@ 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
|
+
|
|
129
156
|
/* A section heading, with its controls on the right. */
|
|
130
157
|
.w-heading {
|
|
131
158
|
margin-top: 6px;
|
|
@@ -330,6 +357,10 @@ export const fallbackCss = `/* wings-ui 1 start
|
|
|
330
357
|
.w-dot[data-tone="danger"] {
|
|
331
358
|
background: var(--wings-danger);
|
|
332
359
|
}
|
|
360
|
+
/* A colour from the plugin's data, like a work item type's, in --w-color. */
|
|
361
|
+
.w-dot[data-color] {
|
|
362
|
+
background: var(--w-color);
|
|
363
|
+
}
|
|
333
364
|
.w-spinner {
|
|
334
365
|
flex: none;
|
|
335
366
|
width: 10px;
|
|
@@ -440,6 +471,14 @@ export const fallbackCss = `/* wings-ui 1 start
|
|
|
440
471
|
.w-button[data-variant="ghost"]:hover {
|
|
441
472
|
background: var(--wings-hover);
|
|
442
473
|
}
|
|
474
|
+
/* Busy: it keeps focus, unlike a disabled button, and looks like one. */
|
|
475
|
+
.w-button[aria-disabled="true"] {
|
|
476
|
+
opacity: 0.5;
|
|
477
|
+
cursor: default;
|
|
478
|
+
}
|
|
479
|
+
.w-button[aria-disabled="true"]:active {
|
|
480
|
+
transform: none;
|
|
481
|
+
}
|
|
443
482
|
.w-button[data-size="small"] {
|
|
444
483
|
height: 24px;
|
|
445
484
|
padding: 0 8px;
|
|
@@ -508,6 +547,28 @@ export const fallbackCss = `/* wings-ui 1 start
|
|
|
508
547
|
font-size: 12px;
|
|
509
548
|
font-weight: 500;
|
|
510
549
|
}
|
|
550
|
+
.w-field[data-layout="inline"] > .w-field-row {
|
|
551
|
+
display: flex;
|
|
552
|
+
align-items: center;
|
|
553
|
+
gap: 8px;
|
|
554
|
+
min-width: 0;
|
|
555
|
+
}
|
|
556
|
+
.w-field-row > .w-field-label {
|
|
557
|
+
flex: none;
|
|
558
|
+
}
|
|
559
|
+
.w-field-group {
|
|
560
|
+
display: flex;
|
|
561
|
+
flex-direction: column;
|
|
562
|
+
gap: 4px;
|
|
563
|
+
min-width: 0;
|
|
564
|
+
margin: 0;
|
|
565
|
+
padding: 0;
|
|
566
|
+
border: 0;
|
|
567
|
+
}
|
|
568
|
+
.w-field-group > legend {
|
|
569
|
+
margin-bottom: 4px;
|
|
570
|
+
padding: 0;
|
|
571
|
+
}
|
|
511
572
|
.w-field-hint {
|
|
512
573
|
font-size: 12px;
|
|
513
574
|
color: var(--wings-muted);
|
|
@@ -530,6 +591,93 @@ export const fallbackCss = `/* wings-ui 1 start
|
|
|
530
591
|
margin: 0;
|
|
531
592
|
}
|
|
532
593
|
|
|
594
|
+
/* A field with suggestions under it, and a menu under a button: raised lists of options. */
|
|
595
|
+
.w-combobox,
|
|
596
|
+
.w-menu-root {
|
|
597
|
+
position: relative;
|
|
598
|
+
min-width: 0;
|
|
599
|
+
}
|
|
600
|
+
.w-menu-root {
|
|
601
|
+
display: inline-block;
|
|
602
|
+
}
|
|
603
|
+
.w-listbox,
|
|
604
|
+
.w-menu {
|
|
605
|
+
position: absolute;
|
|
606
|
+
z-index: 10;
|
|
607
|
+
top: calc(100% + 4px);
|
|
608
|
+
left: 0;
|
|
609
|
+
max-height: 240px;
|
|
610
|
+
margin: 0;
|
|
611
|
+
padding: 4px;
|
|
612
|
+
overflow: auto;
|
|
613
|
+
list-style: none;
|
|
614
|
+
border-radius: var(--wings-radius);
|
|
615
|
+
background: var(--wings-menu-bg);
|
|
616
|
+
box-shadow:
|
|
617
|
+
0 0 0 1px var(--wings-menu-ring),
|
|
618
|
+
var(--wings-shadow);
|
|
619
|
+
}
|
|
620
|
+
.w-listbox {
|
|
621
|
+
right: 0;
|
|
622
|
+
}
|
|
623
|
+
.w-listbox > ul {
|
|
624
|
+
margin: 0;
|
|
625
|
+
padding: 0;
|
|
626
|
+
list-style: none;
|
|
627
|
+
}
|
|
628
|
+
.w-menu {
|
|
629
|
+
min-width: 100%;
|
|
630
|
+
width: max-content;
|
|
631
|
+
}
|
|
632
|
+
.w-menu[data-align="end"] {
|
|
633
|
+
left: auto;
|
|
634
|
+
right: 0;
|
|
635
|
+
}
|
|
636
|
+
.w-listbox[hidden],
|
|
637
|
+
.w-menu[hidden] {
|
|
638
|
+
display: none;
|
|
639
|
+
}
|
|
640
|
+
.w-option,
|
|
641
|
+
.w-menu-item {
|
|
642
|
+
display: flex;
|
|
643
|
+
align-items: baseline;
|
|
644
|
+
gap: 8px;
|
|
645
|
+
padding: 4px 6px;
|
|
646
|
+
border-radius: 8px;
|
|
647
|
+
cursor: default;
|
|
648
|
+
}
|
|
649
|
+
.w-option[aria-selected="true"],
|
|
650
|
+
.w-menu-item:focus {
|
|
651
|
+
background: var(--wings-menu-active);
|
|
652
|
+
}
|
|
653
|
+
/* Like Wings' own menus, the highlight shows where focus is. */
|
|
654
|
+
.w-menu-item:focus-visible {
|
|
655
|
+
outline: none;
|
|
656
|
+
}
|
|
657
|
+
.w-menu-item[data-tone="danger"] {
|
|
658
|
+
color: var(--wings-danger);
|
|
659
|
+
}
|
|
660
|
+
.w-option-label {
|
|
661
|
+
flex: 1;
|
|
662
|
+
min-width: 0;
|
|
663
|
+
overflow: hidden;
|
|
664
|
+
text-overflow: ellipsis;
|
|
665
|
+
white-space: nowrap;
|
|
666
|
+
}
|
|
667
|
+
.w-option-detail {
|
|
668
|
+
flex: none;
|
|
669
|
+
max-width: 45%;
|
|
670
|
+
overflow: hidden;
|
|
671
|
+
text-overflow: ellipsis;
|
|
672
|
+
white-space: nowrap;
|
|
673
|
+
font-size: 12px;
|
|
674
|
+
color: var(--wings-muted);
|
|
675
|
+
}
|
|
676
|
+
.w-option-empty {
|
|
677
|
+
padding: 4px 6px;
|
|
678
|
+
color: var(--wings-muted);
|
|
679
|
+
}
|
|
680
|
+
|
|
533
681
|
/* More behind a summary line. */
|
|
534
682
|
.w-disclosure > summary {
|
|
535
683
|
padding: 4px 6px;
|
|
@@ -543,5 +691,5 @@ export const fallbackCss = `/* wings-ui 1 start
|
|
|
543
691
|
animation: none;
|
|
544
692
|
}
|
|
545
693
|
}
|
|
546
|
-
/* wings-ui
|
|
694
|
+
/* wings-ui 2 end */
|
|
547
695
|
`;
|
package/ui.d.ts
CHANGED
|
@@ -43,9 +43,9 @@ export type Gap = "none" | "xs" | "s" | "m" | "l";
|
|
|
43
43
|
// ---------- layout and text ----------
|
|
44
44
|
|
|
45
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;
|
|
46
|
+
export function Row(props: Props<"div"> & { as?: As | "ul" | "ol"; gap?: Gap; align?: "start" | "baseline"; justify?: "end" | "between"; wrap?: boolean; grow?: boolean }): ReactElement;
|
|
47
|
+
/** Children one under the other. As a `ul` or `ol` it's a list with no bullets, of `li` children. */
|
|
48
|
+
export function Stack(props: Props<"div"> & { as?: As | "ul" | "ol"; gap?: Gap; grow?: boolean }): ReactElement;
|
|
49
49
|
/** Text with a tone and size, that can `grow` to fill a row or `truncate` with an ellipsis. */
|
|
50
50
|
export function Text(
|
|
51
51
|
props: Props<"span"> & {
|
|
@@ -65,9 +65,11 @@ export function Text(
|
|
|
65
65
|
},
|
|
66
66
|
): ReactElement;
|
|
67
67
|
/** 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
|
|
70
|
-
export function
|
|
68
|
+
export function Heading(props: Props<"h2"> & { level?: 2 | 3 | 4; actions?: ReactNode }): ReactElement;
|
|
69
|
+
/** 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. */
|
|
70
|
+
export function Title(props: Props<"h1"> & { as?: "h1" | "h2" | "h3"; size?: "large" }): ReactElement;
|
|
71
|
+
/** A card. `compact` for a summary line. As a `form` it holds a form; as a `ul` or `ol`, a list of `li`. */
|
|
72
|
+
export function Card(props: Props<"div"> & { as?: As | "form" | "article" | "ul" | "ol"; compact?: boolean }): ReactElement;
|
|
71
73
|
|
|
72
74
|
// ---------- figures ----------
|
|
73
75
|
|
|
@@ -98,7 +100,14 @@ export function ListButton(props: Props<"button"> & { align?: "baseline" }): Rea
|
|
|
98
100
|
// ---------- status ----------
|
|
99
101
|
|
|
100
102
|
/** A status dot, green unless `tone` says otherwise. With a `label` it's announced; otherwise it's decoration. */
|
|
101
|
-
export function Dot(
|
|
103
|
+
export function Dot(
|
|
104
|
+
props: Props<"span"> & {
|
|
105
|
+
tone?: Exclude<Tone, "success">;
|
|
106
|
+
/** A colour from your data, like a work item type's `#cc293d`, set as `--w-color`. Wins over `tone`. */
|
|
107
|
+
color?: string;
|
|
108
|
+
label?: string;
|
|
109
|
+
},
|
|
110
|
+
): ReactElement;
|
|
102
111
|
/** A spinner. With a `label` it's announced; otherwise it's decoration. */
|
|
103
112
|
export function Spinner(props: Props<"span"> & { size?: "large"; label?: string }): ReactElement;
|
|
104
113
|
/** A filled pill, like a label on a command. */
|
|
@@ -112,8 +121,11 @@ export function Empty(props: Omit<Props<"div">, "title"> & { title: ReactNode; d
|
|
|
112
121
|
|
|
113
122
|
// ---------- buttons ----------
|
|
114
123
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
124
|
+
/**
|
|
125
|
+
* A button, `type="button"` unless you say otherwise. `busy` shows a spinner and ignores clicks while it keeps the
|
|
126
|
+
* keyboard focus, unlike `disabled`: it's `aria-busy` and `aria-disabled`.
|
|
127
|
+
*/
|
|
128
|
+
export function Button(props: Props<"button"> & { variant?: "primary" | "ghost" | "danger"; size?: "small"; busy?: boolean }): ReactElement;
|
|
117
129
|
/** A small quiet button. With `pressed` it's a toggle. */
|
|
118
130
|
export function Chip(props: Props<"button"> & { pressed?: boolean }): ReactElement;
|
|
119
131
|
export interface Option<V extends string> {
|
|
@@ -122,14 +134,34 @@ export interface Option<V extends string> {
|
|
|
122
134
|
title?: string;
|
|
123
135
|
}
|
|
124
136
|
/** 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>(
|
|
137
|
+
export function Chips<V extends string>(
|
|
138
|
+
props: Omit<Props<"div">, "onChange"> & {
|
|
139
|
+
label: string;
|
|
140
|
+
/** Each chip's other props, like `id`, go to its button. */
|
|
141
|
+
options: (Option<V> & Omit<Props<"button">, "value" | "onClick" | "children">)[];
|
|
142
|
+
value: V;
|
|
143
|
+
onChange: (value: V) => void;
|
|
144
|
+
},
|
|
145
|
+
): ReactElement;
|
|
126
146
|
/** Tabs named `label` over the panel they show, `children`. Arrow keys, Home and End move between tabs. */
|
|
127
147
|
export function Tabs<V extends string>(props: Omit<Props<"div">, "onChange"> & { label: string; items: Option<V>[]; value: V; onChange: (value: V) => void }): ReactElement;
|
|
128
148
|
|
|
129
149
|
// ---------- fields ----------
|
|
130
150
|
|
|
131
151
|
/** A labelled field with a `hint` or an `error` under it, for the `TextInput`, `SearchField` or `Select` in `children`. */
|
|
132
|
-
export function Field(
|
|
152
|
+
export function Field(
|
|
153
|
+
props: Props<"div"> & {
|
|
154
|
+
label: ReactNode;
|
|
155
|
+
hint?: ReactNode;
|
|
156
|
+
error?: ReactNode;
|
|
157
|
+
/** The input's id, and the label's `htmlFor`, for a page that puts focus back by id. Generated otherwise. */
|
|
158
|
+
id?: string;
|
|
159
|
+
/** The label and the input on one line. */
|
|
160
|
+
inline?: boolean;
|
|
161
|
+
},
|
|
162
|
+
): ReactElement;
|
|
163
|
+
/** Several controls under one label (a `fieldset` and its `legend`), like checkboxes, with a `hint` or an `error` under them. */
|
|
164
|
+
export function FieldGroup(props: Props<"fieldset"> & { label: ReactNode; hint?: ReactNode; error?: ReactNode }): ReactElement;
|
|
133
165
|
/** A text input. Outside a `Field`, give it a `label`. */
|
|
134
166
|
export function TextInput(props: Props<"input"> & { label?: string; ref?: Ref<HTMLInputElement> }): ReactElement;
|
|
135
167
|
/** A search box. `label` names it for screen readers. */
|
|
@@ -140,5 +172,45 @@ export function Select<V extends string>(
|
|
|
140
172
|
): ReactElement;
|
|
141
173
|
/** A checkbox with its label, `children`. `onChange` gets whether it's checked. */
|
|
142
174
|
export function Checkbox(props: Omit<Props<"input">, "onChange" | "type"> & { onChange?: (checked: boolean) => void }): ReactElement;
|
|
175
|
+
export interface ComboboxOption {
|
|
176
|
+
value: string;
|
|
177
|
+
label: string;
|
|
178
|
+
/** A second, quieter part, like a project's client. */
|
|
179
|
+
detail?: string;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* A text field with `options` under it. Arrows move, Enter picks, Escape and Tab close, and pressing outside
|
|
183
|
+
* closes it. With `freeText` the field keeps what you type (`value`, `onInput`) and starts with nothing
|
|
184
|
+
* highlighted; otherwise you type to find an option and the field shows `value`, what you picked, once closed.
|
|
185
|
+
* `filter` decides what matches what's typed: the label or the detail containing it, by default.
|
|
186
|
+
*/
|
|
187
|
+
export function Combobox(
|
|
188
|
+
props: Omit<Props<"input">, "value" | "onInput" | "type"> & {
|
|
189
|
+
label?: string;
|
|
190
|
+
value: string;
|
|
191
|
+
onInput?: (text: string) => void;
|
|
192
|
+
options: ComboboxOption[];
|
|
193
|
+
onPick: (option: ComboboxOption) => void;
|
|
194
|
+
freeText?: boolean;
|
|
195
|
+
/** 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. */
|
|
196
|
+
openOnFocus?: boolean;
|
|
197
|
+
filter?: (option: ComboboxOption, query: string) => boolean;
|
|
198
|
+
/** What shows when nothing matches. */
|
|
199
|
+
empty?: ReactNode;
|
|
200
|
+
},
|
|
201
|
+
): ReactElement;
|
|
202
|
+
export interface MenuItem {
|
|
203
|
+
id: string;
|
|
204
|
+
label: ReactNode;
|
|
205
|
+
onSelect: () => void;
|
|
206
|
+
tone?: "danger";
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A button, showing `label`, that opens a menu of `items` under it. Arrows, Home and End move, Enter or Space
|
|
210
|
+
* picks, Escape closes and puts focus back on the button. `align` end lines the menu up with the button's right.
|
|
211
|
+
*/
|
|
212
|
+
export function Menu(
|
|
213
|
+
props: Omit<Props<"button">, "children"> & { label: ReactNode; items: MenuItem[]; align?: "end"; variant?: "primary" | "ghost" | "danger"; size?: "small" },
|
|
214
|
+
): ReactElement;
|
|
143
215
|
/** More behind a `summary` line that opens it. */
|
|
144
216
|
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
|
|
89
|
-
export function
|
|
90
|
-
|
|
88
|
+
/** A page's or a card's title: an `h1` unless `as` says otherwise. `size` large for a bigger one. */
|
|
89
|
+
export function Title({ as = "h1", size, className, ...rest }) {
|
|
90
|
+
return h(as, { ...rest, className: cx("w-title", className), "data-size": size });
|
|
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
|
-
/**
|
|
163
|
-
|
|
164
|
-
|
|
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(
|
|
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((
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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("
|
|
258
|
-
|
|
259
|
-
|
|
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,241 @@ 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
|
-
|
|
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, 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 first = freeText ? -1 : 0;
|
|
378
|
+
const shown = open ? options.filter((o) => filter(o, freeText ? value : query)) : [];
|
|
379
|
+
const current = Math.min(active, shown.length - 1);
|
|
380
|
+
const optionId = (i) => `${id}-option-${i}`;
|
|
381
|
+
|
|
382
|
+
const close = () => {
|
|
383
|
+
setOpen(false);
|
|
384
|
+
setQuery("");
|
|
385
|
+
setActive(-1);
|
|
386
|
+
};
|
|
387
|
+
const openAt = (i) => {
|
|
388
|
+
setOpen(true);
|
|
389
|
+
setActive(i);
|
|
390
|
+
};
|
|
391
|
+
const pick = (option) => {
|
|
392
|
+
onPick(option);
|
|
393
|
+
close();
|
|
394
|
+
};
|
|
395
|
+
useCloseOutside(open, root, close);
|
|
396
|
+
useLayoutEffect(() => {
|
|
397
|
+
if (current >= 0) list.current?.children[current]?.scrollIntoView({ block: "nearest" });
|
|
398
|
+
}, [current]);
|
|
399
|
+
|
|
400
|
+
const onKeyDown = (e) => {
|
|
401
|
+
if (e.key === "ArrowDown") {
|
|
402
|
+
e.preventDefault();
|
|
403
|
+
// A closed free-text field moves to the first option at once; a closed picker opens on its first.
|
|
404
|
+
if (!open) openAt(0);
|
|
405
|
+
else setActive(Math.min(current + 1, shown.length - 1));
|
|
406
|
+
} else if (e.key === "ArrowUp") {
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
setActive(Math.max(current - 1, first));
|
|
409
|
+
} else if (e.key === "Enter" && open && current >= 0) {
|
|
410
|
+
// Picking from the list doesn't also submit a form around it.
|
|
411
|
+
e.preventDefault();
|
|
412
|
+
pick(shown[current]);
|
|
413
|
+
} else if (e.key === "Escape" || e.key === "Tab") {
|
|
414
|
+
// An open list takes the Escape, so a form or dialog around it closes on the next one.
|
|
415
|
+
if (e.key === "Escape" && open) e.preventDefault();
|
|
416
|
+
close();
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
return h(
|
|
421
|
+
"div",
|
|
422
|
+
{ className: cx("w-combobox", className), ref: root },
|
|
423
|
+
h("input", {
|
|
424
|
+
"aria-label": label,
|
|
425
|
+
...field,
|
|
426
|
+
id,
|
|
427
|
+
className: "w-input",
|
|
428
|
+
role: "combobox",
|
|
429
|
+
"aria-autocomplete": "list",
|
|
430
|
+
"aria-controls": `${id}-list`,
|
|
431
|
+
"aria-expanded": open,
|
|
432
|
+
"aria-activedescendant": current >= 0 ? optionId(current) : undefined,
|
|
433
|
+
autoComplete: "off",
|
|
434
|
+
spellCheck: false,
|
|
435
|
+
value: freeText || !open ? value : query,
|
|
436
|
+
onFocus: () => {
|
|
437
|
+
if (!openOnFocus) return;
|
|
438
|
+
if (!freeText) setQuery("");
|
|
439
|
+
openAt(first);
|
|
440
|
+
},
|
|
441
|
+
// A field that kept focus after a pick or Escape opens again when you click it.
|
|
442
|
+
onClick: () => {
|
|
443
|
+
if (open) return;
|
|
444
|
+
if (!freeText) setQuery("");
|
|
445
|
+
openAt(first);
|
|
446
|
+
},
|
|
447
|
+
onChange: (e) => {
|
|
448
|
+
if (freeText) onInput?.(e.currentTarget.value);
|
|
449
|
+
else setQuery(e.currentTarget.value);
|
|
450
|
+
openAt(first);
|
|
451
|
+
},
|
|
452
|
+
onBlur: (e) => {
|
|
453
|
+
if (e.relatedTarget && !root.current.contains(e.relatedTarget)) close();
|
|
454
|
+
},
|
|
455
|
+
onKeyDown,
|
|
456
|
+
}),
|
|
457
|
+
h(
|
|
458
|
+
"div",
|
|
459
|
+
{ className: "w-listbox", hidden: !open },
|
|
460
|
+
h(
|
|
461
|
+
"ul",
|
|
462
|
+
{ id: `${id}-list`, role: "listbox", "aria-label": label, ref: list },
|
|
463
|
+
shown.map((o, i) =>
|
|
464
|
+
h(
|
|
465
|
+
"li",
|
|
466
|
+
{
|
|
467
|
+
key: o.value,
|
|
468
|
+
id: optionId(i),
|
|
469
|
+
role: "option",
|
|
470
|
+
className: "w-option",
|
|
471
|
+
"aria-selected": i === current,
|
|
472
|
+
// Before the field loses focus, so the pick lands.
|
|
473
|
+
onMouseDown: (e) => {
|
|
474
|
+
e.preventDefault();
|
|
475
|
+
pick(o);
|
|
476
|
+
},
|
|
477
|
+
onMouseMove: () => i !== current && setActive(i),
|
|
478
|
+
},
|
|
479
|
+
h("span", { className: "w-option-label" }, o.label),
|
|
480
|
+
o.detail ? h("span", { className: "w-option-detail" }, o.detail) : null,
|
|
481
|
+
),
|
|
482
|
+
),
|
|
483
|
+
),
|
|
484
|
+
open && !shown.length ? h("p", { className: "w-option-empty", role: "status" }, empty) : null,
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* A button that opens a menu of `items` under it, each `{ id, label, onSelect, tone? }`. Arrows, Home and End move,
|
|
491
|
+
* Enter or Space picks, Escape closes and puts focus back on the button. `align` end lines it up on the right.
|
|
492
|
+
*/
|
|
493
|
+
export function Menu({ label, items, align, variant, size, className, ...rest }) {
|
|
494
|
+
const id = useId();
|
|
495
|
+
const [open, setOpen] = useState(false);
|
|
496
|
+
const root = useRef(null);
|
|
497
|
+
const button = useRef(null);
|
|
498
|
+
const entries = useRef([]);
|
|
499
|
+
const close = (refocus) => {
|
|
500
|
+
setOpen(false);
|
|
501
|
+
if (refocus) button.current?.focus();
|
|
502
|
+
};
|
|
503
|
+
useCloseOutside(open, root, () => close(false));
|
|
504
|
+
useEffect(() => {
|
|
505
|
+
if (open) entries.current[0]?.focus();
|
|
506
|
+
}, [open]);
|
|
507
|
+
const select = (item) => {
|
|
508
|
+
close(true);
|
|
509
|
+
item.onSelect();
|
|
510
|
+
};
|
|
511
|
+
const onKeyDown = (e) => {
|
|
512
|
+
const at = entries.current.indexOf(document.activeElement);
|
|
513
|
+
const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: items.length - 1 }[e.key];
|
|
514
|
+
if (next !== undefined) {
|
|
515
|
+
e.preventDefault();
|
|
516
|
+
entries.current[(next + items.length) % items.length]?.focus();
|
|
517
|
+
} else if (e.key === "Enter" || e.key === " ") {
|
|
518
|
+
e.preventDefault();
|
|
519
|
+
if (at >= 0) select(items[at]);
|
|
520
|
+
} else if (e.key === "Escape") {
|
|
521
|
+
e.preventDefault();
|
|
522
|
+
close(true);
|
|
523
|
+
} else if (e.key === "Tab") close(false);
|
|
524
|
+
};
|
|
525
|
+
return h(
|
|
526
|
+
"div",
|
|
527
|
+
{ className: cx("w-menu-root", className), ref: root },
|
|
528
|
+
h(
|
|
529
|
+
"button",
|
|
530
|
+
{
|
|
531
|
+
...rest,
|
|
532
|
+
type: "button",
|
|
533
|
+
id: `${id}-button`,
|
|
534
|
+
ref: button,
|
|
535
|
+
className: "w-button",
|
|
536
|
+
"data-variant": variant,
|
|
537
|
+
"data-size": size,
|
|
538
|
+
"aria-haspopup": "menu",
|
|
539
|
+
"aria-expanded": open,
|
|
540
|
+
"aria-controls": open ? `${id}-menu` : undefined,
|
|
541
|
+
onClick: () => setOpen(!open),
|
|
542
|
+
onKeyDown: (e) => {
|
|
543
|
+
if (e.key === "ArrowDown" && !open) {
|
|
544
|
+
e.preventDefault();
|
|
545
|
+
setOpen(true);
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
label,
|
|
550
|
+
),
|
|
551
|
+
open
|
|
552
|
+
? h(
|
|
553
|
+
"ul",
|
|
554
|
+
{ id: `${id}-menu`, role: "menu", className: "w-menu", "aria-labelledby": `${id}-button`, "data-align": align, onKeyDown },
|
|
555
|
+
items.map((item, i) =>
|
|
556
|
+
h(
|
|
557
|
+
"li",
|
|
558
|
+
{
|
|
559
|
+
key: item.id,
|
|
560
|
+
role: "menuitem",
|
|
561
|
+
tabIndex: -1,
|
|
562
|
+
className: "w-menu-item",
|
|
563
|
+
"data-tone": item.tone,
|
|
564
|
+
ref: (el) => (entries.current[i] = el),
|
|
565
|
+
onClick: () => select(item),
|
|
566
|
+
onMouseMove: (e) => e.currentTarget.focus(),
|
|
567
|
+
},
|
|
568
|
+
item.label,
|
|
569
|
+
),
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
: null,
|
|
573
|
+
);
|
|
285
574
|
}
|
|
286
575
|
|
|
287
576
|
/** More behind a `summary` line that opens it. */
|