@nocobase/app-template-default 1.0.0-beta.82 → 1.0.0-beta.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.agents/rules/build-and-deploy.md +1 -1
  2. package/.agents/rules/shell.md +4 -0
  3. package/AGENTS.md +9 -1
  4. package/README.MD +10 -2
  5. package/client/components/back-button.tsx +3 -5
  6. package/client/components/default-access-editor.tsx +13 -2
  7. package/client/components/feedback-form.tsx +474 -0
  8. package/client/components/lifecycle-diagram.tsx +10 -10
  9. package/client/components/permission-editor.tsx +396 -210
  10. package/client/extensions/nocobase-auth-forms/password-login-form.tsx +17 -6
  11. package/client/extensions/nocobase-auth-forms/password-registration-form.tsx +24 -7
  12. package/client/extensions/nocobase-auth-forms/password-reset-form.tsx +19 -7
  13. package/client/extensions/nocobase-auth-forms/password-reset-request-form.tsx +15 -7
  14. package/client/extensions/nocobase-device-approval/device-approval.tsx +7 -2
  15. package/client/extensions/nocobase-device-approval/locales/en-US.ts +16 -0
  16. package/client/extensions/nocobase-device-approval/locales/zh-CN.ts +7 -0
  17. package/client/extensions/nocobase-file-component-ui/components/file-preview-dialog.tsx +20 -17
  18. package/client/hooks/use-list-data.ts +77 -0
  19. package/client/hooks/use-list-location.ts +23 -0
  20. package/client/hooks/use-list-tabs.ts +42 -0
  21. package/client/hooks/use-table-url-state.ts +143 -30
  22. package/client/locales/system/en-US.ts +10 -0
  23. package/client/locales/system/settings-en-US.ts +8 -0
  24. package/client/locales/system/settings-zh-CN.ts +8 -0
  25. package/client/locales/system/zh-CN.ts +8 -0
  26. package/client/pages/settings/default-access-form.tsx +44 -26
  27. package/client/pages/settings/default-access.tsx +56 -27
  28. package/client/pages/settings/hooks.tsx +4 -59
  29. package/client/pages/settings/page-permissions.ts +17 -0
  30. package/client/pages/settings/permissions-model.ts +54 -58
  31. package/client/pages/settings/role-form.tsx +98 -56
  32. package/client/pages/settings/roles.tsx +11 -3
  33. package/client/pages/settings/shared.tsx +43 -33
  34. package/client/pages/settings/user-form.tsx +51 -30
  35. package/client/pages/settings/users.tsx +39 -27
  36. package/client/routing/session-preparation.tsx +10 -3
  37. package/database/main/migrations/202611020002_create_mutation_receipts.ts +19 -0
  38. package/package.json +13 -12
  39. package/server/providers/index.ts +4 -1
  40. package/server/providers/submission.ts +61 -0
  41. package/tests/client/auth-i18n.test.tsx +9 -0
  42. package/tests/client/back-button.test.tsx +3 -3
  43. package/tests/client/default-access.test.tsx +60 -4
  44. package/tests/client/form-feedback.test.tsx +589 -0
  45. package/tests/client/list-recovery.test.tsx +373 -0
  46. package/tests/client/list-tabs.test.tsx +121 -0
  47. package/tests/client/page-permission-navigation.test.ts +8 -0
  48. package/tests/client/permission-editor.test.tsx +191 -21
  49. package/tests/client/permission-filter-binding.test.tsx +1 -1
  50. package/tests/client/role-return.test.tsx +2 -1
  51. package/tests/client/session-preparation.test.tsx +24 -1
  52. package/tests/client/settings-continuity.test.tsx +5 -4
  53. package/tests/client/settings-feedback.test.tsx +25 -0
  54. package/tests/client/settings-permissions.test.ts +35 -0
  55. package/tests/client/settings.test.tsx +2 -1
  56. package/tests/client/table-url-state.test.tsx +117 -3
  57. package/tests/client/user-drawer.test.tsx +2 -1
  58. package/tests/client/user-permissions.test.tsx +30 -5
  59. package/tests/database/mutation-receipts.test.ts +45 -0
  60. package/tests/fixtures/form-feedback/README.md +33 -0
  61. package/tests/fixtures/form-feedback/equipment-detail.tsx +11 -0
  62. package/tests/fixtures/form-feedback/equipment-layout.tsx +15 -0
  63. package/tests/fixtures/form-feedback/equipment-migration.ts +27 -0
  64. package/tests/fixtures/form-feedback/equipment-server.ts +197 -0
  65. package/tests/fixtures/form-feedback/equipment-validation.tsx +94 -0
  66. package/tests/fixtures/form-feedback/equipment.tsx +369 -0
  67. package/tests/fixtures/form-feedback/verify-inline.mjs +174 -0
  68. package/tests/fixtures/form-feedback/verify.mjs +373 -0
  69. package/tests/fixtures/submission/README.md +35 -0
  70. package/tests/fixtures/submission/browser.test.ts +478 -0
  71. package/tests/fixtures/submission/migration.ts +61 -0
  72. package/tests/fixtures/submission/page.tsx +138 -0
  73. package/tests/fixtures/submission/playwright.config.ts +11 -0
  74. package/tests/fixtures/submission/routes.ts +208 -0
  75. package/tests/helpers/settings-client.tsx +31 -0
  76. package/tests/server/member-role.test.ts +7 -1
  77. package/tests/server/submission-settings.test.ts +241 -0
