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 +4 -4
- package/package.json +5 -3
- package/ui-css.js +14 -0
- package/ui.d.ts +22 -8
- package/ui.js +19 -5
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
|
+
"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
|
-
/**
|
|
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"> & {
|
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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);
|