atlas-react-kit 0.9.3 → 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 CHANGED
@@ -8,6 +8,12 @@ no root component barrel.
8
8
  For installation, see [README.md](./README.md); for styling and accessibility,
9
9
  see [DESIGN_RULES.md](./DESIGN_RULES.md).
10
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
+
11
17
  ## Optional peer dependencies
12
18
 
13
19
  Core entries need only the kit. These feature entries need an extra install
package/README.md CHANGED
@@ -8,7 +8,7 @@ React 19, Tailwind 4, and Node >=20.19 are required. Next 16 is needed only
8
8
  when using `next/tabs-nav-link`.
9
9
 
10
10
  ```sh
11
- npm install --save-exact atlas-react-kit@0.9.3
11
+ npm install --save-exact atlas-react-kit@0.9.4
12
12
  ```
13
13
 
14
14
  Import components by subpath:
@@ -128,7 +128,7 @@ artifact with the package owner's npm login:
128
128
  ```sh
129
129
  mkdir -p release-artifacts
130
130
  npm pack --pack-destination release-artifacts
131
- npm publish ./release-artifacts/atlas-react-kit-0.9.3.tgz --access public
131
+ npm publish ./release-artifacts/atlas-react-kit-0.9.4.tgz --access public
132
132
  ```
133
133
 
134
134
  Pin an exact package version in consuming applications. Roll back an upgrade by
package/dist/combobox.js CHANGED
@@ -5,9 +5,9 @@ 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
+ import { listboxOptionId, moveListboxHighlight, scrollOptionIntoView, } from "./internal/listbox-navigation.js";
9
9
  import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
10
- import { optionState } from "./internal/styles.js";
10
+ import { listboxEmpty, optionState } from "./internal/styles.js";
11
11
  import { cn, onInputChange } from "./utils.js";
12
12
  const CREATE_OPTION_KEY = "__atlas-combobox-create";
13
13
  /**
@@ -96,12 +96,6 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
96
96
  const frame = window.requestAnimationFrame(() => inputRef.current?.focus());
97
97
  return () => window.cancelAnimationFrame(frame);
98
98
  }, [open]);
99
- // Callback ref (not an effect) so scroll runs when Radix mounts the list
100
- // and when the highlight moves. A layout effect on `open` is too early:
101
- // the option node does not exist until the popover content is in the DOM.
102
- const scrollHighlightedOption = React.useCallback((node) => {
103
- node?.scrollIntoView({ block: "nearest" });
104
- }, []);
105
99
  const onQueryChange = onInputChange((next) => {
106
100
  setQuery(next);
107
101
  setHighlight(0);
@@ -114,9 +108,6 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
114
108
  setOpen(false);
115
109
  setQuery("");
116
110
  };
117
- // Index-based ids: option values may contain spaces, which are not valid
118
- // in an id or an aria-activedescendant reference.
119
- const optionId = (index) => `${listId}-option-${index}`;
120
111
  const activeOption = list[highlight];
121
112
  const onSearchKeyDown = (event) => {
122
113
  const nextHighlight = moveListboxHighlight(event.key, highlight, list.length);
@@ -132,10 +123,10 @@ export function Combobox({ options, value, onValueChange, placeholder = "Select"
132
123
  selectValue(option.value);
133
124
  }
134
125
  };
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) => {
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) => {
136
127
  const isCreate = createOption != null && option === createOption;
137
128
  const isSelected = option.value === value && !isCreate;
138
129
  const isActive = index === highlight;
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));
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));
140
131
  })) })] })] }));
141
132
  }
@@ -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 { formControlTriggerClassName } from "./internal/form-control.js";
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(formControlTriggerClassName, "justify-start data-[empty=true]:text-muted-foreground", className), children: [_jsx(CalendarIcon, { "data-icon": "inline-start" }), label] }) }), _jsx(PopoverContent, { align: "start", side: side, collisionPadding: DATE_PICKER_COLLISION_PADDING, className: "w-auto max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0", children: children })] }));
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);
@@ -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 { formControlTriggerClassName } from "./internal/form-control.js";
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(formControlTriggerClassName, "justify-start data-[empty=true]:text-muted-foreground", className), children: [_jsx(CalendarIcon, { "data-icon": "inline-start" }), label] }) }), _jsxs(PopoverContent, { align: "start", side: side, collisionPadding: DATE_TIME_PICKER_COLLISION_PADDING, onInteractOutside: preventDismissForPortaledSelect, className: "w-auto min-w-0 max-w-[min(100vw-2rem,24rem)] max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0", children: [_jsx(Calendar, { mode: "single", selected: date ?? undefined, defaultMonth: defaultMonth ?? date ?? undefined, onSelect: (next) => {
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?: "horizontal" | "vertical" | "responsive" | null | undefined;
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,5 +1,5 @@
1
1
  import { type DateLike } from "../date-picker.js";
2
- import { type FieldMetaState } from "./field-error.js";
2
+ import type { FieldMetaState } from "./field-error.js";
3
3
  type Props = {
4
4
  value: DateLike;
5
5
  onValueChange: (date: Date | undefined) => void;
@@ -1,12 +1,11 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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 { Label } from "../label.js";
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 = meta ? getFieldErrorMessage(meta.errors[0]) : undefined;
8
+ const errorText = metaErrorText(meta);
10
9
  const id = useId();
11
- return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: id, children: label }), _jsx(DatePickerButton, { id: id, value: value, onChange: onValueChange, placeholder: placeholder, disabled: disabled, className: className, "aria-invalid": errorText ? true : undefined }), _jsx(FieldError, { text: errorText })] }));
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,4 +1,4 @@
1
- import { type FieldMetaState } from "./field-error.js";
1
+ import type { FieldMetaState } from "./field-error.js";
2
2
  type Props = {
3
3
  value: Date | null;
4
4
  onValueChange: (date: Date | null) => void;
@@ -1,12 +1,11 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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 { Label } from "../label.js";
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 = meta ? getFieldErrorMessage(meta.errors[0]) : undefined;
8
+ const errorText = metaErrorText(meta);
10
9
  const id = useId();
11
- return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: id, children: label }), _jsx(DateTimePickerButton, { id: id, value: value, onChange: onValueChange, placeholder: placeholder, disabled: disabled, timeStep: timeStep, className: className, "aria-invalid": errorText ? true : undefined }), _jsx(FieldError, { text: errorText })] }));
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 { FieldError, getFieldErrorMessage } from "./field-error.js";
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 (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(Label, { htmlFor: id, children: label }), _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" })] })] }), mode === "write" ? (_jsx(Textarea, { id: id, value: field.state.value, onChange: onChange, onBlur: field.handleBlur, placeholder: placeholder, rows: rows, "aria-invalid": errorText ? true : undefined })) : (_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" })) })), showError ? _jsx(FieldError, { text: errorText }) : null] }));
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,5 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
- import { type FieldMetaState } from "./field-error.js";
2
+ import type { FieldMetaState } from "./field-error.js";
3
3
  type Props = {
4
4
  value: string | undefined;
5
5
  meta?: FieldMetaState;
@@ -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 { FieldError, getFieldErrorMessage, } from "./field-error.js";
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 = meta ? getFieldErrorMessage(meta.errors[0]) : undefined;
8
+ const errorText = metaErrorText(meta);
10
9
  const id = useId();
11
- return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: id, children: label }), _jsxs(Select, { value: value, onValueChange: onValueChange, children: [_jsx(SelectTrigger, { id: id, "aria-invalid": errorText ? true : undefined, children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsx(SelectContent, { children: children })] }), _jsx(FieldError, { text: errorText })] }));
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
  }
@@ -1,4 +1,4 @@
1
- import { type FieldMetaState } from "./field-error.js";
1
+ import type { FieldMetaState } from "./field-error.js";
2
2
  export type TextFieldApi = {
3
3
  name: string;
4
4
  state: {
@@ -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 { FieldError, getFieldErrorMessage, } from "./field-error.js";
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 ? true : undefined,
19
+ "aria-invalid": invalidWhen(errorText),
23
20
  };
24
- return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Label, { htmlFor: id, children: label }), 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 })), showError ? _jsx(FieldError, { text: errorText }) : null] }));
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;
@@ -1,2 +1,14 @@
1
1
  /** Return the next highlighted index for a listbox navigation key. */
