@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.cjs CHANGED
@@ -367,11 +367,16 @@ function AstralPinInput({ length = 6, value, onChange, autoFocus }) {
367
367
  i
368
368
  )) });
369
369
  }
370
- function AstralSelect({ value, onChange, options, placeholder, disabled, searchable, searchPlaceholder, noResults, clearable, creatable }) {
370
+ function AstralSelect({ value, onChange, options, placeholder, disabled, searchable, searchPlaceholder, noResults, clearable, creatable, id, ariaLabel, ariaLabelledBy, clearLabel = "Clear selection" }) {
371
371
  const [open, setOpen] = react.useState(false);
372
372
  const [q, setQ] = react.useState("");
373
373
  const triggerRef = react.useRef(null);
374
374
  const menuRef = react.useRef(null);
375
+ const [activeIndex, setActiveIndex] = react.useState(-1);
376
+ const reactId = react.useId();
377
+ const baseId = id ?? `au-select-${reactId}`;
378
+ const listboxId = `${baseId}-listbox`;
379
+ const optionId = (i) => `${baseId}-opt-${i}`;
375
380
  const [pos, setPos] = react.useState(null);
376
381
  const reposition = react.useCallback(() => {
377
382
  const el = triggerRef.current;
@@ -387,7 +392,10 @@ function AstralSelect({ value, onChange, options, placeholder, disabled, searcha
387
392
  });
388
393
  }, []);
389
394
  react.useEffect(() => {
390
- if (!open) return;
395
+ if (!open) {
396
+ setActiveIndex(-1);
397
+ return;
398
+ }
391
399
  reposition();
392
400
  const onScroll = () => reposition();
393
401
  const onResize = () => setOpen(false);
@@ -410,28 +418,116 @@ function AstralSelect({ value, onChange, options, placeholder, disabled, searcha
410
418
  const display = selected ? selected.label : value ?? "";
411
419
  const filtered = searchable && q.trim() ? options.filter((o) => o.label.toLowerCase().includes(q.trim().toLowerCase())) : options;
412
420
  const showCreate = !!creatable && !!q.trim() && !options.some((o) => o.label.toLowerCase() === q.trim().toLowerCase());
421
+ const rows = [...filtered.map((o) => o.value), ...showCreate ? [q.trim()] : []];
422
+ react.useEffect(() => {
423
+ if (!open || activeIndex < 0) return;
424
+ const el = menuRef.current?.querySelector(`#${CSS.escape(optionId(activeIndex))}`);
425
+ el?.scrollIntoView({ block: "nearest" });
426
+ }, [open, activeIndex, baseId]);
427
+ const commit = (index) => {
428
+ const picked = rows[index];
429
+ if (picked == null) return;
430
+ onChange(picked);
431
+ setOpen(false);
432
+ setQ("");
433
+ triggerRef.current?.focus();
434
+ };
435
+ const onKeyDown = (e) => {
436
+ if (disabled) return;
437
+ const key = e.key;
438
+ if (!open) {
439
+ if (key === "ArrowDown" || key === "ArrowUp" || key === "Enter" || key === " ") {
440
+ e.preventDefault();
441
+ setOpen(true);
442
+ setActiveIndex(0);
443
+ }
444
+ return;
445
+ }
446
+ if (key === "Escape") {
447
+ e.preventDefault();
448
+ e.stopPropagation();
449
+ setOpen(false);
450
+ setQ("");
451
+ triggerRef.current?.focus();
452
+ } else if (key === "ArrowDown") {
453
+ e.preventDefault();
454
+ setActiveIndex((i) => rows.length ? (i + 1) % rows.length : -1);
455
+ } else if (key === "ArrowUp") {
456
+ e.preventDefault();
457
+ setActiveIndex((i) => rows.length ? i <= 0 ? rows.length - 1 : i - 1 : -1);
458
+ } else if (key === "Home") {
459
+ e.preventDefault();
460
+ setActiveIndex(rows.length ? 0 : -1);
461
+ } else if (key === "End") {
462
+ e.preventDefault();
463
+ setActiveIndex(rows.length - 1);
464
+ } else if (key === "Enter") {
465
+ e.preventDefault();
466
+ commit(activeIndex >= 0 ? activeIndex : 0);
467
+ } else if (key === "Tab") {
468
+ setOpen(false);
469
+ setQ("");
470
+ }
471
+ };
413
472
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "au-select", children: [
414
- /* @__PURE__ */ jsxRuntime.jsxs("button", { ref: triggerRef, type: "button", className: "au-input au-select-trigger", disabled, onClick: () => setOpen((o) => !o), children: [
415
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: hasValue ? "" : "ph", children: hasValue ? display : placeholder }),
416
- clearable && hasValue ? /* @__PURE__ */ jsxRuntime.jsx(
417
- "span",
418
- {
419
- className: "au-select-caret au-select-clear",
420
- role: "button",
421
- tabIndex: -1,
422
- onClick: (e) => {
423
- e.stopPropagation();
424
- onChange("");
425
- },
426
- children: /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconX, { size: 14 })
427
- }
428
- ) : /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconChevronDown, { size: 15, className: "au-select-caret" })
429
- ] }),
473
+ /* @__PURE__ */ jsxRuntime.jsxs(
474
+ "button",
475
+ {
476
+ ref: triggerRef,
477
+ id: baseId,
478
+ type: "button",
479
+ className: "au-input au-select-trigger",
480
+ disabled,
481
+ role: "combobox",
482
+ "aria-expanded": open,
483
+ "aria-haspopup": "listbox",
484
+ "aria-controls": open ? listboxId : void 0,
485
+ "aria-activedescendant": open && !searchable && activeIndex >= 0 ? optionId(activeIndex) : void 0,
486
+ "aria-label": ariaLabel,
487
+ "aria-labelledby": ariaLabelledBy,
488
+ onClick: () => setOpen((o) => !o),
489
+ onKeyDown,
490
+ children: [
491
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: hasValue ? "" : "ph", children: hasValue ? display : placeholder }),
492
+ clearable && hasValue ? (
493
+ // Was a <span role="button" tabIndex={-1}>: announced as a button and
494
+ // reachable by nobody, so a keyboard user could not clear the field.
495
+ // It is a real button now, and it stops the click bubbling to the
496
+ // trigger so clearing does not also open the menu.
497
+ /* @__PURE__ */ jsxRuntime.jsx(
498
+ "span",
499
+ {
500
+ className: "au-select-caret au-select-clear",
501
+ role: "button",
502
+ tabIndex: 0,
503
+ "aria-label": clearLabel,
504
+ onClick: (e) => {
505
+ e.stopPropagation();
506
+ onChange("");
507
+ },
508
+ onKeyDown: (e) => {
509
+ if (e.key === "Enter" || e.key === " ") {
510
+ e.preventDefault();
511
+ e.stopPropagation();
512
+ onChange("");
513
+ }
514
+ },
515
+ children: /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconX, { size: 14 })
516
+ }
517
+ )
518
+ ) : /* @__PURE__ */ jsxRuntime.jsx(iconsReact.IconChevronDown, { size: 15, className: "au-select-caret" })
519
+ ]
520
+ }
521
+ ),
430
522
  open && !disabled && pos && reactDom.createPortal(
431
523
  /* @__PURE__ */ jsxRuntime.jsxs(
432
524
  "div",
433
525
  {
434
526
  ref: menuRef,
527
+ id: listboxId,
528
+ role: "listbox",
529
+ "aria-label": ariaLabel,
530
+ "aria-labelledby": ariaLabel ? void 0 : ariaLabelledBy,
435
531
  className: "au-select-menu",
436
532
  style: { left: pos.left, top: pos.top, bottom: pos.bottom, width: pos.width, maxHeight: pos.maxHeight },
437
533
  children: [
@@ -441,35 +537,43 @@ function AstralSelect({ value, onChange, options, placeholder, disabled, searcha
441
537
  autoFocus: true,
442
538
  className: "au-select-search",
443
539
  placeholder: searchPlaceholder,
540
+ "aria-label": searchPlaceholder,
541
+ "aria-controls": listboxId,
542
+ role: "combobox",
543
+ "aria-expanded": true,
544
+ "aria-autocomplete": "list",
545
+ "aria-activedescendant": activeIndex >= 0 ? optionId(activeIndex) : void 0,
444
546
  value: q,
445
- onChange: (e) => setQ(e.currentTarget.value)
547
+ onChange: (e) => {
548
+ setQ(e.currentTarget.value);
549
+ setActiveIndex(0);
550
+ },
551
+ onKeyDown
446
552
  }
447
553
  ),
448
554
  filtered.length === 0 && !showCreate && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "au-select-empty", children: noResults }),
449
- filtered.map((o) => /* @__PURE__ */ jsxRuntime.jsx(
450
- "button",
555
+ filtered.map((o, i) => /* @__PURE__ */ jsxRuntime.jsx(
556
+ "div",
451
557
  {
452
- type: "button",
453
- className: `au-select-opt${o.value === value ? " on" : ""}`,
454
- onClick: () => {
455
- onChange(o.value);
456
- setOpen(false);
457
- setQ("");
458
- },
558
+ id: optionId(i),
559
+ role: "option",
560
+ "aria-selected": o.value === value,
561
+ className: `au-select-opt${o.value === value ? " on" : ""}${i === activeIndex ? " active" : ""}`,
562
+ onMouseEnter: () => setActiveIndex(i),
563
+ onClick: () => commit(i),
459
564
  children: o.label
460
565
  },
461
566
  o.value
462
567
  )),
463
568
  showCreate && /* @__PURE__ */ jsxRuntime.jsxs(
464
- "button",
569
+ "div",
465
570
  {
466
- type: "button",
467
- className: "au-select-opt au-select-create",
468
- onClick: () => {
469
- onChange(q.trim());
470
- setOpen(false);
471
- setQ("");
472
- },
571
+ id: optionId(filtered.length),
572
+ role: "option",
573
+ "aria-selected": false,
574
+ className: `au-select-opt au-select-create${filtered.length === activeIndex ? " active" : ""}`,
575
+ onMouseEnter: () => setActiveIndex(filtered.length),
576
+ onClick: () => commit(filtered.length),
473
577
  children: [
474
578
  "+ ",
475
579
  q.trim()
@@ -659,7 +763,7 @@ function dateTimeToInputStr(d) {
659
763
  if (!d || isNaN(d.getTime())) return "";
660
764
  return `${dateToInputStr(d)}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
661
765
  }
662
- function DateInput({ type = "date", value, onChange, min, max, className, style, disabled, "aria-label": ariaLabel }) {
766
+ function DateInput({ type = "date", value, onChange, min, max, className, style, disabled, id, "aria-label": ariaLabel }) {
663
767
  const isTime = type === "datetime-local";
664
768
  const fmt = isTime ? dateTimeToInputStr : dateToInputStr;
665
769
  const [s, setS] = react.useState(() => fmt(value));
@@ -688,6 +792,7 @@ function DateInput({ type = "date", value, onChange, min, max, className, style,
688
792
  className,
689
793
  style,
690
794
  disabled,
795
+ id,
691
796
  "aria-label": ariaLabel,
692
797
  value: s,
693
798
  min: min ? fmt(min) : void 0,