wings-plugin-sdk 0.3.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 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`, `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
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.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"
@@ -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,6 +203,8 @@ 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;
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. */
@@ -365,7 +365,7 @@ function useCloseOutside(open, root, close) {
365
365
  * keeps what you type (`value`, `onInput`) and starts with nothing highlighted; otherwise you type to find an
366
366
  * option, the field shows `value` once you've picked, and the list starts on the first option.
367
367
  */
368
- export function Combobox({ label, value, onInput, options, onPick, freeText = false, openOnFocus = true, filter = contains, empty = "No matches.", className, ...rest }) {
368
+ export function Combobox({ label, value, onInput, options, onPick, onEnter, onKeyDown: onKeyDownProp, freeText = false, openOnFocus = true, filter = contains, empty = "No matches.", className, ...rest }) {
369
369
  const field = useFieldProps(rest);
370
370
  const generated = useId();
371
371
  const id = field.id ?? generated;
@@ -374,6 +374,7 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
374
374
  const [active, setActive] = useState(-1);
375
375
  const root = useRef(null);
376
376
  const list = useRef(null);
377
+ const input = useRef(null);
377
378
  const first = freeText ? -1 : 0;
378
379
  const shown = open ? options.filter((o) => filter(o, freeText ? value : query)) : [];
379
380
  const current = Math.min(active, shown.length - 1);
@@ -396,8 +397,15 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
396
397
  useLayoutEffect(() => {
397
398
  if (current >= 0) list.current?.children[current]?.scrollIntoView({ block: "nearest" });
398
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]);
399
405
 
400
406
  const onKeyDown = (e) => {
407
+ onKeyDownProp?.(e);
408
+ if (e.defaultPrevented) return;
401
409
  if (e.key === "ArrowDown") {
402
410
  e.preventDefault();
403
411
  // A closed free-text field moves to the first option at once; a closed picker opens on its first.
@@ -410,6 +418,10 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
410
418
  // Picking from the list doesn't also submit a form around it.
411
419
  e.preventDefault();
412
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);
413
425
  } else if (e.key === "Escape" || e.key === "Tab") {
414
426
  // An open list takes the Escape, so a form or dialog around it closes on the next one.
415
427
  if (e.key === "Escape" && open) e.preventDefault();
@@ -424,6 +436,7 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
424
436
  "aria-label": label,
425
437
  ...field,
426
438
  id,
439
+ ref: input,
427
440
  className: "w-input",
428
441
  role: "combobox",
429
442
  "aria-autocomplete": "list",
@@ -433,7 +446,8 @@ export function Combobox({ label, value, onInput, options, onPick, freeText = fa
433
446
  autoComplete: "off",
434
447
  spellCheck: false,
435
448
  value: freeText || !open ? value : query,
436
- onFocus: () => {
449
+ onFocus: (e) => {
450
+ if (!freeText) e.currentTarget.select();
437
451
  if (!openOnFocus) return;
438
452
  if (!freeText) setQuery("");
439
453
  openAt(first);