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.
- package/README.md +42 -20
- package/package.json +9 -2
- package/ui-css.js +166 -4
- package/ui.d.ts +98 -12
- 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
|
|
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
|
-
| `
|
|
125
|
-
| `
|
|
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
|
|
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.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 =
|
|
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,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
|
|
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
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
70
|
-
|
|
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(
|
|
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
|
-
/**
|
|
116
|
-
|
|
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>(
|
|
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(
|
|
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
|
|
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. `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
|
-
/**
|
|
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,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
|
-
|
|
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. */
|