@@ -16,7 +16,7 @@ For a Docker source build, pass `--build-arg CDN_BASE_URL=https://cdn.example.co
16
16
 
17
17
  ## Packing the build for a deployment
18
18
 
19
- `pnpm build --tar` writes `storage/exports/dist.tar.gz` after the build. The archive holds `dist/` as a directory next to `config.example.yml`, so extracting it produces exactly those two paths rather than scattering `server/` and `node_modules/` into whatever directory you unpacked in.
19
+ `pnpm build --tar` writes `storage/exports/dist.tar.gz` after the build. The archive holds `dist/` as a directory next to `config.example.yml`, with the `Dockerfile` and `Dockerfile.dockerignore`, so extracting it produces exactly those paths rather than scattering `server/` and `node_modules/` into whatever directory you unpacked in, and a server can build the image from it alone with `docker build --build-arg DIST=prebuilt .`.
20
20
 
21
21
  `config.example.yml` travels with it because a deployment has to write a `config.yml` before it can start — `pnpm nocobase config init`, run inside `dist/`, does that from the example, in place, and `pnpm nocobase config check` there verifies it, database connection included, before the first start — and the example is the only statement of what may go in it. Directories of executable shims are left out: a `.bin` entry points at a path on the machine that installed it, and a dangling one makes `pnpm install` in the extracted tree report a corrupt store rather than repair it.
22
22
 
@@ -33,3 +33,7 @@ Use `Loading` for page or panel waits and `Spinner` (the shadcn-compatible adapt
33
33
  The shared list of shell behaviors to keep when customizing is in [customizing the shell](../skills/nocobase-app-development/references/frontend/references/shell.md).
34
34
 
35
35
  The signed-in region waits in `client/routing/session-preparation.tsx` before mounting `AppLayout`: the authentication provider first reconnects active realtime subscriptions as the current session and waits for the open notification, including permission invalidation. A ten-second timeout shows a localized retry/sign-out screen. Keep this boundary inside `RequiredAuthentication`; guest login must remain reachable without realtime. Readiness is only for initial session preparation. Subsequent permission notifications and reconnects still use normal fail-closed route unmounting, including portal and effect cleanup.
36
+
37
+ ## List context
38
+
39
+ Keep the identity-keyed `SessionPreparation` boundary: mounted private list conditions and ancestor query memory must be discarded when the signed-in user changes. Public list state uses `useTableUrlState`; tabbed datasets use `useListTabs`, with complete owned parameter keys and permission-filtered tabs. Follow the synchronized [list navigation rules](../skills/nocobase-app-development/references/frontend/references/child-routes.md#list-tab-breadcrumbs-and-private-conditions). Default back links use an empty ancestor query when no source visit exists; explicit return destinations remain application-owned.
package/AGENTS.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  This is a NocoBase 3 application. Do not apply globally installed NocoBase 2 Skills. You are building the application itself — its pages, its API, its database tables. Everything under this directory is application-owned source code that you may edit directly.
4
4
 
5
- Do not create a plugin to add a feature. Plugins are separately published packages for capabilities shared across several applications; building one for this application's own feature adds a package boundary, a version, and a release process to work that belongs in `client/` and `server/`. Create one only when the user explicitly asks for a reusable published package. Ordinary internal workspace packages under `packages/` may share code without becoming plugins or being published; follow [internal packages](.agents/rules/workspace.md) when the application needs that boundary.
5
+ Build application-specific features directly in the application by default. Use a plugin when the user requests one or when code reuse or modular organization calls for a separately registered, maintainable feature. Local plugins belong under `packages/`; the App builds and packages registered local plugins for deployment, so publishing is optional and only needed when distributing the plugin through a package registry. Follow [the project plugin development Skill](.agents/skills/nocobase-app-plugin-development/SKILL.md). Ordinary internal packages can share code without plugin registration; follow [internal packages](.agents/rules/workspace.md) when that is the boundary the application needs.
6
6
 
7
7
  ## Default template scope
8
8
 
@@ -20,12 +20,16 @@ This file stays at most 32 KB and must not grow without bound, because every age
20
20
 
21
21
  ## Load the development skills
22
22
 
23
+ For creating or maintaining a plugin in this App, read [the project plugin development Skill](.agents/skills/nocobase-app-plugin-development/SKILL.md). It ships with `@nocobase/app-skills`; do not install a global copy. If an older global Skill has the same name, read this project's file by path and follow the upgrade Skill's migration guidance to retire the global copy after verifying the replacement.
24
+
23
25
  `pnpm install` runs `pnpm nocobase skills sync` through the application's `postinstall` hook. If `.agents/skills/nocobase-app-development/` is missing, install dependencies from the application root; if install scripts were disabled or synchronized Skills are stale, run `pnpm nocobase skills sync` explicitly.
24
26
 
25
27
  [`.agents/skills/nocobase-app-development/SKILL.md`](.agents/skills/nocobase-app-development/SKILL.md) holds the detailed guidance behind this file. Read it first — it routes to the reference that matches your task. Read the one page your task needs, not the whole directory.
26
28
 
27
29
  `.agents/skills/nocobase-app-upgrade/` is a separate Skill for a separate job: merging a newer release of the template this application was generated from. Read it when the task is upgrading the template rather than building a feature — including when the user asks to upgrade NocoBase or its core version, because a newer template release is how this application moves to a newer NocoBase — and read it before touching anything — an upgrade done by copying the newest template over this application destroys the work that made it this application.
28
30
 
31
+ When upgrading from an older installed Skill, also read the target template package’s `AGENTS.md` and `README.MD` and fetch its declared `@nocobase/app-skills` package before editing. Follow the target’s upgrade requirements in the same upgrade, including application-owned lists and installed Registry copies. Confirm the target baseline only after conflicts are resolved and dependency, source, behavior and retained-data checks pass; see the [upgrade Skill](.agents/skills/nocobase-app-upgrade/SKILL.md).
32
+
29
33
  `.agents/skills/nocobase-deployment/` covers moving this application from source to a production server: building for the target platform, what the archive does and does not carry, migrations and business data, production configuration, and what to verify afterwards. Read it before building for deployment, and when an application starts in production but does not work.
30
34
 
31
35
  ## Where things go
@@ -116,6 +120,10 @@ When the built-in mechanism genuinely cannot express what is being asked, changi
116
120
  - Comment what you changed and why the built-in path did not fit. On upgrade, an agent reconciling the template's version needs to know whether your change is still needed.
117
121
  - Record what the next agent needs to know in the matching `.agents/rules/` file, or a new one linked from the index below, so the guidance describes the application as it actually is without growing this file. If the framework's shared guidance is wrong for every application, update the source `@nocobase/app-skills` package instead of editing its synchronized copy.
118
122
 
123
+ ## Standard submissions
124
+
125
+ Standard Repository writes require an operation key through the registered submission provider. Forms share `useSubmission` across all entries before asynchronous validation; custom effectful APIs explicitly join the receipt transaction. Follow [submission protection](.agents/skills/nocobase-app-development/references/submission.md) and [source upgrades](.agents/skills/nocobase-app-upgrade/references/submission-upgrade.md). Merge copied source and verify retained customizations on upgrade.
126
+
119
127
  ## Index
120
128
 
121
129
  | Read | When |
package/README.MD CHANGED
@@ -4,6 +4,12 @@ A full-stack NocoBase 3 application. The server runs on `@nocobase/app-server` a
4
4
 
5
5
  You own everything in this directory. Add pages, API routes, database tables, and business logic directly here — this is your application's source code, not a framework you extend from the outside.
6
6
 
7
+ ## Upgrade this application
8
+
9
+ Ask the application agent to upgrade NocoBase using the application-local [template upgrade Skill](.agents/skills/nocobase-app-upgrade/SKILL.md). This is the supported version-selection and three-way source reconciliation entry point. Before merging, read the selected target package’s `AGENTS.md`, this section and the upgrade Skill from its declared `@nocobase/app-skills` dependency, even if the application still has an older Skill. Fetch and inspect that guidance without first installing it.
10
+
11
+ Complete the target’s requirements in that same upgrade: reconcile dependencies, copied template and Registry sources, application-owned table/card consumers and translations while preserving business customizations, grants, records and executed migrations. For list feedback, follow the target Skill’s `references/list-feedback-upgrade.md`. Do not finish with only a version bump, dependency update or Skill sync. Missing targets, unresolved business conflicts or failed acceptance stop completion; confirm `defaultTemplateVersion` only after the checks pass. See the target Skill’s `references/versioned-upgrade-verification.md` for package integrity records and verification after publication.
12
+
7
13
  ## Default and Examples
8
14
 
9
15
  Default provides the application shell and product capabilities without example plugins or business demo pages. Application-owned business API routes start empty, and new databases receive no article sample data. A bare create-app shows Home and, for unrestricted administrators only, System settings → App foundation. Foundation is installed and registered on both Client and Server; its read-only overview is available at `/foundation` under the application base path. As business capabilities are developed, the app development skill reconciles missing core administration on every iteration and registers the prepared Users and Roles and permissions contribution (`client/pages/settings/routes.ts`); each actually used configurable capability includes its administration as part of feature delivery. Its built-in Users integration lists direct Authorization Permission Sets as application roles, while access assigned to all authenticated users remains separate. Use `app-template-examples` for article management and runnable plugin demonstrations.
@@ -138,7 +144,7 @@ The three files named `runtime.ts`, `plugins.ts`, and `routes.ts` are compositio
138
144
  | `pnpm dev` | Start the API server and client with hot reload |
139
145
  | `pnpm build` | Build the client and server into `dist/` |
140
146
  | `pnpm start` | Run the built server; requires `pnpm build` first |
141
- | `pnpm build --tar` | Build, then pack `dist/` and `config.example.yml` into `storage/exports/dist.tar.gz` |
147
+ | `pnpm build --tar` | Build, then pack `dist/`, `config.example.yml` and the `Dockerfile` with its `.dockerignore` into `storage/exports/dist.tar.gz` |
142
148
 
143
149
  ### Database
144
150
 
@@ -287,10 +293,12 @@ Pass the file's path relative to `dist/client/`, with or without a leading slash
287
293
 
288
294
  `pnpm build --tar` writes `storage/exports/dist.tar.gz`, the artifact `@nocobase/app-installer` installs on a server.
289
295
 
290
- The archive has two levels: `config.example.yml` and `dist/` sit at the root, and the build is inside `dist/`. What `dist/` holds varies with the application; this is the shape:
296
+ The archive has two levels: `config.example.yml`, `dist/` and, when the application has them, the `Dockerfile` and `Dockerfile.dockerignore` sit at the root, and the build is inside `dist/`. A server builds the image from the extracted archive alone with `docker build --build-arg DIST=prebuilt .`. What `dist/` holds varies with the application; this is the shape:
291
297
 
292
298
  ```text
