atlas-react-kit 0.9.2 → 0.9.4
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 +12 -1
- package/DESIGN_RULES.md +3 -0
- package/README.md +17 -21
- package/dist/combobox.d.ts +19 -1
- package/dist/combobox.js +65 -51
- package/dist/date-picker.js +2 -4
- package/dist/date-time-picker.js +2 -4
- package/dist/field.d.ts +1 -1
- package/dist/form/date-picker-field.d.ts +1 -1
- package/dist/form/date-picker-field.js +4 -5
- package/dist/form/date-time-picker-field.d.ts +1 -1
- package/dist/form/date-time-picker-field.js +4 -5
- package/dist/form/field-shell.d.ts +21 -0
- package/dist/form/field-shell.js +23 -0
- package/dist/form/markdown-field.js +3 -6
- package/dist/form/select-field.d.ts +1 -1
- package/dist/form/select-field.js +3 -4
- package/dist/form/text-field.d.ts +1 -1
- package/dist/form/text-field.js +4 -7
- package/dist/internal/form-control.d.ts +9 -0
- package/dist/internal/form-control.js +9 -0
- package/dist/internal/listbox-navigation.d.ts +14 -0
- package/dist/internal/listbox-navigation.js +37 -0
- package/dist/internal/styles.d.ts +2 -0
- package/dist/internal/styles.js +2 -0
- package/dist/search-picker.js +33 -35
- package/dist/select.js +4 -3
- 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,17 @@ 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
|
+
For LLM-assisted implementation of patterns this kit does not export, use
|
|
12
|
+
[COSS UI](https://coss.com/ui) as a reference before building a component from
|
|
13
|
+
scratch. Its Base UI catalog includes input groups, number fields, OTP fields,
|
|
14
|
+
drawers, and segmented controls. COSS is a separate library; its components
|
|
15
|
+
are not Atlas exports.
|
|
16
|
+
|
|
17
|
+
## Optional peer dependencies
|
|
18
|
+
|
|
8
19
|
Core entries need only the kit. These feature entries need an extra install
|
|
9
20
|
(same version ranges as the kit's optional peers). `Toaster` also reads the
|
|
10
21
|
theme from `next-themes`, so toast consumers install that too.
|
|
@@ -29,7 +40,7 @@ theme from `next-themes`, so toast consumers install that too.
|
|
|
29
40
|
| `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
41
|
| `checkbox` | `Checkbox` |
|
|
31
42
|
| `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
|
|
43
|
+
| `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
44
|
| `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
45
|
| `date-time-picker` | `DateTimePickerButton`, `resolveTimeStep`, `DEFAULT_TIME_STEP`; controlled `value` is `Date` or `null`; `onChange` emits `Date \ |
|
|
35
46
|
| `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`,
|
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.4
|
|
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.4.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 { listboxOptionId, moveListboxHighlight, scrollOptionIntoView, } from "./internal/listbox-navigation.js";
|
|
8
9
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
|
|
9
|
-
import { optionState } from "./internal/styles.js";
|
|
10
|
+
import { listboxEmpty, 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(() => {
|
|
@@ -52,12 +96,6 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
|
|
|
52
96
|
const frame = window.requestAnimationFrame(() => inputRef.current?.focus());
|
|
53
97
|
return () => window.cancelAnimationFrame(frame);
|
|
54
98
|
}, [open]);
|
|
55
|
-
// Callback ref (not an effect) so scroll runs when Radix mounts the list
|
|
56
|
-
// and when the highlight moves. A layout effect on `open` is too early:
|
|
57
|
-
// the option node does not exist until the popover content is in the DOM.
|
|
58
|
-
const scrollHighlightedOption = React.useCallback((node) => {
|
|
59
|
-
node?.scrollIntoView({ block: "nearest" });
|
|
60
|
-
}, []);
|
|
61
99
|
const onQueryChange = onInputChange((next) => {
|
|
62
100
|
setQuery(next);
|
|
63
101
|
setHighlight(0);
|
|
@@ -70,49 +108,25 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
|
|
|
70
108
|
setOpen(false);
|
|
71
109
|
setQuery("");
|
|
72
110
|
};
|
|
73
|
-
|
|
74
|
-
// in an id or an aria-activedescendant reference.
|
|
75
|
-
const optionId = (index) => `${listId}-option-${index}`;
|
|
76
|
-
const activeOption = filtered[highlight];
|
|
111
|
+
const activeOption = list[highlight];
|
|
77
112
|
const onSearchKeyDown = (event) => {
|
|
78
|
-
|
|
113
|
+
const nextHighlight = moveListboxHighlight(event.key, highlight, list.length);
|
|
114
|
+
if (nextHighlight !== undefined) {
|
|
79
115
|
event.preventDefault();
|
|
80
|
-
setHighlight(
|
|
81
|
-
|
|
82
|
-
if (event.key === "ArrowUp") {
|
|
83
|
-
event.preventDefault();
|
|
84
|
-
setHighlight((index) => Math.max(index - 1, 0));
|
|
116
|
+
setHighlight(nextHighlight);
|
|
117
|
+
return;
|
|
85
118
|
}
|
|
86
119
|
if (event.key === "Enter") {
|
|
87
120
|
event.preventDefault();
|
|
88
|
-
const option =
|
|
121
|
+
const option = list[highlight];
|
|
89
122
|
if (option)
|
|
90
123
|
selectValue(option.value);
|
|
91
124
|
}
|
|
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
125
|
};
|
|
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 ?
|
|
114
|
-
const
|
|
126
|
+
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 ? listboxOptionId(listId, highlight) : undefined }) }), _jsx("ul", { id: listId, role: "listbox", className: "max-h-60 overflow-y-auto p-1", children: list.length === 0 ? (_jsx("li", { className: listboxEmpty, children: emptyText })) : (list.map((option, index) => {
|
|
127
|
+
const isCreate = createOption != null && option === createOption;
|
|
128
|
+
const isSelected = option.value === value && !isCreate;
|
|
115
129
|
const isActive = index === highlight;
|
|
116
|
-
return (_jsx("li", { role: "none", children: _jsxs("button", { ref: isActive ?
|
|
130
|
+
return (_jsx("li", { role: "none", children: _jsxs("button", { ref: isActive ? scrollOptionIntoView : undefined, id: listboxOptionId(listId, 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
131
|
})) })] })] }));
|
|
118
132
|
}
|
package/dist/date-picker.js
CHANGED
|
@@ -5,7 +5,7 @@ import { format, isSameDay, parseISO } from "date-fns";
|
|
|
5
5
|
import { CalendarIcon } from "lucide-react";
|
|
6
6
|
import { Button } from "./button.js";
|
|
7
7
|
import { Calendar } from "./calendar.js";
|
|
8
|
-
import {
|
|
8
|
+
import { PICKER_COLLISION_PADDING, pickerContentClassName, pickerTriggerClassName, } from "./internal/form-control.js";
|
|
9
9
|
import { MD_MEDIA_QUERY, useMediaQuery } from "./internal/use-media-query.js";
|
|
10
10
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
|
|
11
11
|
import { cn } from "./utils.js";
|
|
@@ -56,11 +56,9 @@ function formatRangeTriggerLabel(from, to, placeholder) {
|
|
|
56
56
|
return format(from, "PPP");
|
|
57
57
|
return `${format(from, "PPP")} – ${format(to, "PPP")}`;
|
|
58
58
|
}
|
|
59
|
-
/** Gutter used when the calendar popover collides with the viewport. */
|
|
60
|
-
const DATE_PICKER_COLLISION_PADDING = 16;
|
|
61
59
|
/** Shared trigger + auto-sized popover chrome for single and range modes. */
|
|
62
60
|
function DatePickerShell({ id, disabled, ariaInvalid, className, empty, label, open, onOpenChange, side, children, }) {
|
|
63
|
-
return (_jsxs(Popover, { open: open, onOpenChange: onOpenChange, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: id, type: "button", variant: "outline", disabled: disabled, "aria-invalid": ariaInvalid, "data-empty": empty, className: cn(
|
|
61
|
+
return (_jsxs(Popover, { open: open, onOpenChange: onOpenChange, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: id, type: "button", variant: "outline", disabled: disabled, "aria-invalid": ariaInvalid, "data-empty": empty, className: cn(pickerTriggerClassName, className), children: [_jsx(CalendarIcon, { "data-icon": "inline-start" }), label] }) }), _jsx(PopoverContent, { align: "start", side: side, collisionPadding: PICKER_COLLISION_PADDING, className: cn("w-auto", pickerContentClassName), children: children })] }));
|
|
64
62
|
}
|
|
65
63
|
function DatePickerSingleButton({ id, value, onChange, placeholder = "Pick a date", disabled, className, "aria-invalid": ariaInvalid, numberOfMonths, side, defaultMonth, }) {
|
|
66
64
|
const [open, setOpen] = React.useState(false);
|
package/dist/date-time-picker.js
CHANGED
|
@@ -5,14 +5,12 @@ import { format } from "date-fns";
|
|
|
5
5
|
import { CalendarIcon } from "lucide-react";
|
|
6
6
|
import { Button } from "./button.js";
|
|
7
7
|
import { Calendar } from "./calendar.js";
|
|
8
|
-
import {
|
|
8
|
+
import { PICKER_COLLISION_PADDING, pickerContentClassName, pickerTriggerClassName, } from "./internal/form-control.js";
|
|
9
9
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
|
|
10
10
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./select.js";
|
|
11
11
|
import { cn } from "./utils.js";
|
|
12
12
|
/** Default clock increment, in minutes. Must divide 60. */
|
|
13
13
|
export const DEFAULT_TIME_STEP = 15;
|
|
14
|
-
/** Gutter used when the calendar popover collides with the viewport. */
|
|
15
|
-
const DATE_TIME_PICKER_COLLISION_PADDING = 16;
|
|
16
14
|
/**
|
|
17
15
|
* Normalize a minute step so option lists stay even (1, 5, 15, 30, 60).
|
|
18
16
|
* Invalid values fall back to 15 so a bad prop cannot empty the minute list.
|
|
@@ -81,7 +79,7 @@ export function DateTimePickerButton({ id, value, onChange, timeStep, placeholde
|
|
|
81
79
|
const label = date ? format(date, "PPP p") : placeholder;
|
|
82
80
|
const hourId = React.useId();
|
|
83
81
|
const minuteId = React.useId();
|
|
84
|
-
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: id, type: "button", variant: "outline", disabled: disabled, "aria-invalid": ariaInvalid, "data-empty": empty, className: cn(
|
|
82
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { id: id, type: "button", variant: "outline", disabled: disabled, "aria-invalid": ariaInvalid, "data-empty": empty, className: cn(pickerTriggerClassName, className), children: [_jsx(CalendarIcon, { "data-icon": "inline-start" }), label] }) }), _jsxs(PopoverContent, { align: "start", side: side, collisionPadding: PICKER_COLLISION_PADDING, onInteractOutside: preventDismissForPortaledSelect, className: cn("w-auto min-w-0 max-w-[min(100vw-2rem,24rem)]", pickerContentClassName), children: [_jsx(Calendar, { mode: "single", selected: date ?? undefined, defaultMonth: defaultMonth ?? date ?? undefined, onSelect: (next) => {
|
|
85
83
|
// Re-clicking a selected day would clear it; require Clear instead
|
|
86
84
|
// so a time already set is not dropped by an accidental second tap.
|
|
87
85
|
if (!next)
|
package/dist/field.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ declare function FieldLegend({ className, variant, ...props }: React.ComponentPr
|
|
|
6
6
|
}): import("react").JSX.Element;
|
|
7
7
|
declare function FieldGroup({ className, ...props }: React.ComponentProps<"div">): import("react").JSX.Element;
|
|
8
8
|
declare const fieldVariants: (props?: ({
|
|
9
|
-
orientation?: "
|
|
9
|
+
orientation?: "vertical" | "horizontal" | "responsive" | null | undefined;
|
|
10
10
|
} & import("class-variance-authority/types").ClassProp) | undefined) => string;
|
|
11
11
|
declare function Field({ className, orientation, ...props }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>): import("react").JSX.Element;
|
|
12
12
|
declare function FieldContent({ className, ...props }: React.ComponentProps<"div">): import("react").JSX.Element;
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useId } from "react";
|
|
4
4
|
import { DatePickerButton } from "../date-picker.js";
|
|
5
|
-
import {
|
|
6
|
-
import { FieldError, getFieldErrorMessage, } from "./field-error.js";
|
|
5
|
+
import { FormFieldShell, invalidWhen, metaErrorText } from "./field-shell.js";
|
|
7
6
|
/** Date picker with built-in field error text and invalid styling. */
|
|
8
7
|
export function FormDatePickerField({ value, onValueChange, label, meta, placeholder, disabled, className, }) {
|
|
9
|
-
const errorText =
|
|
8
|
+
const errorText = metaErrorText(meta);
|
|
10
9
|
const id = useId();
|
|
11
|
-
return (
|
|
10
|
+
return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, children: _jsx(DatePickerButton, { id: id, value: value, onChange: onValueChange, placeholder: placeholder, disabled: disabled, className: className, "aria-invalid": invalidWhen(errorText) }) }));
|
|
12
11
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useId } from "react";
|
|
4
4
|
import { DateTimePickerButton } from "../date-time-picker.js";
|
|
5
|
-
import {
|
|
6
|
-
import { FieldError, getFieldErrorMessage, } from "./field-error.js";
|
|
5
|
+
import { FormFieldShell, invalidWhen, metaErrorText } from "./field-shell.js";
|
|
7
6
|
/** Date-time picker with built-in field error text and invalid styling. */
|
|
8
7
|
export function FormDateTimePickerField({ value, onValueChange, label, meta, placeholder, disabled, timeStep, className, }) {
|
|
9
|
-
const errorText =
|
|
8
|
+
const errorText = metaErrorText(meta);
|
|
10
9
|
const id = useId();
|
|
11
|
-
return (
|
|
10
|
+
return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, children: _jsx(DateTimePickerButton, { id: id, value: value, onChange: onValueChange, placeholder: placeholder, disabled: disabled, timeStep: timeStep, className: className, "aria-invalid": invalidWhen(errorText) }) }));
|
|
12
11
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type FieldMetaState } from "./field-error.js";
|
|
3
|
+
/**
|
|
4
|
+
* Not a public entry. The layout and error wiring every `form/*` field
|
|
5
|
+
* shares, so a field only says which control it renders.
|
|
6
|
+
*/
|
|
7
|
+
/** First error in `meta`, or `undefined` when there is no meta or no error. */
|
|
8
|
+
export declare function metaErrorText(meta: FieldMetaState | undefined): string | undefined;
|
|
9
|
+
/** `aria-invalid` for the control: `true` while there is error text. */
|
|
10
|
+
export declare function invalidWhen(errorText: string | undefined): true | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Label, control, and error text stacked with the kit's field spacing.
|
|
13
|
+
* `labelAction` sits on the label's row (the Markdown field's mode toggle).
|
|
14
|
+
*/
|
|
15
|
+
export declare function FormFieldShell({ id, label, labelAction, errorText, children, }: {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
labelAction?: ReactNode;
|
|
19
|
+
errorText: string | undefined;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Label } from "../label.js";
|
|
3
|
+
import { FieldError, getFieldErrorMessage, } from "./field-error.js";
|
|
4
|
+
/**
|
|
5
|
+
* Not a public entry. The layout and error wiring every `form/*` field
|
|
6
|
+
* shares, so a field only says which control it renders.
|
|
7
|
+
*/
|
|
8
|
+
/** First error in `meta`, or `undefined` when there is no meta or no error. */
|
|
9
|
+
export function metaErrorText(meta) {
|
|
10
|
+
return meta ? getFieldErrorMessage(meta.errors[0]) : undefined;
|
|
11
|
+
}
|
|
12
|
+
/** `aria-invalid` for the control: `true` while there is error text. */
|
|
13
|
+
export function invalidWhen(errorText) {
|
|
14
|
+
return errorText ? true : undefined;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Label, control, and error text stacked with the kit's field spacing.
|
|
18
|
+
* `labelAction` sits on the label's row (the Markdown field's mode toggle).
|
|
19
|
+
*/
|
|
20
|
+
export function FormFieldShell({ id, label, labelAction, errorText, children, }) {
|
|
21
|
+
const labelNode = _jsx(Label, { htmlFor: id, children: label });
|
|
22
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [labelAction ? (_jsxs("div", { className: "flex items-center justify-between gap-2", children: [labelNode, labelAction] })) : (labelNode), children, _jsx(FieldError, { text: errorText })] }));
|
|
23
|
+
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { Label } from "../label.js";
|
|
5
4
|
import { Markdown } from "../markdown.js";
|
|
6
5
|
import { Textarea } from "../textarea.js";
|
|
7
6
|
import { ToggleGroup, ToggleGroupItem } from "../toggle-group.js";
|
|
8
7
|
import { fieldSurface } from "../internal/styles.js";
|
|
9
8
|
import { cn, onInputChange } from "../utils.js";
|
|
10
|
-
import {
|
|
9
|
+
import { FormFieldShell, invalidWhen, metaErrorText } from "./field-shell.js";
|
|
11
10
|
/**
|
|
12
11
|
* GFM body field: Write is a textarea, Preview is the sanitized Markdown
|
|
13
12
|
* renderer. String-in/string-out — not a WYSIWYG document model.
|
|
@@ -15,13 +14,11 @@ import { FieldError, getFieldErrorMessage } from "./field-error.js";
|
|
|
15
14
|
export function FormMarkdownField({ field, id = field.name, label, placeholder = "Markdown supported", rows = 5, showError = true, }) {
|
|
16
15
|
const [mode, setMode] = useState("write");
|
|
17
16
|
const onChange = onInputChange(field.handleChange);
|
|
18
|
-
const errorText = showError
|
|
19
|
-
? getFieldErrorMessage(field.state.meta.errors[0])
|
|
20
|
-
: undefined;
|
|
17
|
+
const errorText = showError ? metaErrorText(field.state.meta) : undefined;
|
|
21
18
|
const trimmed = field.state.value.trim();
|
|
22
19
|
function handleModeChange(value) {
|
|
23
20
|
if (value === "write" || value === "preview")
|
|
24
21
|
setMode(value);
|
|
25
22
|
}
|
|
26
|
-
return (
|
|
23
|
+
return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, labelAction: _jsxs(ToggleGroup, { type: "single", value: mode, onValueChange: handleModeChange, variant: "outline", size: "sm", spacing: 0, "aria-label": `${label} mode`, children: [_jsx(ToggleGroupItem, { value: "write", children: "Write" }), _jsx(ToggleGroupItem, { value: "preview", children: "Preview" })] }), children: mode === "write" ? (_jsx(Textarea, { id: id, value: field.state.value, onChange: onChange, onBlur: field.handleBlur, placeholder: placeholder, rows: rows, "aria-invalid": invalidWhen(errorText) })) : (_jsx("div", { className: cn(fieldSurface, "min-h-16"), "aria-live": "polite", children: trimmed ? (_jsx(Markdown, { content: trimmed })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "Nothing to preview" })) })) }));
|
|
27
24
|
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId } from "react";
|
|
4
|
-
import { Label } from "../label.js";
|
|
5
4
|
import { Select, SelectContent, SelectTrigger, SelectValue } from "../select.js";
|
|
6
|
-
import {
|
|
5
|
+
import { FormFieldShell, invalidWhen, metaErrorText } from "./field-shell.js";
|
|
7
6
|
/** Select control with built-in field error text and invalid styling. */
|
|
8
7
|
export function FormSelectField({ value, meta, label, onValueChange, placeholder, children, }) {
|
|
9
|
-
const errorText =
|
|
8
|
+
const errorText = metaErrorText(meta);
|
|
10
9
|
const id = useId();
|
|
11
|
-
return (
|
|
10
|
+
return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, children: _jsxs(Select, { value: value, onValueChange: onValueChange, children: [_jsx(SelectTrigger, { id: id, "aria-invalid": invalidWhen(errorText), children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsx(SelectContent, { children: children })] }) }));
|
|
12
11
|
}
|
package/dist/form/text-field.js
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Input } from "../input.js";
|
|
4
|
-
import { Label } from "../label.js";
|
|
5
4
|
import { Textarea } from "../textarea.js";
|
|
6
5
|
import { onInputChange } from "../utils.js";
|
|
7
|
-
import {
|
|
6
|
+
import { FormFieldShell, invalidWhen, metaErrorText } from "./field-shell.js";
|
|
8
7
|
export function FormTextField({ field, id = field.name, label, placeholder, description, autoFocus, multiline, rows = 3, inputType = "text", showError = true, autoComplete, required, minLength, }) {
|
|
9
8
|
const onChange = onInputChange(field.handleChange);
|
|
10
|
-
const errorText = showError
|
|
11
|
-
? getFieldErrorMessage(field.state.meta.errors[0])
|
|
12
|
-
: undefined;
|
|
9
|
+
const errorText = showError ? metaErrorText(field.state.meta) : undefined;
|
|
13
10
|
const controlProps = {
|
|
14
11
|
id,
|
|
15
12
|
value: field.state.value,
|
|
@@ -19,7 +16,7 @@ export function FormTextField({ field, id = field.name, label, placeholder, desc
|
|
|
19
16
|
autoComplete,
|
|
20
17
|
required,
|
|
21
18
|
minLength,
|
|
22
|
-
"aria-invalid": errorText
|
|
19
|
+
"aria-invalid": invalidWhen(errorText),
|
|
23
20
|
};
|
|
24
|
-
return (_jsxs(
|
|
21
|
+
return (_jsxs(FormFieldShell, { id: id, label: label, errorText: errorText, children: [description ? (_jsx("p", { className: "text-xs text-muted-foreground", children: description })) : null, multiline ? (_jsx(Textarea, { ...controlProps, rows: rows })) : (_jsx(Input, { ...controlProps, type: inputType, autoFocus: autoFocus }))] }));
|
|
25
22
|
}
|
|
@@ -7,3 +7,12 @@
|
|
|
7
7
|
* outline Button's purple selected wash.
|
|
8
8
|
*/
|
|
9
9
|
export declare const formControlTriggerClassName = "h-10 w-full border-input bg-background px-3 font-normal text-base tracking-normal shadow-xs normal-case md:text-sm hover:border-input hover:bg-background hover:text-foreground focus-visible:text-foreground aria-expanded:border-input aria-expanded:bg-background aria-expanded:text-foreground has-data-[icon=inline-start]:pl-3 has-data-[icon=inline-end]:pr-3 dark:hover:bg-background dark:aria-expanded:bg-background";
|
|
10
|
+
/**
|
|
11
|
+
* Date and date-time picker trigger: the form-control chrome, start-aligned,
|
|
12
|
+
* with muted text while empty (`data-empty`).
|
|
13
|
+
*/
|
|
14
|
+
export declare const pickerTriggerClassName = "h-10 w-full border-input bg-background px-3 font-normal text-base tracking-normal shadow-xs normal-case md:text-sm hover:border-input hover:bg-background hover:text-foreground focus-visible:text-foreground aria-expanded:border-input aria-expanded:bg-background aria-expanded:text-foreground has-data-[icon=inline-start]:pl-3 has-data-[icon=inline-end]:pr-3 dark:hover:bg-background dark:aria-expanded:bg-background justify-start data-[empty=true]:text-muted-foreground";
|
|
15
|
+
/** Calendar popover body: fits the viewport height and scrolls inside it. */
|
|
16
|
+
export declare const pickerContentClassName = "max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0";
|
|
17
|
+
/** Gutter used when a calendar popover collides with the viewport. */
|
|
18
|
+
export declare const PICKER_COLLISION_PADDING = 16;
|
|
@@ -7,3 +7,12 @@
|
|
|
7
7
|
* outline Button's purple selected wash.
|
|
8
8
|
*/
|
|
9
9
|
export const formControlTriggerClassName = "h-10 w-full border-input bg-background px-3 font-normal text-base tracking-normal shadow-xs normal-case md:text-sm hover:border-input hover:bg-background hover:text-foreground focus-visible:text-foreground aria-expanded:border-input aria-expanded:bg-background aria-expanded:text-foreground has-data-[icon=inline-start]:pl-3 has-data-[icon=inline-end]:pr-3 dark:hover:bg-background dark:aria-expanded:bg-background";
|
|
10
|
+
/**
|
|
11
|
+
* Date and date-time picker trigger: the form-control chrome, start-aligned,
|
|
12
|
+
* with muted text while empty (`data-empty`).
|
|
13
|
+
*/
|
|
14
|
+
export const pickerTriggerClassName = `${formControlTriggerClassName} justify-start data-[empty=true]:text-muted-foreground`;
|
|
15
|
+
/** Calendar popover body: fits the viewport height and scrolls inside it. */
|
|
16
|
+
export const pickerContentClassName = "max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0";
|
|
17
|
+
/** Gutter used when a calendar popover collides with the viewport. */
|
|
18
|
+
export const PICKER_COLLISION_PADDING = 16;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Return the next highlighted index for a listbox navigation key. */
|
|
2
|
+
export declare function moveListboxHighlight(key: string, index: number, length: number): number | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Option id for `aria-activedescendant`. Index-based because option values
|
|
5
|
+
* may contain spaces, which are not valid in an id reference.
|
|
6
|
+
*/
|
|
7
|
+
export declare function listboxOptionId(listId: string, index: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* Callback ref for the highlighted option. It runs when the option mounts
|
|
10
|
+
* (including when a popover first renders the list) and when the highlight
|
|
11
|
+
* moves, keeping the option in view inside the overflow list. Module-level,
|
|
12
|
+
* so the ref is stable without `useCallback`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function scrollOptionIntoView(node: HTMLElement | null): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Option id for `aria-activedescendant`. Index-based because option values
|
|
24
|
+
* may contain spaces, which are not valid in an id reference.
|
|
25
|
+
*/
|
|
26
|
+
export function listboxOptionId(listId, index) {
|
|
27
|
+
return `${listId}-option-${index}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Callback ref for the highlighted option. It runs when the option mounts
|
|
31
|
+
* (including when a popover first renders the list) and when the highlight
|
|
32
|
+
* moves, keeping the option in view inside the overflow list. Module-level,
|
|
33
|
+
* so the ref is stable without `useCallback`.
|
|
34
|
+
*/
|
|
35
|
+
export function scrollOptionIntoView(node) {
|
|
36
|
+
node?.scrollIntoView({ block: "nearest" });
|
|
37
|
+
}
|
|
@@ -37,6 +37,8 @@ export declare const selectedState: {
|
|
|
37
37
|
/** Quiet buttons and toggles on hover. */
|
|
38
38
|
hover: string;
|
|
39
39
|
};
|
|
40
|
+
/** The "No results" row of a listbox. */
|
|
41
|
+
export declare const listboxEmpty = "px-3 py-4 text-center text-sm text-muted-foreground";
|
|
40
42
|
/** Options in menus and listboxes. */
|
|
41
43
|
export declare const optionState: {
|
|
42
44
|
/** Highlight as focus moves: grey fill plus a 2px ring bar (the fill alone is 1.1:1). */
|
package/dist/internal/styles.js
CHANGED
|
@@ -37,6 +37,8 @@ export const selectedState = {
|
|
|
37
37
|
/** Quiet buttons and toggles on hover. */
|
|
38
38
|
hover: "hover:border-selected-foreground hover:bg-selected hover:text-selected-foreground",
|
|
39
39
|
};
|
|
40
|
+
/** The "No results" row of a listbox. */
|
|
41
|
+
export const listboxEmpty = "px-3 py-4 text-center text-sm text-muted-foreground";
|
|
40
42
|
/** Options in menus and listboxes. */
|
|
41
43
|
export const optionState = {
|
|
42
44
|
/** Highlight as focus moves: grey fill plus a 2px ring bar (the fill alone is 1.1:1). */
|
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 {
|
|
6
|
+
import { listboxOptionId, moveListboxHighlight, scrollOptionIntoView, } from "./internal/listbox-navigation.js";
|
|
7
|
+
import { listboxEmpty, 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,50 +30,48 @@ 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;
|
|
37
50
|
}, [filtered, getValue, value]);
|
|
38
51
|
const [highlight, setHighlight] = React.useState(selectedIndex);
|
|
39
|
-
|
|
52
|
+
// Reset the highlight while rendering, not in an effect, when the query or
|
|
53
|
+
// the selected row changes: an effect commits every keystroke twice, once
|
|
54
|
+
// with the stale highlight and again with the reset.
|
|
55
|
+
const [highlightFor, setHighlightFor] = React.useState({
|
|
56
|
+
query,
|
|
57
|
+
selectedIndex,
|
|
58
|
+
});
|
|
59
|
+
if (highlightFor.query !== query ||
|
|
60
|
+
highlightFor.selectedIndex !== selectedIndex) {
|
|
61
|
+
setHighlightFor({ query, selectedIndex });
|
|
40
62
|
setHighlight(query ? 0 : selectedIndex);
|
|
41
|
-
}
|
|
42
|
-
const optionId = (index) => `${listId}-option-${index}`;
|
|
63
|
+
}
|
|
43
64
|
const active = filtered[highlight];
|
|
44
|
-
const scrollHighlightedOption = React.useCallback((node) => {
|
|
45
|
-
node?.scrollIntoView({ block: "nearest" });
|
|
46
|
-
}, []);
|
|
47
65
|
const selectItem = (item) => {
|
|
48
66
|
onSelect(item);
|
|
49
67
|
setQuery("");
|
|
50
68
|
};
|
|
51
69
|
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") {
|
|
70
|
+
const nextHighlight = moveListboxHighlight(event.key, highlight, filtered.length);
|
|
71
|
+
if (nextHighlight !== undefined) {
|
|
75
72
|
event.preventDefault();
|
|
76
|
-
setHighlight(
|
|
73
|
+
setHighlight(nextHighlight);
|
|
74
|
+
return;
|
|
77
75
|
}
|
|
78
76
|
if (event.key === "Enter") {
|
|
79
77
|
event.preventDefault();
|
|
@@ -89,11 +87,11 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
|
|
|
89
87
|
event.currentTarget.blur();
|
|
90
88
|
}
|
|
91
89
|
};
|
|
92
|
-
return (_jsxs("div", { "data-slot": "search-picker", className: cn("flex flex-col gap-1.5", className), 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 ?
|
|
90
|
+
return (_jsxs("div", { "data-slot": "search-picker", className: cn("flex flex-col gap-1.5", className), 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: "max-h-60 overflow-y-auto border border-input bg-background", children: filtered.length === 0 ? (_jsx("li", { className: listboxEmpty, children: emptyText })) : (filtered.map((item, index) => {
|
|
93
91
|
const itemValue = getValue(item);
|
|
94
92
|
const isSelected = itemValue === value;
|
|
95
93
|
const isActive = index === highlight;
|
|
96
|
-
return (_jsx("li", { role: "none", children: _jsx("button", { ref: isActive ?
|
|
94
|
+
return (_jsx("li", { role: "none", children: _jsx("button", { ref: isActive ? scrollOptionIntoView : undefined, id: listboxOptionId(listId, index), type: "button", role: "option", "aria-selected": isSelected, disabled: disabled, className: cn("relative flex w-full cursor-default items-center rounded-none py-2 pr-3 pl-3 text-left text-sm outline-hidden select-none", isSelected && optionState.selected, isActive && optionState.active, isActive &&
|
|
97
95
|
!isSelected &&
|
|
98
96
|
"bg-accent text-accent-foreground"), onMouseEnter: () => setHighlight(index), onClick: () => selectItem(item), children: _jsx("span", { className: "truncate", children: getLabel(item) }) }) }, itemValue));
|
|
99
97
|
})) })] }));
|
package/dist/select.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Select as SelectPrimitive } from "radix-ui";
|
|
|
5
5
|
import { dataDisabledState, disabledState, fieldSurface, focusRing, invalidState, optionState, } from "./internal/styles.js";
|
|
6
6
|
import { cn } from "./utils.js";
|
|
7
7
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
|
|
8
|
+
const scrollButtonClassName = "z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5";
|
|
8
9
|
function Select({ ...props }) {
|
|
9
10
|
return _jsx(SelectPrimitive.Root, { "data-slot": "select", ...props });
|
|
10
11
|
}
|
|
@@ -19,7 +20,7 @@ function SelectTrigger({ className, size = "default", children, ...props }) {
|
|
|
19
20
|
}
|
|
20
21
|
function SelectContent({ className, children, position = "popper", align = "center", sideOffset = 4, collisionPadding = 8, ...props }) {
|
|
21
22
|
return (_jsx(SelectPrimitive.Portal, { children: _jsxs(SelectPrimitive.Content, { "data-slot": "select-content", "data-align-trigger": position === "item-aligned", className: cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-(--radix-select-trigger-width) origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position === "popper" &&
|
|
22
|
-
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className), position: position, align: align, sideOffset: sideOffset, collisionPadding: collisionPadding, ...props, children: [_jsx(SelectScrollUpButton, {}), _jsx(SelectPrimitive.Viewport, { "data-position": position, className:
|
|
23
|
+
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className), position: position, align: align, sideOffset: sideOffset, collisionPadding: collisionPadding, ...props, children: [_jsx(SelectScrollUpButton, {}), _jsx(SelectPrimitive.Viewport, { "data-position": position, className: "data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", children: children }), _jsx(SelectScrollDownButton, {})] }) }));
|
|
23
24
|
}
|
|
24
25
|
function SelectLabel({ className, ...props }) {
|
|
25
26
|
return (_jsx(SelectPrimitive.Label, { "data-slot": "select-label", className: cn("px-3 py-2 text-xs font-semibold tracking-wider text-muted-foreground uppercase", className), ...props }));
|
|
@@ -31,9 +32,9 @@ function SelectSeparator({ className, ...props }) {
|
|
|
31
32
|
return (_jsx(SelectPrimitive.Separator, { "data-slot": "select-separator", className: cn("pointer-events-none -mx-1.5 my-1.5 h-px bg-border/50", className), ...props }));
|
|
32
33
|
}
|
|
33
34
|
function SelectScrollUpButton({ className, ...props }) {
|
|
34
|
-
return (_jsx(SelectPrimitive.ScrollUpButton, { "data-slot": "select-scroll-up-button", className: cn(
|
|
35
|
+
return (_jsx(SelectPrimitive.ScrollUpButton, { "data-slot": "select-scroll-up-button", className: cn(scrollButtonClassName, className), ...props, children: _jsx(ChevronUpIcon, {}) }));
|
|
35
36
|
}
|
|
36
37
|
function SelectScrollDownButton({ className, ...props }) {
|
|
37
|
-
return (_jsx(SelectPrimitive.ScrollDownButton, { "data-slot": "select-scroll-down-button", className: cn(
|
|
38
|
+
return (_jsx(SelectPrimitive.ScrollDownButton, { "data-slot": "select-scroll-down-button", className: cn(scrollButtonClassName, className), ...props, children: _jsx(ChevronDownIcon, {}) }));
|
|
38
39
|
}
|
|
39
40
|
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, };
|
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.4",
|
|
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
|
|