atlas-react-kit 0.11.0 → 0.11.1

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/README.md CHANGED
@@ -8,7 +8,7 @@ React 19, Tailwind 4, and Node >=20.19 are required. Next 16 is needed only
8
8
  when using `next/tabs-nav-link`.
9
9
 
10
10
  ```sh
11
- npm install --save-exact atlas-react-kit@0.11.0
11
+ npm install --save-exact atlas-react-kit@0.11.1
12
12
  ```
13
13
 
14
14
  Import components by subpath:
@@ -128,7 +128,7 @@ artifact with the package owner's npm login:
128
128
  ```sh
129
129
  mkdir -p release-artifacts
130
130
  npm pack --pack-destination release-artifacts
131
- npm publish ./release-artifacts/atlas-react-kit-0.11.0.tgz --access public
131
+ npm publish ./release-artifacts/atlas-react-kit-0.11.1.tgz --access public
132
132
  ```
133
133
 
134
134
  Pin an exact package version in consuming applications. Roll back an upgrade by
package/dist/calendar.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
- import { DayPicker, getDefaultClassNames, } from "react-day-picker";
4
+ import { DayPicker, getDefaultClassNames, useDayPicker, } from "react-day-picker";
5
5
  import { cn } from "./utils.js";
6
6
  import { Button, buttonVariants } from "./button.js";
7
7
  import { ariaDisabledState } from "./internal/styles.js";
@@ -14,6 +14,27 @@ const chevronIcons = {
14
14
  up: ChevronUpIcon,
15
15
  down: ChevronDownIcon,
16
16
  };
17
+ function CalendarRoot({ className, rootRef, ...props }) {
18
+ return (_jsx("div", { "data-slot": "calendar", ref: rootRef, className: cn(className), ...props }));
19
+ }
20
+ function CalendarChevron({ className, orientation = "down", ...props }) {
21
+ const Icon = chevronIcons[orientation];
22
+ return _jsx(Icon, { className: cn("size-4", className), ...props });
23
+ }
24
+ function CalendarDayAdapter(props) {
25
+ const { dayPickerProps: { locale }, } = useDayPicker();
26
+ return _jsx(CalendarDayButton, { locale: locale, ...props });
27
+ }
28
+ function CalendarWeekNumber({ children, ...props }) {
29
+ return (_jsx("td", { ...props, children: _jsx("div", { className: "flex size-(--cell-size) items-center justify-center text-center", children: children }) }));
30
+ }
31
+ // Stable component types keep the calendar DOM and focus on parent updates.
32
+ const calendarComponents = {
33
+ Root: CalendarRoot,
34
+ Chevron: CalendarChevron,
35
+ DayButton: CalendarDayAdapter,
36
+ WeekNumber: CalendarWeekNumber,
37
+ };
17
38
  function Calendar({ className, classNames, showOutsideDays = true, captionLayout = "label", buttonVariant = "ghost", locale, formatters, labels, components, ...props }) {
18
39
  const defaultClassNames = getDefaultClassNames();
19
40
  const navButton = cn(buttonVariants({ variant: buttonVariant }), navButtonClassName);
@@ -72,17 +93,7 @@ function Calendar({ className, classNames, showOutsideDays = true, captionLayout
72
93
  hidden: cn("invisible", defaultClassNames.hidden),
73
94
  ...classNames,
74
95
  }, components: {
75
- Root: ({ className, rootRef, ...props }) => {
76
- return (_jsx("div", { "data-slot": "calendar", ref: rootRef, className: cn(className), ...props }));
77
- },
78
- Chevron: ({ className, orientation = "down", ...props }) => {
79
- const Icon = chevronIcons[orientation];
80
- return _jsx(Icon, { className: cn("size-4", className), ...props });
81
- },
82
- DayButton: ({ ...props }) => (_jsx(CalendarDayButton, { locale: locale, ...props })),
83
- WeekNumber: ({ children, ...props }) => {
84
- return (_jsx("td", { ...props, children: _jsx("div", { className: "flex size-(--cell-size) items-center justify-center text-center", children: children }) }));
85
- },
96
+ ...calendarComponents,
86
97
  ...components,
87
98
  }, ...props }));
