atlas-react-kit 0.9.1 → 0.9.3

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/COMPONENTS.md CHANGED
@@ -5,6 +5,11 @@ underlying element or Radix props, including `className`, alongside their option
5
5
  Form helpers and `TableBodySkeleton` accept only their documented props. There is
6
6
  no root component barrel.
7
7
 
8
+ For installation, see [README.md](./README.md); for styling and accessibility,
9
+ see [DESIGN_RULES.md](./DESIGN_RULES.md).
10
+
11
+ ## Optional peer dependencies
12
+
8
13
  Core entries need only the kit. These feature entries need an extra install
9
14
  (same version ranges as the kit's optional peers). `Toaster` also reads the
10
15
  theme from `next-themes`, so toast consumers install that too.
@@ -29,7 +34,7 @@ theme from `next-themes`, so toast consumers install that too.
29
34
  | `card` | `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardAction`, `CardContent`, `CardFooter`; `Card` supports `flush`; root uses `min-h-min shrink-0` so flex columns do not collapse it, and `min-w-0` so nested tables scroll; `CardContent className="px-0"` overrides padding without `!` |
30
35
  | `checkbox` | `Checkbox` |
31
36
  | `color-legend` | `ColorLegend`; `title`, `items` as `{ color, range?, label }[]`, optional `caption`, `children`, `aria-label`, `className` (overlay chrome). No attribution. |
32
- | `combobox` | `Combobox`; `options` as `{ value, label }[]`, `value`, `onValueChange`; optional placeholder, search, empty text; trigger matches Input/SelectTrigger chrome; keyboard highlight scrolls into view |
37
+ | `combobox` | `Combobox`; `options` as `{ value, label }[]`, `value`, `onValueChange`; optional placeholder, search, empty text, `creatable`, `normalizeCreateValue` (query → value or `null`), `createLabel`; trigger matches Input/SelectTrigger chrome; keyboard highlight scrolls into view; create is a listbox option selected with Enter |
33
38
  | `date-picker` | `DatePickerButton`, `toDate`, `formatLocalDate`, `DateRange`; `mode` `"single"` (default) or `"range"`; single `value` is `Date` or `yyyy-MM-dd`; range `value` is `{ from, to }`; trigger matches Input chrome; optional `numberOfMonths` (range default 1 below `md`, 2 at `md+`) and `side` |
34
39
  | `date-time-picker` | `DateTimePickerButton`, `resolveTimeStep`, `DEFAULT_TIME_STEP`; controlled `value` is `Date` or `null`; `onChange` emits `Date \ |
35
40
  | `dialog` | `Dialog`, trigger, portal, overlay, content, close, header, footer, title, description; `DialogContent` supports `showCloseButton` |
package/DESIGN_RULES.md CHANGED
@@ -4,6 +4,9 @@ Atlas owns these shared rules, its theme, and component implementations. Change
4
4
  them here and release a package version. Build application-specific UI by composing
5
5
  the shipped components.
6
6
 
7
+ Use [COMPONENTS.md](./COMPONENTS.md) for public entry points, props, and
8
+ component-specific behavior.
9
+
7
10
  ## Tokens
8
11
 
9
12
  Use semantic colors, radii, and fonts: `bg-primary`, `text-muted-foreground`,
@@ -85,7 +88,9 @@ still read normal words.
85
88
  Primitives have square corners. Reserve `rounded-full` for dots, avatars,
86
89
  other circles, and the toast close badge. Card and dialog titles already use
87
90
  uppercase, tracked `font-heading` styling. Toasts are overlay popovers with
88
- the same square corners and Alert padding (`px-4 py-3`). The close control is
91
+ the same square corners and Alert left/vertical padding (`pl-4 py-3`). Right
92
+ padding is `pr-6` so copy clears the floating close badge. Icon-to-text is
93
+ `gap-x-2.5` so the status icon and copy read as one unit. The close control is
89
94
  a circular badge centred on the top-right corner (half outside the border),
90
95
  not a control in the text row. A plain `toast()` has no icon — status icons
91
96
  belong to `success`, `info`, `warning`, `error`, and `loading`. Tone colors
package/README.md CHANGED
@@ -4,10 +4,11 @@ Shared React components and a Tailwind theme with square corners, semantic
4
4
  colors, and distinct selected states. 0.6.0 added generic pieces apps were
5
5
  keeping locally (`InfoTip`, `ColorLegend`, `SearchPicker`, `Markdown`,
6
6
  `StatTile`, `Meter`, `Slider`) plus the ToggleGroup selected-state fix.
7
- React 19 and Tailwind 4 are required.
7
+ React 19, Tailwind 4, and Node >=20.19 are required. Next 16 is needed only
8
+ when using `next/tabs-nav-link`.
8
9
 
9
10
  ```sh
10
- npm install --save-exact atlas-react-kit@0.9.1
11
+ npm install --save-exact atlas-react-kit@0.9.3
11
12
  ```
12
13
 
13
14
  Import components by subpath:
@@ -27,17 +28,8 @@ export function Example() {
27
28
  }
28
29
  ```
29
30
 
30
- Core entries need only the kit. These feature entries need an extra install
31
- (same version ranges as the kit's optional peers). `Toaster` also reads the
32
- theme from `next-themes`, so toast consumers install that too.
33
-
34
- | Entry points | Extra install |
35
- | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------ |
36
- | `calendar`, `date-picker`, `date-time-picker`, `form/date-picker-field`, `form/date-time-picker-field` | `npm i react-day-picker@^10.0.1 date-fns@^4.4.0` |
37
- | `markdown`, `form/markdown-field` | `npm i react-markdown@^10.1.0 remark-gfm@^4.0.1` |
38
- | `theme-provider` | `npm i next-themes@^0.4.6` |
39
- | `toast` | `npm i sonner@^2.0.7 next-themes@^0.4.6` |
40
- | `next/tabs-nav-link` | `npm i next@^16.0.0` |
31
+ Core entries need only the kit. Feature entries may need optional peers; see
32
+ the [component guide's install table](./COMPONENTS.md#optional-peer-dependencies).
41
33
 
42
34
  In your global stylesheet, import the theme after Tailwind and register the
43
35
  installed JavaScript directory. Paths in `@source` are relative to that stylesheet;
@@ -55,9 +47,15 @@ on an ancestor to activate dark colors. Supply your own font configuration;
55
47
  `font-heading` uses the configured sans font. Application-specific tokens stay
56
48
  with the application.
57
49
 
58
- See [design rules](./DESIGN_RULES.md) for shared styling decisions and the
59
- [component guide](./COMPONENTS.md) for exports and composition examples. Both
60
- files ship in the npm package.
50
+ ## Documentation
51
+
52
+ - [Component guide](./COMPONENTS.md): imports, props, optional installs, and
53
+ composition. This guide ships in the npm package.
54
+ - [Design rules](./DESIGN_RULES.md): tokens, accessibility, writing, and
55
+ composition. This guide ships in the npm package.
56
+ - [Contributing](./CONTRIBUTING.md): component changes, checks, and releases.
57
+ - [Roadmap to 1.0](./ROADMAP-1.0.md): decisions needed before the 1.0 release.
58
+ - [Changelog](./CHANGELOG.md): published and upcoming changes.
61
59
 
62
60
  ## AI coding assistants
63
61
 
@@ -82,7 +80,8 @@ Public docs and a live playground are at
82
80
  [design.querobines.com](https://design.querobines.com). The site is Storybook:
83
81
  tokens parsed from `theme.css`, these markdown guides rendered from source, and
84
82
  editable component stories with light/dark themes, viewport previews, and
85
- accessibility checks.
83
+ accessibility checks. Its build publishes `docs-index.json` with guide headings
84
+ and package entry points for search and indexing tools.
86
85
 
87
86
  ## Development and releases
88
87
 
@@ -129,12 +128,9 @@ artifact with the package owner's npm login:
129
128
  ```sh
130
129
  mkdir -p release-artifacts
131
130
  npm pack --pack-destination release-artifacts
132
- npm publish ./release-artifacts/atlas-react-kit-0.9.1.tgz --access public
131
+ npm publish ./release-artifacts/atlas-react-kit-0.9.3.tgz --access public
133
132
  ```
134
133
 
135
- See [CONTRIBUTING.md](./CONTRIBUTING.md) for adding components, the browser
136
- checks, versioning, and deprecations.
137
-
138
134
  Pin an exact package version in consuming applications. Roll back an upgrade by
139
135
  restoring the previous version and lockfile. License: MIT; upstream notices are
140
136
  preserved in [NOTICE](./NOTICE).
@@ -17,6 +17,22 @@ type ComboboxProps = {
17
17
  /** Name the trigger when there is no `<Label htmlFor={id}>`. */
18
18
  "aria-label"?: string;
19
19
  "aria-labelledby"?: string;
20
+ /**
21
+ * Offer creating the current query when it is not already an option.
22
+ * Off by default so existing callers stay search-and-select.
23
+ */
24
+ creatable?: boolean;
25
+ /**
26
+ * Turn the search query into a value to create, or `null` to hide the
27
+ * create action. Default trims the query. Apps own domain rules (for
28
+ * example `^[A-Z]{3}$` for currency codes); catalogs stay in the app.
29
+ */
30
+ normalizeCreateValue?: (query: string) => string | null;
31
+ /**
32
+ * Visible and accessible name for the create option. Default is
33
+ * `Create "{value}"`.
34
+ */
35
+ createLabel?: (value: string) => string;
20
36
  };
21
37
  /**
22
38
  * Searchable single-select built from Button, Popover, and Input.
@@ -24,6 +40,8 @@ type ComboboxProps = {
24
40
  * Options are a plain `{ value, label }` list; domain data stays in the app.
25
41
  * Keyboard highlight (arrows, Home/End, PageUp/PageDown) scrolls the active
26
42
  * option into view inside the overflow list.
43
+ * Opt-in `creatable` appends a create action when the query is not already
44
+ * an option; choosing it calls `onValueChange` like a normal selection.
27
45
  */
28
- export declare function Combobox({ options, value, onValueChange, placeholder, searchPlaceholder, emptyText, disabled, id, className, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, }: ComboboxProps): React.JSX.Element;
46
+ export declare function Combobox({ options, value, onValueChange, placeholder, searchPlaceholder, emptyText, disabled, id, className, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, creatable, normalizeCreateValue, createLabel, }: ComboboxProps): React.JSX.Element;
29
47
  export {};
package/dist/combobox.js CHANGED
@@ -5,35 +5,79 @@ import { CheckIcon, 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
+ import { moveListboxHighlight } from "./internal/listbox-navigation.js";
8
9
  import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
9
10
  import { optionState } from "./internal/styles.js";
10
11
  import { cn, onInputChange } from "./utils.js";
12
+ const CREATE_OPTION_KEY = "__atlas-combobox-create";
11
13
  /**
12
- * PageUp/PageDown jump size. The option list is `max-h-60`; ten rows is a
13
- * bit more than one viewport so long lists (timezones, currencies) move
14
- * without requiring an End key.
14
+ * Trimmed query, or `null` when there is nothing to create.
15
15
  */
16
- const LISTBOX_PAGE_SIZE = 10;
16
+ function defaultNormalizeCreateValue(query) {
17
+ const next = query.trim();
18
+ return next.length > 0 ? next : null;
19
+ }
20
+ /**
21
+ * Accessible create action: the word "Create" plus the quoted value.
22
+ */
23
+ function defaultCreateLabel(value) {
24
+ return `Create "${value}"`;
25
+ }
26
+ /**
27
+ * Value to offer as a new option, or `null` when the query is empty,
28
+ * rejected by `normalize`, or already in the list (value or label,
29
+ * case-insensitive). Domain catalogs stay with the caller.
30
+ */
31
+ function resolveCreateValue(query, options, normalize) {
32
+ const created = normalize(query);
33
+ if (created == null)
34
+ return null;
35
+ const value = created.trim();
36
+ if (!value)
37
+ return null;
38
+ const needle = value.toLowerCase();
39
+ const exists = options.some((option) => option.value.toLowerCase() === needle ||
40
+ option.label.toLowerCase() === needle);
41
+ return exists ? null : value;
42
+ }
17
43
  /**
18
44
  * Searchable single-select built from Button, Popover, and Input.
19
45
  * The trigger uses the same form-control chrome as Input and SelectTrigger.
20
46
  * Options are a plain `{ value, label }` list; domain data stays in the app.
21
47
  * Keyboard highlight (arrows, Home/End, PageUp/PageDown) scrolls the active
22
48
  * option into view inside the overflow list.
49
+ * Opt-in `creatable` appends a create action when the query is not already
50
+ * an option; choosing it calls `onValueChange` like a normal selection.
23
51
  */
24
- export function Combobox({ options, value, onValueChange, placeholder = "Select", searchPlaceholder = "Search", emptyText = "No results", disabled, id, className, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, }) {
52
+ export function Combobox({ options, value, onValueChange, placeholder = "Select", searchPlaceholder = "Search", emptyText = "No results", disabled, id, className, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, creatable = false, normalizeCreateValue = defaultNormalizeCreateValue, createLabel = defaultCreateLabel, }) {
25
53
  const [open, setOpen] = React.useState(false);
26
54
  const [query, setQuery] = React.useState("");
27
55
  const [highlight, setHighlight] = React.useState(0);
28
56
  const inputRef = React.useRef(null);
29
57
  const listId = React.useId();
30
- const selected = options.find((option) => option.value === value);
58
+ // A created value may not be in `options` yet (the app owns the catalog).
59
+ // Keep it visible on the trigger and in the list so reopen/Enter still work.
60
+ const resolvedOptions = React.useMemo(() => {
61
+ if (!creatable || !value)
62
+ return options;
63
+ if (options.some((option) => option.value === value))
64
+ return options;
65
+ return [...options, { value, label: value }];
66
+ }, [creatable, options, value]);
67
+ const selected = resolvedOptions.find((option) => option.value === value);
31
68
  const filtered = React.useMemo(() => {
32
69
  const needle = query.trim().toLowerCase();
33
70
  if (!needle)
34
- return options;
35
- return options.filter((option) => option.label.toLowerCase().includes(needle));
36
- }, [options, query]);
71
+ return resolvedOptions;
72
+ return resolvedOptions.filter((option) => option.label.toLowerCase().includes(needle));
73
+ }, [resolvedOptions, query]);
74
+ const createValue = creatable
75
+ ? resolveCreateValue(query, resolvedOptions, normalizeCreateValue)
76
+ : null;
77
+ const createOption = createValue
78
+ ? { value: createValue, label: createLabel(createValue) }
79
+ : null;
80
+ const list = createOption ? [...filtered, createOption] : filtered;
37
81
  const onOpenChange = (next) => {
38
82
  setOpen(next);
39
83
  if (!next) {
@@ -43,7 +87,7 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
43
87
  // Set the highlight in the same turn as open so the first painted list
44
88
  // already points at the selected option (a timezone at the end of 400
45
89
  // rows) instead of flashing index 0 and scrolling twice.
46
- const selectedIndex = options.findIndex((option) => option.value === value);
90
+ const selectedIndex = resolvedOptions.findIndex((option) => option.value === value);
47
91
  setHighlight(selectedIndex >= 0 ? selectedIndex : 0);
48
92
  };
49
93
  React.useEffect(() => {
@@ -73,46 +117,25 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
73
117
  // Index-based ids: option values may contain spaces, which are not valid
74
118
  // in an id or an aria-activedescendant reference.
75
119
  const optionId = (index) => `${listId}-option-${index}`;
76
- const activeOption = filtered[highlight];
120
+ const activeOption = list[highlight];
77
121
  const onSearchKeyDown = (event) => {
78
- if (event.key === "ArrowDown") {
122
+ const nextHighlight = moveListboxHighlight(event.key, highlight, list.length);
123
+ if (nextHighlight !== undefined) {
79
124
  event.preventDefault();
80
- setHighlight((index) => filtered.length === 0 ? 0 : Math.min(index + 1, filtered.length - 1));
81
- }
82
- if (event.key === "ArrowUp") {
83
- event.preventDefault();
84
- setHighlight((index) => Math.max(index - 1, 0));
125
+ setHighlight(nextHighlight);
126
+ return;
85
127
  }
86
128
  if (event.key === "Enter") {
87
129
  event.preventDefault();
88
- const option = filtered[highlight];
130
+ const option = list[highlight];
89
131
  if (option)
90
132
  selectValue(option.value);
91
133
  }
92
- // Home/End/Page jump the highlight; preventDefault so the search caret
93
- // stays put and the page behind the list does not scroll.
94
- if (event.key === "Home") {
95
- event.preventDefault();
96
- setHighlight(0);
97
- }
98
- if (event.key === "End") {
99
- event.preventDefault();
100
- setHighlight(filtered.length === 0 ? 0 : filtered.length - 1);
101
- }
102
- if (event.key === "PageDown") {
103
- event.preventDefault();
104
- setHighlight((index) => filtered.length === 0
105
- ? 0
106
- : Math.min(index + LISTBOX_PAGE_SIZE, filtered.length - 1));
107
- }
108
- if (event.key === "PageUp") {
109
- event.preventDefault();
110
- setHighlight((index) => Math.max(index - LISTBOX_PAGE_SIZE, 0));
111
- }
112
134
  };
113
- return (_jsxs(Popover, { open: open, onOpenChange: onOpenChange, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: id, type: "button", variant: "outline", role: "combobox", "aria-expanded": open, "aria-controls": listId, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, disabled: disabled, className: cn(formControlTriggerClassName, "justify-between", !selected && "text-muted-foreground", className), children: [_jsx("span", { className: "truncate", children: selected?.label ?? placeholder }), _jsx(ChevronDownIcon, { "data-icon": "inline-end", className: "opacity-50" })] }) }), _jsxs(PopoverContent, { align: "start", className: "w-[var(--radix-popover-trigger-width)] gap-0 rounded-none p-0", children: [_jsx("div", { className: "border-b p-2", children: _jsx(Input, { ref: inputRef, value: query, onChange: onQueryChange, onKeyDown: onSearchKeyDown, placeholder: searchPlaceholder, "aria-autocomplete": "list", "aria-controls": listId, "aria-activedescendant": activeOption ? optionId(highlight) : undefined }) }), _jsx("ul", { id: listId, role: "listbox", className: "max-h-60 overflow-y-auto p-1", children: filtered.length === 0 ? (_jsx("li", { className: "px-3 py-4 text-center text-sm text-muted-foreground", children: emptyText })) : (filtered.map((option, index) => {
114
- const isSelected = option.value === value;
135
+ return (_jsxs(Popover, { open: open, onOpenChange: onOpenChange, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: id, type: "button", variant: "outline", role: "combobox", "aria-expanded": open, "aria-controls": listId, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, disabled: disabled, className: cn(formControlTriggerClassName, "justify-between", !selected && "text-muted-foreground", className), children: [_jsx("span", { className: "truncate", children: selected?.label ?? placeholder }), _jsx(ChevronDownIcon, { "data-icon": "inline-end", className: "opacity-50" })] }) }), _jsxs(PopoverContent, { align: "start", className: "w-[var(--radix-popover-trigger-width)] gap-0 rounded-none p-0", children: [_jsx("div", { className: "border-b p-2", children: _jsx(Input, { ref: inputRef, value: query, onChange: onQueryChange, onKeyDown: onSearchKeyDown, placeholder: searchPlaceholder, "aria-autocomplete": "list", "aria-controls": listId, "aria-activedescendant": activeOption ? optionId(highlight) : undefined }) }), _jsx("ul", { id: listId, role: "listbox", className: "max-h-60 overflow-y-auto p-1", children: list.length === 0 ? (_jsx("li", { className: "px-3 py-4 text-center text-sm text-muted-foreground", children: emptyText })) : (list.map((option, index) => {
136
+ const isCreate = createOption != null && option === createOption;
137
+ const isSelected = option.value === value && !isCreate;
115
138
  const isActive = index === highlight;
116
- return (_jsx("li", { role: "none", children: _jsxs("button", { ref: isActive ? scrollHighlightedOption : undefined, id: optionId(index), type: "button", role: "option", "aria-selected": isSelected, className: cn("relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-3 text-left text-sm outline-hidden select-none", isSelected && optionState.selected, isActive && optionState.active), 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] }) }, option.value));
139
+ return (_jsx("li", { role: "none", children: _jsxs("button", { ref: isActive ? scrollHighlightedOption : undefined, id: optionId(index), type: "button", role: "option", "aria-selected": isSelected, className: cn("relative flex w-full cursor-default items-center gap-2 rounded-none py-2 pr-8 pl-3 text-left text-sm outline-hidden select-none", isSelected && optionState.selected, isActive && optionState.active), 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));
117
140
  })) })] })] }));
118
141
  }
@@ -0,0 +1,2 @@
1
+ /** Return the next highlighted index for a listbox navigation key. */
2
+ export declare function moveListboxHighlight(key: string, index: number, length: number): number | undefined;
@@ -0,0 +1,21 @@
1
+ /** Page keys move ten rows, a little more than one max-h-60 list viewport. */
2
+ const PAGE_SIZE = 10;
3
+ /** Return the next highlighted index for a listbox navigation key. */
4
+ export function moveListboxHighlight(key, index, length) {
5
+ switch (key) {
6
+ case "ArrowDown":
7
+ return length === 0 ? 0 : Math.min(index + 1, length - 1);
8
+ case "ArrowUp":
9
+ return Math.max(index - 1, 0);
10
+ case "Home":
11
+ return 0;
12
+ case "End":
13
+ return length === 0 ? 0 : length - 1;
14
+ case "PageDown":
15
+ return length === 0 ? 0 : Math.min(index + PAGE_SIZE, length - 1);
16
+ case "PageUp":
17
+ return Math.max(index - PAGE_SIZE, 0);
18
+ default:
19
+ return undefined;
20
+ }
21
+ }
@@ -3,9 +3,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { Input } from "./input.js";
5
5
  import { Label } from "./label.js";
6
+ import { moveListboxHighlight } from "./internal/listbox-navigation.js";
6
7
  import { optionState } from "./internal/styles.js";
7
8
  import { cn } from "./utils.js";
8
- const LISTBOX_PAGE_SIZE = 10;
9
9
  /**
10
10
  * Case-fold and strip combining marks so "nino" matches "Niño".
11
11
  */
@@ -30,7 +30,20 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
30
30
  const [query, setQuery] = React.useState("");
31
31
  const listId = React.useId();
32
32
  const inputId = React.useId();
33
- const filtered = React.useMemo(() => items.filter((item) => filter(item, query, getLabel)), [items, filter, query, getLabel]);
33
+ const normalizedItems = React.useMemo(() => filter === defaultSearchFilter
34
+ ? items.map((item) => [item, foldSearchText(getLabel(item))])
35
+ : null, [filter, getLabel, items]);
36
+ const foldedQuery = filter === defaultSearchFilter ? foldSearchText(query.trim()) : "";
37
+ const filtered = React.useMemo(() => {
38
+ if (normalizedItems) {
39
+ if (!foldedQuery)
40
+ return items;
41
+ return normalizedItems
42
+ .filter(([, label]) => label.includes(foldedQuery))
43
+ .map(([item]) => item);
44
+ }
45
+ return items.filter((item) => filter(item, query, getLabel));
46
+ }, [items, filter, foldedQuery, getLabel, normalizedItems, query]);
34
47
  const selectedIndex = React.useMemo(() => {
35
48
  const index = filtered.findIndex((item) => getValue(item) === value);
36
49
  return index >= 0 ? index : 0;
@@ -49,31 +62,11 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
49
62
  setQuery("");
50
63
  };
51
64
  const onKeyDown = (event) => {
52
- if (event.key === "ArrowDown") {
53
- event.preventDefault();
54
- setHighlight((index) => filtered.length === 0 ? 0 : Math.min(index + 1, filtered.length - 1));
55
- }
56
- if (event.key === "ArrowUp") {
57
- event.preventDefault();
58
- setHighlight((index) => Math.max(index - 1, 0));
59
- }
60
- if (event.key === "Home") {
61
- event.preventDefault();
62
- setHighlight(0);
63
- }
64
- if (event.key === "End") {
65
- event.preventDefault();
66
- setHighlight(filtered.length === 0 ? 0 : filtered.length - 1);
67
- }
68
- if (event.key === "PageDown") {
69
- event.preventDefault();
70
- setHighlight((index) => filtered.length === 0
71
- ? 0
72
- : Math.min(index + LISTBOX_PAGE_SIZE, filtered.length - 1));
73
- }
74
- if (event.key === "PageUp") {
65
+ const nextHighlight = moveListboxHighlight(event.key, highlight, filtered.length);
66
+ if (nextHighlight !== undefined) {
75
67
  event.preventDefault();
76
- setHighlight((index) => Math.max(index - LISTBOX_PAGE_SIZE, 0));
68
+ setHighlight(nextHighlight);
69
+ return;
77
70
  }
78
71
  if (event.key === "Enter") {
79
72
  event.preventDefault();
package/dist/toast.js CHANGED
@@ -51,13 +51,18 @@ const toast = Object.assign(show(sonnerToast), {
51
51
  * popover fill, border, and muted text beat those fills. The 24px hit area
52
52
  * is the badge itself; the X is 12px. `overflow-visible` on the toast and
53
53
  * toaster keeps stacked badges from being clipped.
54
+ *
55
+ * Padding is asymmetric: `pl-4` matches Alert; `pr-6` clears the badge
56
+ * (about 16px of it sits inside the border). Icon-to-text is `gap-x-2.5`
57
+ * so the status icon and copy read as one unit.
54
58
  */
55
59
  const classNames = {
56
- toast: "relative flex w-max max-w-[min(356px,calc(100vw-2rem))] flex-wrap items-center justify-end gap-x-3 gap-y-2 overflow-visible rounded-none border border-border bg-popover px-4 py-3 text-sm text-popover-foreground shadow-md",
60
+ toast: "relative flex w-max max-w-[min(356px,calc(100vw-2rem))] flex-wrap items-center justify-end gap-x-2.5 gap-y-2 overflow-visible rounded-none border border-border bg-popover py-3 pl-4 pr-6 text-sm text-popover-foreground shadow-md",
57
61
  // Fills the row beside the icon, so action buttons wrap to their own row
58
62
  // (aligned right by the toast's justify-end). Without an icon the basis
59
63
  // falls back to the title's width, so a one-word toast stays compact.
60
- content: "flex min-w-0 flex-1 basis-full flex-col gap-1 [[data-icon]+&]:basis-[calc(100%-1.75rem)]",
64
+ // 1.625rem = 16px icon + 10px gap-x-2.5.
65
+ content: "flex min-w-0 flex-1 basis-full flex-col gap-1 [[data-icon]+&]:basis-[calc(100%-1.625rem)]",
61
66
  title: "font-medium leading-5",
62
67
  description: "leading-5 text-muted-foreground",
63
68
  icon: "flex size-4 shrink-0 items-center justify-center [&_svg]:size-4",
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ChangeEvent } from "react";
2
2
  import { type ClassValue } from "clsx";
3
3
  export declare function cn(...inputs: ClassValue[]): string;
4
- /** Stable onChange handler for controlled text inputs (satisfies react/jsx-no-bind). */
4
+ /** Read the next value from an input event for a controlled text field. */
5
5
  export declare function onInputChange(setValue: (value: string) => void): (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
package/dist/utils.js CHANGED
@@ -3,7 +3,7 @@ import { twMerge } from "tailwind-merge";
3
3
  export function cn(...inputs) {
4
4
  return twMerge(clsx(inputs));
5
5
  }
6
- /** Stable onChange handler for controlled text inputs (satisfies react/jsx-no-bind). */
6
+ /** Read the next value from an input event for a controlled text field. */
7
7
  export function onInputChange(setValue) {
8
8
  return (e) => {
9
9
  setValue(e.target.value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas-react-kit",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Shared React components and Tailwind theme for Atlas",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -243,7 +243,8 @@
243
243
  },
244
244
  "scripts": {
245
245
  "build": "tsc",
246
- "check": "npm run build && prettier --check .",
246
+ "check": "npm run build && npm run docs:check && prettier --check .",
247
+ "docs:check": "node scripts/check-doc-links.mjs",
247
248
  "test": "npm run build && vitest run",
248
249
  "test:coverage": "npm run build && vitest run --coverage",
249
250
  "prepack": "npm run build"
@@ -263,6 +264,7 @@
263
264
  "react-dom": "^19.0.0",
264
265
  "react-markdown": "^10.1.0",
265
266
  "remark-gfm": "^4.0.1",
267
+ "remark-parse": "^11.0.0",
266
268
  "sonner": "^2.0.7",
267
269
  "tailwindcss": "^4.0.0"
268
270
  },
@@ -319,6 +321,7 @@
319
321
  "sonner": "^2.0.7",
320
322
  "tailwindcss": "^4.2.1",
321
323
  "typescript": "^6.0.3",
324
+ "unified": "^11.0.5",
322
325
  "vitest": "^4.1.9"
323
326
  }
324
327
  }
@@ -56,7 +56,7 @@ Import each component from its subpath, e.g.
56
56
  | Need | Use |
57
57
  | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
58
58
  | Short fixed list (a handful of options) | `Select` family from `select` |
59
- | Long or searchable list (timezones, currencies) | `Combobox` from `combobox` with `{ value, label }[]` options |
59
+ | Long or searchable list (timezones, currencies) | `Combobox` from `combobox` with `{ value, label }[]` options; set `creatable` to invent a value the catalog lacks |
60
60
  | Always-visible searchable list (places) | `SearchPicker` from `search-picker` |
61
61
  | One date, or a from/to range | `DatePickerButton` from `date-picker` (`mode="range"` for ranges) |
62
62
  | Date and time (not `datetime-local`) | `DateTimePickerButton` from `date-time-picker` |
@@ -97,42 +97,12 @@ buttons). Don't copy that style into new UI. Move what the task touches onto kit
97
97
  components and tokens, and mention the rest to the user instead of rewriting
98
98
  files the task doesn't need.
99
99
 
100
- ## 4. Style with tokens, not values
101
-
102
- These rules are what make Atlas look like Atlas. `DESIGN_RULES.md` has the full
103
- list; the ones that come up most:
104
-
105
- - **Semantic colors only**: `bg-primary`, `text-muted-foreground`,
106
- `border-border`, `bg-card`, `text-destructive`. Raw palette classes
107
- (`bg-purple-600`, `text-gray-500`) and paired `dark:` colors drift from the
108
- theme; tokens already switch with light and dark.
109
- - **Selected and active state**: `bg-selected text-selected-foreground` plus
110
- a `border-selected-foreground` border (or bar) for the current nav item,
111
- chip, row, or toggle; the wash alone is too faint to show state. Grey fills
112
- read as hover, not selection.
113
- - **Status**: `success`, `warning`, and `destructive` for good, caution, and
114
- bad. Use them as text or soft tints (the `Badge` tones), which meet AA in
115
- both themes. Don't override a soft badge's colors. There is no foreground
116
- token for text on a solid status fill, so avoid that combination.
117
- - **Brand-colored text**: `text-selected-foreground`, not `text-primary`,
118
- which is too dark to read on the dark background.
119
- - **Accessibility**: the kit targets WCAG 2.2 AA. Give every control a name
120
- (`Label htmlFor`, or `aria-label` on icon-only buttons and an unlabelled
121
- `Combobox`), keep the shipped focus rings (solid `ring-ring`, never
122
- `ring-ring/30`), keep field borders on `border-input` (3:1; `border-border`
123
- is for dividers), and don't lower contrast with raw colors or opacity.
124
- - **Square corners**: primitives have none. Use `rounded-full` only for real
125
- circles (avatars, dots). Adding `rounded-md` to a card or button breaks the
126
- system.
127
- - **Don't restyle kit controls**: `Input`, `SelectTrigger`, `DatePickerButton`,
128
- `DateTimePickerButton`, and `Combobox` share 40px form-control chrome.
129
- Overriding their height, padding, or border makes one field look different
130
- from its neighbors.
131
- - **Layout overrides**: `CardContent className="px-0"` makes one section flush
132
- without `!important`. Tables and tab rows already scroll horizontally, so do
133
- not wrap them in another overflow container.
134
- - **Headings**: `CardTitle` and `DialogTitle` already apply the uppercase,
135
- tracked heading style; use `font-heading` for other headings.
100
+ ## 4. Follow the design rules
101
+
102
+ Use semantic tokens and the shipped component APIs. Avoid raw palette color
103
+ classes and rounded corners on square primitives; do not restyle kit controls.
104
+ For accessibility, copy, selection, status, and layout details, follow the
105
+ matching sections in `DESIGN_RULES.md`; it is the source of truth.
136
106
 
137
107
  ## 5. Check before you finish
138
108