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 +6 -1
- package/DESIGN_RULES.md +6 -1
- package/README.md +17 -21
- package/dist/combobox.d.ts +19 -1
- package/dist/combobox.js +64 -41
- package/dist/internal/listbox-navigation.d.ts +2 -0
- package/dist/internal/listbox-navigation.js +21 -0
- package/dist/search-picker.js +19 -26
- package/dist/toast.js +7 -2
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js +1 -1
- package/package.json +5 -2
- package/skills/atlas-react-kit/SKILL.md +7 -37
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
|
|
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 (`
|
|
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
|
|
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.
|
|
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.
|
|
31
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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.
|
|
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).
|
package/dist/combobox.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
return
|
|
36
|
-
}, [
|
|
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 =
|
|
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 =
|
|
120
|
+
const activeOption = list[highlight];
|
|
77
121
|
const onSearchKeyDown = (event) => {
|
|
78
|
-
|
|
122
|
+
const nextHighlight = moveListboxHighlight(event.key, highlight, list.length);
|
|
123
|
+
if (nextHighlight !== undefined) {
|
|
79
124
|
event.preventDefault();
|
|
80
|
-
setHighlight(
|
|
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 =
|
|
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:
|
|
114
|
-
const
|
|
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,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
|
+
}
|
package/dist/search-picker.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
53
|
-
|
|
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(
|
|
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-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|