88
99
  }
package/dist/combobox.js CHANGED
@@ -1,15 +1,17 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
- import { CheckIcon, ChevronDownIcon } from "lucide-react";
4
+ import { ChevronDownIcon } from "lucide-react";
5
5
  import { Button } from "./button.js";
6
6
  import { Input } from "./input.js";
7
7
  import { formControlTriggerClassName } from "./internal/form-control.js";
8
8
  import { foldSearchText, handleListboxKey, listboxOptionId, } from "./internal/listbox-navigation.js";
9
- import { ListboxEmptyStatus, ListboxOption } from "./internal/listbox-option.js";
9
+ import { ListboxEmptyStatus, PickerOption } from "./internal/listbox-option.js";
10
+ import { createSearchIndex, filterSearchIndex, normalizeSearchQuery, } from "./internal/search.js";
10
11
  import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
11
12
  import { cn, onInputChange } from "./utils.js";
12
13
  const CREATE_OPTION_KEY = "__atlas-combobox-create";
14
+ const getOptionLabel = (option) => option.label;
13
15
  /**
14
16
  * Trimmed query, or `null` when there is nothing to create.
15
17
  */
@@ -28,7 +30,7 @@ function defaultCreateLabel(value) {
28
30
  * rejected by `normalize`, or already in the list (value or label,
29
31
  * ignoring case and accents). Domain catalogs stay with the caller.
30
32
  */
31
- function resolveCreateValue(query, options, normalize) {
33
+ function resolveCreateValue(query, existingValues, normalize) {
32
34
  const created = normalize(query);
33
35
  if (created == null)
34
36
  return null;
@@ -36,9 +38,7 @@ function resolveCreateValue(query, options, normalize) {
36
38
  if (!value)
37
39
  return null;
38
40
  const needle = foldSearchText(value);
39
- const exists = options.some((option) => foldSearchText(option.value) === needle ||
40
- foldSearchText(option.label) === needle);
41
- return exists ? null : value;
41
+ return existingValues.has(needle) ? null : value;
42
42
  }
43
43
  /**
44
44
  * Searchable single-select built from Button, Popover, and Input.
@@ -67,18 +67,27 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
67
67
  return [...options, { value, label: value }];
68
68
  }, [creatable, options, value]);
69
69
  const selected = resolvedOptions.find((option) => option.value === value);
70
+ const searchIndex = React.useMemo(() => createSearchIndex(resolvedOptions, getOptionLabel), [resolvedOptions]);
71
+ const existingValues = React.useMemo(() => new Set(creatable
72
+ ? searchIndex.flatMap(({ item, label }) => [
73
+ foldSearchText(item.value),
74
+ label,
75
+ ])
76
+ : []), [creatable, searchIndex]);
70
77
  const filtered = React.useMemo(() => {
71
- const needle = foldSearchText(query.trim());
78
+ const needle = normalizeSearchQuery(query);
72
79
  if (!needle)
73
80
  return resolvedOptions;
74
- return resolvedOptions.filter((option) => foldSearchText(option.label).includes(needle));
75
- }, [resolvedOptions, query]);
76
- const createValue = creatable
77
- ? resolveCreateValue(query, resolvedOptions, normalizeCreateValue)
78
- : null;
79
- const createOption = createValue
80
- ? { value: createValue, label: createLabel(createValue) }
81
- : null;
81
+ return filterSearchIndex(searchIndex, needle);
82
+ }, [resolvedOptions, searchIndex, query]);
83
+ const createOption = React.useMemo(() => {
84
+ const createValue = creatable
85
+ ? resolveCreateValue(query, existingValues, normalizeCreateValue)
86
+ : null;
87
+ return createValue
88
+ ? { value: createValue, label: createLabel(createValue) }
89
+ : null;
90
+ }, [creatable, query, existingValues, normalizeCreateValue, createLabel]);
82
91
  const list = createOption ? [...filtered, createOption] : filtered;
83
92
  const onOpenChange = (next) => {
84
93
  setOpen(next);
@@ -105,11 +114,11 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
105
114
  // Closing from here does not go through Radix's onOpenChange, so clear the
106
115
  // query too. A stale query would filter the next open while the highlight
107
116
  // is computed against the full list, and Enter would pick the wrong option.
108
- const selectValue = (next) => {
109
- onValueChange(next);
117
+ const selectOption = React.useCallback((option) => {
118
+ onValueChange(option.value);
110
119
  setOpen(false);
111
120
  setQuery("");
112
- };
121
+ }, [onValueChange]);
113
122
  const activeOption = list[highlight];
114
123
  const empty = list.length === 0;
115
124
  // APG combobox: ArrowDown, ArrowUp, and Alt+ArrowDown open the list from
@@ -128,7 +137,7 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
128
137
  onEnter: (index) => {
129
138
  const option = list[index];
130
139
  if (option)
131
- selectValue(option.value);
140
+ selectOption(option);
132
141
  },
133
142
  });
134
143
  };
@@ -137,6 +146,6 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
137
146
  : cn("max-h-60 overflow-y-auto p-1", listClassName), children: list.map((option, index) => {
138
147
  const isCreate = createOption != null && option === createOption;
139
148
  const isSelected = option.value === value && !isCreate;
140
- return (_jsxs(ListboxOption, { "data-slot": "combobox-option", listId: listId, index: index, active: index === highlight, selected: isSelected, className: "pr-8", onMouseEnter: () => setHighlight(index), onClick: () => selectValue(option.value), children: [_jsx("span", { className: "truncate", children: option.label }), isSelected ? (_jsx(CheckIcon, { className: "pointer-events-none absolute right-2 size-3.5" })) : null] }, isCreate ? CREATE_OPTION_KEY : option.value));
149
+ return (_jsx(PickerOption, { "data-slot": "combobox-option", listId: listId, index: index, active: index === highlight, selected: isSelected, showCheck: true, item: option, getLabel: getOptionLabel, onHighlight: setHighlight, onSelect: selectOption }, isCreate ? CREATE_OPTION_KEY : option.value));
141
150
  }) }), _jsx(ListboxEmptyStatus, { empty: empty, children: emptyText })] })] }));
142
151
  }
@@ -14,6 +14,22 @@ type ListboxOptionProps = Omit<React.ComponentProps<"button">, "id" | "role" | "
14
14
  * component's right padding and indicator.
15
15
  */
16
16
  export declare function ListboxOption({ listId, index, active, selected, className, ...props }: ListboxOptionProps): React.JSX.Element;
17
+ type PickerOptionProps<T> = {
18
+ item: T;
19
+ getLabel: (item: T) => string;
20
+ onSelect: (item: T) => void;
21
+ onHighlight: (index: number) => void;
22
+ listId: string;
23
+ index: number;
24
+ active: boolean;
25
+ selected: boolean;
26
+ disabled?: boolean;
27
+ showCheck?: boolean;
28
+ "data-slot": string;
29
+ };
30
+ /** Only the old and new active rows rerender during keyboard navigation. */
31
+ declare function PickerOptionInner<T>({ item, getLabel, onSelect, onHighlight, showCheck, ...props }: PickerOptionProps<T>): React.JSX.Element;
32
+ export declare const PickerOption: typeof PickerOptionInner;
17
33
  /**
18
34
  * The "No results" status. It sits outside the listbox, which may own only
19
35
  * options, and stays mounted (sr-only when not empty) so screen readers
@@ -1,5 +1,6 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
+ import { CheckIcon } from "lucide-react";
3
4
  import { cn } from "../utils.js";
4
5
  import { listboxOptionId, scrollOptionIntoView } from "./listbox-navigation.js";
5
6
  import { optionRow } from "./overlay-styles.js";
@@ -13,6 +14,11 @@ import { listboxEmpty, optionState } from "./styles.js";
13
14
  export function ListboxOption({ listId, index, active, selected, className, ...props }) {
14
15
  return (_jsx("li", { role: "none", children: _jsx("button", { ref: active ? scrollOptionIntoView : undefined, id: listboxOptionId(listId, index), type: "button", role: "option", tabIndex: -1, "aria-selected": selected, className: cn(optionRow, selected && optionState.selected, active && optionState.active, className), ...props }) }));
15
16
  }
17
+ /** Only the old and new active rows rerender during keyboard navigation. */
18
+ function PickerOptionInner({ item, getLabel, onSelect, onHighlight, showCheck, ...props }) {
19
+ return (_jsxs(ListboxOption, { ...props, className: showCheck ? "pr-8" : "pr-3", onMouseEnter: () => onHighlight(props.index), onClick: () => onSelect(item), children: [_jsx("span", { className: "truncate", children: getLabel(item) }), showCheck && props.selected ? (_jsx(CheckIcon, { className: "pointer-events-none absolute right-2 size-3.5" })) : null] }));
20
+ }
21
+ export const PickerOption = React.memo(PickerOptionInner);
16
22
  /**
17
23
  * The "No results" status. It sits outside the listbox, which may own only
18
24
  * options, and stays mounted (sr-only when not empty) so screen readers
@@ -0,0 +1,8 @@
1
+ /** Fold the query once; item labels are folded when their catalog changes. */
2
+ export declare function normalizeSearchQuery(query: string): string;
3
+ export declare function matchesSearchLabel(label: string, needle: string): boolean;
4
+ export declare function createSearchIndex<T>(items: T[], getLabel: (item: T) => string): {
5
+ item: T;
6
+ label: string;
7
+ }[];
8
+ export declare function filterSearchIndex<T>(index: ReturnType<typeof createSearchIndex<T>>, needle: string): T[];
@@ -0,0 +1,16 @@
1
+ import { foldSearchText } from "./listbox-navigation.js";
2
+ /** Fold the query once; item labels are folded when their catalog changes. */
3
+ export function normalizeSearchQuery(query) {
4
+ return foldSearchText(query.trim());
5
+ }
6
+ export function matchesSearchLabel(label, needle) {
7
+ return label.includes(needle);
8
+ }
9
+ export function createSearchIndex(items, getLabel) {
10
+ return items.map((item) => ({ item, label: foldSearchText(getLabel(item)) }));
11
+ }
12
+ export function filterSearchIndex(index, needle) {
13
+ return index
14
+ .filter(({ label }) => matchesSearchLabel(label, needle))
15
+ .map(({ item }) => item);
16
+ }
@@ -4,17 +4,18 @@ import * as React from "react";
4
4
  import { Input } from "./input.js";
5
5
  import { Label } from "./label.js";
6
6
  import { foldSearchText, handleListboxKey, listboxOptionId, } from "./internal/listbox-navigation.js";
7
- import { ListboxEmptyStatus, ListboxOption } from "./internal/listbox-option.js";
7
+ import { ListboxEmptyStatus, PickerOption } from "./internal/listbox-option.js";
8
+ import { createSearchIndex, filterSearchIndex, matchesSearchLabel, normalizeSearchQuery, } from "./internal/search.js";
8
9
  import { cn } from "./utils.js";
9
10
  export { foldSearchText };
10
11
  /**
11
12
  * Default SearchPicker filter: the folded label includes the folded query.
12
13
  */
13
14
  export function defaultSearchFilter(item, query, getLabel) {
14
- const needle = foldSearchText(query.trim());
15
+ const needle = normalizeSearchQuery(query);
15
16
  if (!needle)
16
17
  return true;
17
- return foldSearchText(getLabel(item)).includes(needle);
18
+ return matchesSearchLabel(foldSearchText(getLabel(item)), needle);
18
19
  }
19
20
  /** Frame shared by the listbox and the empty state that replaces it. */
20
21
  const listFrame = "border border-input bg-background";
@@ -28,16 +29,14 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
28
29
  const listId = React.useId();
29
30
  const inputId = React.useId();
30
31
  const normalizedItems = React.useMemo(() => filter === defaultSearchFilter
31
- ? items.map((item) => [item, foldSearchText(getLabel(item))])
32
+ ? createSearchIndex(items, getLabel)
32
33
  : null, [filter, getLabel, items]);
33
- const foldedQuery = filter === defaultSearchFilter ? foldSearchText(query.trim()) : "";
34
+ const foldedQuery = filter === defaultSearchFilter ? normalizeSearchQuery(query) : "";
34
35
  const filtered = React.useMemo(() => {
35
36
  if (normalizedItems) {
36
37
  if (!foldedQuery)
37
38
  return items;
38
- return normalizedItems
39
- .filter(([, label]) => label.includes(foldedQuery))
40
- .map(([item]) => item);
39
+ return filterSearchIndex(normalizedItems, foldedQuery);
41
40
  }
42
41
  return items.filter((item) => filter(item, query, getLabel));
43
42
  }, [items, filter, foldedQuery, getLabel, normalizedItems, query]);
@@ -45,6 +44,7 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
45
44
  const index = filtered.findIndex((item) => getValue(item) === value);
46
45
  return index >= 0 ? index : 0;
47
46
  }, [filtered, getValue, value]);
47
+ const rows = React.useMemo(() => filtered.map((item) => ({ item, value: getValue(item) })), [filtered, getValue]);
48
48
  const [highlight, setHighlight] = React.useState(selectedIndex);
49
49
  // Reset the highlight while rendering, not in an effect, when the query or
50
50
  // the selected row changes: an effect commits every keystroke twice, once
@@ -60,10 +60,10 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
60
60
  }