2
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;
@@ -19,3 +19,19 @@ export function moveListboxHighlight(key, index, length) {
19
19
  return undefined;
20
20
  }
21
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). */
@@ -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). */
@@ -3,8 +3,8 @@ 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";
7
- import { optionState } from "./internal/styles.js";
6
+ import { listboxOptionId, moveListboxHighlight, scrollOptionIntoView, } from "./internal/listbox-navigation.js";
7
+ import { listboxEmpty, optionState } from "./internal/styles.js";
8
8
  import { cn } from "./utils.js";
9
9
  /**
10
10
  * Case-fold and strip combining marks so "nino" matches "Niño".
@@ -49,14 +49,19 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
49
49
  return index >= 0 ? index : 0;
50
50
  }, [filtered, getValue, value]);
51
51
  const [highlight, setHighlight] = React.useState(selectedIndex);
52
- React.useEffect(() => {
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 });
53
62
  setHighlight(query ? 0 : selectedIndex);
54
- }, [query, selectedIndex]);
55
- const optionId = (index) => `${listId}-option-${index}`;
63
+ }
56
64
  const active = filtered[highlight];
57
- const scrollHighlightedOption = React.useCallback((node) => {
58
- node?.scrollIntoView({ block: "nearest" });
59
- }, []);
60
65
  const selectItem = (item) => {
61
66
  onSelect(item);
62
67
  setQuery("");
@@ -82,11 +87,11 @@ function SearchPicker({ items, getValue, getLabel, filter = defaultSearchFilter,
82
87
  event.currentTarget.blur();
83
88
  }
84
89
  };
85
- 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 ? optionId(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: "px-3 py-4 text-center text-sm text-muted-foreground", children: emptyText })) : (filtered.map((item, index) => {
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) => {
86
91
  const itemValue = getValue(item);
87
92
  const isSelected = itemValue === value;
88
93
  const isActive = index === highlight;
89
- return (_jsx("li", { role: "none", children: _jsx("button", { ref: isActive ? scrollHighlightedOption : undefined, id: optionId(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 &&
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 &&
90
95
  !isSelected &&
91
96
  "bg-accent text-accent-foreground"), onMouseEnter: () => setHighlight(index), onClick: () => selectItem(item), children: _jsx("span", { className: "truncate", children: getLabel(item) }) }) }, itemValue));
92
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: cn("data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)", position === "popper" && ""), children: children }), _jsx(SelectScrollDownButton, {})] }) }));
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("z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5", className), ...props, children: _jsx(ChevronUpIcon, {}) }));
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("z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5", className), ...props, children: _jsx(ChevronDownIcon, {}) }));
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas-react-kit",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Shared React components and Tailwind theme for Atlas",
5
5
  "type": "module",
6
6
  "license": "MIT",