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.
Files changed (5) hide show
  1. package/README.md +42 -20
  2. package/package.json +6 -1
  3. package/ui-css.js +152 -4
  4. package/ui.d.ts +83 -11
  5. 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 that builds its DOM itself, without React, gets the same shell
109
+ `Generated by wings-plugin build` line). A popover's page takes its sidebar's title, the name Wings gives the
110
+ popover too. A page that builds its DOM itself, without React, gets the same shell
110
111
  from `mountShell(place, options)` in `wings-plugin-sdk/shell`, which returns the element to fill.
111
112
 
112
113
  ### Talking to main
@@ -119,28 +120,41 @@ component mounts, so main answers with what it has. `useBroadcast(listener)` get
119
120
 
120
121
  | Component | Props | Renders |
121
122
  |---|---|---|
122
- | `Row`, `Stack` | `gap` (`none`, `xs`, `s`, `m`, `l`), `grow`; `Row` also `align`, `justify`, `wrap`; `as` | Children side by side or one under the other |
123
+ | `Row`, `Stack` | `gap` (`none`, `xs`, `s`, `m`, `l`), `grow`; `Row` also `align`, `justify`, `wrap`; `as` | 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
- | `Heading` | `actions` | A section heading, `h2`, with chips or a button on its right |
125
- | `Card` | `compact` | A card |
125
+ | `Title` | `as` (`h1`, `h2`, `h3`), `size` (`large`) | 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 loads after Wings', so it wins where you
183
- style a component, like `.my-list .w-list-button { height: 24px; }`.
197
+ classes and two older helpers, `.muted` and `.card`; nothing else, so your other classes are yours. Your CSS
198
+ loads after Wings', so it wins where you style a component, like `.my-list .w-list-button { height: 24px; }`.
184
199
 
185
200
  ### The class contract
186
201
 
187
202
  The components render plain elements with `w-*` class names and data attributes, like
188
203
  `<div class="w-card" data-size="compact">`, and no styles of their own. Wings styles them in its
189
204
  `plugin-ui.css`, which ships with the app, so a Wings update restyles every plugin. The names are a versioned
190
- public contract: `--wings-ui` on the page's root is its version, `1` now, and a version only adds classes.
191
- `Meter` sets `--w-value`, how full it is, which is data rather than a style.
205
+ public contract: `--wings-ui` on the page's root is its version, `2` now, and a version only adds classes and
206
+ rules. `Meter` sets `--w-value`, how full it is, and a `Dot` with a `color` sets `--w-color`; both are data
207
+ rather than styles.
192
208
 
193
209
  `w-shell` with `data-place` (`sidebar`, `popover`, `view`, `tab`, `settings`) and `data-fill`; `w-row`,
194
210
  `w-stack` with `data-gap`, `data-align`, `data-justify`, `data-wrap`, `data-grow`; `w-text` with `data-tone`,
195
211
  `data-size`, `data-weight`, `data-mono`, `data-numeric`, `data-truncate`, `data-clamp`, `data-grow`;
196
- `w-heading`, `w-heading-row`; `w-card` with `data-size`; `w-gauge`, `w-gauge-label`, `w-gauge-value`,
212
+ `w-title` with `data-size`; `w-heading`, `w-heading-row`; `w-card` with `data-size`; `w-gauge`, `w-gauge-label`, `w-gauge-value`,
197
213
  `w-gauge-note`, `w-meter` with `data-tone`, `w-meter-fill`; `w-stats`, `w-stat-label`, `w-stat-value`;
198
214
  `w-props`, `w-prop`, `w-prop-label`, `w-prop-value`; `w-list` with `data-density`, `w-list-item`,