61
61
  const active = filtered[highlight];
62
62
  const empty = filtered.length === 0;
63
- const selectItem = (item) => {
63
+ const selectItem = React.useCallback((item) => {
64
64
  onSelect(item);
65
65
  setQuery("");
66
- };
66
+ }, [onSelect]);
67
67
  const onKeyDown = (event) => {
68
68
  const handled = handleListboxKey(event, {
69
69
  highlight,
@@ -86,9 +86,6 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
86
86
  };
87
87
  return (_jsxs("div", { "data-slot": "search-picker", className: cn("flex flex-col gap-1.5", className), ...props, children: [_jsx(Label, { htmlFor: inputId, children: label }), _jsx(Input, { id: inputId, role: "combobox", "aria-expanded": true, "aria-controls": listId, "aria-autocomplete": "list", "aria-activedescendant": active ? listboxOptionId(listId, highlight) : undefined, value: query, disabled: disabled, placeholder: placeholder, onChange: (event) => setQuery(event.target.value), onKeyDown: onKeyDown }), _jsx("ul", { id: listId, role: "listbox", "aria-label": listLabel ?? label, className: empty
88
88
  ? "sr-only"
89
- : cn("max-h-60 overflow-y-auto", listFrame, listClassName), children: filtered.map((item, index) => {
90
- const itemValue = getValue(item);
91
- return (_jsx(ListboxOption, { "data-slot": "search-picker-option", listId: listId, index: index, active: index === highlight, selected: itemValue === value, disabled: disabled, className: "pr-3", onMouseEnter: () => setHighlight(index), onClick: () => selectItem(item), children: _jsx("span", { className: "truncate", children: getLabel(item) }) }, itemValue));
92
- }) }), _jsx(ListboxEmptyStatus, { "data-slot": "search-picker-empty", empty: empty, className: listFrame, children: emptyText })] }));
89
+ : cn("max-h-60 overflow-y-auto", listFrame, listClassName), children: rows.map(({ item, value: itemValue }, index) => (_jsx(PickerOption, { "data-slot": "search-picker-option", listId: listId, index: index, active: index === highlight, selected: itemValue === value, disabled: disabled, item: item, getLabel: getLabel, onHighlight: setHighlight, onSelect: selectItem }, itemValue))) }), _jsx(ListboxEmptyStatus, { "data-slot": "search-picker-empty", empty: empty, className: listFrame, children: emptyText })] }));
93
90
  }
94
91
  export { SearchPicker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas-react-kit",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Shared React components and Tailwind theme for Atlas",
5
5
  "type": "module",
6
6
  "license": "MIT",