@godxjp/ui 18.13.1 → 18.15.0

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.
@@ -511,8 +511,12 @@ DataTable.Content = function DataTableContent() {
511
511
  style: columnWidth(col.width).style,
512
512
  className: cn(
513
513
  columnWidth(col.width).className,
514
- col.align === "right" && "text-end",
515
- col.align === "center" && "text-center",
514
+ // `headerAlign` when the heading differs from the rows,
515
+ // `align` otherwise. A table wanting centred headings
516
+ // over start-aligned text could not say so before, and
517
+ // consumers reached for `[&_th_button]:justify-center`.
518
+ (col.headerAlign ?? col.align) === "right" && "text-end",
519
+ (col.headerAlign ?? col.align) === "center" && "text-center",
516
520
  col.hiddenOnMobile && "hidden md:table-cell",
517
521
  isSortable && "select-none",
518
522
  col.pin === "end" && "ui-data-table-pin-end"
@@ -0,0 +1,45 @@
1
+ import * as React from "react";
2
+ import type { FormErrorsProp, FormErrorsProviderProp } from "../../props/components/data-entry.prop.js";
3
+ import type { ErrorBagProp } from "../../props/vocabulary/index.js";
4
+ export type { FormErrorsProp, FormErrorsProp as FormErrorsProps, FormErrorsProviderProp, FormErrorsProviderProp as FormErrorsProviderProps, } from "../../props/components/data-entry.prop.js";
5
+ /**
6
+ * Registry shared by a `Form` with its FormFields and `<FormErrors />`. Each mounted
7
+ * `FormField name="…"` CLAIMS its error-bag key (it is the visible owner of that message);
8
+ * `<FormErrors />` renders only the unclaimed remainder. Claims are reference-counted so
9
+ * duplicate names and unmount order stay correct.
10
+ */
11
+ interface FormErrorsRegistryValue {
12
+ errors: ErrorBagProp;
13
+ claimed: ReadonlyMap<string, number>;
14
+ /** Claim `key`; returns the release function (stable identity — safe as an effect dep). */
15
+ claim: (key: string) => () => void;
16
+ }
17
+ /** Read the nearest Form's error registry (null when used outside a Form). */
18
+ export declare function useFormErrorsRegistry(): FormErrorsRegistryValue | null;
19
+ /** First message of a bag entry — Laravel arrays surface their first message on the field. */
20
+ export declare function firstBagMessage(entry: string | string[] | undefined): string | undefined;
21
+ /**
22
+ * FormErrorsProvider — one shared error registry (error bag + claim set) over a region.
23
+ * 兄弟 Form 群で 1 つのエラーバッグを共有するための公開プロバイダ。Two ways to get one:
24
+ *
25
+ * 1. `Form errors={…}` renders this provider itself (both `<form>` and `asChild` modes) —
26
+ * the single-form case needs nothing else.
27
+ * 2. An edit screen split into several sibling Card+Form sections (the standard exseli shape)
28
+ * wraps the REGION in `<FormErrorsProvider errors={…}>` instead: Forms **without** their own
29
+ * `errors` join the surrounding registry, every `FormField name="…"` inside claims into it,
30
+ * and one `<FormErrors />` anywhere in the region renders the unclaimed remainder.
31
+ *
32
+ * A nested Form WITH its own `errors` starts a new registry that shadows this one — its claims
33
+ * and messages stay inside it.
34
+ */
35
+ export declare function FormErrorsProvider({ errors, children }: FormErrorsProviderProp): React.JSX.Element;
36
+ /** FormField calls this to claim its error-bag key while mounted (no-op outside a Form). */
37
+ export declare function useClaimErrorKey(name: string | undefined): void;
38
+ /**
39
+ * FormErrors — renders the error-bag entries no mounted `FormField name="…"` displays: server
40
+ * validation errors attached to hidden/derived fields (an `action_mode`, a `page`, a source-record
41
+ * id) that would otherwise fail silently. Place it inside a `Form errors={…}` — typically above
42
+ * the fields; it renders nothing while every entry is claimed or the bag is empty. Composed on
43
+ * `Alert tone="destructive"` (role="alert"), so appearing messages are announced assertively.
44
+ */
45
+ export declare function FormErrors({ errors: errorsProp, title, className }: FormErrorsProp): React.JSX.Element | null;
@@ -0,0 +1,71 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useTranslation } from "../../i18n/use-translation.js";
5
+ import { Alert, AlertContent, AlertDescription, AlertTitle } from "../feedback/alert.js";
6
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
7
+ const FormErrorsRegistryContext = React.createContext(null);
8
+ function useFormErrorsRegistry() {
9
+ return React.useContext(FormErrorsRegistryContext);
10
+ }
11
+ function firstBagMessage(entry) {
12
+ const message = Array.isArray(entry) ? entry[0] : entry;
13
+ return message ? message : void 0;
14
+ }
15
+ function FormErrorsProvider({ errors, children }) {
16
+ const [claimed, setClaimed] = React.useState(/* @__PURE__ */ new Map());
17
+ const claim = React.useCallback((key) => {
18
+ setClaimed((prev) => {
19
+ const next = new Map(prev);
20
+ next.set(key, (next.get(key) ?? 0) + 1);
21
+ return next;
22
+ });
23
+ return () => {
24
+ setClaimed((prev) => {
25
+ const next = new Map(prev);
26
+ const count = (next.get(key) ?? 0) - 1;
27
+ if (count <= 0) next.delete(key);
28
+ else next.set(key, count);
29
+ return next;
30
+ });
31
+ };
32
+ }, []);
33
+ const value = React.useMemo(
34
+ () => ({ errors: errors ?? {}, claimed, claim }),
35
+ [errors, claimed, claim]
36
+ );
37
+ return /* @__PURE__ */ jsx(FormErrorsRegistryContext.Provider, { value, children });
38
+ }
39
+ function useClaimErrorKey(name) {
40
+ const registry = useFormErrorsRegistry();
41
+ const claim = registry?.claim;
42
+ useIsomorphicLayoutEffect(() => {
43
+ if (!name || !claim) return;
44
+ return claim(name);
45
+ }, [name, claim]);
46
+ }
47
+ function FormErrors({ errors: errorsProp, title, className }) {
48
+ const { t } = useTranslation();
49
+ const registry = useFormErrorsRegistry();
50
+ const errors = errorsProp ?? registry?.errors;
51
+ const claimed = registry?.claimed;
52
+ const messages = [];
53
+ for (const [key, entry] of Object.entries(errors ?? {})) {
54
+ if (entry == null || (claimed?.get(key) ?? 0) > 0) continue;
55
+ for (const message of Array.isArray(entry) ? entry : [entry]) {
56
+ if (message) messages.push({ key, message });
57
+ }
58
+ }
59
+ if (!messages.length) return null;
60
+ return /* @__PURE__ */ jsx(Alert, { tone: "destructive", className, children: /* @__PURE__ */ jsxs(AlertContent, { children: [
61
+ /* @__PURE__ */ jsx(AlertTitle, { children: title ?? t("dataEntry.formErrors.title") }),
62
+ messages.map(({ key, message }, index) => /* @__PURE__ */ jsx(AlertDescription, { children: message }, `${key}-${index}`))
63
+ ] }) });
64
+ }
65
+ export {
66
+ FormErrors,
67
+ FormErrorsProvider,
68
+ firstBagMessage,
69
+ useClaimErrorKey,
70
+ useFormErrorsRegistry
71
+ };
@@ -1,4 +1,4 @@
1
1
  import * as React from "react";
2
2
  import type { FormFieldProp } from "../../props/components/data-entry.prop.js";
3
3
  export type { FormFieldProp, FormFieldProp as FormFieldProps, } from "../../props/components/data-entry.prop.js";
4
- export declare function FormField({ id, label, required, helper, error, labelAddon, layout: layoutProp, labelWidth: labelWidthProp, controlWidth: controlWidthProp, colSpan, className, children, staticText, }: FormFieldProp): React.JSX.Element;
4
+ export declare function FormField({ id, name, label, required, helper, error: errorProp, labelAddon, layout: layoutProp, labelWidth: labelWidthProp, controlWidth: controlWidthProp, colSpan, className, children, staticText, }: FormFieldProp): React.JSX.Element;
@@ -5,14 +5,16 @@ import { Label } from "../data-entry/label.js";
5
5
  import { cn } from "../../lib/utils.js";
6
6
  import { FieldNameContext, mergeAriaIds } from "../../lib/field-a11y.js";
7
7
  import { useFormLayout } from "./form.js";
8
+ import { firstBagMessage, useClaimErrorKey, useFormErrorsRegistry } from "./form-errors.js";
8
9
  const toCssLength = (v) => typeof v === "number" ? `${v}px` : v;
9
10
  const FOCUSABLE_SELECTOR = 'input:not([type="hidden"]), select, textarea, button, [tabindex]:not([tabindex="-1"])';
10
11
  function FormField({
11
12
  id,
13
+ name,
12
14
  label,
13
15
  required,
14
16
  helper,
15
- error,
17
+ error: errorProp,
16
18
  labelAddon,
17
19
  layout: layoutProp,
18
20
  labelWidth: labelWidthProp,
@@ -28,6 +30,9 @@ function FormField({
28
30
  const controlWidth = controlWidthProp ?? form?.controlWidth;
29
31
  const labelAlign = form?.labelAlign ?? "end";
30
32
  const collapseBelow = form?.collapseBelow ?? "md";
33
+ const errorsRegistry = useFormErrorsRegistry();
34
+ useClaimErrorKey(name);
35
+ const error = errorProp ?? (name && errorsRegistry ? firstBagMessage(errorsRegistry.errors[name]) : void 0);
31
36
  const autoId = React.useId();
32
37
  const resolvedId = id ?? autoId;
33
38
  const labelId = `${resolvedId}-label`;
@@ -32,6 +32,7 @@ export declare const Form: React.ForwardRefExoticComponent<React.FormHTMLAttribu
32
32
  collapseBelow?: BreakpointProp | false;
33
33
  columns?: import("../../props/components/layout.prop.js").ResponsiveGridColumnsProp;
34
34
  density?: import("../../props/index.js").DensityProp;
35
+ errors?: import("../../props/index.js").ErrorBagProp;
35
36
  asChild?: boolean;
36
37
  className?: import("../../props/index.js").ClassNameProp;
37
38
  } & React.RefAttributes<HTMLFormElement>>;
@@ -3,6 +3,7 @@ import { jsx } from "react/jsx-runtime";
3
3
  import { Slot } from "@radix-ui/react-slot";
4
4
  import * as React from "react";
5
5
  import { cn } from "../../lib/utils.js";
6
+ import { FormErrorsProvider } from "./form-errors.js";
6
7
  import { ResponsiveGrid } from "../layout/responsive-grid.js";
7
8
  const FormLayoutContext = React.createContext(null);
8
9
  function useFormLayout() {
@@ -16,6 +17,7 @@ const Form = React.forwardRef(function Form2({
16
17
  collapseBelow = "md",
17
18
  columns,
18
19
  density,
20
+ errors,
19
21
  asChild = false,
20
22
  className,
21
23
  children,
@@ -26,17 +28,20 @@ const Form = React.forwardRef(function Form2({
26
28
  [layout, labelWidth, controlWidth, labelAlign, collapseBelow]
27
29
  );
28
30
  const content = columns != null ? /* @__PURE__ */ jsx(ResponsiveGrid, { columns, children }) : children;
31
+ const withRegistry = (node) => errors !== void 0 ? /* @__PURE__ */ jsx(FormErrorsProvider, { errors, children: node }) : node;
29
32
  if (asChild) {
30
- return /* @__PURE__ */ jsx(FormLayoutContext.Provider, { value: ctx, children: /* @__PURE__ */ jsx(
31
- Slot,
32
- {
33
- ref,
34
- "data-slot": "form",
35
- "data-layout": layout,
36
- className: cn("ui-form", density && `ui-density-${density}`, className),
37
- ...props,
38
- children
39
- }
33
+ return /* @__PURE__ */ jsx(FormLayoutContext.Provider, { value: ctx, children: withRegistry(
34
+ /* @__PURE__ */ jsx(
35
+ Slot,
36
+ {
37
+ ref,
38
+ "data-slot": "form",
39
+ "data-layout": layout,
40
+ className: cn("ui-form", density && `ui-density-${density}`, className),
41
+ ...props,
42
+ children
43
+ }
44
+ )
40
45
  ) });
41
46
  }
42
47
  return /* @__PURE__ */ jsx(
@@ -47,7 +52,7 @@ const Form = React.forwardRef(function Form2({
47
52
  "data-layout": layout,
48
53
  className: cn("ui-form", density && `ui-density-${density}`, className),
49
54
  ...props,
50
- children: /* @__PURE__ */ jsx(FormLayoutContext.Provider, { value: ctx, children: content })
55
+ children: /* @__PURE__ */ jsx(FormLayoutContext.Provider, { value: ctx, children: withRegistry(content) })
51
56
  }
52
57
  );
53
58
  });
@@ -13,6 +13,8 @@ export { Form, useFormLayout, type FormLayoutContextValue } from "./form.js";
13
13
  export type { FormProp, FormProps } from "./form.js";
14
14
  export { FormField } from "./form-field.js";
15
15
  export type { FormFieldProp, FormFieldProps } from "./form-field.js";
16
+ export { FormErrors, FormErrorsProvider } from "./form-errors.js";
17
+ export type { FormErrorsProp, FormErrorsProps, FormErrorsProviderProp, FormErrorsProviderProps, } from "./form-errors.js";
16
18
  export { Field } from "./field.js";
17
19
  export type { FieldProps } from "./field.js";
18
20
  export { SearchInput } from "./search-input.js";
@@ -19,6 +19,7 @@ import { Radio, RadioGroup, RadioItem, RadioGroupRoot } from "./radio.js";
19
19
  import { Textarea } from "./textarea.js";
20
20
  import { Form, useFormLayout } from "./form.js";
21
21
  import { FormField } from "./form-field.js";
22
+ import { FormErrors, FormErrorsProvider } from "./form-errors.js";
22
23
  import { Field } from "./field.js";
23
24
  import { SearchInput } from "./search-input.js";
24
25
  import { Switch } from "./switch.js";
@@ -69,6 +70,8 @@ export {
69
70
  DateRangePicker,
70
71
  Field,
71
72
  Form,
73
+ FormErrors,
74
+ FormErrorsProvider,
72
75
  FormField,
73
76
  Input,
74
77
  InputOTP,
@@ -4,4 +4,4 @@ export type { UploadProp, UploadProp as UploadProps, UploadFileItemProp, UploadV
4
4
  export type { UploadFileItem, UploadVariant, UploadCommitAction } from "./upload-types.js";
5
5
  export { collectUploadCommitActions, createUploadItem } from "./upload-types.js";
6
6
  export { useUploadDraft } from "./use-upload-draft.js";
7
- export declare function Upload({ variant, value, defaultValue, onValueChange, accept: acceptProp, multiple: multipleProp, maxCount: maxCountProp, maxSizeBytes, disabled, removable, onUpload, id, className, children, ...ariaProps }: UploadProp): React.JSX.Element;
7
+ export declare function Upload({ variant, triggerSize, value, defaultValue, onValueChange, accept: acceptProp, multiple: multipleProp, maxCount: maxCountProp, maxSizeBytes, disabled, removable, onUpload, id, className, children, ...ariaProps }: UploadProp): React.JSX.Element;
@@ -75,6 +75,7 @@ async function runUpload(file, item, onUpload, setItems) {
75
75
  }
76
76
  function Upload({
77
77
  variant = "dropzone",
78
+ triggerSize,
78
79
  value,
79
80
  defaultValue,
80
81
  onValueChange,
@@ -212,13 +213,26 @@ function Upload({
212
213
  ] });
213
214
  }
214
215
  if (variant === "button") {
216
+ const iconOnly = typeof triggerSize === "string" && triggerSize.startsWith("icon");
217
+ const label = children ?? t("dataEntry.upload.buttonLabel");
215
218
  return /* @__PURE__ */ jsxs("div", { className: cn("ui-stack-sm", className), children: [
216
219
  hiddenInput,
217
220
  liveRegion,
218
- /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", disabled, onClick: openPicker, children: [
219
- /* @__PURE__ */ jsx(UploadIcon, { className: "me-2 size-4", "aria-hidden": "true" }),
220
- children ?? t("dataEntry.upload.buttonLabel")
221
- ] }),
221
+ /* @__PURE__ */ jsxs(
222
+ Button,
223
+ {
224
+ type: "button",
225
+ variant: "outline",
226
+ size: triggerSize,
227
+ disabled,
228
+ onClick: openPicker,
229
+ "aria-label": iconOnly ? typeof label === "string" ? label : void 0 : void 0,
230
+ children: [
231
+ /* @__PURE__ */ jsx(UploadIcon, { className: iconOnly ? "size-4" : "me-2 size-4", "aria-hidden": "true" }),
232
+ iconOnly ? null : label
233
+ ]
234
+ }
235
+ ),
222
236
  items.length > 0 && /* @__PURE__ */ jsx(UploadFileList, { items, onRemove: removable ? removeItem : void 0 })
223
237
  ] });
224
238
  }
@@ -2,7 +2,13 @@ import type { ReactNode } from "react";
2
2
  export type SplitPaneProps = {
3
3
  children: ReactNode;
4
4
  aside: ReactNode;
5
- asideWidth?: "sm" | "md";
5
+ /**
6
+ * How wide the rail is once the pane is wide enough to split: `sm` 20rem,
7
+ * `md` 22rem, `lg` 30rem. `lg` is for rails that carry a panel rather than a
8
+ * list — a metadata table, a recently-updated feed — and it holds off
9
+ * splitting until 64rem so the main column stays the wider of the two.
10
+ */
11
+ asideWidth?: "sm" | "md" | "lg";
6
12
  /** Accessible complementary landmark name; required when multiple panes share a document. */
7
13
  asideLabel?: string;
8
14
  };
@@ -21,6 +21,7 @@ function FormFieldControl({
21
21
  FormField,
22
22
  {
23
23
  id: fieldName,
24
+ name: fieldName,
24
25
  label,
25
26
  required,
26
27
  helper,
@@ -46,6 +47,7 @@ function FormFieldControl({
46
47
  FormField,
47
48
  {
48
49
  id: String(name),
50
+ name: String(name),
49
51
  label,
50
52
  required,
51
53
  helper,
@@ -128,6 +128,9 @@
128
128
  "denied": "You do not have permission to view branches",
129
129
  "loading": "Loading branches…",
130
130
  "noMatches": "No branches match your search"
131
+ },
132
+ "formErrors": {
133
+ "title": "Please review the following errors"
131
134
  }
132
135
  },
133
136
  "feedback": {
@@ -125,6 +125,9 @@
125
125
  "denied": "ブランチを表示する権限がありません",
126
126
  "loading": "ブランチを読み込み中…",
127
127
  "noMatches": "検索に一致するブランチがありません"
128
+ },
129
+ "formErrors": {
130
+ "title": "入力内容にエラーがあります"
128
131
  }
129
132
  },
130
133
  "feedback": {
@@ -125,6 +125,9 @@
125
125
  "denied": "Bạn không có quyền xem chi nhánh",
126
126
  "loading": "Đang tải chi nhánh…",
127
127
  "noMatches": "Không có chi nhánh khớp với tìm kiếm"
128
+ },
129
+ "formErrors": {
130
+ "title": "Dữ liệu nhập có lỗi, vui lòng kiểm tra"
128
131
  }
129
132
  },
130
133
  "feedback": {
@@ -8,7 +8,7 @@ import type { DateRange } from "react-day-picker";
8
8
  import type * as React from "react";
9
9
  import type { UploadFileItem } from "../../components/data-entry/upload-types.js";
10
10
  import type { FieldA11yProps } from "../../lib/field-a11y.js";
11
- import type { ClassNameProp, DisabledProp, EmptyMessageProp, ErrorProp, HelperProp, IdProp, LabelProp, NameProp, OnChangeProp, OnValueChangeProp, OnSearchChangeProp, OpenProp, OnOpenChangeProp, PlaceholderProp, RequiredProp, ValueProp, DefaultValueProp, FormLayoutProp, WidthProp, BreakpointProp, DensityProp, SizeProp } from "../vocabulary/index.js";
11
+ import type { ClassNameProp, DisabledProp, EmptyMessageProp, ErrorBagProp, ErrorProp, HelperProp, IdProp, LabelProp, NameProp, OnChangeProp, OnValueChangeProp, OnSearchChangeProp, OpenProp, OnOpenChangeProp, PlaceholderProp, RequiredProp, ValueProp, DefaultValueProp, FormLayoutProp, WidthProp, BreakpointProp, DensityProp, SizeProp, TitleProp } from "../vocabulary/index.js";
12
12
  import type { ResponsiveGridColumnsProp } from "./layout.prop.js";
13
13
  /** One-outline-per-group appearance for the compound InputOTP control. */
14
14
  export type InputOTPGroupAppearanceProp = "slots" | "grouped";
@@ -88,6 +88,15 @@ export type FormProp = React.FormHTMLAttributes<HTMLFormElement> & {
88
88
  collapseBelow?: BreakpointProp | false;
89
89
  columns?: ResponsiveGridColumnsProp;
90
90
  density?: DensityProp;
91
+ /**
92
+ * Server validation error bag (e.g. Inertia's `form.errors`). Each `FormField name="…"` inside
93
+ * resolves its own message from the bag automatically and CLAIMS its key; `<FormErrors />`
94
+ * renders the remaining, unclaimed entries — errors attached to hidden/derived fields that no
95
+ * visible field displays. Works in both the `<form>` and `asChild` modes. A Form WITHOUT this
96
+ * prop joins a surrounding `FormErrorsProvider` instead (sibling Card+Form sections sharing one
97
+ * bag); a Form WITH it starts its own (shadowing) registry.
98
+ */
99
+ errors?: ErrorBagProp;
91
100
  /**
92
101
  * Render the caller's own element instead of a `<form>`, keeping only the layout context.
93
102
  * For routing libraries that own the form element (Inertia, TanStack Form) — two `<form>`
@@ -110,6 +119,13 @@ export type FormProp = React.FormHTMLAttributes<HTMLFormElement> & {
110
119
  export type FormFieldProp = {
111
120
  /** Optional — auto-generated and injected into the child control when omitted. */
112
121
  id?: IdProp;
122
+ /**
123
+ * Error-bag key of this field. When the surrounding `Form` carries `errors`, the field
124
+ * resolves its message from `errors[name]` automatically (an explicit `error` prop wins)
125
+ * and CLAIMS the key so `<FormErrors />` does not repeat it. Not injected into the child —
126
+ * pass `name` on the control itself for native form submission.
127
+ */
128
+ name?: NameProp;
113
129
  label: LabelProp;
114
130
  required?: RequiredProp;
115
131
  helper?: HelperProp;
@@ -130,6 +146,12 @@ export type FormFieldProp = {
130
146
  } | {
131
147
  /** Optional — auto-generated and injected into the child control when omitted. */
132
148
  id?: IdProp;
149
+ /**
150
+ * Error-bag key of this field. When the surrounding `Form` carries `errors`, the field
151
+ * resolves its message from `errors[name]` automatically (an explicit `error` prop wins)
152
+ * and CLAIMS the key so `<FormErrors />` does not repeat it.
153
+ */
154
+ name?: NameProp;
133
155
  label: LabelProp;
134
156
  required?: RequiredProp;
135
157
  helper?: HelperProp;
@@ -149,6 +171,37 @@ export type FormFieldProp = {
149
171
  /** Read-only value — renders as `Descriptions.Item`-matched text instead of a control. */
150
172
  staticText: React.ReactNode;
151
173
  };
174
+ /**
175
+ * @see FormErrors — the "no field to stand on" error summary. Renders the entries of the
176
+ * surrounding `Form`'s error bag that no mounted `FormField name="…"` has claimed — validation
177
+ * errors attached to hidden/derived fields (`action_mode`, `page`, a source-record id…) that
178
+ * would otherwise fail silently. Renders nothing while every error is claimed or the bag is empty.
179
+ */
180
+ export type FormErrorsProp = {
181
+ /**
182
+ * Explicit error bag — overrides the surrounding `Form errors`. Use it when the component sits
183
+ * outside a `Form` (e.g. inside `FormRoot`); field claiming still applies when a `Form` provides
184
+ * the registry.
185
+ */
186
+ errors?: ErrorBagProp;
187
+ /** Heading above the messages. Defaults to the localized "please review your input" title. */
188
+ title?: TitleProp;
189
+ className?: ClassNameProp;
190
+ };
191
+ /**
192
+ * @see FormErrorsProvider — one shared error registry over a REGION of sibling Forms. An edit
193
+ * screen split into several Card+Form sections shares a single server bag: wrap the sections in
194
+ * this provider instead of passing `errors` to each Form, and every `FormField name="…"` inside
195
+ * (Forms without their own `errors` join the surrounding registry) claims into the same registry,
196
+ * so one `<FormErrors />` anywhere in the region renders exactly the unclaimed remainder.
197
+ * `Form errors={…}` renders this provider itself — a Form WITH its own `errors` starts a new
198
+ * (shadowing) registry.
199
+ */
200
+ export type FormErrorsProviderProp = {
201
+ /** Server validation error bag shared by every Form/FormField in the region. */
202
+ errors?: ErrorBagProp;
203
+ children?: React.ReactNode;
204
+ };
152
205
  /** @see SearchInput */
153
206
  export type SearchInputProp = FieldA11yProps & {
154
207
  id?: IdProp;
@@ -467,6 +520,15 @@ export type UploadProp = FieldA11yProps & {
467
520
  }>;
468
521
  /** Injected by FormField (or set directly) — applied to the native `<input type="file">`. */
469
522
  id?: IdProp;
523
+ /**
524
+ * `variant="button"` only — the size of the visible trigger, forwarded to
525
+ * Button. An icon size renders the trigger icon-only and moves the label to
526
+ * `aria-label`, which is what a toolbar wants: a 32px square beside the other
527
+ * icon buttons rather than a 147px labelled one that outweighs them.
528
+ *
529
+ * @see Button — same size scale
530
+ */
531
+ triggerSize?: "default" | "md" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg";
470
532
  className?: ClassNameProp;
471
533
  children?: React.ReactNode;
472
534
  };
@@ -69,6 +69,11 @@ export declare const VOCABULARY_REGISTRY: {
69
69
  readonly category: "shared";
70
70
  readonly description: "Validation error message";
71
71
  };
72
+ readonly ErrorBagProp: {
73
+ readonly file: "vocabulary/shared.prop.ts";
74
+ readonly category: "shared";
75
+ readonly description: "Server validation error bag keyed by field name (Laravel/Inertia errors)";
76
+ };
72
77
  readonly PlaceholderProp: {
73
78
  readonly file: "vocabulary/shared.prop.ts";
74
79
  readonly category: "shared";
@@ -863,12 +868,22 @@ export declare const COMPONENT_PROP_REGISTRY: {
863
868
  readonly FormProp: {
864
869
  readonly group: "data-entry";
865
870
  readonly file: "components/data-entry.prop.ts";
866
- readonly vocabulary: readonly ["FormLayoutProp", "WidthProp", "BreakpointProp", "DensityProp"];
871
+ readonly vocabulary: readonly ["FormLayoutProp", "WidthProp", "BreakpointProp", "DensityProp", "ErrorBagProp"];
867
872
  };
868
873
  readonly FormFieldProp: {
869
874
  readonly group: "data-entry";
870
875
  readonly file: "components/data-entry.prop.ts";
871
- readonly vocabulary: readonly ["IdProp", "LabelProp", "RequiredProp", "HelperProp", "ErrorProp", "FormLayoutProp", "WidthProp"];
876
+ readonly vocabulary: readonly ["IdProp", "NameProp", "LabelProp", "RequiredProp", "HelperProp", "ErrorProp", "FormLayoutProp", "WidthProp"];
877
+ };
878
+ readonly FormErrorsProp: {
879
+ readonly group: "data-entry";
880
+ readonly file: "components/data-entry.prop.ts";
881
+ readonly vocabulary: readonly ["ErrorBagProp", "TitleProp", "ClassNameProp"];
882
+ };
883
+ readonly FormErrorsProviderProp: {
884
+ readonly group: "data-entry";
885
+ readonly file: "components/data-entry.prop.ts";
886
+ readonly vocabulary: readonly ["ErrorBagProp"];
872
887
  };
873
888
  readonly SearchInputProp: {
874
889
  readonly group: "data-entry";
@@ -65,6 +65,11 @@ const VOCABULARY_REGISTRY = {
65
65
  category: "shared",
66
66
  description: "Validation error message"
67
67
  },
68
+ ErrorBagProp: {
69
+ file: "vocabulary/shared.prop.ts",
70
+ category: "shared",
71
+ description: "Server validation error bag keyed by field name (Laravel/Inertia errors)"
72
+ },
68
73
  PlaceholderProp: {
69
74
  file: "vocabulary/shared.prop.ts",
70
75
  category: "shared",
@@ -959,13 +964,14 @@ const COMPONENT_PROP_REGISTRY = {
959
964
  FormProp: {
960
965
  group: "data-entry",
961
966
  file: "components/data-entry.prop.ts",
962
- vocabulary: ["FormLayoutProp", "WidthProp", "BreakpointProp", "DensityProp"]
967
+ vocabulary: ["FormLayoutProp", "WidthProp", "BreakpointProp", "DensityProp", "ErrorBagProp"]
963
968
  },
964
969
  FormFieldProp: {
965
970
  group: "data-entry",
966
971
  file: "components/data-entry.prop.ts",
967
972
  vocabulary: [
968
973
  "IdProp",
974
+ "NameProp",
969
975
  "LabelProp",
970
976
  "RequiredProp",
971
977
  "HelperProp",
@@ -974,6 +980,16 @@ const COMPONENT_PROP_REGISTRY = {
974
980
  "WidthProp"
975
981
  ]
976
982
  },
983
+ FormErrorsProp: {
984
+ group: "data-entry",
985
+ file: "components/data-entry.prop.ts",
986
+ vocabulary: ["ErrorBagProp", "TitleProp", "ClassNameProp"]
987
+ },
988
+ FormErrorsProviderProp: {
989
+ group: "data-entry",
990
+ file: "components/data-entry.prop.ts",
991
+ vocabulary: ["ErrorBagProp"]
992
+ },
977
993
  SearchInputProp: {
978
994
  group: "data-entry",
979
995
  file: "components/data-entry.prop.ts",
@@ -34,6 +34,18 @@ export type ColumnDefProp<T> = {
34
34
  */
35
35
  width?: string;
36
36
  align?: ColumnAlignProp;
37
+ /**
38
+ * Alignment of the header cell, when it differs from the body.
39
+ *
40
+ * Defaults to `align`, which is the usual case. It exists because a table
41
+ * can want centred headings over start-aligned text — a long subject reads
42
+ * from the left while its column heading sits over the column — and `align`
43
+ * alone cannot say that: setting it to `center` centres the rows too.
44
+ *
45
+ * Consumers were reaching for `[&_th_button]:justify-center` to get there,
46
+ * which is a hand-tuned override of a layout the table owns.
47
+ */
48
+ headerAlign?: ColumnAlignProp;
37
49
  hiddenOnMobile?: boolean;
38
50
  /**
39
51
  * List this column in DataTable.ViewOptions (the column show/hide "set view"
@@ -1,5 +1,5 @@
1
1
  /** Barrel — all vocabulary prop types. */
2
- export type { ClassNameProp, ChildrenProp, IdProp, OpenProp, DefaultOpenProp, OnOpenChangeProp, HandlerProp, PendingProp, RequiredProp, DisabledProp, LabelProp, HelperProp, ErrorProp, PlaceholderProp, NameProp, ValueProp, DefaultValueProp, OnValueChangeProp, OnChangeProp, OnClickProp, AsChildProp, WidthProp, } from "./shared.prop.js";
2
+ export type { ClassNameProp, ChildrenProp, IdProp, OpenProp, DefaultOpenProp, OnOpenChangeProp, HandlerProp, PendingProp, RequiredProp, DisabledProp, LabelProp, HelperProp, ErrorProp, ErrorBagProp, PlaceholderProp, NameProp, ValueProp, DefaultValueProp, OnValueChangeProp, OnChangeProp, OnClickProp, AsChildProp, WidthProp, } from "./shared.prop.js";
3
3
  export type { TitleProp, SubtitleProp, StatusProp, DescriptionProp, ExtraProp, FooterProp, ActionProp, IconProp, ConfirmLabelProp, CancelLabelProp, ActionsProp, EmptyMessageProp, } from "./content.prop.js";
4
4
  export type { PageDensityProp, PageContainerVariantProp, CenteredShellWidthProp, CenteredShellAlignProp, CenteredShellPresetProp, ErrorSurfaceModeProp, ErrorSurfaceStatusProp, AuthShellPresetProp, TableDensityProp, DensityProp, GapProp, } from "./layout.prop.js";
5
5
  export type { ButtonVariantProp, ButtonSizeProp, BadgeVariantProp, AppSettingPickerAppearanceProp, ShapeProp, AvatarShapeProp, TextSizeProp, TextToneProp, FontWeightProp, HeadingLevelProp, TextAlignProp, SizeProp, FormLayoutProp, BreakpointProp, ConfirmVariantProp, ToneProp, AlertVariantProp, SortDirectionProp, ColumnAlignProp, SortStateProp, RevealDelayProp, } from "./interaction.prop.js";
@@ -29,6 +29,11 @@ export type LabelProp = React.ReactNode;
29
29
  export type HelperProp = React.ReactNode;
30
30
  /** Validation error message. */
31
31
  export type ErrorProp = React.ReactNode;
32
+ /**
33
+ * Server validation error bag, keyed by field name — the shape Laravel hands Inertia
34
+ * (`errors: { field: "message" }`) or a JSON API returns (`{ field: ["m1", "m2"] }`).
35
+ */
36
+ export type ErrorBagProp = Partial<Record<string, string | string[]>>;
32
37
  /** Placeholder text for inputs. */
33
38
  export type PlaceholderProp = string;
34
39
  /** HTML input `name` attribute. */
@@ -33,6 +33,16 @@
33
33
  margin-block-start: var(--form-field-row-gap);
34
34
  }
35
35
 
36
+ /* …except when the fields are ResponsiveGrid ITEMS (Form columns={n}): there the grid's own
37
+ * `gap` owns the rhythm, and a per-item margin double-spaces every row AND breaks column
38
+ * alignment — the FIRST field has no preceding sibling so no margin, while its row-mate does,
39
+ * shifting row 1's columns 11px apart and inflating every grid track by the margin. Fields
40
+ * merely NESTED somewhere inside a grid cell (a stacked pair in one cell) are not direct
41
+ * children, keep the margin, and are unaffected. */
42
+ .ui-responsive-grid > .ui-form-field + .ui-form-field {
43
+ margin-block-start: 0;
44
+ }
45
+
36
46
  /* Vertical (default + collapsed state): label stacked above the control column, LEFT-aligned.
37
47
  * Gap = the shared --field-label-gap so FormField matches Descriptions and every label-above stack. */
38
48
  .ui-form-field {
@@ -384,6 +384,17 @@
384
384
  }
385
385
  }
386
386
 
387
+ /* `lg` splits later than the other two ON PURPOSE. A rail is only useful when
388
+ * the column it sits beside still reads as the main one, and at the 48rem
389
+ * threshold a 30rem rail would leave 16.5rem of main — narrower than the rail
390
+ * itself, which inverts the whole point. 64rem leaves the main column 33.5rem,
391
+ * about twice the rail, so the page still reads main-plus-rail. */
392
+ @container split-pane (min-width: 64rem) {
393
+ .ui-split-pane[data-aside-width="lg"] {
394
+ grid-template-columns: minmax(0, 1fr) 30rem;
395
+ }
396
+ }
397
+
387
398
  .ui-split-pane-main,
388
399
  .ui-split-pane-aside {
389
400
  min-width: 0;
@@ -169,6 +169,44 @@
169
169
  gap: var(--space-inline-xs);
170
170
  }
171
171
 
172
+ /* A centred header centres its LABEL, not its button.
173
+ *
174
+ * The sort chevron lives inside the label, so centring the button alone
175
+ * leaves the words half an icon to the left of the column they name —
176
+ * measured at 8px on a 12px icon, which is enough to read as a mistake in a
177
+ * row of headings.
178
+ *
179
+ * The fix is symmetry, not a counterweight. Reserving the chevron's width on
180
+ * BOTH sides and taking the chevron itself out of flow means the words are
181
+ * centred by construction — the label's own box is centred in the cell, and
182
+ * nothing inside it can shift them. A counterweight sitting in flow as a
183
+ * flex sibling was the first attempt and it holds only while there is room
184
+ * to spare: flex items shrink, so on a column narrow enough to squeeze them
185
+ * the balance collapses and the label slides back off centre. Measured at
186
+ * 8px on a 56px column, 0px on the wide ones — a bug that only appears in
187
+ * the narrow case is worse than one that appears everywhere.
188
+ *
189
+ * Only when the column asked to be centred: a start-aligned header wants the
190
+ * icon to follow the text with nothing before it. */
191
+ :is(th, td).text-center > .ui-data-table-sort-button > .ui-data-table-sort-label {
192
+ position: relative;
193
+ /* The icon's width plus the gap it would have sat behind — the space the
194
+ * chevron occupied when it was in flow, now reserved on each side. */
195
+ padding-inline: calc(0.75rem + var(--space-inline-xs));
196
+ }
197
+
198
+ /* Out of flow, in the inline-end reservation its own padding just made.
199
+ * Logical inset, so RTL puts it on the other side with the padding. */
200
+ :is(th, td).text-center
201
+ > .ui-data-table-sort-button
202
+ > .ui-data-table-sort-label
203
+ > :not(:first-child) {
204
+ position: absolute;
205
+ inset-inline-end: 0;
206
+ top: 50%;
207
+ transform: translateY(-50%);
208
+ }
209
+
172
210
  .ui-data-table-scroll {
173
211
  position: relative;
174
212
  overflow-x: auto;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "18.13.1",
4
- "godxUiMcp": "18.13.1",
3
+ "version": "18.15.0",
4
+ "godxUiMcp": "18.15.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",