@godxjp/ui 26.4.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.
- package/dist/components/data-entry/control-surface.d.ts +5 -2
- package/dist/components/data-entry/control-surface.js +1 -1
- package/dist/components/data-entry/form-errors.d.ts +5 -0
- package/dist/components/data-entry/form-errors.js +6 -0
- package/dist/components/data-entry/search-select.js +1 -1
- package/dist/components/data-entry/tree-select.js +1 -1
- package/dist/components/feedback/dialog.d.ts +4 -3
- package/dist/components/feedback/dialog.js +46 -19
- package/dist/components/feedback/non-modal-layer.d.ts +16 -0
- package/dist/components/feedback/non-modal-layer.js +37 -0
- package/dist/components/feedback/sheet.d.ts +7 -4
- package/dist/components/feedback/sheet.js +63 -40
- package/dist/components/navigation/filter-bar.js +1 -0
- package/dist/components/query/mutation-feedback.js +2 -4
- package/dist/contracts/measurement.json +1 -1
- package/dist/form/form-root.d.ts +1 -1
- package/dist/form/form-root.js +25 -6
- package/dist/props/components/data-entry.prop.d.ts +16 -4
- package/dist/props/components/feedback.prop.d.ts +7 -1
- package/dist/props/components/form.prop.d.ts +18 -1
- package/docs/FORMS.md +11 -10
- package/docs/data-display/permission-matrix.tsx +0 -2
- package/docs/data-entry/chat-composer.tsx +0 -1
- package/docs/data-entry/country-picker-recipe.tsx +4 -4
- package/docs/data-entry/select-matrix.tsx +1 -0
- package/docs/data-entry/select.tsx +3 -3
- package/docs/feedback/dialog.tsx +82 -0
- package/docs/feedback/sheet.tsx +84 -0
- package/docs/showcase/permission-matrix.tsx +0 -2
- package/package.json +3 -2
|
@@ -36,8 +36,11 @@ export type ResolvedAllowClear = {
|
|
|
36
36
|
* Reconcile antd's `allowClear` with this library's own `clearable`.
|
|
37
37
|
*
|
|
38
38
|
* `allowClear` wins when present because it is the more specific statement — the object form
|
|
39
|
-
* carries an icon and a label that `clearable` has no way to express. `clearable`
|
|
40
|
-
*
|
|
39
|
+
* carries an icon and a label that `clearable` has no way to express. `clearable` stays as this
|
|
40
|
+
* library's own alias with the SAME default as antd's `allowClear` for the control: the caller
|
|
41
|
+
* passes that default (`undefined` reads as OFF — antd Select/TreeSelect/Input; a picker whose antd
|
|
42
|
+
* default is ON, DatePicker/TimePicker/Cascader, passes `true`). An antd-named prop takes antd's
|
|
43
|
+
* default too (docs/DESIGN-AUTHORITY.md), so a required select is not one click from empty.
|
|
41
44
|
*/
|
|
42
45
|
export declare function resolveAllowClear(allowClear: AllowClearProp | undefined, clearable: boolean | undefined, fallbackLabel?: string): ResolvedAllowClear;
|
|
43
46
|
/**
|
|
@@ -20,7 +20,7 @@ function resolveAllowClear(allowClear, clearable, fallbackLabel) {
|
|
|
20
20
|
label: allowClear.label ?? fallbackLabel
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
return { enabled: clearable
|
|
23
|
+
return { enabled: clearable === true, label: fallbackLabel };
|
|
24
24
|
}
|
|
25
25
|
function applyMaxTagCount(items, maxTagCount, maxTagPlaceholder) {
|
|
26
26
|
if (maxTagCount === void 0 || maxTagCount < 0 || items.length <= maxTagCount) {
|
|
@@ -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
|
|
@@ -237,7 +237,7 @@ function TreeSelectRoot({
|
|
|
237
237
|
break;
|
|
238
238
|
}
|
|
239
239
|
};
|
|
240
|
-
const clearControl = resolveAllowClear(allowClear,
|
|
240
|
+
const clearControl = resolveAllowClear(allowClear, false, t("dataEntry.treeSelect.clear"));
|
|
241
241
|
const showClear = clearControl.enabled && displayKeys.length > 0 && !disabled && !readOnly && !loading;
|
|
242
242
|
const surface = controlSurfaceAttrs({ variant, status, size });
|
|
243
243
|
return /* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
|
|
@@ -12,14 +12,15 @@ interface OverlayOpenProps {
|
|
|
12
12
|
}
|
|
13
13
|
interface DialogRootProps extends OverlayOpenProps, Pick<DialogProp, "variant"> {
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
|
@@ -97,6 +97,7 @@ function Toolbar({
|
|
|
97
97
|
onValueChange: (selected) => filter.onSelectedChange?.(selected),
|
|
98
98
|
placeholder: filter.placeholder,
|
|
99
99
|
disabled: disabled || filter.disabled,
|
|
100
|
+
allowClear: true,
|
|
100
101
|
clearLabel: typeof filter.label === "string" ? t("navigation.filterBar.clearFilter", { label: filter.label }) : void 0
|
|
101
102
|
}
|
|
102
103
|
)
|
|
@@ -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 &&
|
|
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": "
|
|
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,
|
package/dist/form/form-root.d.ts
CHANGED
|
@@ -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;
|
package/dist/form/form-root.js
CHANGED
|
@@ -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 [
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
setSubmitFailure(null);
|
|
77
88
|
try {
|
|
78
89
|
await work();
|
|
79
90
|
} catch (error) {
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
setSubmitFailure(null);
|
|
155
174
|
form.reset();
|
|
156
175
|
onReset?.();
|
|
157
176
|
},
|
|
@@ -1158,7 +1158,10 @@ export type SearchSelectBaseProp = {
|
|
|
1158
1158
|
* as-is unless `renderError` is provided. */
|
|
1159
1159
|
errorMessage?: string;
|
|
1160
1160
|
clearLabel?: string;
|
|
1161
|
-
/**
|
|
1161
|
+
/**
|
|
1162
|
+
* Show the clear ✕ when a value is selected (default false — antd `allowClear`). This library's
|
|
1163
|
+
* own alias for `allowClear`; pass it on an OPTIONAL field whose empty state is a valid answer.
|
|
1164
|
+
*/
|
|
1162
1165
|
clearable?: boolean;
|
|
1163
1166
|
disabled?: DisabledProp;
|
|
1164
1167
|
/**
|
|
@@ -1241,9 +1244,10 @@ export type SearchSelectBaseProp = {
|
|
|
1241
1244
|
*/
|
|
1242
1245
|
popupMatchSelectWidth?: PopupMatchWidthProp;
|
|
1243
1246
|
/**
|
|
1244
|
-
* antd `allowClear
|
|
1245
|
-
* stays as this library's own name); the OBJECT form additionally replaces
|
|
1246
|
-
* accessible label. When both are given, `allowClear` wins — it is the more
|
|
1247
|
+
* antd `allowClear` (default false, as in antd). `true`/`false` toggles the clear ✕ (same meaning
|
|
1248
|
+
* as `clearable`, which stays as this library's own name); the OBJECT form additionally replaces
|
|
1249
|
+
* the icon and/or the accessible label. When both are given, `allowClear` wins — it is the more
|
|
1250
|
+
* specific statement.
|
|
1247
1251
|
*/
|
|
1248
1252
|
allowClear?: AllowClearProp;
|
|
1249
1253
|
/** Fired after the value is cleared through the ✕ (antd `onClear`). */
|
|
@@ -1547,6 +1551,10 @@ export type CascaderProp = FieldA11yProps & {
|
|
|
1547
1551
|
id?: IdProp;
|
|
1548
1552
|
expandTrigger?: "click" | "hover";
|
|
1549
1553
|
fieldNames?: TreeFieldNamesProp;
|
|
1554
|
+
/**
|
|
1555
|
+
* antd `allowClear` — the ✕ that clears the selected path (default true, as in antd's Cascader).
|
|
1556
|
+
* Pass `false` on a required field. The OBJECT form replaces the icon and/or the accessible label.
|
|
1557
|
+
*/
|
|
1550
1558
|
allowClear?: AllowClearProp;
|
|
1551
1559
|
/** Control height tier (antd `size`) — the shared `--control-height` ladder. */
|
|
1552
1560
|
size?: SizeProp;
|
|
@@ -1622,6 +1630,10 @@ export type TreeSelectProp = FieldA11yProps & {
|
|
|
1622
1630
|
* per checked value under the same name (the native `<select multiple>` contract).
|
|
1623
1631
|
*/
|
|
1624
1632
|
name?: NameProp;
|
|
1633
|
+
/**
|
|
1634
|
+
* antd `allowClear` — the ✕ that clears the selection (default false, as in antd's TreeSelect).
|
|
1635
|
+
* The OBJECT form additionally replaces the icon and/or the accessible label.
|
|
1636
|
+
*/
|
|
1625
1637
|
allowClear?: AllowClearProp;
|
|
1626
1638
|
className?: ClassNameProp;
|
|
1627
1639
|
id?: IdProp;
|
|
@@ -16,7 +16,13 @@ export type DialogProp = {
|
|
|
16
16
|
defaultOpen?: DefaultOpenProp;
|
|
17
17
|
onOpenChange?: OnOpenChangeProp;
|
|
18
18
|
variant?: ConfirmVariantProp;
|
|
19
|
-
/**
|
|
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
|
-
|
|
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
|
-
| `
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
69
|
-
| `
|
|
70
|
-
| `
|
|
71
|
-
| `
|
|
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
|
|
@@ -122,7 +122,6 @@ export default function Demo() {
|
|
|
122
122
|
value={compareA}
|
|
123
123
|
onValueChange={setCompareA}
|
|
124
124
|
options={roleOptions}
|
|
125
|
-
clearable={false}
|
|
126
125
|
className="w-40"
|
|
127
126
|
/>
|
|
128
127
|
</FormField>
|
|
@@ -131,7 +130,6 @@ export default function Demo() {
|
|
|
131
130
|
value={compareB}
|
|
132
131
|
onValueChange={setCompareB}
|
|
133
132
|
options={roleOptions}
|
|
134
|
-
clearable={false}
|
|
135
133
|
className="w-40"
|
|
136
134
|
/>
|
|
137
135
|
</FormField>
|
|
@@ -95,7 +95,7 @@ export default function Demo() {
|
|
|
95
95
|
value は ISO 3166-1 alpha-2 コード、ラベルは
|
|
96
96
|
Intl.DisplayNames(CLDR)で生成する(国名を
|
|
97
97
|
ハードコードしない・絵文字フラグも使わない)。 showSearch で絞り込み、name
|
|
98
|
-
でフォーム送信。
|
|
98
|
+
でフォーム送信。 必須項目なのでクリア ✕ は出さない(allowClear はデフォルト false)。
|
|
99
99
|
</CardDescription>
|
|
100
100
|
</CardHeader>
|
|
101
101
|
<CardContent>
|
|
@@ -106,7 +106,6 @@ export default function Demo() {
|
|
|
106
106
|
value={billing}
|
|
107
107
|
onValueChange={setBilling}
|
|
108
108
|
showSearch
|
|
109
|
-
clearable={false}
|
|
110
109
|
searchPlaceholder="国名 / ISO コードで検索..."
|
|
111
110
|
emptyMessage="該当する国がありません"
|
|
112
111
|
options={COUNTRIES}
|
|
@@ -119,8 +118,8 @@ export default function Demo() {
|
|
|
119
118
|
<CardHeader>
|
|
120
119
|
<CardTitle level={2}>取引先の国(任意・クリア可)</CardTitle>
|
|
121
120
|
<CardDescription>
|
|
122
|
-
|
|
123
|
-
任意項目なので未選択(プレースホルダー表示)を初期値にしている。
|
|
121
|
+
allowClear を付けて「指定なし」に戻せるようにする(デフォルトは false)。placeholder
|
|
122
|
+
で空状態を案内する。 任意項目なので未選択(プレースホルダー表示)を初期値にしている。
|
|
124
123
|
</CardDescription>
|
|
125
124
|
</CardHeader>
|
|
126
125
|
<CardContent>
|
|
@@ -131,6 +130,7 @@ export default function Demo() {
|
|
|
131
130
|
value={partner}
|
|
132
131
|
onValueChange={setPartner}
|
|
133
132
|
showSearch
|
|
133
|
+
allowClear
|
|
134
134
|
placeholder="国を選択..."
|
|
135
135
|
searchPlaceholder="国名 / ISO コードで検索..."
|
|
136
136
|
emptyMessage="該当する国がありません"
|
|
@@ -127,7 +127,6 @@ export default function Demo() {
|
|
|
127
127
|
value={currency}
|
|
128
128
|
onValueChange={setCurrency}
|
|
129
129
|
showSearch
|
|
130
|
-
clearable={false}
|
|
131
130
|
searchPlaceholder="通貨を検索..."
|
|
132
131
|
options={[
|
|
133
132
|
{ value: "JPY", label: "日本円", group: "アジア" },
|
|
@@ -144,8 +143,8 @@ export default function Demo() {
|
|
|
144
143
|
<CardHeader>
|
|
145
144
|
<CardTitle level={2}>Searchable + clearable</CardTitle>
|
|
146
145
|
<CardDescription>
|
|
147
|
-
With a value set and
|
|
148
|
-
selection without opening the list.
|
|
146
|
+
With a value set and allowClear (off by default, as in antd), an inline ✕ on the
|
|
147
|
+
trigger resets the selection without opening the list.
|
|
149
148
|
</CardDescription>
|
|
150
149
|
</CardHeader>
|
|
151
150
|
<CardContent>
|
|
@@ -156,6 +155,7 @@ export default function Demo() {
|
|
|
156
155
|
value={assignee}
|
|
157
156
|
onValueChange={setAssignee}
|
|
158
157
|
showSearch
|
|
158
|
+
allowClear
|
|
159
159
|
searchPlaceholder="担当者を検索..."
|
|
160
160
|
placeholder="担当者を選択"
|
|
161
161
|
options={people}
|
package/docs/feedback/dialog.tsx
CHANGED
|
@@ -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={false} 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
|
);
|
package/docs/feedback/sheet.tsx
CHANGED
|
@@ -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={false} 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="left", 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
|
);
|
|
@@ -173,7 +173,6 @@ export default function Demo() {
|
|
|
173
173
|
value={compareA}
|
|
174
174
|
onValueChange={setCompareA}
|
|
175
175
|
options={roleOptions}
|
|
176
|
-
clearable={false}
|
|
177
176
|
className="w-40"
|
|
178
177
|
/>
|
|
179
178
|
</FormField>
|
|
@@ -182,7 +181,6 @@ export default function Demo() {
|
|
|
182
181
|
value={compareB}
|
|
183
182
|
onValueChange={setCompareB}
|
|
184
183
|
options={roleOptions}
|
|
185
|
-
clearable={false}
|
|
186
184
|
className="w-40"
|
|
187
185
|
/>
|
|
188
186
|
</FormField>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godxjp/ui",
|
|
3
|
-
"version": "
|
|
4
|
-
"godxUiMcp": "
|
|
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",
|