@astralui/core 0.1.12 → 0.1.13

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.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;
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()