atlas-react-kit 1.2.1 → 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 (51) hide show
  1. package/COMPONENTS.md +22 -18
  2. package/dist/alert.js +2 -2
  3. package/dist/card.js +2 -1
  4. package/dist/combobox.d.ts +4 -3
  5. package/dist/combobox.js +3 -7
  6. package/dist/dialog.js +2 -1
  7. package/dist/dropdown-menu.js +6 -4
  8. package/dist/form/checkbox-field.d.ts +2 -4
  9. package/dist/form/checkbox-field.js +9 -12
  10. package/dist/form/combobox-field.d.ts +2 -2
  11. package/dist/form/combobox-field.js +5 -6
  12. package/dist/form/control-field.d.ts +5 -5
  13. package/dist/form/control-field.js +9 -7
  14. package/dist/form/date-picker-field.d.ts +1 -1
  15. package/dist/form/date-picker-field.js +3 -10
  16. package/dist/form/date-time-picker-field.d.ts +1 -1
  17. package/dist/form/date-time-picker-field.js +3 -10
  18. package/dist/form/field-error.d.ts +10 -1
  19. package/dist/form/field-shell.d.ts +27 -16
  20. package/dist/form/field-shell.js +27 -2
  21. package/dist/form/focus-first-invalid.d.ts +7 -4
  22. package/dist/form/focus-first-invalid.js +10 -6
  23. package/dist/form/markdown-field.d.ts +1 -1
  24. package/dist/form/markdown-field.js +8 -4
  25. package/dist/form/select-field.d.ts +2 -8
  26. package/dist/form/select-field.js +5 -7
  27. package/dist/form/text-field.d.ts +2 -8
  28. package/dist/form/text-field.js +8 -3
  29. package/dist/form/toggle-group-field.d.ts +2 -2
  30. package/dist/form/toggle-group-field.js +5 -6
  31. package/dist/info-tip.d.ts +3 -1
  32. package/dist/info-tip.js +4 -1
  33. package/dist/internal/form-control.d.ts +6 -0
  34. package/dist/internal/form-control.js +6 -0
  35. package/dist/internal/kanban-layout.js +2 -1
  36. package/dist/internal/listbox-navigation.d.ts +8 -8
  37. package/dist/internal/listbox-navigation.js +23 -23
  38. package/dist/internal/overlay-styles.d.ts +5 -0
  39. package/dist/internal/overlay-styles.js +5 -0
  40. package/dist/internal/styles.d.ts +5 -0
  41. package/dist/internal/styles.js +5 -0
  42. package/dist/kanban.js +1 -1
  43. package/dist/picker-shell.d.ts +3 -1
  44. package/dist/picker-shell.js +3 -3
  45. package/dist/popover.js +4 -4
  46. package/dist/search-picker.js +4 -8
  47. package/dist/select.js +3 -3
  48. package/dist/toggle-group.d.ts +1 -1
  49. package/dist/toggle-group.js +36 -2
  50. package/package.json +1 -1
  51. package/skills/atlas-react-kit/SKILL.md +1 -1
@@ -1,18 +1,17 @@
1
- import type { ComponentProps, ReactNode } from "react";
1
+ import { type ComponentProps, type ComponentType, type ReactNode } from "react";
2
2
  import type { DateLike, DatePickerCalendarProps } from "../date-picker.js";
3
3
  import type { AsChildProps } from "../internal/as-child.js";
4
4
  import { type ErrorTimingProps, type FieldMetaState, type FormFieldBaseProps, type FormFieldMetaProps } from "./field-error.js";
5
5
  /**
6
- * Not a public entry. The layout and error wiring every `form/*` field
7
- * shares, so a field only says which control it renders. Custom controls
8
- * get the same shell through the public `FormControlField`.
6
+ * The error text a `form/*` field shows (`metaErrorText`) and its control
7
+ * id. The id is generated per field, so two forms that share a field name
8
+ * (login and sign-up `email`) never share an id, and each label, hint, and
9
+ * error links to its own control.
9
10
  */
