@astralui/core 0.1.12 → 0.1.14

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/dist/index.d.cts CHANGED
@@ -68,7 +68,7 @@ interface AstralSelectOption {
68
68
  label: string;
69
69
  }
70
70
  /** Astral select with optional search/create/clear - combobox. */
71
- declare function AstralSelect({ value, onChange, options, placeholder, disabled, searchable, searchPlaceholder, noResults, clearable, creatable }: {
71
+ declare function AstralSelect({ value, onChange, options, placeholder, disabled, searchable, searchPlaceholder, noResults, clearable, creatable, id, ariaLabel, ariaLabelledBy, clearLabel }: {
72
72
  value: string | null;
73
73
  onChange: (value: string) => void;
74
74
  options: AstralSelectOption[];
@@ -79,6 +79,14 @@ declare function AstralSelect({ value, onChange, options, placeholder, disabled,
79
79
  noResults?: string;
80
80
  clearable?: boolean;
81
81
  creatable?: boolean;
82
+ /** Put this on the trigger so a visible <label htmlFor> can name it. */
83
+ id?: string;
84
+ /** Name the control when there is no visible label to point at. */
85
+ ariaLabel?: string;
86
+ /** Id of the visible label element naming this control. */
87
+ ariaLabelledBy?: string;
88
+ /** Accessible name for the clear button on a `clearable` select. */
89
+ clearLabel?: string;
82
90
  }): react.JSX.Element;
83
91
  interface AstralMenuItem {
84
92
  key?: string;
@@ -140,8 +148,11 @@ interface Props {
140
148
  style?: CSSProperties;
141
149
  disabled?: boolean;
142
150
  'aria-label'?: string;
151
+ /** So a visible <label htmlFor> can name it. aria-label names the control
152
+ * too, but only htmlFor also makes clicking the label focus it. */
153
+ id?: string;
143
154
  }
144
- declare function DateInput({ type, value, onChange, min, max, className, style, disabled, 'aria-label': ariaLabel }: Props): react.JSX.Element;
155
+ declare function DateInput({ type, value, onChange, min, max, className, style, disabled, id, 'aria-label': ariaLabel }: Props): react.JSX.Element;
145
156
 
146
157
  declare function Spinner({ size, className, style }: {
147
158
  size?: number;
package/dist/index.d.ts CHANGED
@@ -68,7 +68,7 @@ interface AstralSelectOption {
68
68
  label: string;
69
69
  }
70
70
  /** Astral select with optional search/create/clear - combobox. */
71
- declare function AstralSelect({ value, onChange, options, placeholder, disabled, searchable, searchPlaceholder, noResults, clearable, creatable }: {
71
+ declare function AstralSelect({ value, onChange, options, placeholder, disabled, searchable, searchPlaceholder, noResults, clearable, creatable, id, ariaLabel, ariaLabelledBy, clearLabel }: {
72
72
  value: string | null;
73
73
  onChange: (value: string) => void;
74
74
  options: AstralSelectOption[];
@@ -79,6 +79,14 @@ declare function AstralSelect({ value, onChange, options, placeholder, disabled,
79
79
  noResults?: string;
80
80
  clearable?: boolean;
81
81
  creatable?: boolean;
82
+ /** Put this on the trigger so a visible <label htmlFor> can name it. */
83
+ id?: string;
84
+ /** Name the control when there is no visible label to point at. */
85
+ ariaLabel?: string;
86
+ /** Id of the visible label element naming this control. */
87
+ ariaLabelledBy?: string;
88
+ /** Accessible name for the clear button on a `clearable` select. */
89
+ clearLabel?: string;
82
90
  }): react.JSX.Element;
83
91
  interface AstralMenuItem {
84
92
  key?: string;
@@ -140,8 +148,11 @@ interface Props {
140
148
  style?: CSSProperties;
141
149
  disabled?: boolean;
142
150
  'aria-label'?: string;
151
+ /** So a visible <label htmlFor> can name it. aria-label names the control
152
+ * too, but only htmlFor also makes clicking the label focus it. */
153
+ id?: string;
143
154
  }
144
- declare function DateInput({ type, value, onChange, min, max, className, style, disabled, 'aria-label': ariaLabel }: Props): react.JSX.Element;
155
+ declare function DateInput({ type, value, onChange, min, max, className, style, disabled, id, 'aria-label': ariaLabel }: Props): react.JSX.Element;
145
156
 
146
157
  declare function Spinner({ size, className, style }: {
147
158
  size?: number;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createContext, useState, useEffect, useCallback, useContext, useMemo, useRef, useSyncExternalStore, useLayoutEffect } from 'react';
1
+ import { createContext, useState, useEffect, useCallback, useContext, useMemo, useRef, useId, useSyncExternalStore, useLayoutEffect } from 'react';
2
2
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
3
  import chroma from 'chroma-js';
4
4
  import { createPortal } from 'react-dom';
@@ -361,11 +361,16 @@ function AstralPinInput({ length = 6, value, onChange, autoFocus }) {
361
361
  i
362
362
  )) });
363
363
  }
364
- function AstralSelect({ value, onChange, options, placeholder, disabled, searchable, searchPlaceholder, noResults, clearable, creatable }) {
364
+ function AstralSelect({ value, onChange, options, placeholder, disabled, searchable, searchPlaceholder, noResults, clearable, creatable, id, ariaLabel, ariaLabelledBy, clearLabel = "Clear selection" }) {
365
365
  const [open, setOpen] = useState(false);
366
366
  const [q, setQ] = useState("");
367
367
  const triggerRef = useRef(null);
368
368
  const menuRef = useRef(null);
369
+ const [activeIndex, setActiveIndex] = useState(-1);
370
+ const reactId = useId();
371
+ const baseId = id ?? `au-select-${reactId}`;
372
+ const listboxId = `${baseId}-listbox`;
373
+ const optionId = (i) => `${baseId}-opt-${i}`;
369
374
  const [pos, setPos] = useState(null);
370
375
  const reposition = useCallback(() => {
371
376
  const el = triggerRef.current;
@@ -381,7 +386,10 @@ function AstralSelect({ value, onChange, options, placeholder, disabled, searcha
381
386
  });
382
387
  }, []);
383
388
  useEffect(() => {
384
- if (!open) return;
389
+ if (!open) {
390
+ setActiveIndex(-1);
391
+ return;
392
+ }
385
393
  reposition();
386
394
  const onScroll = () => reposition();
387
395
  const onResize = () => setOpen(false);
@@ -404,28 +412,116 @@ function AstralSelect({ value, onChange, options, placeholder, disabled, searcha
404
412
  const display = selected ? selected.label : value ?? "";
405
413
  const filtered = searchable && q.trim() ? options.filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase())) : options;
406
414
  const showCreate = !!creatable && !!q.trim() && !options.some((o) => o.label.toLowerCase() === q.trim().toLowerCase());
415
+ const rows = [...filtered.map((o) => o.value), ...showCreate ? [q.trim()] : []];
416
+ useEffect(() => {
417
+ if (!open || activeIndex < 0) return;
418
+ const el = menuRef.current?.querySelector(`#${CSS.escape(optionId(activeIndex))}`);
419
+ el?.scrollIntoView({ block: "nearest" });
420
+ }, [open, activeIndex, baseId]);
421
+ const commit = (index) => {
422
+ const picked = rows[index];
423
+ if (picked == null) return;
424
+ onChange(picked);
425
+ setOpen(false);
426
+ setQ("");
427
+ triggerRef.current?.focus();
428
+ };
429
+ const onKeyDown = (e) => {
430
+ if (disabled) return;
431
+ const key = e.key;
432
+ if (!open) {
433
+ if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === " ") {
434
+ e.preventDefault();
435
+ setOpen(true);
436
+ setActiveIndex(0);
437
+ }
438
+ return;
439
+ }
440
+ if (key === "Escape") {
441
+ e.preventDefault();
442
+ e.stopPropagation();
443
+ setOpen(false);
444
+ setQ("");
445
+ triggerRef.current?.focus();
446
+ } else if (key === "ArrowDown") {
447
+ e.preventDefault();
448
+ setActiveIndex((i) => rows.length ? (i + 1) % rows.length : -1);
449
+ } else if (key === "ArrowUp") {
450
+ e.preventDefault();
451
+ setActiveIndex((i) => rows.length ? i <= 0 ? rows.length - 1 : i - 1 : -1);
452
+ } else if (key === "Home") {
453
+ e.preventDefault();
454
+ setActiveIndex(rows.length ? 0 : -1);
455
+ } else if (key === "End") {
456
+ e.preventDefault();
457
+ setActiveIndex(rows.length - 1);
458
+ } else if (key === "Enter") {
459
+ e.preventDefault();
460
+ commit(activeIndex >= 0 ? activeIndex : 0);
461
+ } else if (key === "Tab") {
462
+ setOpen(false);
463
+ setQ("");
464
+ }
465
+ };
407
466
  return /* @__PURE__ */ jsxs("div", { className: "au-select", children: [
408
- /* @__PURE__ */ jsxs("button", { ref: triggerRef, type: "button", className: "au-input au-select-trigger", disabled, onClick: () => setOpen((o) => !o), children: [
409
- /* @__PURE__ */ jsx("span", { className: hasValue ? "" : "ph", children: hasValue ? display : placeholder }),
410
- clearable && hasValue ? /* @__PURE__ */ jsx(
411
- "span",
412
- {
413
- className: "au-select-caret au-select-clear",
414
- role: "button",
415
- tabIndex: -1,
416
- onClick: (e) => {
417
- e.stopPropagation();
418
- onChange("");
419
- },
420
- children: /* @__PURE__ */ jsx(IconX, { size: 14 })
421
- }
422
- ) : /* @__PURE__ */ jsx(IconChevronDown, { size: 15, className: "au-select-caret" })
423
- ] }),
467
+ /* @__PURE__ */ jsxs(
468
+ "button",
469
+ {
470
+ ref: triggerRef,
471
+ id: baseId,
472
+ type: "button",
473
+ className: "au-input au-select-trigger",
474
+ disabled,
475
+ role: "combobox",
476
+ "aria-expanded": open,
477
+ "aria-haspopup": "listbox",
478
+ "aria-controls": open ? listboxId : void 0,
479
+ "aria-activedescendant": open && !searchable && activeIndex >= 0 ? optionId(activeIndex) : void 0,
480
+ "aria-label": ariaLabel,
481
+ "aria-labelledby": ariaLabelledBy,
482
+ onClick: () => setOpen((o) => !o),
483
+ onKeyDown,
484
+ children: [
485
+ /* @__PURE__ */ jsx("span", { className: hasValue ? "" : "ph", children: hasValue ? display : placeholder }),
486
+ clearable && hasValue ? (
487
+ // Was a <span role="button" tabIndex={-1}>: announced as a button and
488
+ // reachable by nobody, so a keyboard user could not clear the field.
489
+ // It is a real button now, and it stops the click bubbling to the
490
+ // trigger so clearing does not also open the menu.
491
+ /* @__PURE__ */ jsx(
492
+ "span",
493
+ {
494
+ className: "au-select-caret au-select-clear",
495
+ role: "button",
496
+ tabIndex: 0,
497
+ "aria-label": clearLabel,
498
+ onClick: (e) => {
499
+ e.stopPropagation();
500
+ onChange("");
501
+ },
502
+ onKeyDown: (e) => {
503
+ if (e.key === "Enter" || e.key === " ") {
504
+ e.preventDefault();
505
+ e.stopPropagation();
506
+ onChange("");
507
+ }
508
+ },
509
+ children: /* @__PURE__ */ jsx(IconX, { size: 14 })
510
+ }
511
+ )
512
+ ) : /* @__PURE__ */ jsx(IconChevronDown, { size: 15, className: "au-select-caret" })
513
+ ]
514
+ }
515
+ ),
424
516
  open && !disabled && pos && createPortal(
425
517
  /* @__PURE__ */ jsxs(
426
518
  "div",
427
519
  {
428
520
  ref: menuRef,
521
+ id: listboxId,
522
+ role: "listbox",
523
+ "aria-label": ariaLabel,
524
+ "aria-labelledby": ariaLabel ? void 0 : ariaLabelledBy,
429
525
  className: "au-select-menu",
430
526
  style: { left: pos.left, top: pos.top, bottom: pos.bottom, width: pos.width, maxHeight: pos.maxHeight },
431
527
  children: [
@@ -435,35 +531,43 @@ function AstralSelect({ value, onChange, options, placeholder, disabled, searcha
435
531
  autoFocus: true,
436
532
  className: "au-select-search",
437
533
  placeholder: searchPlaceholder,
534
+ "aria-label": searchPlaceholder,
535
+ "aria-controls": listboxId,
536
+ role: "combobox",
537
+ "aria-expanded": true,
538
+ "aria-autocomplete": "list",
539
+ "aria-activedescendant": activeIndex >= 0 ? optionId(activeIndex) : void 0,
438
540
  value: q,
439
- onChange: (e) => setQ(e.currentTarget.value)
541
+ onChange: (e) => {
542
+ setQ(e.currentTarget.value);
543
+ setActiveIndex(0);
544
+ },
545
+ onKeyDown
440
546
  }
441
547
  ),
442
548
  filtered.length === 0 && !showCreate && /* @__PURE__ */ jsx("div", { className: "au-select-empty", children: noResults }),
443
- filtered.map((o) => /* @__PURE__ */ jsx(
444
- "button",
549
+ filtered.map((o, i) => /* @__PURE__ */ jsx(
550
+ "div",
445
551
  {
446
- type: "button",
447
- className: `au-select-opt${o.value === value ? " on" : ""}`,
448
- onClick: () => {
449
- onChange(o.value);
450
- setOpen(false);
451
- setQ("");
452
- },
552
+ id: optionId(i),
553
+ role: "option",
554
+ "aria-selected": o.value === value,
555
+ className: `au-select-opt${o.value === value ? " on" : ""}${i === activeIndex ? " active" : ""}`,
556
+ onMouseEnter: () => setActiveIndex(i),
557
+ onClick: () => commit(i),
453
558
  children: o.label
454
559
  },
455
560
  o.value
456
561
  )),
457
562
  showCreate && /* @__PURE__ */ jsxs(
458
- "button",
563
+ "div",
459
564
  {
460
- type: "button",
461
- className: "au-select-opt au-select-create",
462
- onClick: () => {
463
- onChange(q.trim());
464
- setOpen(false);
465
- setQ("");
466
- },
565
+ id: optionId(filtered.length),
566
+ role: "option",
567
+ "aria-selected": false,
568
+ className: `au-select-opt au-select-create${filtered.length === activeIndex ? " active" : ""}`,
569
+ onMouseEnter: () => setActiveIndex(filtered.length),
570
+ onClick: () => commit(filtered.length),
467
571
  children: [
468
572
  "+ ",
469
573
  q.trim()
@@ -653,7 +757,7 @@ function dateTimeToInputStr(d) {
653
757
  if (!d || isNaN(d.getTime())) return "";
654
758
  return `${dateToInputStr(d)}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
655
759
  }
656
- function DateInput({ type = "date", value, onChange, min, max, className, style, disabled, "aria-label": ariaLabel }) {
760
+ function DateInput({ type = "date", value, onChange, min, max, className, style, disabled, id, "aria-label": ariaLabel }) {
657
761
  const isTime = type === "datetime-local";
658
762
  const fmt = isTime ? dateTimeToInputStr : dateToInputStr;
659
763
  const [s, setS] = useState(() => fmt(value));
@@ -682,6 +786,7 @@ function DateInput({ type = "date", value, onChange, min, max, className, style,
682
786
  className,
683
787
  style,
684
788
  disabled,
789
+ id,
685
790
  "aria-label": ariaLabel,
686
791
  value: s,
687
792
  min: min ? fmt(min) : void 0,