wings-plugin-sdk 0.3.0 → 0.5.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 +4 -4
  2. package/package.json +5 -3
  3. package/ui-css.js +14 -0
  4. package/ui.d.ts +30 -8
  5. package/ui.js +39 -17
package/README.md CHANGED
@@ -120,11 +120,11 @@ component mounts, so main answers with what it has. `useBroadcast(listener)` get
120
120
 
121
121
  | Component | Props | Renders |
122
122
  |---|---|---|
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
+ | `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` |
124
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` |
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 |
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
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 |
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` |
128
128
  | `Gauge`, `Meter` | `label`, `percent`, `note`, `tone` or `levels` | A labelled bar with its percentage, or the bar alone, with the meter role |
129
129
  | `Stats` | `items: { label, value, tone? }[]` | Small figures side by side, label over value |
130
130
  | `Properties` | `items: { label, value }[]` | Label and value pairs |
@@ -139,7 +139,7 @@ component mounts, so main answers with what it has. `useBroadcast(listener)` get
139
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 |
140
140
  | `TextInput`, `SearchField`, `Select` | `label`; `Select`: `options`, `value`, `onChange` | Inputs Wings styles; outside a `Field`, `label` names them |
141
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 |
142
+ | `Combobox` | `label`, `value`, `onInput`, `options: { value, label, detail? }[]`, `onPick`, `onEnter`, `onKeyDown`, `freeText`, `openOnFocus`, `open`, `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/). `open="whileText"` keeps the list shown for as long as the field has text and an option matches, even after Escape, Tab or a press outside (those only clear the highlighted option) and it closes when the text is emptied or an option is picked; `aria-expanded` follows whether the list is shown. Accessibility caveat: the APG pattern has Escape dismiss the popup and treats it as shown only while the user is working in the combobox, so this mode departs from it. Use it only where the suggestions are the page's main content, such as a people search that is useful to keep in view, and not for a field where a stray list would cover other controls |
143
143
  | `Menu` | `label`, `items: { id, label, onSelect, tone? }[]`, `align`, `variant`, `size` | A button that opens a menu: arrows, Home, End, Enter, Escape |
144
144
  | `Disclosure` | `summary` | More behind a line that opens it |
145
145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wings-plugin-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.5.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"
@@ -70,8 +70,10 @@
70
70
  }
71
71
  },
72
72
  "devDependencies": {
73
+ "@types/react": "^19.1.8",
73
74
  "jsdom": "^30.1.2",
74
75
  "react": "^19.1.0",
75
- "react-dom": "^19.1.0"
76
+ "react-dom": "^19.1.0",
77
+ "typescript": "~6.0.3"
76
78
  }
77
79
  }
package/ui-css.js CHANGED
@@ -152,6 +152,15 @@ export const fallbackCss = `/* wings-ui 2 start
152
152
  .w-title[data-size="large"] {
153
153
  font-size: 15px;
154
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
+ }
155
164
 
156
165
  /* A section heading, with its controls on the right. */