199
- `w-list-button` with `data-align`; `w-dot`, `w-pill`, `w-tag` with `data-tone`; `w-spinner` with `data-size`;
200
- `w-loading`, `w-empty`, `w-empty-detail`; `w-button` with `data-variant`, `data-size`; `w-chips`, `w-chip`;
201
- `w-tabs`, `w-tab`; `w-field`, `w-field-label`, `w-field-hint`, `w-input`, `w-search`, `w-select`, `w-check`;
202
- `w-disclosure`.
215
+ `w-list-button` with `data-align`; `w-dot` with `data-tone` or `data-color`; `w-pill`, `w-tag` with `data-tone`; `w-spinner` with `data-size`;
216
+ `w-loading`, `w-empty`, `w-empty-detail`; `w-button` with `data-variant`, `data-size`, `aria-disabled`; `w-chips`, `w-chip`;
217
+ `w-tabs`, `w-tab`; `w-field` with `data-layout`, `w-field-row`, `w-field-label`, `w-field-hint`,
218
+ `w-field-group`, `w-input`, `w-search`, `w-select`, `w-check`; `w-combobox`, `w-listbox`, `w-option`,
219
+ `w-option-label`, `w-option-detail`, `w-option-empty`; `w-menu-root`, `w-menu` with `data-align`, `w-menu-item`
220
+ with `data-tone`; `w-disclosure`. A `ul` or `ol` with `w-row`, `w-stack` or `w-card` loses its bullets.
221
+
222
+ Version 2 added `w-title`, `Heading`'s levels, `w-field-group`, `w-field-row`, the combobox and menu classes,
223
+ `data-color` on `w-dot`, busy buttons (`aria-disabled` on `w-button`), lists as rows, stacks and cards, and the four raised-layer variables.
203
224
 
204
225
  ### Older Wings
205
226
 
206
- Wings before 0.1.15 has no `w-*` styles. A page built with `wings-plugin-sdk/ui` checks `--wings-ui` when it
207
- mounts, and on a Wings without it, or with an older version, adds the same styles from the package, before
208
- your CSS. So a plugin works on older Wings without a `minWingsVersion`, and on a current Wings it always gets
227
+ Wings before 0.1.15 has no `w-*` styles, and a Wings with `--wings-ui: 1` lacks version 2's. A page built with
228
+ `wings-plugin-sdk/ui` checks `--wings-ui` when it mounts, and on a Wings without it, or with an older version,
229
+ adds the package's own copy of the styles, before Wings' and your CSS. Since a version only adds rules, the old
230
+ ones Wings has and the new ones from the package fit together. So a plugin works on older Wings without a `minWingsVersion`, and on a current Wings it always gets
209
231
  Wings' own styles.
210
232
 
211
233
  ### A linked SDK
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wings-plugin-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.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 = 1;
5
+ export const UI_VERSION = 2;
6
6
 
7
- export const fallbackCss = `/* wings-ui 1 start
7
+ export const fallbackCss = `/* wings-ui 2 start
8
8
  The classes wings-plugin-sdk/ui renders, a public contract: the names and data attributes stay, and Wings may
9
9
  restyle them. wings-plugin-sdk carries a copy of this block for older Wings without --wings-ui, so keep the
10
- two the same (apps/desktop/src/lib/plugin-ui.test.ts checks). Bump --wings-ui when a class is added. */
10
+ two the same (apps/desktop/src/lib/plugin-ui.test.ts checks). Bump --wings-ui when a class is added, and only add
11
+ rules: on an older Wings the SDK's copy loads before Wings' own, so a changed rule would lose to the old one. */
12
+
13
+ /* Raised layers, like a list of suggestions or a menu, as Wings' own menus draw them. */
14
+ :root {
15
+ --wings-menu-bg: rgb(32 29 35);
16
+ --wings-menu-ring: rgb(236 233 228 / 0.1);
17
+ --wings-menu-active: rgb(255 255 255 / 0.08);
18
+ --wings-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
19
+ }
11
20
 
12
21
  /* The page's place: a sidebar, popover, view (modal, window or tab) or settings page. */