293
299
  dist.tar.gz
300
+ ├── Dockerfile
301
+ ├── Dockerfile.dockerignore
294
302
  ├── config.example.yml
295
303
  └── dist/
296
304
  ├── cli/
@@ -1,7 +1,7 @@
1
1
  import { useTranslation } from '@nocobase/i18n/client';
2
2
  import { ArrowLeftIcon } from 'lucide-react';
3
3
  import type { ReactElement, ReactNode } from 'react';
4
- import { Link, type To, useLocation, useResolvedPath } from 'react-router';
4
+ import { Link, type To, useResolvedPath } from 'react-router';
5
5
 
6
6
  import { useRouteSearches } from '../routing/route-context.js';
7
7
 
@@ -10,7 +10,7 @@ import { cn } from 'cn';
10
10
  export interface BackButtonProps {
11
11
  /**
12
12
  * Where it leads. Defaults to the parent route with its last visited query; a directly opened detail falls back
13
- * to the current query, as closing a route overlay does. An explicit destination is kept unchanged.
13
+ * to the default parent list. An explicit destination is kept unchanged.
14
14
  */
15
15
  readonly to?: To;
16
16
  /** The label, "Back" by default. */
@@ -33,7 +33,6 @@ export function BackButton({
33
33
  to,
34
34
  }: BackButtonProps = {}): ReactElement {
35
35
  const { t } = useTranslation();
36
- const location = useLocation();
37
36
  const parent = useResolvedPath('..');
38
37
  const searches = useRouteSearches();
39
38
 
@@ -48,8 +47,7 @@ export function BackButton({
48
47
  to ?? {
49
48
  pathname: '..',
50
49
  search:
51
- searches.get(parent.pathname.replace(/\/+$/u, '') || '/') ??
52
- location.search,
50
+ searches.get(parent.pathname.replace(/\/+$/u, '') || '/') ?? '',
53
51
  }
54
52
  }
55
53
  >
@@ -83,6 +83,7 @@ export function DefaultAccessEditor({
83
83
  <Input
84
84
  id={`${id}-key`}
85
85
  name='ruleKey'
86
+ data-field-path='key'
86
87
  value={ruleKey}
87
88
  required
88
89
  disabled={existing || readOnly}
@@ -91,7 +92,11 @@ export function DefaultAccessEditor({
91
92
  onChange={(event) => onKeyChange(event.target.value)}
92
93
  />
93
94
  {keyError && (
94
- <p id={`${id}-key-error`} className='text-sm text-destructive'>
95
+ <p
96
+ data-feedback-field-error
97
+ id={`${id}-key-error`}
98
+ className='text-sm text-destructive'
99
+ >
95
100
  {keyError}
96
101
  </p>
97
102
  )}
@@ -108,6 +113,7 @@ export function DefaultAccessEditor({
108
113
  >
109
114
  <SelectTrigger
110
115
  id={`${id}-resource`}
116
+ data-field-path='resource'
111
117
  className='w-full'
112
118
  aria-invalid={Boolean(resourceError)}
113
119
  aria-describedby={
@@ -127,7 +133,11 @@ export function DefaultAccessEditor({
127
133
  </SelectContent>
128
134
  </Select>
129
135
  {resourceError && (
130
- <p id={`${id}-resource-error`} className='text-sm text-destructive'>
136
+ <p
137
+ data-feedback-field-error
138
+ id={`${id}-resource-error`}
139
+ className='text-sm text-destructive'
140
+ >
131
141
  {resourceError}
132
142
  </p>
133
143
  )}
@@ -187,6 +197,7 @@ export function DefaultAccessEditor({
187
197
  </Select>
188
198
  {scope.error && (
189
199
  <p
200
+ data-feedback-field-error
190
201
  id={`${id}-${scope.id}-error`}
191
202
  className='text-sm text-destructive'
192
203
  >
@@ -0,0 +1,474 @@
1
+ import {
2
+ FormFeedback,
3
+ formFeedbackErrors,
4
+ type FormFeedbackAttempt,
5
+ type FormFeedbackField,
6
+ type FormFeedbackOptions,
7
+ type FormFeedbackState,
8
+ } from '@nocobase/app-client';
9
+ import { useTranslation } from '@nocobase/i18n/client';
10
+ import {
11
+ useEffect,
12
+ useId,
13
+ useLayoutEffect,
14
+ useMemo,
15
+ useReducer,
16
+ useRef,
17
+ useState,
18
+ useSyncExternalStore,
19
+ type ComponentProps,
20
+ type ReactElement,
21
+ type ReactNode,
22
+ } from 'react';
23
+ import { createPortal } from 'react-dom';
24
+
25
+ export interface FeedbackFormProps extends Omit<
26
+ ComponentProps<'form'>,
27
+ 'onSubmit'
28
+ > {
29
+ readonly onSubmit?: (
30
+ event: React.FormEvent<HTMLFormElement>,
31
+ ) => void | Promise<void>;
32
+ /** Pass the caught request error; input state continues to belong to the caller. */
33
+ readonly error?: unknown;
34
+ /** Trusted application copy/actions for a request failure; never pass raw server content. */
35
+ readonly errorContent?: ReactNode;
36
+ readonly submitting?: boolean;
37
+ /** For unmounted tabs, repeated rows and extension controls, register stable descriptors. */
38
+ readonly feedback?: FormFeedback;
39
+ readonly fieldMessage?: FormFeedbackOptions['fieldMessage'];
40
+ /** React Hook Form errors. Set shouldFocusError: false so layout order owns focus. */
41
+ readonly validationErrors?: unknown;
42
+ }
43
+
44
+ /** Native fields use name (or data-field-path). Custom controls use a FormFeedback descriptor. */
45
+ export function FeedbackForm({
46
+ children,
47
+ onSubmit,
48
+ error,
49
+ errorContent,
50
+ feedback: supplied,
51
+ fieldMessage,
52
+ validationErrors,
53
+ noValidate,
54
+ submitting = false,
55
+ ref,
56
+ ...props
57
+ }: FeedbackFormProps): ReactElement {
58
+ const { t } = useTranslation();
59
+ const options = useMemo<FormFeedbackOptions>(
60
+ () => ({
61
+ messages: {
62
+ field: t('formFeedback.field', {
63
+ defaultValue: 'This value was rejected. Check it and try again.',
64
+ }),
65
+ invalid: t('formFeedback.invalid', {
66
+ defaultValue: 'Check the highlighted fields and try again.',
67
+ }),
68
+ failed: t('formFeedback.failed', {
69
+ defaultValue: 'Unable to save. Your input has been kept. Try again.',
70
+ }),
71
+ network: t('formFeedback.network', {
72
+ defaultValue:
73
+ 'Unable to connect. Check your connection and try again. Your input has been kept.',
74
+ }),
75
+ denied: t('formFeedback.denied', {
76
+ defaultValue:
77
+ 'You cannot submit this form with your current access. Your input has been kept.',
78
+ }),
79
+ },
80
+ ...(fieldMessage ? { fieldMessage } : {}),
81
+ }),
82
+ [t, fieldMessage],
83
+ );
84
+ const [owned] = useState(() => new FormFeedback(options));
85
+ const feedback = supplied ?? owned;
86
+ useLayoutEffect(() => {
87
+ feedback.configure(options);
88
+ }, [feedback, options]);
89
+ const state = useSyncExternalStore(
90
+ feedback.subscribe,
91
+ feedback.getSnapshot,
92
+ feedback.getSnapshot,
93
+ );
94
+ const [unfocusableState, setUnfocusableState] = useState<FormFeedbackState>();
95
+ const formRef = useRef<HTMLFormElement>(null);
96
+ const messageRef = useRef<HTMLDivElement>(null);
97
+ const attemptRef = useRef<FormFeedbackAttempt | undefined>(undefined);
98
+ const prefixId = useId();
99
+ const localValidationRef = useRef(false);
100
+ const focusPendingRef = useRef(false);
101
+ const controlsRef = useRef(
102
+ new Map<
103
+ HTMLElement,
104
+ { field: FormFeedbackField; unregister: () => void }
105
+ >(),
106
+ );
107
+ const generatedRef = useRef(0);
108
+ const [, redraw] = useReducer((value: number) => value + 1, 0);
109
+
110
+ useEffect(() => {
111
+ const element = formRef.current;
112
+ if (!element) return;
113
+ const registered = controlsRef.current;
114
+ const collect = () => {
115
+ for (const [control, entry] of registered)
116
+ if (!element.contains(control)) {
117
+ entry.unregister();
118
+ registered.delete(control);
119
+ }
120
+ for (const control of element.querySelectorAll<HTMLElement>(
121
+ 'input[name], textarea[name], select[name], [data-field-path]',
122
+ )) {
123
+ if (registered.has(control)) continue;
124
+ // Application descriptors take precedence for custom/virtualized fields.
125
+ const path = () =>
126
+ control.dataset.fieldPath ?? control.getAttribute('name') ?? '';
127
+ if (feedback.hasPath(path())) continue;
128
+ const id = `${prefixId}-field-${++generatedRef.current}`;
129
+ const field: FormFeedbackField = {
130
+ id,
131
+ path,
132
+ label: () => fieldLabel(control),
133
+ anchor: () => control,
134
+ control: () => control,
135
+ value: () => {
136
+ if (control instanceof HTMLInputElement) {
137
+ if (control.type === 'file')
138
+ return Array.from(control.files ?? []);
139
+ if (control.type === 'checkbox' || control.type === 'radio')
140
+ return control.checked;
141
+ }
142
+ return 'value' in control ? control.value : control.textContent;
143
+ },
144
+ accessible: () =>
145
+ element.contains(control) &&
146
+ !control.closest(
147
+ '[data-feedback-hidden], [hidden], [inert], [aria-hidden="true"]',
148
+ ) &&
149
+ ancestors(control).every((element) => {
150
+ const style = getComputedStyle(element);
151
+ return style.display !== 'none' && style.visibility !== 'hidden';
152
+ }) &&
153
+ !(control instanceof HTMLInputElement && control.type === 'hidden'),
154
+ editable: () =>
155
+ !control.matches(':disabled, [aria-disabled="true"], [readonly]'),
156
+ reveal: () => {
157
+ for (const details of ancestors(control))
158
+ if (details instanceof HTMLDetailsElement) details.open = true;
159
+ },
160
+ };
161
+ registered.set(control, {
162
+ field,
163
+ unregister: feedback.register(field),
164
+ });
165
+ }
166
+ };
167
+ collect();
168
+ const observer = new MutationObserver(() => {
169
+ collect();
170
+ redraw();
171
+ });
172
+ observer.observe(element, {
173
+ childList: true,
174
+ subtree: true,
175
+ attributes: true,
176
+ attributeFilter: [
177
+ 'hidden',
178
+ 'inert',
179
+ 'aria-hidden',
180
+ 'disabled',
181
+ 'data-feedback-hidden',
182
+ ],
183
+ });
184
+ const change = (event: Event) => {
185
+ const control = event.target;
186
+ if (control instanceof HTMLElement) {
187
+ const entry = registered.get(control);
188
+ if (entry) feedback.clear(entry.field.id);
189
+ else feedback.clearControl(control);
190
+ }
191
+ };
192
+ element.addEventListener('input', change);
193
+ element.addEventListener('change', change);
194
+ return () => {
195
+ observer.disconnect();
196
+ element.removeEventListener('input', change);
197
+ element.removeEventListener('change', change);
198
+ for (const entry of registered.values()) entry.unregister();
199
+ registered.clear();
200
+ };
201
+ }, [feedback, prefixId]);
202
+
203
+ useEffect(() => {
204
+ if (!error || !attemptRef.current) return;
205
+ if (feedback.getAttemptSequence() !== attemptRef.current.sequence) return;
206
+ focusPendingRef.current = true;
207
+ feedback.failure(error, attemptRef.current);
208
+ }, [error, feedback]);
209
+
210
+ useEffect(() => {
211
+ if (
212
+ (state.message || state.fields.length) &&
213
+ focusPendingRef.current &&
214
+ !submitting
215
+ ) {
216
+ focusPendingRef.current = false;
217
+ void feedback.focus(messageRef.current, undefined, () => {
218
+ if (!state.message && feedback.getSnapshot() === state)
219
+ setUnfocusableState(state);
220
+ });
221
+ }
222
+ }, [state, feedback, submitting]);
223
+
224
+ // Existing controlled validators keep their messages and state. Adopt their invalid controls once per submission.
225
+ useEffect(() => {
226
+ if (
227
+ validationErrors !== undefined ||
228
+ !localValidationRef.current ||
229
+ !attemptRef.current
230
+ )
231
+ return;
232
+ if (state.fields.length) {
233
+ localValidationRef.current = false;
234
+ return;
235
+ }
236
+ const invalid = [...controlsRef.current.keys()].filter(
237
+ (control) => control.getAttribute('aria-invalid') === 'true',
238
+ );
239
+ if (!invalid.length) return;
240
+ localValidationRef.current = false;
241
+ focusPendingRef.current = true;
242
+ feedback.invalid(
243
+ invalid.map((control) => {
244
+ return {
245
+ field:
246
+ control.dataset.fieldPath ?? control.getAttribute('name') ?? '',
247
+ message:
248
+ (existingFieldError(control)?.textContent ||
249
+ options.messages.field) ??
250
+ options.messages.invalid,
251
+ };
252
+ }),
253
+ attemptRef.current,
254
+ );
255
+ });
256
+
257
+ const validationKey = JSON.stringify(formFeedbackErrors(validationErrors));
258
+ const attemptSequence = feedback.getAttemptSequence();
259
+ useEffect(() => {
260
+ if (
261
+ !localValidationRef.current ||
262
+ submitting ||
263
+ !attemptSequence ||
264
+ !attemptRef.current
265
+ )
266
+ return;
267
+ localValidationRef.current = false;
268
+ if (validationKey === '[]') return;
269
+ const errors = formFeedbackErrors(validationErrors);
270
+ if (errors.length) {
271
+ focusPendingRef.current = true;
272
+ feedback.invalid(errors, attemptRef.current);
273
+ }
274
+ }, [validationErrors, validationKey, feedback, attemptSequence, submitting]);
275
+
276
+ const errors = feedback.errors();
277
+ const inaccessible = state.fields.length > 0 && errors.length === 0;
278
+ const formMessage =
279
+ state.message ||
280
+ (inaccessible || unfocusableState === state
281
+ ? options.messages.failed
282
+ : undefined);
283
+ useLayoutEffect(() => {
284
+ if (inaccessible || unfocusableState === state) messageRef.current?.focus();
285
+ }, [state, inaccessible, unfocusableState]);
286
+ useLayoutEffect(() => {
287
+ const restore: (() => void)[] = [];
288
+ for (const entry of errors) {
289
+ const control = feedback.field(entry.id)?.control();
290
+ if (!control) continue;
291
+ const describedBy = control.getAttribute('aria-describedby');
292
+ const invalid = control.getAttribute('aria-invalid');
293
+ const ownsInvalid = invalid !== 'true';
294
+ const existing = existingFieldError(control);
295
+ const previousId = existing?.getAttribute('id');
296
+ const id = existing?.id || `${entry.id}-error`;
297
+ const ownsDescription = !(describedBy?.split(/\s+/) ?? []).includes(id);
298
+ if (existing && !existing.id) existing.id = id;
299
+ control.setAttribute('aria-invalid', 'true');
300
+ control.setAttribute(
301
+ 'aria-describedby',
302
+ [...new Set([...(describedBy?.split(/\s+/) ?? []), id])]
303
+ .filter(Boolean)
304
+ .join(' '),
305
+ );
306
+ restore.push(() => {
307
+ if (existing && previousId === null && existing.id === id)
308
+ existing.removeAttribute('id');
309
+ // React may already have committed new caller-owned attributes. Remove only our additions.
310
+ if (ownsDescription) {
311
+ const remaining = (control.getAttribute('aria-describedby') ?? '')
312
+ .split(/\s+/)
313
+ .filter((value) => value && value !== id);
314
+ if (remaining.length)
315
+ control.setAttribute('aria-describedby', remaining.join(' '));
316
+ else control.removeAttribute('aria-describedby');
317
+ }
318
+ if (ownsInvalid && control.getAttribute('aria-invalid') === 'true') {
319
+ if (invalid === null) control.removeAttribute('aria-invalid');
320
+ else control.setAttribute('aria-invalid', invalid);
321
+ }
322
+ });
323
+ }
324
+ return () => {
325
+ for (const reset of restore) reset();
326
+ };
327
+ });
328
+
329
+ return (
330
+ <form
331
+ {...props}
332
+ noValidate
333
+ ref={(element) => {
334
+ formRef.current = element;
335
+ if (typeof ref === 'function') return ref(element);
336
+ if (ref) ref.current = element;
337
+ }}
338
+ onSubmit={(event) => {
339
+ event.preventDefault();
340
+ const current = feedback.begin();
341
+ attemptRef.current = current;
342
+ localValidationRef.current = true;
343
+ const invalid = [...event.currentTarget.elements].filter(
344
+ (
345
+ control,
346
+ ): control is
347
+ HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement =>
348
+ (control instanceof HTMLInputElement ||
349
+ control instanceof HTMLSelectElement ||
350
+ control instanceof HTMLTextAreaElement) &&
351
+ !noValidate &&
352
+ control.willValidate &&
353
+ !control.validity.valid,
354
+ );
355
+ if (invalid.length) {
356
+ event.preventDefault();
357
+ focusPendingRef.current = true;
358
+ feedback.invalid(
359
+ invalid.map((control) => ({
360
+ field: control.dataset.fieldPath ?? control.name,
361
+ message: control.validationMessage,
362
+ })),
363
+ current,
364
+ );
365
+ return;
366
+ }
367
+ try {
368
+ const result = onSubmit?.(event);
369
+ if (result)
370
+ void result.catch((next: unknown) => {
371
+ if (feedback.getAttemptSequence() !== current.sequence) return;
372
+ focusPendingRef.current = true;
373
+ feedback.failure(next, current);
374
+ });
375
+ } catch (next) {
376
+ if (feedback.getAttemptSequence() !== current.sequence) return;
377
+ focusPendingRef.current = true;
378
+ feedback.failure(next, current);
379
+ }
380
+ }}
381
+ >
382
+ {(formMessage ||
383
+ (!attemptSequence && Boolean(error) && errorContent != null)) && (
384
+ <div
385
+ ref={messageRef}
386
+ role='alert'
387
+ tabIndex={-1}
388
+ className='space-y-2 text-sm text-destructive'
389
+ >
390
+ {error && errors.length === 0 && errorContent != null ? (
391
+ errorContent
392
+ ) : (
393
+ <p>{formMessage}</p>
394
+ )}
395
+ </div>
396
+ )}
397
+ {children}
398
+ {errors.map((entry) => {
399
+ const control = feedback.field(entry.id)?.control();
400
+ return control?.parentElement && !existingFieldError(control)
401
+ ? createPortal(
402
+ <p
403
+ id={`${entry.id}-error`}
404
+ data-form-feedback-error
405
+ className='text-sm text-destructive'
406
+ >
407
+ {entry.message}
408
+ </p>,
409
+ control.parentElement,
410
+ entry.id,
411
+ )
412
+ : null;
413
+ })}
414
+ </form>
415
+ );
416
+ }
417
+
418
+ function ancestors(control: HTMLElement): HTMLElement[] {
419
+ const result: HTMLElement[] = [];
420
+ for (
421
+ let parent = control.parentElement;
422
+ parent;
423
+ parent = parent.parentElement
424
+ )
425
+ result.unshift(parent);
426
+ return result;
427
+ }
428
+
429
+ /** Only explicitly associated application error nodes are trusted, never arbitrary nearby text. */
430
+ function existingFieldError(control: HTMLElement): HTMLElement | undefined {
431
+ const errorIds = (control.getAttribute('aria-errormessage') ?? '').split(
432
+ /\s+/,
433
+ );
434
+ const described = (control.getAttribute('aria-describedby') ?? '')
435
+ .split(/\s+/)
436
+ .map((id) => control.ownerDocument.getElementById(id))
437
+ .filter((node) =>
438
+ node?.matches('[data-slot="field-error"], [data-feedback-field-error]'),
439
+ );
440
+ const slot = control
441
+ .closest('[data-slot="field"]')
442
+ ?.querySelector<HTMLElement>('[data-slot="field-error"]');
443
+ return [
444
+ ...errorIds.map((id) => control.ownerDocument.getElementById(id)),
445
+ ...described,
446
+ slot,
447
+ ].find((node): node is HTMLElement =>
448
+ Boolean(node && !node.hasAttribute('data-form-feedback-error')),
449
+ );
450
+ }
451
+
452
+ /** Resolve explicit accessible names; group entry buttons may use their visible text. */
453
+ function fieldLabel(control: HTMLElement): string {
454
+ const labelledBy = (control.getAttribute('aria-labelledby') ?? '')
455
+ .split(/\s+/)
456
+ .map((id) => control.ownerDocument.getElementById(id)?.textContent?.trim())
457
+ .filter(Boolean)
458
+ .join(' ');
459
+ if (labelledBy) return labelledBy;
460
+ const ariaLabel = control.getAttribute('aria-label')?.trim();
461
+ if (ariaLabel) return ariaLabel;
462
+ if ('labels' in control) {
463
+ const labels = Array.from(
464
+ (control as HTMLInputElement).labels ?? [],
465
+ (label) => label.textContent?.trim(),
466
+ )
467
+ .filter(Boolean)
468
+ .join(' ');
469
+ if (labels) return labels;
470
+ }
471
+ return control.matches('button, [role="button"]')
472
+ ? (control.textContent?.trim() ?? '')
473
+ : '';
474
+ }