atlas-react-kit 1.2.0 → 1.2.2

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.
Files changed (72) hide show
  1. package/COMPONENTS.md +40 -40
  2. package/DESIGN_RULES.md +14 -40
  3. package/README.md +7 -6
  4. package/dist/alert.js +7 -6
  5. package/dist/calendar.js +5 -4
  6. package/dist/card.js +2 -1
  7. package/dist/combobox.d.ts +4 -3
  8. package/dist/combobox.js +8 -17
  9. package/dist/date-time-picker.js +2 -1
  10. package/dist/dialog.js +2 -1
  11. package/dist/dropdown-menu.js +7 -5
  12. package/dist/field.d.ts +6 -3
  13. package/dist/field.js +3 -2
  14. package/dist/form/checkbox-field.d.ts +3 -5
  15. package/dist/form/checkbox-field.js +13 -19
  16. package/dist/form/combobox-field.d.ts +2 -2
  17. package/dist/form/combobox-field.js +5 -6
  18. package/dist/form/control-field.d.ts +5 -5
  19. package/dist/form/control-field.js +6 -9
  20. package/dist/form/date-picker-field.d.ts +1 -1
  21. package/dist/form/date-picker-field.js +3 -7
  22. package/dist/form/date-time-picker-field.d.ts +1 -1
  23. package/dist/form/date-time-picker-field.js +3 -7
  24. package/dist/form/field-error.d.ts +13 -4
  25. package/dist/form/field-shell.d.ts +63 -47
  26. package/dist/form/field-shell.js +67 -35
  27. package/dist/form/focus-first-invalid.d.ts +7 -4
  28. package/dist/form/focus-first-invalid.js +10 -6
  29. package/dist/form/markdown-field.d.ts +1 -1
  30. package/dist/form/markdown-field.js +8 -4
  31. package/dist/form/select-field.d.ts +2 -8
  32. package/dist/form/select-field.js +5 -7
  33. package/dist/form/text-field.d.ts +2 -8
  34. package/dist/form/text-field.js +9 -6
  35. package/dist/form/toggle-group-field.d.ts +2 -2
  36. package/dist/form/toggle-group-field.js +5 -6
  37. package/dist/info-tip.d.ts +7 -4
  38. package/dist/info-tip.js +12 -4
  39. package/dist/internal/active-link.d.ts +7 -0
  40. package/dist/internal/active-link.js +9 -0
  41. package/dist/internal/calendar-size.d.ts +5 -0
  42. package/dist/internal/calendar-size.js +5 -0
  43. package/dist/internal/form-control.d.ts +7 -1
  44. package/dist/internal/form-control.js +8 -1
  45. package/dist/internal/kanban-layout.js +2 -1
  46. package/dist/internal/listbox-navigation.d.ts +8 -8
  47. package/dist/internal/listbox-navigation.js +23 -23
  48. package/dist/internal/listbox-option.d.ts +16 -3
  49. package/dist/internal/listbox-option.js +22 -5
  50. package/dist/internal/overlay-styles.d.ts +15 -5
  51. package/dist/internal/overlay-styles.js +15 -5
  52. package/dist/internal/search.d.ts +4 -0
  53. package/dist/internal/search.js +6 -0
  54. package/dist/internal/styles.d.ts +21 -0
  55. package/dist/internal/styles.js +21 -0
  56. package/dist/kanban.js +3 -2
  57. package/dist/label.js +2 -1
  58. package/dist/lazy-calendar.js +3 -2
  59. package/dist/nav-link.js +2 -1
  60. package/dist/picker-shell.d.ts +3 -1
  61. package/dist/picker-shell.js +3 -3
  62. package/dist/popover.js +6 -5
  63. package/dist/search-picker.js +8 -16
  64. package/dist/select.js +5 -5
  65. package/dist/skip-link.js +2 -1
  66. package/dist/tabs.js +3 -2
  67. package/dist/toast.js +11 -10
  68. package/dist/toggle-group.d.ts +1 -1
  69. package/dist/toggle-group.js +36 -2
  70. package/dist/tooltip.js +2 -2
  71. package/package.json +1 -1
  72. package/skills/atlas-react-kit/SKILL.md +46 -42
@@ -1,5 +1,8 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { pickerValueId } from "../internal/form-control.js";
3
+ import { useId, } from "react";
4
+ import { Slot } from "radix-ui";
5
+ import { fieldNameProps, pickerValueId } from "../internal/form-control.js";
3
6
  import { Label } from "../label.js";
4
7
  import { cn } from "../utils.js";
5
8
  import { FieldError, getFieldErrorMessage, } from "./field-error.js";
@@ -13,27 +16,32 @@ import { FieldError, getFieldErrorMessage, } from "./field-error.js";
13
16
  * `showError` is false, or the field is untouched (`isTouched: false`)
14
17
  * and the form has not been submitted.
15
18
  */