10
- /**
11
- * First error in `meta`, or `undefined` when there is no meta, no error,
12
- * `showError` is false, or the field is untouched (`isTouched: false`)
13
- * and the form has not been submitted.
14
- */
15
- export declare function metaErrorText(meta: FieldMetaState | undefined, { submitted, showError }?: ErrorTimingProps): string | undefined;
11
+ export declare function useFormField(meta: FieldMetaState | undefined, timing: ErrorTimingProps): {
12
+ id: string;
13
+ errorText: string | undefined;
14
+ };
16
15
  /**
17
16
  * ARIA for a field's control, from the ids `FormFieldShell` renders:
18
17
  * invalid while there is error text, described by the hint, the hidden
@@ -37,12 +36,6 @@ export declare function fieldAria(id: string, errorText: string | undefined, { d
37
36
  * `onValueChange`, and `placeholder`.
38
37
  */
39
38
  export type PickerFieldBaseProps = FormFieldBaseProps & FormFieldMetaProps & {
40
- /**
41
- * Id of the trigger the label points at. Defaults to a generated id;
42
- * pass the field name (`id={field.name}`) so a submit handler such as
43
- * `focusFirstInvalid` can find the control.
44
- */
45
- id?: string;
46
39
  /** Disables the trigger so no date can be picked. */
47
40
  disabled?: boolean;
48
41
  /** Marks the label and adds "Required" to the trigger's description. */
@@ -58,6 +51,23 @@ export type PickerFieldBaseProps = FormFieldBaseProps & FormFieldMetaProps & {
58
51
  /** Day Picker options forwarded to the calendar (disabled days, etc.). */
59
52
  calendarProps?: DatePickerCalendarProps;
60
53
  };
54
+ /** The props a date or date-time field keeps for its shell, not the picker. */
55
+ type PickerFieldOwnKey = "fieldName" | "label" | "meta" | "submitted" | "showError" | "required" | "fieldClassName";
56
+ /**
57
+ * The date and date-time fields: `picker` (the trigger) in a
58
+ * `FormFieldShell`, given every prop of `field` the shell does not take. A
59
+ * button cannot carry `required`, so the trigger is described by the hidden
60
+ * "Required" text and reads "label, value".
61
+ */
62
+ export declare function FormPickerField<P extends PickerFieldBaseProps>({ field, picker: Picker, }: {
63
+ /** The field's props. */
64
+ field: P;
65
+ /** The picker trigger. */
66
+ picker: ComponentType<Omit<P, PickerFieldOwnKey> & {
67
+ id: string;
68
+ "data-field-name"?: string;
69
+ } & ReturnType<typeof fieldAria>>;
70
+ }): import("react").JSX.Element;
61
71
  /**
62
72
  * Every `form/*` field's wrapper (`data-slot="form-field"`): children with
63
73
  * the field spacing, then the error text. `asChild` makes the one child the
@@ -96,3 +106,4 @@ export declare function FormFieldShell({ id, label, labelAction, description, er
96
106
  required?: boolean;
97
107
  children: ReactNode;
98
108
  }): import("react").JSX.Element;
109
+ export {};
@@ -1,6 +1,8 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, } from "react";
2
4
  import { Slot } from "radix-ui";
3
- import { pickerValueId } from "../internal/form-control.js";
5
+ import { fieldNameProps, pickerValueId } from "../internal/form-control.js";
4
6
  import { Label } from "../label.js";
5
7
  import { cn } from "../utils.js";
6
8
  import { FieldError, getFieldErrorMessage, } from "./field-error.js";
@@ -14,13 +16,22 @@ import { FieldError, getFieldErrorMessage, } from "./field-error.js";
14
16
  * `showError` is false, or the field is untouched (`isTouched: false`)
15
17
  * and the form has not been submitted.
16
18
  */
17
- export function metaErrorText(meta, { submitted, showError = true } = {}) {
19
+ function metaErrorText(meta, { submitted, showError = true } = {}) {
18
20
  if (!meta || !showError)
19
21
  return undefined;
20
22
  if (meta.isTouched === false && !submitted)
21
23
  return undefined;
22
24
  return getFieldErrorMessage(meta.errors[0]);
23
25
  }
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) };
34
+ }
24
35
  /** Id of the label `FormFieldShell` renders for control `id`. */
25
36
  function labelIdFor(id) {
26
37
  return `${id}-label`;
@@ -65,6 +76,20 @@ export function fieldAria(id, errorText, { description, required, labelledBy, }
65
76
  }),
