@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.cjs +140 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +141 -37
- package/dist/index.js.map +1 -1
- package/dist/styles.css +5 -0
- package/package.json +1 -1
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)
|
|
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(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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) =>
|
|
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
|
-
"
|
|
549
|
+
filtered.map((o, i) => /* @__PURE__ */ jsx(
|
|
550
|
+
"div",
|
|
445
551
|
{
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
"
|
|
563
|
+
"div",
|
|
459
564
|
{
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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()
|