157
166
  .w-heading {
@@ -484,6 +493,11 @@ export const fallbackCss = `/* wings-ui 2 start
484
493
  padding: 0 8px;
485
494
  font-size: 12px;
486
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
+ }
487
501
 
488
502
  /* Chips: small quiet buttons, and a group of them that filters what shows. */
489
503
  .w-chips {
package/ui.d.ts CHANGED
@@ -37,15 +37,24 @@ export function useMainState<T extends { type: string }>(type: T["type"], hello?
37
37
  export type Tone = "muted" | "info" | "success" | "warn" | "danger";
38
38
  type Props<Tag extends keyof JSX.IntrinsicElements> = ComponentPropsWithoutRef<Tag>;
39
39
  type As = "div" | "span" | "p" | "section" | "header" | "footer" | "strong" | "time" | "label" | "li";
40
+ /** The elements `Row` and `Stack` can be. */
41
+ type BoxAs = As | "form" | "ul" | "ol";
42
+ /** The elements `Card` can be. */
43
+ type CardAs = As | "form" | "article" | "ul" | "ol";
44
+ /** The props of element `E`, with `own` on top: where they share a name, `own` wins. */
45
+ type Own<E extends keyof JSX.IntrinsicElements, own> = Omit<Props<E>, keyof own> & own;
40
46
  /** Spacing between children: none, xs (2px), s (4px), m (8px, the default) or l (12px), set by Wings. */
41
47
  export type Gap = "none" | "xs" | "s" | "m" | "l";
42
48
 
43
49
  // ---------- layout and text ----------
44
50
 
45
- /** Children side by side, centred. */
46
- export function Row(props: Props<"div"> & { as?: As | "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;
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"> & {
@@ -67,9 +76,10 @@ export function Text(
67
76
  /** A section heading, an `h2`, with `actions` like chips or a button on its right. */
68
77
  export function Heading(props: Props<"h2"> & { level?: 2 | 3 | 4; actions?: ReactNode }): ReactElement;
69
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. */
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;
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;
73
83
 
74
84
  // ---------- figures ----------
75
85
 
@@ -182,7 +192,9 @@ export interface ComboboxOption {
182
192
  * A text field with `options` under it. Arrows move, Enter picks, Escape and Tab close, and pressing outside
183
193
  * closes it. With `freeText` the field keeps what you type (`value`, `onInput`) and starts with nothing
184
194
  * 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.
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.
186
198
  */
187
199
  export function Combobox(
188
200
  props: Omit<Props<"input">, "value" | "onInput" | "type"> & {
@@ -191,9 +203,19 @@ export function Combobox(
191
203
  onInput?: (text: string) => void;
192
204
  options: ComboboxOption[];
193
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;
194
208
  freeText?: boolean;
195
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. */
196
210
  openOnFocus?: boolean;
211
+ /**
212
+ * When the list is shown. `"auto"` (default): on focus, a click, a key or typing, and Escape, Tab, blur to another
213
+ * element or a press outside close it. `"whileText"`: the list is shown exactly while the field has text and an
214
+ * option matches it, whether or not the field has focus. Escape, Tab and a press outside only clear the highlighted
215
+ * option (`aria-activedescendant`); emptying the text or picking closes it, and there's no `empty` message. A
216
+ * list left open after the field loses focus is a screen reader caveat, see the README.
217
+ */
218
+ open?: "auto" | "whileText";
197
219
  filter?: (option: ComboboxOption, query: string) => boolean;
198
220
  /** What shows when nothing matches. */
199
221
  empty?: ReactNode;
package/ui.js CHANGED
@@ -85,9 +85,9 @@ export function Text({ as = "span", tone, size, weight, mono, numeric, truncate,
85
85
  });
86
86
  }
87
87
 
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 });
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
91
  }
92
92
 
93
93
  /** A small section heading, `h2` unless `level` says 3 or 4, with `actions` like chips or a button on its right. */
@@ -363,41 +363,57 @@ function useCloseOutside(open, root, close) {
363
363
  /**
364
364
  * A text field with `options` under it. Arrows move, Enter picks, Escape and Tab close. With `freeText` the field
365
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.
366
+ * option, the field shows `value` once you've picked, and the list starts on the first option. `open="whileText"`
367
+ * keeps the list shown while the field has text and something matches, which Escape, Tab and a press outside don't close.
367
368
  */
368
- export function Combobox({ label, value, onInput, options, onPick, freeText = false, openOnFocus = true, filter = contains, empty = "No matches.", className, ...rest }) {
369
+ export function Combobox({ label, value, onInput, options, onPick, onEnter, onKeyDown: onKeyDownProp, freeText = false, openOnFocus = true, open: openMode = "auto", filter = contains, empty = "No matches.", className, ...rest }) {
369
370
  const field = useFieldProps(rest);
370
371
  const generated = useId();
371
372
  const id = field.id ?? generated;
372
- const [open, setOpen] = useState(false);
373
+ const [opened, setOpened] = useState(false);
373
374
  const [query, setQuery] = useState("");
374
375
  const [active, setActive] = useState(-1);
375
376
  const root = useRef(null);
376
377
  const list = useRef(null);
378
+ const input = useRef(null);
377
379
  const first = freeText ? -1 : 0;
378
- const shown = open ? options.filter((o) => filter(o, freeText ? value : query)) : [];
380
+ const text = freeText ? value : query;
381
+ // "whileText": the list is open exactly while there is text and something matches it, whatever has focus.
382
+ const whileText = openMode === "whileText";
383
+ const open = whileText ? text !== "" : opened;
384
+ const shown = open ? options.filter((o) => filter(o, text)) : [];
385
+ const visible = whileText ? shown.length > 0 : open;
379
386
  const current = Math.min(active, shown.length - 1);
380
387
  const optionId = (i) => `${id}-option-${i}`;
381
388
 
382
389
  const close = () => {
383
- setOpen(false);
390
+ setOpened(false);
384
391
  setQuery("");
385
392
  setActive(-1);
386
393
  };
394
+ // Escape, Tab and a press outside: a "whileText" list stays, so only the highlight goes.
395
+ const dismiss = whileText ? () => setActive(-1) : close;
387
396
  const openAt = (i) => {
388
- setOpen(true);
397
+ setOpened(true);
389
398
  setActive(i);
390
399
  };
391
400
  const pick = (option) => {
392
401
  onPick(option);
393
402
  close();
394
403
  };
395
- useCloseOutside(open, root, close);
404
+ useCloseOutside(open && !whileText, root, dismiss);
396
405
  useLayoutEffect(() => {
397
406
  if (current >= 0) list.current?.children[current]?.scrollIntoView({ block: "nearest" });
398
407
  }, [current]);
408
+ // A picker shows what was picked while it's closed, so select that text: typing replaces it, as it does in a
409
+ // native select-and-type field, instead of landing after it. MUI's Autocomplete does the same (`selectOnFocus`).
410
+ useLayoutEffect(() => {
411
+ if (!freeText && !open && document.activeElement === input.current) input.current.select();
412
+ }, [freeText, open, value]);
399
413
 
400
414
  const onKeyDown = (e) => {
415
+ onKeyDownProp?.(e);
416
+ if (e.defaultPrevented) return;
401
417
  if (e.key === "ArrowDown") {
402
418
  e.preventDefault();
403
419
  // A closed free-text field moves to the first option at once; a closed picker opens on its first.
@@ -410,10 +426,14 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
410
426
  // Picking from the list doesn't also submit a form around it.
411
427
  e.preventDefault();
412
428
  pick(shown[current]);
429
+ } else if (e.key === "Enter" && onEnter) {
430
+ // Nothing highlighted: the text is the answer, and Enter doesn't submit a form around it.
431
+ e.preventDefault();
432
+ onEnter(e.currentTarget.value);
413
433
  } else if (e.key === "Escape" || e.key === "Tab") {
414
434
  // 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();
435
+ if (e.key === "Escape" && (whileText ? current >= 0 : open)) e.preventDefault();
436
+ dismiss();
417
437
  }
418
438
  };
419
439
 
@@ -424,16 +444,18 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
424
444
  "aria-label": label,
425
445
  ...field,
426
446
  id,
447
+ ref: input,
427
448
  className: "w-input",
428
449
  role: "combobox",
429
450
  "aria-autocomplete": "list",
430
451
  "aria-controls": `${id}-list`,
431
- "aria-expanded": open,
452
+ "aria-expanded": visible,
432
453
  "aria-activedescendant": current >= 0 ? optionId(current) : undefined,
433
454
  autoComplete: "off",
434
455
  spellCheck: false,
435
456
  value: freeText || !open ? value : query,
436
- onFocus: () => {
457
+ onFocus: (e) => {
458
+ if (!freeText) e.currentTarget.select();
437
459
  if (!openOnFocus) return;
438
460
  if (!freeText) setQuery("");
439
461
  openAt(first);
@@ -450,13 +472,13 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
450
472
  openAt(first);
451
473
  },
452
474
  onBlur: (e) => {
453
- if (e.relatedTarget && !root.current.contains(e.relatedTarget)) close();
475
+ if (e.relatedTarget && !root.current.contains(e.relatedTarget)) dismiss();
454
476
  },
455
477
  onKeyDown,
456
478
  }),
457
479
  h(
458
480
  "div",
459
- { className: "w-listbox", hidden: !open },
481
+ { className: "w-listbox", hidden: !visible },
460
482
  h(
461
483
  "ul",
462
484
  { id: `${id}-list`, role: "listbox", "aria-label": label, ref: list },
@@ -481,7 +503,7 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
481
503
  ),
482
504
  ),
483
505
  ),
484
- open && !shown.length ? h("p", { className: "w-option-empty", role: "status" }, empty) : null,
506
+ open && !whileText && !shown.length ? h("p", { className: "w-option-empty", role: "status" }, empty) : null,
485
507
  ),
486
508
  );
487
509
  }