@godxjp/ui 27.0.0 → 27.1.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.
@@ -17,6 +17,11 @@ interface FormErrorsRegistryValue {
17
17
  export declare function useFormErrorsRegistry(): FormErrorsRegistryValue | null;
18
18
  /** First message of a bag entry — Laravel arrays surface their first message on the field. */
19
19
  export declare function firstBagMessage(entry: string | string[] | undefined): string | undefined;
20
+ /**
21
+ * `true` when an error bag holds at least one non-empty message. A bag with no keys, or keys whose
22
+ * entries are empty (`{ code: [] }`), shows nothing anywhere — so it never counts as "displayed".
23
+ */
24
+ export declare function errorBagHasMessage(errors: ErrorBagProp | undefined): boolean;
20
25
  /**
21
26
  * FormErrorsProvider — one shared error registry (error bag + claim set) over a region. 兄弟 Form 群で
22
27
  * 1 つのエラーバッグを共有するための公開プロバイダ。Two ways to get one: 1. `Form errors={…}` renders this provider itself
@@ -12,6 +12,11 @@ function firstBagMessage(entry) {
12
12
  const message = Array.isArray(entry) ? entry[0] : entry;
13
13
  return message ? message : void 0;
14
14
  }
15
+ function errorBagHasMessage(errors) {
16
+ return Object.values(errors ?? {}).some(
17
+ (entry) => Array.isArray(entry) ? entry.some(Boolean) : Boolean(entry)
18
+ );
19
+ }
15
20
  function FormErrorsProvider({ errors, children }) {
16
21
  const [claimed, setClaimed] = React.useState(/* @__PURE__ */ new Map());