66
77
  };
67
78
  }
79
+ /**
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".
84
+ */
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
+ }
68
93
  /**
69
94
  * Every `form/*` field's wrapper (`data-slot="form-field"`): children with
70
95
  * the field spacing, then the error text. `asChild` makes the one child the
@@ -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 { fieldAria, FormFieldShell, 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, ...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" })) })) }));
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 { fieldAria, FormFieldShell, 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, ...fieldAria(id, errorText, { required }), 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,18 +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 { fieldAria, FormFieldShell, 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
18
  const controlProps = {
15
19
  id,
20
+ ...fieldNameProps(field.name),
16
21
  value: field.state.value,
17
22
  onChange,
18
23
  onBlur: field.handleBlur,
@@ -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 { fieldAria, FormFieldShell, 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, ...fieldAria(id, errorText, { description, labelledBy: "label" }), 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
  }
@@ -13,7 +13,9 @@ export type InfoTipProps = {
13
13
  /**
14
14
  * `inline` sits in a line of text: the glyph is 1em, a quarter em after
15
15
  * the word, on the text's cap height, and keeps the line height; the 44px
16
- * hit area overflows. Default `box`.
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`.
17
19
  */
18
20
  layout?: InfoTipLayout;
19
21
  /** Panel side. Default `top`; Radix still flips on collision. */