13
22
  .w-shell {
@@ -44,6 +53,13 @@ export const fallbackCss = `/* wings-ui 1 start
44
53
  gap: 8px;
45
54
  min-width: 0;
46
55
  }
56
+ :is(ul, ol):is(.w-row, .w-stack, .w-card) {
57
+ margin: 0;
58
+ list-style: none;
59
+ }
60
+ :is(ul, ol):is(.w-row, .w-stack) {
61
+ padding: 0;
62
+ }
47
63
  :is(.w-row, .w-stack)[data-gap="none"] {
48
64
  gap: 0;
49
65
  }
@@ -126,6 +142,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 1 end */
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. `compact` for a summary line. */
70
- export function Card(props: Props<"div"> & { as?: As; compact?: boolean }): ReactElement;
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(props: Props<"span"> & { tone?: Exclude<Tone, "success">; label?: string }): ReactElement;
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
- /** A button, `type="button"` unless you say otherwise. */
116
- export function Button(props: Props<"button"> & { variant?: "primary" | "ghost" | "danger"; size?: "small" }): ReactElement;
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>(props: Omit<Props<"div">, "onChange"> & { label: string; options: Option<V>[]; value: V; onChange: (value: V) => void }): ReactElement;
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(props: Props<"div"> & { label: ReactNode; hint?: ReactNode; error?: ReactNode }): ReactElement;
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 section heading, with `actions` like chips or a button on its right. */
89
- export function Heading({ actions, className, ...rest }) {
90
- const heading = h("h2", { ...rest, className: cx("w-heading", className) });
88
+ /** A page's or a card's title: an `h1` unless `as` says otherwise. `size` large for a bigger one. */
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
- /** A status dot. With a `label` it's announced; otherwise it's decoration next to text that says the same. */
163
- export function Dot({ tone, label, className, ...rest }) {
164
- return h("span", { ...rest, className: cx("w-dot", className), "data-tone": tone, ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }) });
167
+ /**
168
+ * A status dot. With a `label` it's announced; otherwise it's decoration next to text that says the same.
169
+ * `color` is a colour from your data, like a work item type's; otherwise `tone` colours it.
170
+ */
171
+ export function Dot({ tone, color, label, className, style, ...rest }) {
172
+ return h("span", {
173
+ ...rest,
174
+ className: cx("w-dot", className),
175
+ // A colour that comes with the data, not a style: plugin-ui.css paints the dot with it, over any tone.
176
+ style: color ? { ...style, "--w-color": color } : style,
177
+ "data-color": color ? "" : undefined,
178
+ "data-tone": color ? undefined : tone,
179
+ ...(label ? { role: "img", "aria-label": label } : { "aria-hidden": true }),
180
+ });
165
181
  }
166
182
 
167
183
  /** A spinner. With a `label` it's announced. */
@@ -192,8 +208,23 @@ export function Empty({ title, detail, className, ...rest }) {
192
208
  // ---------- buttons ----------
193
209
 
194
210
  /** A button. `variant` is primary, ghost or danger; `size` small. */
195
- export function Button({ variant, size, type = "button", className, ...rest }) {
196
- return h("button", { ...rest, type, className: cx("w-button", className), "data-variant": variant, "data-size": size });
211
+ export function Button({ variant, size, busy, type = "button", onClick, children, className, ...rest }) {
212
+ return h(
213
+ "button",
214
+ {
215
+ ...rest,
216
+ type,
217
+ className: cx("w-button", className),
218
+ "data-variant": variant,
219
+ "data-size": size,
220
+ // Busy, not disabled: a disabled button would drop the keyboard focus on the page.
221
+ "aria-busy": busy || undefined,
222
+ "aria-disabled": busy || rest["aria-disabled"],
223
+ onClick: busy ? (e) => e.preventDefault() : onClick,
224
+ },
225
+ busy ? h(Spinner) : null,
226
+ children,
227
+ );
197
228
  }
198
229
 
199
230
  /** A small quiet button. With `pressed` it's a toggle. */
@@ -206,7 +237,7 @@ export function Chips({ label, options, value, onChange, className, ...rest }) {
206
237
  return h(
207
238
  "div",
208
239
  { ...rest, className: cx("w-chips", className), role: "group", "aria-label": label },
209
- options.map((o) => h(Chip, { key: o.value, pressed: o.value === value, title: o.title, onClick: () => onChange(o.value) }, o.label)),
240
+ options.map(({ value: v, label: text, ...chip }) => h(Chip, { ...chip, key: v, pressed: v === value, onClick: () => onChange(v) }, text)),
210
241
  );
211
242
  }
212
243
 
@@ -247,16 +278,40 @@ function useFieldProps(rest) {
247
278
  return field ? { id: field.id, "aria-describedby": field.hint, "aria-invalid": field.invalid || undefined, ...rest } : rest;
248
279
  }
249
280
 
250
- /** A labelled field with a `hint` or an `error` under it, for the input in `children`. */
251
- export function Field({ label, hint, error, children, className, ...rest }) {
252
- const id = useId();
253
- const note = error ?? hint;
281
+ /** A hint or an error under a field or a group. */
282
+ const fieldNote = (id, hint, error) => (error ?? hint ? h("p", { id, className: "w-field-hint", "data-tone": error ? "danger" : undefined }, error ?? hint) : null);
283
+
284
+ /**
285
+ * A labelled field with a `hint` or an `error` under it, for the input in `children`. `id` is the input's id, for
286
+ * a page that puts focus back by id; `inline` puts the label and the input on one line.
287
+ */
288
+ export function Field({ id: given, label, hint, error, inline, children, className, ...rest }) {
289
+ const generated = useId();
290
+ const id = given ?? generated;
291
+ const hintId = error ?? hint ? `${id}-hint` : undefined;
292
+ const labelled = h("label", { className: "w-field-label", htmlFor: id }, label);
293
+ const control = h(FieldContext.Provider, { value: { id, hint: hintId, invalid: Boolean(error) } }, children);
254
294
  return h(
255
295
  "div",
256
- { ...rest, className: cx("w-field", className) },
257
- h("label", { className: "w-field-label", htmlFor: id }, label),
258
- h(FieldContext.Provider, { value: { id, hint: note ? `${id}-hint` : undefined, invalid: Boolean(error) } }, children),
259
- note ? h("p", { id: `${id}-hint`, className: "w-field-hint", "data-tone": error ? "danger" : undefined }, note) : null,
296
+ { ...rest, className: cx("w-field", className), "data-layout": inline ? "inline" : undefined },
297
+ inline ? h("div", { className: "w-field-row" }, labelled, control) : labelled,
298
+ inline ? null : control,
299
+ fieldNote(hintId, hint, error),
300
+ );
301
+ }
302
+
303
+ const GroupContext = createContext(null);
304
+
305
+ /** Several controls under one label, like a list of checkboxes, with a `hint` or an `error` under them. */
306
+ export function FieldGroup({ label, hint, error, children, className, ...rest }) {
307
+ const id = useId();
308
+ const hintId = error ?? hint ? `${id}-hint` : undefined;
309
+ return h(
310
+ "fieldset",
311
+ { ...rest, className: cx("w-field-group", className), "aria-describedby": hintId },
312
+ h("legend", { className: "w-field-label" }, label),
313
+ h(GroupContext.Provider, { value: { invalid: Boolean(error) } }, children),
314
+ fieldNote(hintId, hint, error),
260
315
  );
261
316
  }
262
317
 
@@ -281,7 +336,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
- return h("label", { className: cx("w-check", className) }, h("input", { ...rest, type: "checkbox", onChange: (e) => onChange?.(e.currentTarget.checked) }), children);
339
+ // In a FieldGroup with an error, each box says so: a group can't be marked invalid itself.
340
+ const group = useContext(GroupContext);
341
+ return h("label", { className: cx("w-check", className) }, h("input", { "aria-invalid": group?.invalid || undefined, ...rest, type: "checkbox", onChange: (e) => onChange?.(e.currentTarget.checked) }), children);
342
+ }
343
+
344
+ const contains = (option, query) => {
345
+ const q = query.trim().toLowerCase();
346
+ return !q || option.label.toLowerCase().includes(q) || Boolean(option.detail?.toLowerCase().includes(q));
347
+ };
348
+
349
+ /** Closes a raised list when you press the mouse outside `root`, while it's open. */
350
+ function useCloseOutside(open, root, close) {
351
+ const latest = useRef(close);
352
+ latest.current = close;
353
+ useEffect(() => {
354
+ if (!open) return;
355
+ const away = (e) => {
356
+ if (!root.current?.contains(e.target)) latest.current();
357
+ };
358
+ document.addEventListener("mousedown", away);
359
+ return () => document.removeEventListener("mousedown", away);
360
+ }, [open, root]);
361
+ }
362
+
363
+ /**
364
+ * A text field with `options` under it. Arrows move, Enter picks, Escape and Tab close. With `freeText` the field
365
+ * keeps what you type (`value`, `onInput`) and starts with nothing highlighted; otherwise you type to find an
366
+ * option, the field shows `value` once you've picked, and the list starts on the first option.
367
+ */
368
+ export function Combobox({ label, value, onInput, options, onPick, 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. */