16
- export function metaErrorText(meta, { submitted, showError = true } = {}) {
19
+ function metaErrorText(meta, { submitted, showError = true } = {}) {
17
20
  if (!meta || !showError)
18
21
  return undefined;
19
22
  if (meta.isTouched === false && !submitted)
20
23
  return undefined;
21
24
  return getFieldErrorMessage(meta.errors[0]);
22
25
  }
23
- /** `aria-invalid` for the control: `true` while there is error text. */
24
- export function invalidWhen(errorText) {
25
- return errorText ? true : undefined;
26
+ /**
27
+ * The error text a `form/*` field shows (`metaErrorText`) and its control
28
+ * id. The id is generated per field, so two forms that share a field name
29
+ * (login and sign-up `email`) never share an id, and each label, hint, and
30
+ * error links to its own control.
31
+ */
32
+ export function useFormField(meta, timing) {
33
+ return { id: useId(), errorText: metaErrorText(meta, timing) };
26
34
  }
27
35
  /** Id of the label `FormFieldShell` renders for control `id`. */
28
- export function labelIdFor(id) {
36
+ function labelIdFor(id) {
29
37
  return `${id}-label`;
30
38
  }
31
39
  /** Id of the hint `FormFieldShell` renders for control `id`. */
32
- export function descriptionIdFor(id) {
40
+ function descriptionIdFor(id) {
33
41
  return `${id}-description`;
34
42
  }
35
43
  /** Id of the error text `FormFieldShell` renders for control `id`. */
36
- export function errorIdFor(id) {
44
+ function errorIdFor(id) {
37
45
  return `${id}-error`;
38
46
  }
39
47
  /**
@@ -44,39 +52,63 @@ function requiredIdFor(id) {
44
52
  return `${id}-required`;
45
53
  }
46
54
  /**
47
- * `aria-describedby` for the control: the given hint ids, plus the error
48
- * text while there is one, or `undefined` when there is nothing to read.
55
+ * ARIA for a field's control, from the ids `FormFieldShell` renders:
56
+ * invalid while there is error text, described by the hint, the hidden
57
+ * "Required" text, and the error. `required: "described"` is for a button
58
+ * trigger, which cannot take `aria-required`; leave `required` out where
59
+ * the control carries native `required`. `labelledBy: "label"` names a
60
+ * group; `"label-value"` reads a picker "label, value".
49
61
  */
50
- export function describedBy(id, errorText, ...hintIds) {
51
- const ids = [...hintIds, errorText ? errorIdFor(id) : undefined].filter(Boolean);
52
- return ids.length ? ids.join(" ") : undefined;
62
+ export function fieldAria(id, errorText, { description, required, labelledBy, } = {}) {
63
+ const describedBy = [
64
+ description ? descriptionIdFor(id) : undefined,
65
+ required === "described" ? requiredIdFor(id) : undefined,
66
+ errorText ? errorIdFor(id) : undefined,
67
+ ].filter(Boolean);
68
+ return {
69
+ "aria-invalid": errorText ? true : undefined,
70
+ "aria-describedby": describedBy.length ? describedBy.join(" ") : undefined,
71
+ "aria-required": required === true ? true : undefined,
72
+ ...(labelledBy && {
73
+ "aria-labelledby": labelledBy === "label"
74
+ ? labelIdFor(id)
75
+ : `${labelIdFor(id)} ${pickerValueId(id)}`,
76
+ }),
77
+ };
53
78
  }
54
79
  /**
55
- * Trigger ARIA for the date and date-time fields: invalid while there is
56
- * error text, named "label, value" (a `<label for>` alone would hide the
57
- * picked value), and described by "Required" (a button cannot take
58
- * `aria-required`) and the error.
80
+ * The date and date-time fields: `picker` (the trigger) in a
81
+ * `FormFieldShell`, given every prop of `field` the shell does not take. A
82
+ * button cannot carry `required`, so the trigger is described by the hidden
83
+ * "Required" text and reads "label, value".
59
84
  */
60
- export function pickerFieldAria(id, errorText, required) {
61
- return {
62
- "aria-invalid": invalidWhen(errorText),
63
- "aria-labelledby": `${labelIdFor(id)} ${pickerValueId(id)}`,
64
- "aria-describedby": describedBy(id, errorText, required ? requiredIdFor(id) : undefined),
65
- };
85
+ export function FormPickerField({ field, picker: Picker, }) {
86
+ const { fieldName, label, meta, submitted, showError, required, fieldClassName, ...pickerProps } = field;
87
+ const { id, errorText } = useFormField(meta, { submitted, showError });
88
+ return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, required: required, className: fieldClassName, children: _jsx(Picker, { ...pickerProps, id: id, ...fieldNameProps(fieldName), ...fieldAria(id, errorText, {
89
+ required: required && "described",
90
+ labelledBy: "label-value",
91
+ }) }) }));
92
+ }
93
+ /**
94
+ * Every `form/*` field's wrapper (`data-slot="form-field"`): children with
95
+ * the field spacing, then the error text. `asChild` makes the one child the
96
+ * wrapper (the checkbox group's fieldset).
97
+ */
98
+ export function FormFieldFrame({ id, errorText, asChild, className, children, ...props }) {
99
+ const Comp = asChild ? Slot.Root : "div";
100
+ return (_jsxs(Comp, { "data-slot": "form-field", className: cn("flex flex-col gap-1.5", className), ...props, children: [_jsx(Slot.Slottable, { children: children }), _jsx(FieldError, { id: errorIdFor(id), text: errorText })] }));
101
+ }
102
+ /** A field's hint, with id `descriptionIdFor(id)`. */
103
+ export function FormFieldDescription({ id, children, }) {
104
+ return (_jsx("p", { id: descriptionIdFor(id), "data-slot": "form-field-description", className: "text-xs text-muted-foreground", children: children }));
66
105
  }
67
106
  /**
68
- * Label, control, and error text stacked with the kit's field spacing.
69
- * `labelAction` sits on the label's row (the Markdown field's mode toggle).
70
- * Set `labelFor` to false when the control is not rendered (Markdown
71
- * preview), so the label does not point at a missing element.
72
- * `description` is a hint between the label and the control, with id
73
- * `descriptionIdFor(id)`. `className` and other div props go to the
74
- * wrapper (`data-slot="form-field"`), e.g. a width.
75
- * `required` marks the label; the control still sets its own `required`.
76
- * The error sits below the control and takes no space while there is none,
77
- * so put fields in a grid row with `items-start` to keep controls aligned.
107
+ * Label, hint, control, and error in a `FormFieldFrame`. `labelAction` sits
108
+ * on the label's row; `labelFor={false}` when the control is not rendered
109
+ * (Markdown preview). `required` marks the label only.
78
110
  */
79
- export function FormFieldShell({ id, label, labelAction, description, errorText, labelFor = true, required, className, children, ...props }) {
111
+ export function FormFieldShell({ id, label, labelAction, description, errorText, labelFor = true, required, children, ...props }) {
80
112
  const labelNode = (_jsx(Label, { id: labelIdFor(id), htmlFor: labelFor ? id : undefined, required: required, children: label }));
81
- return (_jsxs("div", { "data-slot": "form-field", className: cn("flex flex-col gap-1.5", className), ...props, children: [labelAction ? (_jsxs("div", { "data-slot": "form-field-header", className: "flex items-center justify-between gap-2", children: [labelNode, labelAction] })) : (labelNode), description ? (_jsx("p", { id: descriptionIdFor(id), "data-slot": "form-field-description", className: "text-xs text-muted-foreground", children: description })) : null, children, required ? (_jsx("span", { id: requiredIdFor(id), hidden: true, children: "Required" })) : null, _jsx(FieldError, { id: errorIdFor(id), text: errorText })] }));
113
+ return (_jsxs(FormFieldFrame, { ...props, id: id, errorText: errorText, children: [labelAction ? (_jsxs("div", { "data-slot": "form-field-header", className: "flex items-center justify-between gap-2", children: [labelNode, labelAction] })) : (labelNode), description ? (_jsx(FormFieldDescription, { id: id, children: description })) : null, children, required ? (_jsx("span", { id: requiredIdFor(id), hidden: true, children: "Required" })) : null] }));
82
114
  }
@@ -15,14 +15,17 @@ export type InvalidSubmitEvent = {
15
15
  /**
16
16
  * Invalid-submit handler that focuses the first field, in page order, whose
17
17
  * meta has errors. It reads the form state, not the rendered `aria-invalid`,
18
- * so it works before React has re-rendered. Fields are matched by control
19
- * id, so give each `form/*` field `id={field.name}` (`FormTextField`
20
- * defaults to it). The form library needs no focus API of its own.
18
+ * so it works before React has re-rendered. Fields are matched by the
19
+ * `data-field-name` each `form/*` field puts on its control: `FormTextField`
20
+ * and `FormMarkdownField` read `field.name`; the other fields take
21
+ * `fieldName={field.name}`. The lookup stays inside the form with id
22
+ * `formId`, so a second form with the same field names is never touched.
23
+ * The form library needs no focus API of its own.
21
24
  *
22
25
  * ```tsx
23
26
  * const form = useForm({ onSubmitInvalid: focusFirstInvalid("order-form") })
24
27
  * <form id="order-form" onSubmit={...}>
25
- * <FormSelectField id={field.name} ... />
28
+ * <FormSelectField fieldName={field.name} ... />
26
29
  * ```
27
30
  */
28
31
  export declare function focusFirstInvalid(formId: string): ({ formApi }: InvalidSubmitEvent) => void;
@@ -1,14 +1,18 @@
1
+ import { FIELD_NAME_ATTRIBUTE } from "../internal/form-control.js";
1
2
  /**
2
3
  * Invalid-submit handler that focuses the first field, in page order, whose
3
4
  * meta has errors. It reads the form state, not the rendered `aria-invalid`,
4
- * so it works before React has re-rendered. Fields are matched by control
5
- * id, so give each `form/*` field `id={field.name}` (`FormTextField`
6
- * defaults to it). The form library needs no focus API of its own.
5
+ * so it works before React has re-rendered. Fields are matched by the
6
+ * `data-field-name` each `form/*` field puts on its control: `FormTextField`
7
+ * and `FormMarkdownField` read `field.name`; the other fields take
8
+ * `fieldName={field.name}`. The lookup stays inside the form with id
9
+ * `formId`, so a second form with the same field names is never touched.
10
+ * The form library needs no focus API of its own.
7
11
  *
8
12
  * ```tsx
9
13
  * const form = useForm({ onSubmitInvalid: focusFirstInvalid("order-form") })
10
14
  * <form id="order-form" onSubmit={...}>
11
- * <FormSelectField id={field.name} ... />
15
+ * <FormSelectField fieldName={field.name} ... />
12
16
  * ```
13
17
  */
14
18
  export function focusFirstInvalid(formId) {
@@ -19,8 +23,8 @@ export function focusFirstInvalid(formId) {
19
23
  const invalid = new Set(Object.entries(formApi.state.fieldMeta)
20
24
  .filter(([, meta]) => meta?.errors.length)
21
25
  .map(([name]) => name));
22
- for (const element of form.elements) {
23
- if (element instanceof HTMLElement && invalid.has(element.id)) {
26
+ for (const element of form.querySelectorAll(`[${FIELD_NAME_ATTRIBUTE}]`)) {
27
+ if (invalid.has(element.getAttribute(FIELD_NAME_ATTRIBUTE) ?? "")) {
24
28
  element.focus();
25
29
  return;
26
30
  }
@@ -3,7 +3,7 @@ import type { TextFieldBaseProps } from "./text-field.js";
3
3
  * GFM body field: Write is a textarea, Preview is the sanitized Markdown
4
4
  * renderer. String-in/string-out — not a WYSIWYG document model.
5
5
  */
6
- export declare function FormMarkdownField({ field, id, label, placeholder, rows, showError, submitted, required, className, fieldClassName, previewClassName, }: TextFieldBaseProps & {
6
+ export declare function FormMarkdownField({ field, label, placeholder, rows, showError, submitted, required, className, fieldClassName, previewClassName, }: TextFieldBaseProps & {
7
7
  /** Write textarea placeholder. Default "Markdown supported". */
8
8
  placeholder?: string;
9
9
  /** Write textarea rows. Default 5. */
@@ -4,9 +4,10 @@ import { lazy, Suspense, useState } from "react";
4
4
  import { Spinner } from "../spinner.js";
5
5
  import { Textarea } from "../textarea.js";
6
6
  import { ToggleGroup, ToggleGroupItem } from "../toggle-group.js";
7
+ import { fieldNameProps } from "../internal/form-control.js";
7
8
  import { fieldSurface } from "../internal/styles.js";
8
9
  import { cn, onInputChange } from "../utils.js";
9
- import { describedBy, FormFieldShell, invalidWhen, metaErrorText, } from "./field-shell.js";
10
+ import { fieldAria, FormFieldShell, useFormField } from "./field-shell.js";
10
11
  // The renderer (react-markdown + remark-gfm) is fetched the first time
11
12
  // Preview is shown or hovered, so a form in Write mode stays light.
12
13
  const loadMarkdown = () => import("../markdown.js");
@@ -16,14 +17,17 @@ const Markdown = lazy(() => loadMarkdown().then((module) => ({ default: module.M
16
17
  * GFM body field: Write is a textarea, Preview is the sanitized Markdown
17
18
  * renderer. String-in/string-out — not a WYSIWYG document model.
18
19
  */
19
- export function FormMarkdownField({ field, id = field.name, label, placeholder = "Markdown supported", rows = 5, showError, submitted, required, className, fieldClassName, previewClassName, }) {
20
+ export function FormMarkdownField({ field, label, placeholder = "Markdown supported", rows = 5, showError, submitted, required, className, fieldClassName, previewClassName, }) {
21
+ const { id, errorText } = useFormField(field.state.meta, {
22
+ submitted,
23
+ showError,
24
+ });
20
25
  const [mode, setMode] = useState("write");
21
26
  const onChange = onInputChange(field.handleChange);
22
- const errorText = metaErrorText(field.state.meta, { submitted, showError });
23
27
  const trimmed = field.state.value.trim();
24
28
  function handleModeChange(value) {
25
29
  if (value === "write" || value === "preview")
26
30
  setMode(value);
27
31
  }
28
- return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, labelFor: mode === "write", required: required, className: fieldClassName, 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", onPointerEnter: preloadMarkdown, onFocus: preloadMarkdown, children: "Preview" })] }), children: mode === "write" ? (_jsx(Textarea, { id: id, value: field.state.value, onChange: onChange, onBlur: field.handleBlur, placeholder: placeholder, rows: rows, required: required, className: className, "aria-invalid": invalidWhen(errorText), "aria-describedby": describedBy(id, errorText) })) : (_jsx("div", { "data-slot": "form-markdown-preview", className: cn(fieldSurface, "min-h-16", previewClassName), "aria-live": "polite", children: trimmed ? (_jsx(Suspense, { fallback: _jsx(Spinner, { "aria-label": "Loading preview" }), children: _jsx(Markdown, { content: trimmed }) })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "Nothing to preview" })) })) }));
32
+ return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, labelFor: mode === "write", required: required, className: fieldClassName, 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", onPointerEnter: preloadMarkdown, onFocus: preloadMarkdown, children: "Preview" })] }), children: mode === "write" ? (_jsx(Textarea, { id: id, ...fieldNameProps(field.name), value: field.state.value, onChange: onChange, onBlur: field.handleBlur, placeholder: placeholder, rows: rows, required: required, className: className, ...fieldAria(id, errorText) })) : (_jsx("div", { "data-slot": "form-markdown-preview", className: cn(fieldSurface, "min-h-16", previewClassName), "aria-live": "polite", children: trimmed ? (_jsx(Suspense, { fallback: _jsx(Spinner, { "aria-label": "Loading preview" }), children: _jsx(Markdown, { content: trimmed }) })) : (_jsx("p", { className: "text-sm text-muted-foreground", children: "Nothing to preview" })) })) }));
29
33
  }
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
  import type { FormFieldBaseProps, FormFieldMetaProps } from "./field-error.js";
3
3
  type Props = FormFieldBaseProps & FormFieldMetaProps & {
4
4
  /** The selected item's value, or `undefined` for none. */
@@ -7,12 +7,6 @@ type Props = FormFieldBaseProps & FormFieldMetaProps & {
7
7
  onValueChange: (value: string) => void;
8
8
  /** Trigger text while no item is selected. */
9
9
  placeholder?: string;
10
- /**
11
- * Id of the trigger the label points at. Defaults to a generated id;
12
- * pass the field name (`id={field.name}`) so a submit handler such as
13
- * `focusFirstInvalid` can find the control.
14
- */
15
- id?: string;
16
10
  /** Requires a value (native form validation) and marks the label. */
17
11
  required?: boolean;
18
12
  /** Forwarded to the select trigger. */
@@ -23,5 +17,5 @@ type Props = FormFieldBaseProps & FormFieldMetaProps & {
23
17
  children: ReactNode;
24
18
  };
25
19
  /** Select control with built-in field error text and invalid styling. */
26
- export declare function FormSelectField({ id: idProp, value, meta, submitted, showError, label, onValueChange, placeholder, required, className, fieldClassName, contentClassName, children, }: Props): import("react").JSX.Element;
20
+ export declare function FormSelectField({ fieldName, value, meta, submitted, showError, label, onValueChange, placeholder, required, className, fieldClassName, contentClassName, children, }: Props): import("react").JSX.Element;
27
21
  export {};
@@ -1,12 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useId } from "react";
3
+ import { fieldNameProps } from "../internal/form-control.js";
4
4
  import { Select, SelectContent, SelectTrigger, SelectValue } from "../select.js";
5
- import { describedBy, FormFieldShell, invalidWhen, metaErrorText, } from "./field-shell.js";
5
+ import { fieldAria, FormFieldShell, useFormField } from "./field-shell.js";
6
6
  /** Select control with built-in field error text and invalid styling. */
7
- export function FormSelectField({ id: idProp, value, meta, submitted, showError, label, onValueChange, placeholder, required, className, fieldClassName, contentClassName, children, }) {
8
- const errorText = metaErrorText(meta, { submitted, showError });
9
- const generatedId = useId();
10
- const id = idProp ?? generatedId;
11
- return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, required: required, className: fieldClassName, children: _jsxs(Select, { value: value, onValueChange: onValueChange, required: required, children: [_jsx(SelectTrigger, { id: id, className: className, "aria-required": required || undefined, "aria-invalid": invalidWhen(errorText), "aria-describedby": describedBy(id, errorText), children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsx(SelectContent, { className: contentClassName, children: children })] }) }));
7
+ export function FormSelectField({ fieldName, value, meta, submitted, showError, label, onValueChange, placeholder, required, className, fieldClassName, contentClassName, children, }) {
8
+ const { id, errorText } = useFormField(meta, { submitted, showError });
9
+ return (_jsx(FormFieldShell, { id: id, label: label, errorText: errorText, required: required, className: fieldClassName, children: _jsxs(Select, { value: value, onValueChange: onValueChange, required: required, children: [_jsx(SelectTrigger, { id: id, ...fieldNameProps(fieldName), className: className, ...fieldAria(id, errorText, { required }), children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsx(SelectContent, { className: contentClassName, children: children })] }) }));
12
10
  }
@@ -1,6 +1,6 @@
1
1
  import type { FieldMetaState, FormFieldBaseProps } from "./field-error.js";
2
2
  export type TextFieldApi = {
3
- /** Field name; the default control id. */
3
+ /** Field name; put on the control as `data-field-name` for `focusFirstInvalid`. */
4
4
  name: string;
5
5
  state: {
6
6
  /** Current text. */
@@ -20,12 +20,6 @@ export type TextFieldApi = {
20
20
  export type TextFieldBaseProps = FormFieldBaseProps & {
21
21
  /** The TanStack Form field (or the same shape) the control reads and writes. */
22
22
  field: TextFieldApi;
23
- /**
24
- * Control id the label points at. Defaults to `field.name`; pass a unique
25
- * id when two forms on one page share a field name (login and sign-up
26
- * `email`), or the second label focuses the first input.
27
- */
28
- id?: string;
29
23
  /** Sets `required` on the text control and marks the label. */
30
24
  required?: boolean;
31
25
  /** Forwarded to the text control (the input or textarea). */
@@ -35,7 +29,7 @@ export type TextFieldBaseProps = FormFieldBaseProps & {
35
29
  * Text input, or a textarea with `multiline`, bound to a TanStack Form
36
30
  * `field`, with a label, optional hint, and the `form/*` error text.
37
31
  */
38
- export declare function FormTextField({ field, id, label, placeholder, description, autoFocus, multiline, rows, inputType, showError, submitted, autoComplete, required, minLength, className, fieldClassName, }: TextFieldBaseProps & {
32
+ export declare function FormTextField({ field, label, placeholder, description, autoFocus, multiline, rows, inputType, showError, submitted, autoComplete, required, minLength, className, fieldClassName, }: TextFieldBaseProps & {
39
33
  /** Text shown in the empty control. */
40
34
  placeholder?: string;
41
35
  /** Optional hint under the label (employer vs referrer, etc.). */
@@ -1,19 +1,23 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { Input } from "../input.js";
4
+ import { fieldNameProps } from "../internal/form-control.js";
4
5
  import { Textarea } from "../textarea.js";
5
6
  import { onInputChange } from "../utils.js";
6
- import { describedBy, descriptionIdFor, FormFieldShell, invalidWhen, metaErrorText, } from "./field-shell.js";
7
+ import { fieldAria, FormFieldShell, useFormField } from "./field-shell.js";
7
8
  /**
8
9
  * Text input, or a textarea with `multiline`, bound to a TanStack Form
9
10
  * `field`, with a label, optional hint, and the `form/*` error text.
10
11
  */
11
- export function FormTextField({ field, id = field.name, label, placeholder, description, autoFocus, multiline, rows = 3, inputType = "text", showError, submitted, autoComplete, required, minLength, className, fieldClassName, }) {
12
+ export function FormTextField({ field, label, placeholder, description, autoFocus, multiline, rows = 3, inputType = "text", showError, submitted, autoComplete, required, minLength, className, fieldClassName, }) {
13
+ const { id, errorText } = useFormField(field.state.meta, {
14
+ submitted,
15
+ showError,
16
+ });
12
17
  const onChange = onInputChange(field.handleChange);
13
- const errorText = metaErrorText(field.state.meta, { submitted, showError });
14
- const descriptionId = description ? descriptionIdFor(id) : undefined;
15
18
  const controlProps = {
16
19
  id,
20
+ ...fieldNameProps(field.name),
17
21
  value: field.state.value,
18
22
  onChange,
19
23
  onBlur: field.handleBlur,
@@ -22,8 +26,7 @@ export function FormTextField({ field, id = field.name, label, placeholder, desc
22
26
  required,
23
27
  minLength,
24
28
  className,
25
- "aria-invalid": invalidWhen(errorText),
26
- "aria-describedby": describedBy(id, errorText, descriptionId),
29
+ ...fieldAria(id, errorText, { description }),
27
30
  };
28
31
  return (_jsx(FormFieldShell, { id: id, label: label, description: description, errorText: errorText, required: required, className: fieldClassName, children: multiline ? (_jsx(Textarea, { ...controlProps, rows: rows })) : (_jsx(Input, { ...controlProps, type: inputType, autoFocus: autoFocus })) }));
29
32
  }
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import type { ReactNode } from "react";
2
2
  import type { FormFieldBaseProps, FormFieldDescriptionProps, FormFieldMetaProps } from "./field-error.js";
3
3
  export type ToggleGroupFieldOption<T extends string> = {
4
4
  /** Value passed to `onValueChange` when this item is pressed. */
@@ -26,5 +26,5 @@ type Props<T extends string> = FormFieldBaseProps & FormFieldMetaProps & FormFie
26
26
  * One choice from a few as a joined outline toggle group (a segmented
27
27
  * control), with a visible label and the `form/*` error wiring.
28
28
  */
29
- export declare function FormToggleGroupField<T extends string>({ label, description, value, onValueChange, options, meta, submitted, showError, required, disabled, size, className, fieldClassName, }: Props<T>): import("react").JSX.Element;
29
+ export declare function FormToggleGroupField<T extends string>({ fieldName, label, description, value, onValueChange, options, meta, submitted, showError, required, disabled, size, className, fieldClassName, }: Props<T>): import("react").JSX.Element;
30
30
  export {};
@@ -1,19 +1,18 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useId } from "react";
3
+ import { fieldNameProps } from "../internal/form-control.js";
4
4
  import { ToggleGroup, ToggleGroupItem } from "../toggle-group.js";
5
- import { describedBy, descriptionIdFor, FormFieldShell, invalidWhen, labelIdFor, metaErrorText, } from "./field-shell.js";
5
+ import { fieldAria, FormFieldShell, useFormField } from "./field-shell.js";
6
6
  /**
7
7
  * One choice from a few as a joined outline toggle group (a segmented
8
8
  * control), with a visible label and the `form/*` error wiring.
9
9
  */
10
- export function FormToggleGroupField({ label, description, value, onValueChange, options, meta, submitted, showError, required, disabled, size = "sm", className, fieldClassName, }) {
11
- const id = useId();
12
- const errorText = metaErrorText(meta, { submitted, showError });
10
+ export function FormToggleGroupField({ fieldName, label, description, value, onValueChange, options, meta, submitted, showError, required, disabled, size = "sm", className, fieldClassName, }) {
11
+ const { id, errorText } = useFormField(meta, { submitted, showError });
13
12
  function handleValueChange(next) {
14
13
  const option = options.find((o) => o.value === next);
15
14
  if (option)
16
15
  onValueChange(option.value);
17
16
  }
18
- return (_jsx(FormFieldShell, { id: id, label: label, labelFor: false, description: description, errorText: errorText, required: required, className: fieldClassName, children: _jsx(ToggleGroup, { id: id, type: "single", value: value, onValueChange: handleValueChange, variant: "outline", size: size, spacing: 0, disabled: disabled, "aria-labelledby": labelIdFor(id), "aria-invalid": invalidWhen(errorText), "aria-describedby": describedBy(id, errorText, description ? descriptionIdFor(id) : undefined), className: className, children: options.map((option) => (_jsx(ToggleGroupItem, { value: option.value, children: option.label }, option.value))) }) }));
17
+ return (_jsx(FormFieldShell, { id: id, label: label, labelFor: false, description: description, errorText: errorText, required: required, className: fieldClassName, children: _jsx(ToggleGroup, { id: id, ...fieldNameProps(fieldName), type: "single", value: value, onValueChange: handleValueChange, variant: "outline", size: size, spacing: 0, disabled: disabled, ...fieldAria(id, errorText, { description, labelledBy: "label" }), className: className, children: options.map((option) => (_jsx(ToggleGroupItem, { value: option.value, children: option.label }, option.value))) }) }));
19
18
  }
@@ -11,8 +11,11 @@ export type InfoTipProps = {
11
11
  /** Extra body after the title and description. */
12
12
  children?: React.ReactNode;
13
13
  /**
14
- * `inline` keeps the surrounding line height; the 44px hit area overflows.
15
- * Default `box`.
14
+ * `inline` sits in a line of text: the glyph is 1em, a quarter em after
15
+ * the word, on the text's cap height, and keeps the line height; the 44px
16
+ * hit area overflows. `box` is a 44px button; after a label (any earlier
17
+ * sibling) its glyph sits that same quarter em from the text, so leave the
18
+ * gap off the parent. Default `box`.
16
19
  */
17
20
  layout?: InfoTipLayout;
18
21
  /** Panel side. Default `top`; Radix still flips on collision. */
@@ -29,8 +32,8 @@ export type InfoTipProps = {
29
32
  contentClassName?: string;
30
33
  } & Omit<React.ComponentProps<"button">, "title" | "children" | "type" | "onMouseEnter" | "onMouseLeave" | "onClick">;
31
34
  /**
32
- * Longer help than a Tooltip: a Popover with a 20px glyph and a 44px hit
33
- * area. Hover opens only on a fine pointer; click, tap, and keyboard always
35
+ * Longer help than a Tooltip: a Popover with a 20px glyph (1em inline) and
36
+ * a 44px hit area. Hover opens only on a fine pointer; click, tap, and keyboard always
34
37
  * toggle it. Not a glossary or "coming soon" chip — pass that copy in.
35
38
  */
36
39
  declare function InfoTip({ label, title, description, children, layout, side, align, badge, icon, className, contentClassName, ...props }: InfoTipProps): React.JSX.Element;
package/dist/info-tip.js CHANGED
@@ -7,8 +7,8 @@ import { focusRing, hitArea44, quietTextHover } from "./internal/styles.js";
7
7
  import { HOVER_MEDIA_QUERY, useMediaQuery } from "./internal/use-media-query.js";
8
8
  import { cn } from "./utils.js";
9
9
  /**
10
- * Longer help than a Tooltip: a Popover with a 20px glyph and a 44px hit
11
- * area. Hover opens only on a fine pointer; click, tap, and keyboard always
10
+ * Longer help than a Tooltip: a Popover with a 20px glyph (1em inline) and
11
+ * a 44px hit area. Hover opens only on a fine pointer; click, tap, and keyboard always
12
12
  * toggle it. Not a glossary or "coming soon" chip — pass that copy in.
13
13
  */
14
14
  function InfoTip({ label, title, description, children, layout = "box", side = "top", align = "center", badge, icon, className, contentClassName, ...props }) {
@@ -51,7 +51,15 @@ function InfoTip({ label, title, description, children, layout = "box", side = "
51
51
  if (next)
52
52
  openedBy.current = "click";
53
53
  setOpen(next);
54
- }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { ...props, type: "button", "aria-label": label, "data-slot": "info-tip-trigger", "data-layout": layout, className: cn(focusRing, "relative inline-flex shrink-0 items-center justify-center rounded-none text-muted-foreground", quietTextHover, inline ? cn("size-5 align-middle", hitArea44) : "size-11", className), onMouseEnter: openFromHover, onMouseLeave: closeFromHover, onClick: (event) => {
54
+ }, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsx("button", { ...props, type: "button", "aria-label": label, "data-slot": "info-tip-trigger", "data-layout": layout, className: cn(focusRing, "relative inline-flex shrink-0 items-center justify-center rounded-none text-muted-foreground", quietTextHover,
55
+ // Inline: 1em scales with the sentence; -0.125em centres the
56
+ // glyph on the cap height without growing the line box.
57
+ // Box: the 44px target leaves 12px either side of the 20px
58
+ // glyph. After a label, pull it back over that padding so the
59
+ // glyph sits the same quarter em from the text as it does inline.
60
+ inline
61
+ ? cn("ml-[0.25em] size-[1em] align-[-0.125em]", hitArea44)
62
+ : "size-11 not-first:ml-[calc(0.25em-0.75rem)]", className), onMouseEnter: openFromHover, onMouseLeave: closeFromHover, onClick: (event) => {
55
63
  // Hover already opened the panel; this click pins it instead of
56
64
  // toggling it closed (Playwright and real mice both hover before
57
65
  // they click). Preventing default skips Radix's toggle.
@@ -60,7 +68,7 @@ function InfoTip({ label, title, description, children, layout = "box", side = "
60
68
  cancelClose();
61
69
  openedBy.current = "click";
62
70
  }
63
- }, children: icon ?? (_jsx(CircleHelpIcon, { className: "size-5", "data-icon": "info-tip", "aria-hidden": true })) }) }), _jsxs(PopoverContent, { side: side, align: align, className: cn("w-72", contentClassName), "aria-labelledby": title ? titleId : undefined, "aria-label": title ? undefined : label,
71
+ }, children: icon ?? (_jsx(CircleHelpIcon, { className: inline ? "size-full" : "size-5", "data-icon": "info-tip", "aria-hidden": true })) }) }), _jsxs(PopoverContent, { side: side, align: align, className: cn("w-72", contentClassName), "aria-labelledby": title ? titleId : undefined, "aria-label": title ? undefined : label,
64
72
  // A pointer passing over the glyph must not take focus from where
65
73
  // the user is typing, nor hand it to the trigger on close.
66
74
  onOpenAutoFocus: (event) => {
@@ -0,0 +1,7 @@
1
+ /** How NavLink and TabsNavLink mark the current page (`selectedState.active`). */
2
+ export declare function activeLinkAttributes(active: boolean): {
3
+ "data-state": string;
4
+ "aria-current": "page" | undefined;
5
+ };
6
+ /** A link marked by `activeLinkAttributes`, or an active Radix tab. */
7
+ export declare const activeLinkSelector = "[aria-current=\"page\"], [data-state=\"active\"]";
@@ -0,0 +1,9 @@
1
+ /** How NavLink and TabsNavLink mark the current page (`selectedState.active`). */
2
+ export function activeLinkAttributes(active) {
3
+ return {
4
+ "data-state": active ? "active" : "inactive",
5
+ "aria-current": active ? "page" : undefined,
6
+ };
7
+ }
8
+ /** A link marked by `activeLinkAttributes`, or an active Radix tab. */
9
+ export const activeLinkSelector = '[aria-current="page"], [data-state="active"]';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Calendar cell size and padding, shared with LazyCalendar's placeholder
3
+ * (here, not in calendar.tsx, so the placeholder skips Day Picker).
4
+ */
5
+ export declare const calendarSize = "[--cell-size:--spacing(8)] [--calendar-padding:--spacing(3)]";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Calendar cell size and padding, shared with LazyCalendar's placeholder
3
+ * (here, not in calendar.tsx, so the placeholder skips Day Picker).
4
+ */
5
+ export const calendarSize = "[--cell-size:--spacing(8)] [--calendar-padding:--spacing(3)]";
@@ -12,11 +12,17 @@ export declare const formControlTriggerClassName = "h-10 w-full border-input bg-
12
12
  * with muted text while empty (`data-empty`). Intrinsic padding makes one
13
13
  * line 40px tall while longer values wrap and consumer height classes work.
14
14
  */
15
- 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 h-auto justify-start py-[calc((--spacing(10)_-_1lh_-_2px)/2)] data-[empty=true]:text-muted-foreground";
15
+ 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 h-auto justify-start py-[calc((--spacing(10)_-_1lh_-_2px)/2)] data-placeholder:text-muted-foreground data-[empty=true]:text-muted-foreground";
16
16
  /** Calendar popover body: fits the viewport height and scrolls inside it. */
17
17
  export declare const pickerContentClassName = "max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0";
18
18
  /** Gutter used when a calendar popover collides with the viewport. */
19
19
  export declare const PICKER_COLLISION_PADDING = 16;
20
+ /** Carries a field's name on its control, for `focusFirstInvalid`. */
21
+ export declare const FIELD_NAME_ATTRIBUTE = "data-field-name";
22
+ /** The props that mark a control with its field's name. */
23
+ export declare function fieldNameProps(name: string | undefined): {
24
+ "data-field-name": string | undefined;
25
+ };
20
26
  /**
21
27
  * Id of a picker trigger's value text. A form field lists it after the label
22
28
  * id in `aria-labelledby` so the trigger reads "label, value".
@@ -1,3 +1,4 @@
1
+ import { emptyValueText } from "./styles.js";
1
2
  /**
2
3
  * Chrome that makes a Button-based picker trigger match Input / SelectTrigger:
3
4
  * 40px height, px-3, body text (16px below md so iOS does not zoom), border-input,
@@ -12,11 +13,17 @@ export const formControlTriggerClassName = "h-10 w-full border-input bg-backgrou
12
13
  * with muted text while empty (`data-empty`). Intrinsic padding makes one
13
14
  * line 40px tall while longer values wrap and consumer height classes work.
14
15
  */
15
- export const pickerTriggerClassName = `${formControlTriggerClassName} h-auto justify-start py-[calc((--spacing(10)_-_1lh_-_2px)/2)] data-[empty=true]:text-muted-foreground`;
16
+ export const pickerTriggerClassName = `${formControlTriggerClassName} h-auto justify-start py-[calc((--spacing(10)_-_1lh_-_2px)/2)] ${emptyValueText}`;
16
17
  /** Calendar popover body: fits the viewport height and scrolls inside it. */
17
18
  export const pickerContentClassName = "max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0";
18
19
  /** Gutter used when a calendar popover collides with the viewport. */
19
20
  export const PICKER_COLLISION_PADDING = 16;
21
+ /** Carries a field's name on its control, for `focusFirstInvalid`. */
22
+ export const FIELD_NAME_ATTRIBUTE = "data-field-name";
23
+ /** The props that mark a control with its field's name. */
24
+ export function fieldNameProps(name) {
25
+ return { [FIELD_NAME_ATTRIBUTE]: name };
26
+ }
20
27
  /**
21
28
  * Id of a picker trigger's value text. A form field lists it after the label
22
29
  * id in `aria-labelledby` so the trigger reads "label, value".
@@ -1,3 +1,4 @@
1
+ import { clamp } from "./math.js";
1
2
  /** Drop zone ids for the drag-time action rail. */
2
3
  export const KANBAN_COMPLETE_ID = "__kanban_complete__";
3
4
  export const KANBAN_DELETE_ID = "__kanban_delete__";
@@ -45,7 +46,7 @@ export function resolveKanbanDrop(input, groups) {
45
46
  return null;
46
47
  const columnItems = groups[toColumnId] ?? [];
47
48
  const remaining = columnItems.filter((id) => id !== itemId);
48
- const index = Math.max(0, Math.min(rawIndex ?? remaining.length, remaining.length));
49
+ const index = clamp(rawIndex ?? remaining.length, 0, remaining.length);
49
50
  if (initialGroup === toColumnId && index === columnItems.indexOf(itemId)) {
50
51
  return null;
51
52
  }
@@ -12,19 +12,19 @@ export declare function listboxOptionId(listId: string, index: number): string;
12
12
  export declare function scrollOptionIntoView(node: HTMLElement | null): void;
13
13
  /**
14
14
  * Shared keydown for a search input that drives a listbox: navigation keys
15
- * move the highlight, Enter picks the highlighted index. Returns `true` when
16
- * the key is handled (or an IME is composing, when every key belongs to the
17
- * IME), so the caller can stop; `false` leaves the key to the caller (for
18
- * example SearchPicker's Escape).
15
+ * move the highlight over `items`, Enter picks the highlighted item. Returns
16
+ * `true` when the key is handled (or an IME is composing, when every key
17
+ * belongs to the IME), so the caller can stop; `false` leaves the key to the
18
+ * caller (for example SearchPicker's Escape).
19
19
  */
20
- export declare function handleListboxKey(event: {
20
+ export declare function handleListboxKey<T>(event: {
21
21
  key: string;
22
22
  keyCode: number;
23
23
  nativeEvent: KeyboardEvent;
24
24
  preventDefault: () => void;
25
- }, { highlight, length, setHighlight, onEnter, }: {
25
+ }, { items, highlight, setHighlight, onPick, }: {
26
+ items: readonly T[];
26
27
  highlight: number;
27
- length: number;
28
28
  setHighlight: (index: number) => void;
29
- onEnter: (index: number) => void;
29
+ onPick: (item: T) => void;
30
30
  }): boolean;