package/dist/info-tip.js CHANGED
@@ -54,9 +54,12 @@ function InfoTip({ label, title, description, children, layout = "box", side = "
54
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
55
  // Inline: 1em scales with the sentence; -0.125em centres the
56
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.
57
60
  inline
58
61
  ? cn("ml-[0.25em] size-[1em] align-[-0.125em]", hitArea44)
59
- : "size-11", className), onMouseEnter: openFromHover, onMouseLeave: closeFromHover, onClick: (event) => {
62
+ : "size-11 not-first:ml-[calc(0.25em-0.75rem)]", className), onMouseEnter: openFromHover, onMouseLeave: closeFromHover, onClick: (event) => {
60
63
  // Hover already opened the panel; this click pins it instead of
61
64
  // toggling it closed (Playwright and real mice both hover before
62
65
  // they click). Preventing default skips Radix's toggle.
@@ -17,6 +17,12 @@ export declare const pickerTriggerClassName = "h-10 w-full border-input bg-backg
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".
@@ -18,6 +18,12 @@ export const pickerTriggerClassName = `${formControlTriggerClassName} h-auto jus
18
18
  export const pickerContentClassName = "max-h-(--radix-popover-content-available-height) overflow-y-auto rounded-none p-0";
19
19
  /** Gutter used when a calendar popover collides with the viewport. */
20
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
+ }
21
27
  /**
22
28
  * Id of a picker trigger's value text. A form field lists it after the label
23
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;
@@ -1,23 +1,21 @@
1
+ import { clamp } from "./math.js";
1
2
  /** Page keys move ten rows, a little more than one max-h-60 list viewport. */
2
3
  const PAGE_SIZE = 10;
4
+ /** Rows each key moves the highlight; Home and End jump to the ends. */
5
+ const keySteps = new Map([
6
+ ["ArrowDown", 1],
7
+ ["ArrowUp", -1],
8
+ ["PageDown", PAGE_SIZE],
9
+ ["PageUp", -PAGE_SIZE],
10
+ ["Home", -Infinity],
11
+ ["End", Infinity],
12
+ ]);
3
13
  /** Return the next highlighted index for a listbox navigation key. */
4
14
  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
- }
15
+ const step = keySteps.get(key);
16
+ if (step === undefined)
17
+ return undefined;
18
+ return clamp(index + step, 0, Math.max(length - 1, 0));
21
19
  }
22
20
  /**
23
21
  * Option id for `aria-activedescendant`. Index-based because option values
@@ -45,15 +43,15 @@ function isComposingKey(event) {
45
43
  }
46
44
  /**
47
45
  * Shared keydown for a search input that drives a listbox: navigation keys
48
- * move the highlight, Enter picks the highlighted index. Returns `true` when
49
- * the key is handled (or an IME is composing, when every key belongs to the
50
- * IME), so the caller can stop; `false` leaves the key to the caller (for
51
- * example SearchPicker's Escape).
46
+ * move the highlight over `items`, Enter picks the highlighted item. Returns
47
+ * `true` when the key is handled (or an IME is composing, when every key
48
+ * belongs to the IME), so the caller can stop; `false` leaves the key to the
49
+ * caller (for example SearchPicker's Escape).
52
50
  */
53
- export function handleListboxKey(event, { highlight, length, setHighlight, onEnter, }) {
51
+ export function handleListboxKey(event, { items, highlight, setHighlight, onPick, }) {
54
52
  if (isComposingKey(event))
55
53
  return true;
56
- const next = moveListboxHighlight(event.key, highlight, length);
54
+ const next = moveListboxHighlight(event.key, highlight, items.length);
57
55
  if (next !== undefined) {
58
56
  event.preventDefault();
59
57
  setHighlight(next);
@@ -61,7 +59,9 @@ export function handleListboxKey(event, { highlight, length, setHighlight, onEnt
61
59
  }
62
60
  if (event.key === "Enter") {
63
61
  event.preventDefault();
64
- onEnter(highlight);
62
+ const item = items[highlight];
63
+ if (item !== undefined)
64
+ onPick(item);
65
65
  return true;
66
66
  }
67
67
  return false;
@@ -17,6 +17,11 @@ export declare const overlaySideSlide = "data-[side=bottom]:slide-in-from-top-2
17
17
  export declare const overlayFade = "duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0";
18
18
  /** Fade plus a slight zoom, for panels (the backdrop only fades). */
19
19
  export declare const overlayFadeZoom = "duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 data-open:zoom-in-95 data-closed:zoom-out-95";
20
+ /**
21
+ * A panel opened from a trigger (Popover, Select, DropdownMenu): the
22
+ * surface, sliding in from the trigger's side with a fade and zoom.
23
+ */
24
+ export declare const anchoredPanel = "rounded-none bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 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 duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 data-open:zoom-in-95 data-closed:zoom-out-95";
20
25
  /**
21
26
  * Base of a menu or listbox row (SelectItem, DropdownMenuItem, Combobox and
22
27
  * SearchPicker options). Each component adds its own right padding and
@@ -17,6 +17,11 @@ export const overlaySideSlide = "data-[side=bottom]:slide-in-from-top-2 data-[si
17
17
  export const overlayFade = "duration-100 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0";
18
18
  /** Fade plus a slight zoom, for panels (the backdrop only fades). */
19
19
  export const overlayFadeZoom = `${overlayFade} data-open:zoom-in-95 data-closed:zoom-out-95`;
20
+ /**
21
+ * A panel opened from a trigger (Popover, Select, DropdownMenu): the
22
+ * surface, sliding in from the trigger's side with a fade and zoom.
23
+ */
24
+ export const anchoredPanel = `${overlaySurface} ${overlaySideSlide} ${overlayFadeZoom}`;
20
25
  /**
21
26
  * Base of a menu or listbox row (SelectItem, DropdownMenuItem, Combobox and
22
27
  * SearchPicker options). Each component adds its own right padding and
@@ -97,6 +97,11 @@ export declare const controlSize: {
97
97
  * tailwind-merge then drops the `text-xs`.
98
98
  */
99
99
  export declare const controlText = "text-xs font-semibold tracking-widest whitespace-nowrap uppercase";
100
+ /**
101
+ * Secondary text under a title (CardDescription, PopoverDescription,
102
+ * DialogDescription, AlertDescription).
103
+ */
104
+ export declare const descriptionText = "text-sm leading-relaxed text-muted-foreground";
100
105
  /** Field-label text (Label, FieldLegend, FieldTitle, PopoverTitle). */
101
106
  export declare const labelText = "text-xs font-semibold tracking-wide uppercase";
102
107
  /**
@@ -97,6 +97,11 @@ export const controlSize = {
97
97
  * tailwind-merge then drops the `text-xs`.
98
98
  */
99
99
  export const controlText = "text-xs font-semibold tracking-widest whitespace-nowrap uppercase";
100
+ /**
101
+ * Secondary text under a title (CardDescription, PopoverDescription,
102
+ * DialogDescription, AlertDescription).
103
+ */
104
+ export const descriptionText = "text-sm leading-relaxed text-muted-foreground";
100
105
  /** Field-label text (Label, FieldLegend, FieldTitle, PopoverTitle). */
101
106
  export const labelText = "text-xs font-semibold tracking-wide uppercase";
102
107
  /**
package/dist/kanban.js CHANGED
@@ -125,7 +125,7 @@ function KanbanColumnView({ column, count, emptyLabel, loading, className, child
125
125
  collisionPriority: COLUMN_DROP_PRIORITY,
126
126
  });
127
127
  const empty = count === 0;
128
- return (_jsxs("div", { ref: ref, "data-slot": "kanban-column", "data-drop-target": isDropTarget || undefined, className: cn("flex snap-start flex-col gap-3 p-3", "bg-muted/40 ring-1 ring-foreground/5", "data-drop-target:bg-muted/60 data-drop-target:ring-2 data-drop-target:ring-ring/60", columnSize, className), children: [_jsxs("header", { "data-slot": "kanban-column-header", className: "flex items-start justify-between gap-2", children: [_jsxs("span", { className: "flex items-center gap-2 text-xs font-semibold tracking-widest uppercase", children: [column.color ? (_jsx("span", { "aria-hidden": true, className: "size-2 shrink-0 rounded-full", style: { backgroundColor: column.color } })) : null, _jsx("span", { className: "leading-tight", children: column.label })] }), _jsxs("span", { className: "flex shrink-0 flex-col items-end gap-0.5 text-muted-foreground tabular-nums", children: [_jsx("span", { className: "text-xs", children: count }), column.badge ? (_jsx("span", { className: "text-[10px] font-medium", children: column.badge })) : null] })] }), _jsx("div", { className: "flex min-h-32 flex-1 flex-col gap-2 overflow-y-auto pr-1", children: loading ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20" }), _jsx(Skeleton, { className: "h-16" })] })) : (_jsxs(_Fragment, { children: [children, _jsx("p", { "data-slot": "kanban-column-empty", "aria-hidden": !empty, className: cn("border border-dashed px-3 py-6 text-center text-xs text-muted-foreground", !empty && "hidden"), children: emptyLabel })] })) })] }));
128
+ return (_jsxs("div", { ref: ref, "data-slot": "kanban-column", "data-drop-target": isDropTarget || undefined, className: cn("flex snap-start flex-col gap-3 p-3", "bg-muted/40 ring-1 ring-foreground/5", "data-drop-target:bg-muted/60 data-drop-target:ring-2 data-drop-target:ring-ring/60", columnSize, className), children: [_jsxs("header", { "data-slot": "kanban-column-header", className: "flex flex-wrap items-start justify-between gap-x-2 gap-y-0.5", children: [_jsxs("span", { className: "flex max-w-full min-w-0 items-center gap-2 text-xs font-semibold tracking-widest uppercase", children: [column.color ? (_jsx("span", { "aria-hidden": true, className: "size-2 shrink-0 rounded-full", style: { backgroundColor: column.color } })) : null, _jsx("span", { className: "truncate leading-tight", title: column.label, children: column.label })] }), _jsxs("span", { className: "ml-auto flex max-w-full min-w-0 flex-col items-end gap-0.5 text-muted-foreground tabular-nums", children: [_jsx("span", { className: "text-xs", children: count }), column.badge ? (_jsx("span", { className: "max-w-full truncate text-[10px] font-medium", children: column.badge })) : null] })] }), _jsx("div", { className: "-m-1 mr-0 flex min-h-32 flex-1 flex-col gap-2 overflow-y-auto p-1", children: loading ? (_jsxs(_Fragment, { children: [_jsx(Skeleton, { className: "h-20" }), _jsx(Skeleton, { className: "h-16" })] })) : (_jsxs(_Fragment, { children: [children, _jsx("p", { "data-slot": "kanban-column-empty", "aria-hidden": !empty, className: cn("border border-dashed px-3 py-6 text-center text-xs text-muted-foreground", !empty && "hidden"), children: emptyLabel })] })) })] }));
129
129
  }
130
130
  const cardChrome = cn("flex border border-border bg-card text-card-foreground");
131
131
  const handleChrome = cn("flex shrink-0 items-center self-stretch border-r border-border", "bg-muted/30 px-1.5 text-muted-foreground");