17
22
  const claim = React.useCallback((key) => {
@@ -65,6 +70,7 @@ function FormErrors({ errors: errorsProp, title, className }) {
65
70
  export {
66
71
  FormErrors,
67
72
  FormErrorsProvider,
73
+ errorBagHasMessage,
68
74
  firstBagMessage,
69
75
  useClaimErrorKey,
70
76
  useFormErrorsRegistry
@@ -12,14 +12,15 @@ interface OverlayOpenProps {
12
12
  }
13
13
  interface DialogRootProps extends OverlayOpenProps, Pick<DialogProp, "variant"> {
14
14
  /**
15
- * Giữ tên prop của Radix. RAC không có kiểu overlay không-modal: `Modal` LUÔN khoá cuộn và ẩn
16
- * nền khỏi trình đọc màn hình, nên `modal={false}` không còn tắt được điều đó.
15
+ * `false` = hộp thoại không-modal (WAI-ARIA APG cho phép): trang phía sau vẫn tương tác được và
16
+ * vẫn trong cây trợ năng, không khoá cuộn, không màn nền, bấm ra ngoài không đóng; tiêu điểm vào
17
+ * hộp thoại khi mở, Tab ra được, Esc (tiêu điểm ở trong) đóng. Xem đầu tệp, mục gh#696.
17
18
  */
18
19
  modal?: boolean;
19
20
  children?: React.ReactNode;
20
21
  }
21
22
  /** `data-slot="dialog"` cũ nằm trên Root của Radix, thứ không dựng thẻ nào — nó chưa từng ra DOM. */
22
- declare function DialogRoot({ children, modal: _modal, variant, ...openProps }: DialogRootProps): React.JSX.Element;
23
+ declare function DialogRoot({ children, modal, variant, ...openProps }: DialogRootProps): React.JSX.Element;
23
24
  interface DialogTriggerProps extends React.ComponentPropsWithRef<"button"> {
24
25
  /** Mượn thẻ của con thay vì dựng `<button>` riêng. */
25
26
  asChild?: boolean;
@@ -6,9 +6,11 @@ import { chain, mergeRefs } from "@react-aria/utils";
6
6
  import { Dialog as RacDialog, Modal, ModalOverlay } from "react-aria-components";
7
7
  import { AlertCircle, X } from "lucide-react";
8
8
  import { cn } from "../../lib/utils.js";
9
+ import { isDevelopment } from "../../lib/dev.js";
9
10
  import { Slot } from "../../lib/slot.js";
10
11
  import { overlayHeaderToneClass } from "./overlay-header-tone.js";
11
12
  import { useOverlayCloseFocus } from "./overlay-close-focus.js";
13
+ import { closeOnEscape, NonModalPortal, useNonModalPortal } from "./non-modal-layer.js";
12
14
  import { buttonVariants } from "../general/button.js";
13
15
  import { useTranslation } from "../../i18n/use-translation.js";
14
16
  import { Button } from "../general/button.js";
@@ -41,6 +43,7 @@ function useDialogOpenState(component) {
41
43
  }
42
44
  const DialogLabelContext = React.createContext(null);
43
45
  const DialogVariantContext = React.createContext("default");
46
+ const DialogModalContext = React.createContext(true);
44
47
  const OVERLAY_CLASS = "ui-dialog-overlay data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0";
45
48
  const CONTENT_CLASS = "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 duration-200 outline-none";
46
49
  function DialogShell({
@@ -59,8 +62,47 @@ function DialogShell({
59
62
  const descriptionId = React.useId();
60
63
  const labels = React.useMemo(() => ({ titleId, descriptionId }), [titleId, descriptionId]);
61
64
  const dataState = state.isOpen ? "open" : "closed";
62
- useOverlayCloseFocus(state.isOpen, onCloseAutoFocus);
65
+ const requestedModal = React.useContext(DialogModalContext);
66
+ const modal = requestedModal || role === "alertdialog";
67
+ const ignoresNonModal = !requestedModal && role === "alertdialog";
68
+ React.useEffect(() => {
69
+ if (ignoresNonModal && isDevelopment()) {
70
+ console.warn(
71
+ '[godxjp-ui] Dialog: `modal={false}` is ignored for `variant="destructive"` \u2014 an alertdialog is always modal (WAI-ARIA APG).'
72
+ );
73
+ }
74
+ }, [ignoresNonModal]);
75
+ const { contentRef, isMounted } = useNonModalPortal(state.isOpen, !modal);
76
+ useOverlayCloseFocus(state.isOpen, onCloseAutoFocus, modal ? void 0 : contentRef);
63
77
  const overlayPortalContainer = useOverlayPortalContainer();
78
+ const dialog = /* @__PURE__ */ jsx(
79
+ RacDialog,
80
+ {
81
+ role,
82
+ "aria-labelledby": props["aria-labelledby"] ?? titleId,
83
+ "aria-describedby": props["aria-describedby"] ?? descriptionId,
84
+ "data-slot": "dialog-content",
85
+ "data-state": dataState,
86
+ className: cn(CONTENT_CLASS, className),
87
+ render: (racProps) => {
88
+ const { "data-rac": _rac, ref: racRef, ...rest } = racProps;
89
+ return modal ? /* @__PURE__ */ jsx("section", { ...rest, ...props, style, ref: mergeRefs(ref, racRef) }) : /* @__PURE__ */ jsx(
90
+ "section",
91
+ {
92
+ ...rest,
93
+ ...props,
94
+ style,
95
+ ref: mergeRefs(ref, racRef, contentRef),
96
+ onKeyDown: chain(props.onKeyDown, closeOnEscape(() => state.setOpen(false)))
97
+ }
98
+ );
99
+ },
100
+ children: /* @__PURE__ */ jsx(DialogLabelContext.Provider, { value: labels, children })
101
+ }
102
+ );
103
+ if (!modal) {
104
+ return isMounted ? /* @__PURE__ */ jsx(NonModalPortal, { container: overlayPortalContainer, children: dialog }) : null;
105
+ }
64
106
  return /* @__PURE__ */ jsx(
65
107
  ModalOverlay,
66
108
  {
@@ -71,33 +113,18 @@ function DialogShell({
71
113
  "data-slot": "dialog-overlay",
72
114
  "data-state": dataState,
73
115
  className: cn(OVERLAY_CLASS, overlayClassName),
74
- children: /* @__PURE__ */ jsx(Modal, { className: "contents", children: /* @__PURE__ */ jsx(
75
- RacDialog,
76
- {
77
- role,
78
- "aria-labelledby": props["aria-labelledby"] ?? titleId,
79
- "aria-describedby": props["aria-describedby"] ?? descriptionId,
80
- "data-slot": "dialog-content",
81
- "data-state": dataState,
82
- className: cn(CONTENT_CLASS, className),
83
- render: (racProps) => {
84
- const { "data-rac": _rac, ref: racRef, ...rest } = racProps;
85
- return /* @__PURE__ */ jsx("section", { ...rest, ...props, style, ref: mergeRefs(ref, racRef) });
86
- },
87
- children: /* @__PURE__ */ jsx(DialogLabelContext.Provider, { value: labels, children })
88
- }
89
- ) })
116
+ children: /* @__PURE__ */ jsx(Modal, { className: "contents", children: dialog })
90
117
  }
91
118
  );
92
119
  }
93
120
  function DialogRoot({
94
121
  children,
95
- modal: _modal,
122
+ modal = true,
96
123
  variant = "default",
97
124
  ...openProps
98
125
  }) {
99
126
  const state = useOverlayOpenState(openProps);
100
- return /* @__PURE__ */ jsx(DialogOpenContext.Provider, { value: state, children: /* @__PURE__ */ jsx(DialogVariantContext.Provider, { value: variant, children }) });
127
+ return /* @__PURE__ */ jsx(DialogOpenContext.Provider, { value: state, children: /* @__PURE__ */ jsx(DialogModalContext.Provider, { value: modal, children: /* @__PURE__ */ jsx(DialogVariantContext.Provider, { value: variant, children }) }) });
101
128
  }
102
129
  function DialogTrigger({ asChild, onClick, ...props }) {
103
130
  const state = useDialogOpenState("DialogTrigger");
@@ -0,0 +1,16 @@
1
+ import * as React from "react";
2
+ /**
3
+ * Trạng thái dựng của nhánh non-modal. `enabled=false` (nhánh modal) thì không làm gì: `contentRef`
4
+ * không được gắn, và `ModalOverlay` tự lo hoạt ảnh thoát.
5
+ */
6
+ export declare function useNonModalPortal(isOpen: boolean, enabled: boolean): {
7
+ contentRef: React.RefObject<HTMLElement | null>;
8
+ isMounted: boolean;
9
+ };
10
+ /** `onKeyDown` cho thẻ dialog non-modal: Esc (tiêu điểm ở trong) đóng. */
11
+ export declare function closeOnEscape(close: () => void): (event: React.KeyboardEvent<HTMLElement>) => void;
12
+ /** Portal + `FocusScope autoFocus restoreFocus` (không `contain`) quanh bề mặt non-modal. */
13
+ export declare function NonModalPortal({ container, children, }: {
14
+ container: Element | undefined;
15
+ children: React.ReactNode;
16
+ }): React.ReactPortal | null;
@@ -0,0 +1,37 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { useExitAnimation } from "@react-aria/utils";
6
+ import { FocusScope } from "react-aria";
7
+ function useNonModalPortal(isOpen, enabled) {
8
+ const contentRef = React.useRef(null);
9
+ const isExiting = useExitAnimation(contentRef, enabled && isOpen);
10
+ return { contentRef, isMounted: isOpen || isExiting };
11
+ }
12
+ function closeOnEscape(close) {
13
+ return (event) => {
14
+ if (event.key === "Escape" && !event.defaultPrevented && !event.nativeEvent.isComposing) {
15
+ event.preventDefault();
16
+ event.stopPropagation();
17
+ close();
18
+ }
19
+ };
20
+ }
21
+ function NonModalPortal({
22
+ container,
23
+ children
24
+ }) {
25
+ if (typeof document === "undefined") {
26
+ return null;
27
+ }
28
+ return createPortal(
29
+ /* @__PURE__ */ jsx(FocusScope, { autoFocus: true, restoreFocus: true, children }),
30
+ container ?? document.body
31
+ );
32
+ }
33
+ export {
34
+ NonModalPortal,
35
+ closeOnEscape,
36
+ useNonModalPortal
37
+ };
@@ -19,13 +19,16 @@ export interface SheetProps {
19
19
  /** Gọi khi trạng thái mở đổi. */
20
20
  onOpenChange?: (open: boolean) => void;
21
21
  /**
22
- * Giữ tên prop của Radix. RAC không có kiểu overlay không-modal: `Modal` LUÔN khoá cuộn và ẩn
23
- * nền khỏi trình đọc màn hình, nên `modal={false}` không còn tắt được điều đó.
22
+ * `false` renders a NON-MODAL sheet (WAI-ARIA APG allows non-modal dialogs): the page behind
23
+ * stays interactive and in the accessibility tree, no scroll lock, no scrim, an outside press
24
+ * does not close it, no `aria-modal`. Focus moves in on open, Tab can leave, Escape closes while
25
+ * focus is inside, focus returns to the trigger. Side, size and tokens are unchanged. Default
26
+ * `true`. gh#701.
24
27
  */
25
28
  modal?: boolean;
26
29
  children?: React.ReactNode;
27
30
  }
28
- export declare function Sheet({ open, defaultOpen, onOpenChange, modal: _modal, children }: SheetProps): React.JSX.Element;
31
+ export declare function Sheet({ open, defaultOpen, onOpenChange, modal, children, }: SheetProps): React.JSX.Element;
29
32
  export interface SheetTriggerProps extends React.ComponentPropsWithRef<"button"> {
30
33
  /** Mượn thẻ của con thay vì dựng `<button>` riêng. */
31
34
  asChild?: boolean;
@@ -74,7 +77,7 @@ export interface SheetContentProps extends React.ComponentPropsWithRef<"section"
74
77
  */
75
78
  responsive?: SheetResponsiveProp;
76
79
  }
77
- export declare function SheetContent({ side, className, children, showCloseButton, overlayClassName, forceMount: _forceMount, width, responsive, style, ref, ...props }: SheetContentProps): React.JSX.Element;
80
+ export declare function SheetContent({ side, className, children, showCloseButton, overlayClassName, forceMount: _forceMount, width, responsive, style, ref, ...props }: SheetContentProps): React.JSX.Element | null;
78
81
  export declare namespace SheetContent {
79
82
  var displayName: string;
80
83
  }
@@ -11,6 +11,7 @@ import { cn } from "../../lib/utils.js";
11
11
  import { Slot } from "../../lib/slot.js";
12
12
  import { overlayHeaderToneClass } from "./overlay-header-tone.js";
13
13
  import { useOverlayCloseFocus } from "./overlay-close-focus.js";
14
+ import { closeOnEscape, NonModalPortal, useNonModalPortal } from "./non-modal-layer.js";
14
15
  import { useTranslation } from "../../i18n/use-translation.js";
15
16
  const toCssLength = (v) => typeof v === "number" ? `${v}px` : v;
16
17
  const SHEET_BREAKPOINT_TOKEN = "--sheet-responsive-breakpoint-width";
@@ -28,8 +29,15 @@ function useSheetOpenState(component) {
28
29
  }
29
30
  return state;
30
31
  }
32
+ const SheetModalContext = React.createContext(true);
31
33
  const SheetLabelContext = React.createContext(null);
32
- function Sheet({ open, defaultOpen, onOpenChange, modal: _modal, children }) {
34
+ function Sheet({
35
+ open,
36
+ defaultOpen,
37
+ onOpenChange,
38
+ modal = true,
39
+ children
40
+ }) {
33
41
  const [uncontrolled, setUncontrolled] = React.useState(defaultOpen ?? false);
34
42
  const isOpen = open ?? uncontrolled;
35
43
  const setOpen = React.useCallback(
@@ -45,7 +53,7 @@ function Sheet({ open, defaultOpen, onOpenChange, modal: _modal, children }) {
45
53
  [isOpen, open, onOpenChange]
46
54
  );
47
55
  const state = React.useMemo(() => ({ isOpen, setOpen }), [isOpen, setOpen]);
48
- return /* @__PURE__ */ jsx(SheetOpenContext.Provider, { value: state, children });
56
+ return /* @__PURE__ */ jsx(SheetOpenContext.Provider, { value: state, children: /* @__PURE__ */ jsx(SheetModalContext.Provider, { value: modal, children }) });
49
57
  }
50
58
  function SheetTrigger({ asChild, onClick, ...props }) {
51
59
  const state = useSheetOpenState("SheetTrigger");
@@ -132,8 +140,59 @@ function SheetContent({
132
140
  const widthSet = width != null && horizontal;
133
141
  const mergedStyle = widthSet ? { ...style, ["--sheet-width"]: toCssLength(width) } : style;
134
142
  const dataState = state.isOpen ? "open" : "closed";
135
- useOverlayCloseFocus(state.isOpen);
143
+ const modal = React.useContext(SheetModalContext);
144
+ const { contentRef, isMounted } = useNonModalPortal(state.isOpen, !modal);
145
+ useOverlayCloseFocus(state.isOpen, void 0, modal ? void 0 : contentRef);
136
146
  const overlayPortalContainer = useOverlayPortalContainer();
147
+ const dialog = /* @__PURE__ */ jsx(
148
+ RacDialog,
149
+ {
150
+ "aria-labelledby": props["aria-labelledby"] ?? titleId,
151
+ "aria-describedby": props["aria-describedby"] ?? descriptionId,
152
+ "data-slot": "sheet-content",
153
+ "data-state": dataState,
154
+ "data-responsive": responsive,
155
+ "data-side": resolvedSide,
156
+ className: cn(
157
+ sheetVariants({ side: resolvedSide }),
158
+ // `width` caps at the viewport: full-width panel on a small screen, capped on a large one.
159
+ widthSet && "w-[min(var(--sheet-width),100%)] max-w-none sm:max-w-none",
160
+ // Only the RESPONSIVE bottom presentation is capped — a plain `side="bottom"` sheet keeps
161
+ // its content-sized height so existing usage is untouched.
162
+ bottomSheet && "max-h-[var(--sheet-bottom-max-height)]",
163
+ className
164
+ ),
165
+ render: (racProps) => {
166
+ const { "data-rac": _rac, ref: racRef, ...rest } = racProps;
167
+ return modal ? /* @__PURE__ */ jsx("section", { ...rest, ...props, style: mergedStyle, ref: mergeRefs(ref, racRef) }) : /* @__PURE__ */ jsx(
168
+ "section",
169
+ {
170
+ ...rest,
171
+ ...props,
172
+ style: mergedStyle,
173
+ ref: mergeRefs(ref, racRef, contentRef),
174
+ onKeyDown: chain(props.onKeyDown, closeOnEscape(() => state.setOpen(false)))
175
+ }
176
+ );
177
+ },
178
+ children: /* @__PURE__ */ jsxs(SheetLabelContext.Provider, { value: labels, children: [
179
+ children,
180
+ showCloseButton ? /* @__PURE__ */ jsxs(
181
+ SheetClose,
182
+ {
183
+ className: "ui-sheet-close ui-focus-ring disabled:pointer-events-none",
184
+ children: [
185
+ /* @__PURE__ */ jsx(X, { className: "ui-sheet-close-icon", "aria-hidden": "true" }),
186
+ /* @__PURE__ */ jsx("span", { className: "sr-only", children: t("feedback.alert.dismiss") })
187
+ ]
188
+ }
189
+ ) : null
190
+ ] })
191
+ }
192
+ );
193
+ if (!modal) {
194
+ return isMounted ? /* @__PURE__ */ jsx(NonModalPortal, { container: overlayPortalContainer, children: dialog }) : null;
195
+ }
137
196
  return /* @__PURE__ */ jsx(
138
197
  ModalOverlay,
139
198
  {
@@ -144,43 +203,7 @@ function SheetContent({
144
203
  "data-slot": "sheet-overlay",
145
204
  "data-state": dataState,
146
205
  className: cn(SHEET_OVERLAY_CLASS, overlayClassName),
147
- children: /* @__PURE__ */ jsx(Modal, { className: "contents", children: /* @__PURE__ */ jsx(
148
- RacDialog,
149
- {
150
- "aria-labelledby": props["aria-labelledby"] ?? titleId,
151
- "aria-describedby": props["aria-describedby"] ?? descriptionId,
152
- "data-slot": "sheet-content",
153
- "data-state": dataState,
154
- "data-responsive": responsive,
155
- "data-side": resolvedSide,
156
- className: cn(
157
- sheetVariants({ side: resolvedSide }),
158
- // `width` caps at the viewport: full-width panel on a small screen, capped on a large one.
159
- widthSet && "w-[min(var(--sheet-width),100%)] max-w-none sm:max-w-none",
160
- // Only the RESPONSIVE bottom presentation is capped — a plain `side="bottom"` sheet keeps
161
- // its content-sized height so existing usage is untouched.
162
- bottomSheet && "max-h-[var(--sheet-bottom-max-height)]",
163
- className
164
- ),
165
- render: (racProps) => {
166
- const { "data-rac": _rac, ref: racRef, ...rest } = racProps;
167
- return /* @__PURE__ */ jsx("section", { ...rest, ...props, style: mergedStyle, ref: mergeRefs(ref, racRef) });
168
- },
169
- children: /* @__PURE__ */ jsxs(SheetLabelContext.Provider, { value: labels, children: [
170
- children,
171
- showCloseButton ? /* @__PURE__ */ jsxs(
172
- SheetClose,
173
- {
174
- className: "ui-sheet-close ui-focus-ring disabled:pointer-events-none",
175
- children: [
176
- /* @__PURE__ */ jsx(X, { className: "ui-sheet-close-icon", "aria-hidden": "true" }),
177
- /* @__PURE__ */ jsx("span", { className: "sr-only", children: t("feedback.alert.dismiss") })
178
- ]
179
- }
180
- ) : null
181
- ] })
182
- }
183
- ) })
206
+ children: /* @__PURE__ */ jsx(Modal, { className: "contents", children: dialog })
184
207
  }
185
208
  );
186
209
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { Fragment, jsx } from "react/jsx-runtime";
3
3
  import { AlertQueryError } from "../feedback/alert.js";
4
- import { useFormErrorsRegistry } from "../data-entry/form-errors.js";
4
+ import { errorBagHasMessage, useFormErrorsRegistry } from "../data-entry/form-errors.js";
5
5
  import { classifyQueryError } from "../../lib/query-error.js";
6
6
  function AlertMutationFeedback({
7
7
  mutation,
@@ -12,9 +12,7 @@ function AlertMutationFeedback({
12
12
  className
13
13
  }) {
14
14
  const registry = useFormErrorsRegistry();
15
- const bagShowsErrors = registry !== null && Object.values(registry.errors).some(
16
- (entry) => Array.isArray(entry) ? entry.some(Boolean) : Boolean(entry)
17
- );
15
+ const bagShowsErrors = registry !== null && errorBagHasMessage(registry.errors);
18
16
  if (mutation.isPending && pending) return /* @__PURE__ */ jsx(Fragment, { children: pending });
19
17
  if (!mutation.isError || mutation.error == null) return null;
20
18
  if ((ignoreValidationErrors ?? bagShowsErrors) && classifyQueryError(mutation.error).category === "validation") {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "27.0.0",
3
+ "version": "27.1.0",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -7,4 +7,4 @@ export type { FormRootProp } from "../props/components/form.prop.js";
7
7
  * validation) OR `adapter` (a framework-agnostic {@link FormStateAdapter} for a server-driven form
8
8
  * library — Inertia's `useForm` via `@godxjp/ui/inertia`, formik, TanStack Form).
9
9
  */
10
- export declare function FormRoot<TFieldValues extends FieldValues>({ form, adapter, onSubmit, onSubmitFailed, onSubmitError, onReset, scrollToFirstError, disabled, layout, labelWidth, controlWidth, labelAlign, collapseBelow, density, columns, requiredMark, errors, children, className, id, }: FormRootProp<TFieldValues>): React.JSX.Element;
10
+ export declare function FormRoot<TFieldValues extends FieldValues>({ form, adapter, onSubmit, onSubmitFailed, onSubmitError, submitFailedMessage, onReset, scrollToFirstError, disabled, layout, labelWidth, controlWidth, labelAlign, collapseBelow, density, columns, requiredMark, errors, children, className, id, }: FormRootProp<TFieldValues>): React.JSX.Element;
@@ -5,8 +5,10 @@ import { FormProvider } from "react-hook-form";
5
5
  import { Alert, AlertDescription } from "../components/feedback/alert.js";
6
6
  import { ResponsiveGrid } from "../components/layout/responsive-grid.js";
7
7
  import { useTranslation } from "../i18n/use-translation.js";
8
+ import { classifyQueryError } from "../lib/query-error.js";
8
9
  import { cn } from "../lib/utils.js";
9
10
  import { Form } from "../components/data-entry/form.js";
11
+ import { errorBagHasMessage } from "../components/data-entry/form-errors.js";
10
12
  import { FormAdapterContext, FormOptionsContext } from "./form-context.js";
11
13
  function collectErrorNames(errors, prefix = "", out = []) {
12
14
  if (!errors || typeof errors !== "object") return out;
@@ -43,6 +45,7 @@ function FormRoot({
43
45
  onSubmit,
44
46
  onSubmitFailed,
45
47
  onSubmitError,
48
+ submitFailedMessage,
46
49
  onReset,
47
50
  scrollToFirstError = true,
48
51
  disabled = false,
@@ -63,21 +66,37 @@ function FormRoot({
63
66
  const { t } = useTranslation();
64
67
  const pending = React.useRef(false);
65
68
  const [submitting, setSubmitting] = React.useState(false);
66
- const [submitFailed, setSubmitFailed] = React.useState(false);
69
+ const [submitFailure, setSubmitFailure] = React.useState(null);
67
70
  const options = React.useMemo(() => ({ disabled, submitting }), [disabled, submitting]);
71
+ const fieldsShowFailure = submitFailure?.validation === true && errorBagHasMessage(errors);
72
+ React.useEffect(() => {
73
+ if (!fieldsShowFailure) return;
74
+ setSubmitFailure(
75
+ (current) => current && !current.shownByFields ? { ...current, shownByFields: true } : current
76
+ );
77
+ }, [fieldsShowFailure]);
78
+ const showSubmitFailed = submitFailure !== null && submitFailure.settled && !submitFailure.shownByFields && !fieldsShowFailure && submitFailedMessage !== false;
68
79
  const content = /* @__PURE__ */ jsxs(Fragment, { children: [
69
- submitFailed && /* @__PURE__ */ jsx(Alert, { tone: "destructive", children: /* @__PURE__ */ jsx(AlertDescription, { children: t("dataEntry.form.submitFailed") }) }),
80
+ showSubmitFailed && /* @__PURE__ */ jsx(Alert, { tone: "destructive", children: /* @__PURE__ */ jsx(AlertDescription, { children: submitFailedMessage ?? t("dataEntry.form.submitFailed") }) }),
70
81
  columns ? /* @__PURE__ */ jsx(ResponsiveGrid, { columns, children }) : children
71
82
  ] });
72
83
  const submit = async (work) => {
73
84
  if (disabled || pending.current || adapter?.isSubmitting) return;
74
85
  pending.current = true;
75
86
  setSubmitting(true);
76
- setSubmitFailed(false);
87
+ setSubmitFailure(null);
77
88
  try {
78
89
  await work();
79
90
  } catch (error) {
80
- setSubmitFailed(true);
91
+ const validation = classifyQueryError(error).category === "validation";
92
+ setSubmitFailure({ error, validation, settled: !validation, shownByFields: false });
93
+ if (validation) {
94
+ setTimeout(
95
+ () => setSubmitFailure(
96
+ (current) => current && current.error === error ? { ...current, settled: true } : current
97
+ )
98
+ );
99
+ }
81
100
  onSubmitError?.(error);
82
101
  } finally {
83
102
  pending.current = false;
@@ -117,7 +136,7 @@ function FormRoot({
117
136
  onReset: (event) => {
118
137
  event.preventDefault();
119
138
  if (pending.current) return;
120
- setSubmitFailed(false);
139
+ setSubmitFailure(null);
121
140
  adapter.reset?.();
122
141
  onReset?.();
123
142
  },
@@ -151,7 +170,7 @@ function FormRoot({
151
170
  onReset: (event) => {
152
171
  event.preventDefault();
153
172
  if (pending.current) return;
154
- setSubmitFailed(false);
173
+ setSubmitFailure(null);
155
174
  form.reset();
156
175
  onReset?.();
157
176
  },
@@ -16,7 +16,13 @@ export type DialogProp = {
16
16
  defaultOpen?: DefaultOpenProp;
17
17
  onOpenChange?: OnOpenChangeProp;
18
18
  variant?: ConfirmVariantProp;
19
- /** Kept from Radix. RAC's `Modal` always locks scroll, so `false` no longer turns that off. */
19
+ /**
20
+ * `false` renders a NON-MODAL dialog (allowed by WAI-ARIA APG, Dialog (Modal) pattern): the page
21
+ * behind stays interactive and in the accessibility tree, no scroll lock, no scrim, an outside
22
+ * press does not close it, no `aria-modal`. Focus moves in on open, Tab can leave, Escape closes
23
+ * while focus is inside, focus returns to the trigger. Ignored (dev warning) for
24
+ * `variant="destructive"` — an alertdialog is always modal. Default `true`.
25
+ */
20
26
  modal?: boolean;
21
27
  children?: ChildrenProp;
22
28
  };
@@ -58,6 +58,18 @@ export type FormRootProp<TFieldValues extends FieldValues> = {
58
58
  * render a form-level error instead.
59
59
  */
60
60
  onSubmitError?: (error: unknown) => void;
61
+ /**
62
+ * The form-level banner shown when `onSubmit` rejects. Default: the localized
63
+ * `dataEntry.form.submitFailed` text. A node replaces that text; `false` never shows the banner.
64
+ *
65
+ * The banner is skipped by default for a validation rejection (`classifyQueryError` category
66
+ * `"validation"`: 400/422) once the `errors` bag holds at least one message — a field or
67
+ * `<FormErrors />` already shows it (antd shows field errors, not a form banner) — and stays
68
+ * skipped for that failure if the app later clears the bag. A validation rejection with an empty
69
+ * or message-less bag, and every other rejection (5xx, network, unknown), shows it. Errors mapped with react-hook-form `setError` instead of `errors` are not detected —
70
+ * pass `false` there.
71
+ */
72
+ submitFailedMessage?: React.ReactNode | false;
61
73
  /**
62
74
  * A native `<Button type="reset">` inside the form restores `defaultValues` (adapter path:
63
75
  * `adapter.reset()`); this runs afterwards for any extra app state (a status banner, a step).
@@ -159,7 +171,12 @@ export type FormFieldControlProp<TFieldValues extends FieldValues> = Pick<import
159
171
  onChange: (...args: unknown[]) => void;
160
172
  onValueChange: (...args: unknown[]) => void;
161
173
  onBlur: () => void;
162
- ref: React.Ref<HTMLInputElement>;
174
+ /**
175
+ * Callback ref typed on `HTMLElement`, so `{...field}` spreads onto every godx-ui control
176
+ * (Input, Textarea, Select, NumberInput, DatePicker) with no cast. react-hook-form only needs
177
+ * `focus()` on it to focus the first invalid field.
178
+ */
179
+ ref: React.RefCallback<HTMLElement>;
163
180
  /** Present (and `true`) only when the field is disabled, so `{...field}` never re-enables a control. */
164
181
  disabled?: DisabledProp;
165
182
  }) => React.ReactNode;
package/docs/FORMS.md CHANGED
@@ -59,16 +59,17 @@ export function CreateCustomerForm({ onSubmit }: { onSubmit: (v: Values) => void
59
59
 
60
60
  `onSubmit` runs only after validation passes (antd `onFinish`). Everything else is optional:
61
61
 
62
- | Prop | antd | Notes |
63
- | ----------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------- |
64
- | `onSubmitFailed(errors)` | `onFinishFailed` | RHF error tree; the first invalid field is focused regardless |
65
- | `onSubmitError(error)` | — | Optional error callback; rejected submissions show localized feedback and retain values |
66
- | `onReset` | — | A native `<Button type="reset">` restores `defaultValues`; this runs after |
67
- | `scrollToFirstError` | `scrollToFirstError` | Default `true`; centres the field row, honours `prefers-reduced-motion` |
68
- | `disabled` | Form `disabled` | Disables fields and submission; values remain in state. Buttons read `useFormDisabled()` |
69
- | `layout` `labelWidth` `controlWidth` `labelAlign` `collapseBelow` | `layout` `labelCol` … | Renders the `Form` layout shell (see the spacing note below) |
70
- | `density` / `requiredMark` | `size` / `requiredMark` | Forwarded to the same shell |
71
- | `errors` | — | Server error bag; fields claim their keys, `<FormErrors />` shows the rest |
62
+ | Prop | antd | Notes |
63
+ | ----------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
64
+ | `onSubmitFailed(errors)` | `onFinishFailed` | RHF error tree; the first invalid field is focused regardless |
65
+ | `onSubmitError(error)` | — | Optional error callback; rejected submissions show localized feedback and retain values |
66
+ | `submitFailedMessage` | — | Rejection banner text; `false` never shows it. Skipped for a 400/422 once `errors` holds a message |
67
+ | `onReset` | — | A native `<Button type="reset">` restores `defaultValues`; this runs after |
68
+ | `scrollToFirstError` | `scrollToFirstError` | Default `true`; centres the field row, honours `prefers-reduced-motion` |
69
+ | `disabled` | Form `disabled` | Disables fields and submission; values remain in state. Buttons read `useFormDisabled()` |
70
+ | `layout` `labelWidth` `controlWidth` `labelAlign` `collapseBelow` | `layout` `labelCol` … | Renders the `Form` layout shell (see the spacing note below) |
71
+ | `density` / `requiredMark` | `size` / `requiredMark` | Forwarded to the same shell |
72
+ | `errors` | — | Server error bag; fields claim their keys, `<FormErrors />` shows the rest |
72
73
 
73
74
  > **Spacing.** A bare `FormRoot` stacks its children with `ui-stack-md`. Passing ANY layout prop
74
75
  > switches it to the `Form` shell, whose `--form-block-gap` owns the spacing instead — the two are
@@ -34,6 +34,15 @@ const headerTones = [
34
34
  "neutral",
35
35
  ] as const;
36
36
 
37
+ /** Order lines behind the non-modal payment dialog — they stay editable while it is open. */
38
+ const initialLines = [
39
+ { id: "coffee", name: "ブレンドコーヒー", unitPrice: 480, quantity: 2 },
40
+ { id: "sandwich", name: "ミックスサンド", unitPrice: 650, quantity: 1 },
41
+ { id: "cake", name: "チーズケーキ", unitPrice: 520, quantity: 1 },
42
+ ];
43
+
44
+ const yen = new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" });
45
+
37
46
  /**
38
47
  * Dialog · compound controlled modal for form-style flows. Always control via
39
48
  * open + onOpenChange. Include DialogHeader > DialogTitle (required for a11y).
@@ -46,6 +55,16 @@ export default function Demo() {
46
55
  const [headerTone, setHeaderTone] = useState<(typeof headerTones)[number]>("default");
47
56
  const [terminateOpen, setTerminateOpen] = useState(false);
48
57
  const [reason, setReason] = useState("");
58
+ const [paymentOpen, setPaymentOpen] = useState(false);
59
+ const [lines, setLines] = useState(initialLines);
60
+ const total = lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);
61
+ const changeQuantity = (id: string, delta: number) => {
62
+ setLines((current) =>
63
+ current.map((line) =>
64
+ line.id === id ? { ...line, quantity: Math.max(0, line.quantity + delta) } : line,
65
+ ),
66
+ );
67
+ };
49
68
 
50
69
  return (
51
70
  <PageContainer
@@ -249,6 +268,69 @@ export default function Demo() {
249
268
  </Dialog>
250
269
  </CardContent>
251
270
  </Card>
271
+
272
+ <Card>
273
+ <CardHeader>
274
+ <CardTitle level={2}>Non-modal dialog: the list behind stays editable</CardTitle>
275
+ <CardDescription>
276
+ modal=&#123;false&#125; renders a non-modal dialog, a pattern WAI-ARIA APG allows. The
277
+ page behind keeps working: no scrim, no scroll lock, nothing hidden from assistive
278
+ tech, and a press outside does not close the dialog. Open the payment dialog, then
279
+ change a quantity below. The total inside the dialog follows. Focus moves into the
280
+ dialog on open, Tab can leave it, Escape closes it while focus is inside, and focus
281
+ returns to the trigger.
282
+ </CardDescription>
283
+ </CardHeader>
284
+ <CardContent>
285
+ <Flex direction="col" gap="md">
286
+ {lines.map((line) => (
287
+ <Flex key={line.id} direction="row" align="center" justify="between" gap="sm">
288
+ <Text>{line.name}</Text>
289
+ <Flex direction="row" align="center" gap="sm">
290
+ <Button
291
+ variant="outline"
292
+ size="sm"
293
+ aria-label={`${line.name}を減らす`}
294
+ disabled={line.quantity === 0}
295
+ onClick={() => changeQuantity(line.id, -1)}
296
+ >
297
+ −
298
+ </Button>
299
+ <Text aria-live="polite">{line.quantity}</Text>
300
+ <Button
301
+ variant="outline"
302
+ size="sm"
303
+ aria-label={`${line.name}を増やす`}
304
+ onClick={() => changeQuantity(line.id, 1)}
305
+ >
306
+ +
307
+ </Button>
308
+ </Flex>
309
+ </Flex>
310
+ ))}
311
+ <Dialog modal={false} open={paymentOpen} onOpenChange={setPaymentOpen}>
312
+ <DialogTrigger asChild>
313
+ <Button size="sm">お会計へ</Button>
314
+ </DialogTrigger>
315
+ <DialogContent className="max-w-sm">
316
+ <DialogHeader title="お会計" subtitle="注文明細は背面で編集を続けられます。" />
317
+ <DialogBody>
318
+ <Flex direction="row" justify="between">
319
+ <Text tone="muted">合計</Text>
320
+ <Text>{yen.format(total)}</Text>
321
+ </Flex>
322
+ </DialogBody>
323
+ <DialogFooter>
324
+ <Button variant="outline" onClick={() => setPaymentOpen(false)}>
325
+ キャンセル
326
+ </Button>
327
+ <Button onClick={() => setPaymentOpen(false)}>支払いを確定</Button>
328
+ </DialogFooter>
329
+ </DialogContent>
330
+ </Dialog>
331
+ </Flex>
332
+ </CardContent>
333
+ </Card>
252
334
  </Flex>
253
335
  </PageContainer>
254
336
  );
@@ -42,6 +42,15 @@ const headerTones = [
42
42
  "neutral",
43
43
  ] as const;
44
44
 
45
+ /** Order lines behind the non-modal sheet — they stay editable while it is open. */
46
+ const initialLines = [
47
+ { id: "coffee", name: "ブレンドコーヒー", unitPrice: 480, quantity: 2 },
48
+ { id: "sandwich", name: "ミックスサンド", unitPrice: 650, quantity: 1 },
49
+ { id: "cake", name: "チーズケーキ", unitPrice: 520, quantity: 1 },
50
+ ];
51
+
52
+ const yen = new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" });
53
+
45
54
  /**
46
55
  * Sheet · side-panel drawer (Radix Dialog). Slides in from an edge. Compose
47
56
  * Sheet > SheetTrigger (asChild) > SheetContent(side) > SheetHeader >
@@ -84,6 +93,18 @@ export default function Demo() {
84
93
  const [toneOpen, setToneOpen] = useState(false);
85
94
  const [headerTone, setHeaderTone] = useState<(typeof headerTones)[number]>("default");
86
95
 
96
+ // Card 6 · non-modal sheet: the order lines behind it stay editable while it is open.
97
+ const [summaryOpen, setSummaryOpen] = useState(false);
98
+ const [lines, setLines] = useState(initialLines);
99
+ const total = lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);
100
+ const changeQuantity = (id: string, delta: number) => {
101
+ setLines((current) =>
102
+ current.map((line) =>
103
+ line.id === id ? { ...line, quantity: Math.max(0, line.quantity + delta) } : line,
104
+ ),
105
+ );
106
+ };
107
+
87
108
  return (
88
109
  <PageContainer
89
110
  title="Sheet"
@@ -415,6 +436,69 @@ export default function Demo() {
415
436
  </Flex>
416
437
  </CardContent>
417
438
  </Card>
439
+
440
+ <Card>
441
+ <CardHeader>
442
+ <CardTitle level={2}>Non-modal sheet: the list behind stays editable</CardTitle>
443
+ <CardDescription>
444
+ modal=&#123;false&#125; on Sheet renders a non-modal panel, a pattern WAI-ARIA APG
445
+ allows. The page behind keeps working: no scrim, no scroll lock, nothing hidden from
446
+ assistive tech, and a press outside does not close the panel. Open the order summary
447
+ (side=&quot;left&quot;, so the quantity controls stay uncovered), then change a quantity
448
+ below. The total inside the panel follows. Focus moves into the
449
+ panel on open, Tab can leave it, Escape closes it while focus is inside, and focus
450
+ returns to the trigger.
451
+ </CardDescription>
452
+ </CardHeader>
453
+ <CardContent>
454
+ <Flex direction="col" gap="md">
455
+ {lines.map((line) => (
456
+ <Flex key={line.id} direction="row" align="center" justify="between" gap="sm">
457
+ <Text>{line.name}</Text>
458
+ <Flex direction="row" align="center" gap="sm">
459
+ <Button
460
+ variant="outline"
461
+ size="sm"
462
+ aria-label={`${line.name}を減らす`}
463
+ disabled={line.quantity === 0}
464
+ onClick={() => changeQuantity(line.id, -1)}
465
+ >
466
+ −
467
+ </Button>
468
+ <Text aria-live="polite">{line.quantity}</Text>
469
+ <Button
470
+ variant="outline"
471
+ size="sm"
472
+ aria-label={`${line.name}を増やす`}
473
+ onClick={() => changeQuantity(line.id, 1)}
474
+ >
475
+ +
476
+ </Button>
477
+ </Flex>
478
+ </Flex>
479
+ ))}
480
+ <Sheet modal={false} open={summaryOpen} onOpenChange={setSummaryOpen}>
481
+ <SheetTrigger asChild>
482
+ <Button variant="outline" size="sm">
483
+ 注文サマリー
484
+ </Button>
485
+ </SheetTrigger>
486
+ <SheetContent side="left" width={360}>
487
+ <SheetHeader title="注文サマリー" subtitle="明細は背面で編集を続けられます。" />
488
+ <SheetBody>
489
+ <Flex direction="row" justify="between">
490
+ <Text tone="muted">合計</Text>
491
+ <Text>{yen.format(total)}</Text>
492
+ </Flex>
493
+ </SheetBody>
494
+ <SheetFooter>
495
+ <Button onClick={() => setSummaryOpen(false)}>完了</Button>
496
+ </SheetFooter>
497
+ </SheetContent>
498
+ </Sheet>
499
+ </Flex>
500
+ </CardContent>
501
+ </Card>
418
502
  </Flex>
419
503
  </PageContainer>
420
504
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "27.0.0",
4
- "godxUiMcp": "27.0.0",
3
+ "version": "27.1.0",
4
+ "godxUiMcp": "27.1.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -460,6 +460,7 @@
460
460
  "input-otp": "^1.5.0",
461
461
  "lucide-react": "^1.37.0",
462
462
  "qrcode.react": "4.2.0",
463
+ "react-aria": "3.52.1",
463
464
  "react-aria-components": "^1.21.1",
464
465
  "react-day-picker": "^10.0.1",
465
466
  "react-resizable-panels": "^4.12.3",