wings-plugin-sdk 0.4.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 (4) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/ui.d.ts +8 -0
  4. package/ui.js +21 -13
package/README.md CHANGED
@@ -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`, `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/) |
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.4.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",
package/ui.d.ts CHANGED
@@ -208,6 +208,14 @@ export function Combobox(
208
208
  freeText?: boolean;
209
209
  /** Open the list when the field gets focus, by default; false opens it on a click, a key or typing only, for a page that puts focus back by id after it renders. */
210
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";
211
219
  filter?: (option: ComboboxOption, query: string) => boolean;
212
220
  /** What shows when nothing matches. */
213
221
  empty?: ReactNode;
package/ui.js CHANGED
@@ -363,37 +363,45 @@ 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, onEnter, onKeyDown: onKeyDownProp, 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);
377
378
  const input = useRef(null);
378
379
  const first = freeText ? -1 : 0;
379
- 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;
380
386
  const current = Math.min(active, shown.length - 1);
381
387
  const optionId = (i) => `${id}-option-${i}`;
382
388
 
383
389
  const close = () => {
384
- setOpen(false);
390
+ setOpened(false);
385
391
  setQuery("");
386
392
  setActive(-1);
387
393
  };
394
+ // Escape, Tab and a press outside: a "whileText" list stays, so only the highlight goes.
395
+ const dismiss = whileText ? () => setActive(-1) : close;
388
396
  const openAt = (i) => {
389
- setOpen(true);
397
+ setOpened(true);
390
398
  setActive(i);
391
399
  };
392
400
  const pick = (option) => {
393
401
  onPick(option);
394
402
  close();
395
403
  };
396
- useCloseOutside(open, root, close);
404
+ useCloseOutside(open && !whileText, root, dismiss);
397
405
  useLayoutEffect(() => {
398
406
  if (current >= 0) list.current?.children[current]?.scrollIntoView({ block: "nearest" });
399
407
  }, [current]);
@@ -424,8 +432,8 @@ export function Combobox({ label, value, onInput, options, onPick, onEnter, onKe
424
432
  onEnter(e.currentTarget.value);
425
433
  } else if (e.key === "Escape" || e.key === "Tab") {
426
434
  // An open list takes the Escape, so a form or dialog around it closes on the next one.
427
- if (e.key === "Escape" && open) e.preventDefault();
428
- close();
435
+ if (e.key === "Escape" && (whileText ? current >= 0 : open)) e.preventDefault();
436
+ dismiss();
429
437
  }
430
438
  };
431
439
 
@@ -441,7 +449,7 @@ export function Combobox({ label, value, onInput, options, onPick, onEnter, onKe
441
449
  role: "combobox",
442
450
  "aria-autocomplete": "list",
443
451
  "aria-controls": `${id}-list`,
444
- "aria-expanded": open,
452
+ "aria-expanded": visible,
445
453
  "aria-activedescendant": current >= 0 ? optionId(current) : undefined,
446
454
  autoComplete: "off",
447
455
  spellCheck: false,
@@ -464,13 +472,13 @@ export function Combobox({ label, value, onInput, options, onPick, onEnter, onKe
464
472
  openAt(first);
465
473
  },
466
474
  onBlur: (e) => {
467
- if (e.relatedTarget && !root.current.contains(e.relatedTarget)) close();
475
+ if (e.relatedTarget && !root.current.contains(e.relatedTarget)) dismiss();
468
476
  },
469
477
  onKeyDown,
470
478
  }),
471
479
  h(
472
480
  "div",
473
- { className: "w-listbox", hidden: !open },
481
+ { className: "w-listbox", hidden: !visible },
474
482
  h(
475
483
  "ul",
476
484
  { id: `${id}-list`, role: "listbox", "aria-label": label, ref: list },
@@ -495,7 +503,7 @@ export function Combobox({ label, value, onInput, options, onPick, onEnter, onKe
495
503
  ),
496
504
  ),
497
505
  ),
498
- 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,
499
507
  ),
500
508
  );
501
509
  }