nuxt-ui-tools 1.3.0 → 1.4.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.
Files changed (68) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +8 -1
  3. package/dist/runtime/form/components/actions/form-actions.vue +13 -127
  4. package/dist/runtime/form/components/page/form-page-actions.d.vue.ts +3 -0
  5. package/dist/runtime/form/components/page/form-page-actions.vue +40 -0
  6. package/dist/runtime/form/components/page/form-page-actions.vue.d.ts +3 -0
  7. package/dist/runtime/form/components/page/form-page-header.d.vue.ts +24 -0
  8. package/dist/runtime/form/components/page/form-page-header.vue +99 -0
  9. package/dist/runtime/form/components/page/form-page-header.vue.d.ts +24 -0
  10. package/dist/runtime/form/components/page/form-page-navigation.d.vue.ts +34 -0
  11. package/dist/runtime/form/components/page/form-page-navigation.vue +229 -0
  12. package/dist/runtime/form/components/page/form-page-navigation.vue.d.ts +34 -0
  13. package/dist/runtime/form/components/page/form-page-section.d.vue.ts +19 -0
  14. package/dist/runtime/form/components/page/form-page-section.vue +114 -0
  15. package/dist/runtime/form/components/page/form-page-section.vue.d.ts +19 -0
  16. package/dist/runtime/form/components/page/form-page-sections.d.vue.ts +21 -0
  17. package/dist/runtime/form/components/page/form-page-sections.vue +75 -0
  18. package/dist/runtime/form/components/page/form-page-sections.vue.d.ts +21 -0
  19. package/dist/runtime/form/components/page/form-page.d.vue.ts +80 -0
  20. package/dist/runtime/form/components/page/form-page.vue +91 -0
  21. package/dist/runtime/form/components/page/form-page.vue.d.ts +80 -0
  22. package/dist/runtime/form/components/renderer/form-field-shell.vue +7 -11
  23. package/dist/runtime/form/components/root/form.vue +25 -194
  24. package/dist/runtime/form/composables/use-form-action-buttons.d.ts +32 -0
  25. package/dist/runtime/form/composables/use-form-action-buttons.js +89 -0
  26. package/dist/runtime/form/composables/use-form-actions.d.ts +8 -5
  27. package/dist/runtime/form/composables/use-form-actions.js +1 -1
  28. package/dist/runtime/form/composables/use-form-page-scroll.d.ts +22 -0
  29. package/dist/runtime/form/composables/use-form-page-scroll.js +178 -0
  30. package/dist/runtime/form/composables/use-form-page.d.ts +114 -0
  31. package/dist/runtime/form/composables/use-form-page.js +83 -0
  32. package/dist/runtime/form/composables/use-form-root.d.ts +34 -0
  33. package/dist/runtime/form/composables/use-form-root.js +171 -0
  34. package/dist/runtime/form/composables/use-form-runtime.js +13 -4
  35. package/dist/runtime/form/composables/use-form.js +2 -22
  36. package/dist/runtime/form/fields/checkbox-card/component.vue +28 -19
  37. package/dist/runtime/form/fields/checkbox-card/types.d.ts +2 -5
  38. package/dist/runtime/form/fields/choice-card/choice-card-label.d.vue.ts +17 -0
  39. package/dist/runtime/form/fields/choice-card/choice-card-label.vue +50 -0
  40. package/dist/runtime/form/fields/choice-card/choice-card-label.vue.d.ts +17 -0
  41. package/dist/runtime/form/fields/choice-card/types.d.ts +31 -0
  42. package/dist/runtime/form/fields/choice-card/types.js +0 -0
  43. package/dist/runtime/form/fields/choice-card/use-choice-card.d.ts +22 -0
  44. package/dist/runtime/form/fields/choice-card/use-choice-card.js +52 -0
  45. package/dist/runtime/form/fields/radio-card/component.vue +17 -18
  46. package/dist/runtime/form/fields/radio-card/types.d.ts +2 -4
  47. package/dist/runtime/form/index.d.ts +1 -1
  48. package/dist/runtime/form/index.js +7 -1
  49. package/dist/runtime/form/schema/index.d.ts +1 -1
  50. package/dist/runtime/form/schema/index.js +1 -0
  51. package/dist/runtime/form/schema/page.d.ts +50 -0
  52. package/dist/runtime/form/schema/page.js +25 -0
  53. package/dist/runtime/form/types/index.d.ts +1 -0
  54. package/dist/runtime/form/types/page.d.ts +172 -0
  55. package/dist/runtime/form/types/page.js +0 -0
  56. package/dist/runtime/form/types/ui.d.ts +60 -0
  57. package/dist/runtime/form/utils/controls.d.ts +15 -0
  58. package/dist/runtime/form/utils/controls.js +38 -0
  59. package/dist/runtime/form/utils/layout.js +3 -1
  60. package/dist/runtime/form/utils/page.d.ts +20 -0
  61. package/dist/runtime/form/utils/page.js +107 -0
  62. package/dist/runtime/form/utils/state.d.ts +2 -0
  63. package/dist/runtime/form/utils/state.js +1 -1
  64. package/dist/runtime/form/utils/ui.js +1 -0
  65. package/dist/runtime/i18n/locales/en.js +18 -0
  66. package/dist/runtime/i18n/locales/fr.js +18 -0
  67. package/dist/runtime/i18n/types.d.ts +29 -0
  68. package/package.json +1 -1
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Presentation of the radio and checkbox cards.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * // A fixed grid of cards, each with its icon in a tile above the label and a check in the
7
+ * // corner once selected.
8
+ * props: { columns: '1 md:2 lg:3', icon: 'tile', indicator: 'corner' }
9
+ * ```
10
+ */
11
+ export interface FormChoiceCardProps {
12
+ /**
13
+ * Lays the cards out in a fixed grid of this many equal columns. Accepts breakpoints:
14
+ * `3` or `'1 md:2 lg:3'`. Without it, cards flow along `orientation`.
15
+ */
16
+ columns?: number | string;
17
+ /** Direction of the cards when they are not in a `columns` grid. */
18
+ orientation?: 'horizontal' | 'vertical';
19
+ /**
20
+ * Selection mark: the radio or checkbox before the content (`start`, the default), after it
21
+ * (`end`), none (`hidden`), or a check in the top corner of the selected cards (`corner`).
22
+ */
23
+ indicator?: 'start' | 'end' | 'hidden' | 'corner';
24
+ /**
25
+ * Where an option's `icon` shows: before its label (`inline`, the default), or in a tile above
26
+ * it (`tile`) that takes the selection color. Style the parts the engine renders with the
27
+ * `ui` slots `tile`, `tileIcon`, `optionIcon`, `check`, and `checkIcon`, next to the Nuxt UI
28
+ * slots of the group.
29
+ */
30
+ icon?: 'inline' | 'tile';
31
+ }
File without changes
@@ -0,0 +1,22 @@
1
+ import type { FormControlUi } from '../../types/index.js';
2
+ import type { FormChoiceCardProps } from './types.js';
3
+ /** Props the card fields render themselves, kept away from the Nuxt UI group. */
4
+ export declare const CHOICE_CARD_PROPS: string[];
5
+ /**
6
+ * Presentation of a radio or checkbox card group: the flowing row or fixed grid of cards, the
7
+ * selection mark, and where option icons go. Returns the props and `ui` slots for the Nuxt UI
8
+ * group, and what the card content renders (`tile`, `corner`).
9
+ */
10
+ export declare function useChoiceCard(params: {
11
+ props: () => FormChoiceCardProps;
12
+ ui: () => FormControlUi | undefined;
13
+ }): {
14
+ corner: import("vue").ComputedRef<boolean>;
15
+ indicator: import("vue").ComputedRef<"end" | "start" | "hidden" | undefined>;
16
+ orientation: import("vue").ComputedRef<"horizontal" | "vertical" | undefined>;
17
+ style: import("vue").ComputedRef<{
18
+ '--nut-choice-columns': string;
19
+ } | undefined>;
20
+ tile: import("vue").ComputedRef<boolean>;
21
+ ui: import("vue").ComputedRef<FormControlUi>;
22
+ };
@@ -0,0 +1,52 @@
1
+ import { computed } from "vue";
2
+ import { useResponsiveValue } from "../../../shared/composables/use-responsive-value.js";
3
+ import { mergeFormUiClass } from "../../utils/ui.js";
4
+ import { CARD_SELECTED_RING } from "../card-selection.js";
5
+ export const CHOICE_CARD_PROPS = ["columns", "icon", "indicator", "orientation"];
6
+ export function useChoiceCard(params) {
7
+ const columnsValue = useResponsiveValue(() => String(params.props().columns ?? ""));
8
+ const columns = computed(() => {
9
+ const count = Math.trunc(Number(columnsValue.value));
10
+ return count > 0 ? count : void 0;
11
+ });
12
+ const corner = computed(() => params.props().indicator === "corner");
13
+ const tile = computed(() => params.props().icon === "tile");
14
+ const indicator = computed(() => {
15
+ const value = params.props().indicator;
16
+ return value === "corner" ? "hidden" : value;
17
+ });
18
+ const orientation = computed(() => params.props().orientation);
19
+ const style = computed(
20
+ () => columns.value ? { "--nut-choice-columns": String(columns.value) } : void 0
21
+ );
22
+ const ui = computed(() => {
23
+ const current = params.ui();
24
+ return {
25
+ ...current,
26
+ fieldset: mergeFormUiClass(
27
+ columns.value ? "grid grid-cols-[repeat(var(--nut-choice-columns),minmax(0,1fr))] gap-2.5" : `gap-2.5${orientation.value === "horizontal" ? " flex-wrap" : ""}`,
28
+ current?.fieldset
29
+ ),
30
+ item: mergeFormUiClass(
31
+ `border-default ${CARD_SELECTED_RING}`,
32
+ corner.value ? "relative" : void 0,
33
+ current?.item
34
+ ),
35
+ // A tile stacks above the title, and the description below it, with gaps.
36
+ label: mergeFormUiClass(
37
+ tile.value ? "flex flex-col items-start gap-2.5 font-semibold" : void 0,
38
+ current?.label
39
+ ),
40
+ // A hidden indicator centers the text: keep it aligned, and clear of an inline corner check.
41
+ wrapper: mergeFormUiClass(
42
+ tile.value ? "flex flex-col gap-1.5" : void 0,
43
+ [
44
+ indicator.value === "hidden" ? "ms-0 text-start" : "",
45
+ corner.value && !tile.value ? "pe-7" : ""
46
+ ].join(" "),
47
+ current?.wrapper
48
+ )
49
+ };
50
+ });
51
+ return { corner, indicator, orientation, style, tile, ui };
52
+ }
@@ -1,20 +1,21 @@
1
1
  <script setup>
2
- import UIcon from "@nuxt/ui/components/Icon.vue";
3
2
  import URadioGroup from "@nuxt/ui/components/RadioGroup.vue";
4
3
  import { computed } from "vue";
5
4
  import FormFieldShell from "../../components/renderer/form-field-shell.vue";
6
5
  import { useFieldControl } from "../../composables/use-field-control";
7
6
  import { isBoolean, isNumber, isString } from "../../utils/predicate";
8
- import { mergeFormUiClass } from "../../utils/ui";
9
- import { CARD_SELECTED_RING } from "../card-selection";
7
+ import ChoiceCardLabel from "../choice-card/choice-card-label.vue";
8
+ import { CHOICE_CARD_PROPS, useChoiceCard } from "../choice-card/use-choice-card";
10
9
  const props = defineProps({
11
10
  field: { type: Object, required: true },
12
11
  path: { type: Array, required: true }
13
12
  });
14
13
  const { fieldProps, form, controlProps, disabled, handleBlur, options } = useFieldControl(
15
14
  () => props.field,
16
- () => props.path
15
+ () => props.path,
16
+ { omit: CHOICE_CARD_PROPS }
17
17
  );
18
+ const cards = useChoiceCard({ props: () => fieldProps.value, ui: () => controlProps.value.ui });
18
19
  const model = computed({
19
20
  get: () => {
20
21
  const value = form.getValue(props.path);
@@ -26,14 +27,6 @@ const model = computed({
26
27
  set: (value) => form.setValue(props.path, value)
27
28
  });
28
29
  const items = computed(() => [...options.items.value]);
29
- const groupUi = computed(() => ({
30
- ...controlProps.value.ui,
31
- fieldset: mergeFormUiClass(
32
- controlProps.value.ui?.fieldset,
33
- fieldProps.value.orientation === "horizontal" ? "flex-wrap" : void 0
34
- ),
35
- item: mergeFormUiClass(CARD_SELECTED_RING, controlProps.value.ui?.item)
36
- }));
37
30
  </script>
38
31
 
39
32
  <template>
@@ -45,16 +38,22 @@ const groupUi = computed(() => ({
45
38
  label-key="label"
46
39
  variant="card"
47
40
  :items="items"
48
- :orientation="fieldProps.orientation"
49
- :ui="groupUi"
41
+ :orientation="cards.orientation.value"
42
+ :indicator="cards.indicator.value"
43
+ :ui="cards.ui.value"
44
+ :style="cards.style.value"
50
45
  :disabled="disabled"
51
46
  @blur="handleBlur"
52
47
  >
53
48
  <template #label="{ item }">
54
- <span class="inline-flex items-center gap-2">
55
- <UIcon v-if="item.icon" :name="item.icon" class="size-4 shrink-0" aria-hidden="true" />
56
- <span>{{ item.label }}</span>
57
- </span>
49
+ <ChoiceCardLabel
50
+ :label="item.label"
51
+ :icon="item.icon"
52
+ :tile="cards.tile.value"
53
+ :corner="cards.corner.value"
54
+ :selected="model === item.value"
55
+ :ui="cards.ui.value"
56
+ />
58
57
  </template>
59
58
  </URadioGroup>
60
59
  </FormFieldShell>
@@ -1,10 +1,8 @@
1
1
  import type { FormStatefulFieldBase } from '../../types/field-base.js';
2
2
  import type { FieldOptionValue, NullableValue } from '../../types/field-output-utils.js';
3
3
  import type { FormOptionItem, FormOptionValue, FormOptionsSource } from '../../types/options.js';
4
- export interface FormRadioCardProps {
5
- orientation?: 'horizontal' | 'vertical';
6
- }
7
- export interface FormRadioCardField<TContext = NonNullable<unknown>, TDeps = NonNullable<unknown>, TValue extends FormOptionValue = FormOptionValue, TOption extends FormOptionItem<TValue> = FormOptionItem<TValue>> extends FormStatefulFieldBase<'radio-card', TValue | null, TContext, TDeps, FormRadioCardProps> {
4
+ import type { FormChoiceCardProps } from '../choice-card/types.js';
5
+ export interface FormRadioCardField<TContext = NonNullable<unknown>, TDeps = NonNullable<unknown>, TValue extends FormOptionValue = FormOptionValue, TOption extends FormOptionItem<TValue> = FormOptionItem<TValue>> extends FormStatefulFieldBase<'radio-card', TValue | null, TContext, TDeps, FormChoiceCardProps> {
8
6
  options: FormOptionsSource<TOption, TContext, TDeps, TValue | null>;
9
7
  }
10
8
  export type RadioCardFieldOutput<TField> = FieldOptionValue<TField> | NullableValue;
@@ -1,4 +1,4 @@
1
- export { defineFormField, defineFormFields, defineFormSchema } from './schema/index.js';
1
+ export { defineFormField, defineFormFields, defineFormPageSchema, defineFormPageSection, defineFormSchema, } from './schema/index.js';
2
2
  export { useForm } from './composables/use-form.js';
3
3
  export { useFormUi } from './composables/use-form-ui.js';
4
4
  export { provideFormApi, useFormApi } from './composables/use-form-api.js';
@@ -1,4 +1,10 @@
1
- export { defineFormField, defineFormFields, defineFormSchema } from "./schema/index.js";
1
+ export {
2
+ defineFormField,
3
+ defineFormFields,
4
+ defineFormPageSchema,
5
+ defineFormPageSection,
6
+ defineFormSchema
7
+ } from "./schema/index.js";
2
8
  export { useForm } from "./composables/use-form.js";
3
9
  export { useFormUi } from "./composables/use-form-ui.js";
4
10
  export { provideFormApi, useFormApi } from "./composables/use-form-api.js";
@@ -81,4 +81,4 @@ export declare function defineFormField<const TField extends FormField>(field: T
81
81
  * Defines a reusable field group while preserving literal inference.
82
82
  */
83
83
  export declare function defineFormFields<const TFields extends readonly FormField[]>(fields: TFields): TFields;
84
- export {};
84
+ export { defineFormPageSchema, defineFormPageSection } from './page.js';
@@ -7,3 +7,4 @@ export function defineFormField(field) {
7
7
  export function defineFormFields(fields) {
8
8
  return fields;
9
9
  }
10
+ export { defineFormPageSchema, defineFormPageSection } from "./page.js";
@@ -0,0 +1,50 @@
1
+ import type { FormContextData, FormContextDefinition, FormPageSchema, FormPageSchemaInput, FormPageSection } from '../types/index.js';
2
+ /**
3
+ * Defines one section of a form page while preserving literal field inference, so each section
4
+ * can live in its own file as a plain typed function.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * export function billingSection({ hasActiveContract }: { hasActiveContract: boolean }) {
9
+ * return defineFormPageSection({
10
+ * key: 'billing',
11
+ * label: () => t('account.sections.billing'),
12
+ * fields: [
13
+ * {
14
+ * key: 'vtestId',
15
+ * type: 'text',
16
+ * label: 'VTEST ID',
17
+ * // `accountType` belongs to another section: a page is one form, one state.
18
+ * dependencies: ['accountType'],
19
+ * required: ({ deps }) => hasActiveContract || 'accountType' in deps,
20
+ * },
21
+ * ],
22
+ * })
23
+ * }
24
+ * ```
25
+ */
26
+ export declare function defineFormPageSection<const TSection extends FormPageSection>(section: TSection): TSection;
27
+ /**
28
+ * Defines a form page: one form whose fields are grouped in `sections`. Each section becomes a
29
+ * card of the page and an entry of its navigation; render it with `<UiFormPage :form />`.
30
+ *
31
+ * The result is a normal form schema. Its `fields` are the cards generated from the sections,
32
+ * so `useForm`, validation, cross-section dependencies, and the typed `formData` work as for any
33
+ * schema, and the same schema opens in a modal or drawer as a stack of cards.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * export function accountFormSchema({ account }: { account?: Account } = {}) {
38
+ * return defineFormPageSchema({
39
+ * header: { title: () => t(account ? 'account.edit' : 'account.create') },
40
+ * controls: { dirtyCheck: Boolean(account) },
41
+ * navigation: { title: () => t('account.sections.title') },
42
+ * sections: [accountTypeSection(), identitySection(), billingSection({ hasActiveContract: false })],
43
+ * })
44
+ * }
45
+ * ```
46
+ */
47
+ export declare function defineFormPageSchema<const TContext extends FormContextDefinition, const TSections extends readonly FormPageSection<FormContextData<NoInfer<TContext>>>[]>(schema: FormPageSchemaInput<TContext, TSections> & {
48
+ context: TContext;
49
+ }): FormPageSchema<FormPageSchemaInput<TContext, TSections>>;
50
+ export declare function defineFormPageSchema<const TSections extends readonly FormPageSection<FormContextData<undefined>>[]>(schema: FormPageSchemaInput<undefined, TSections>): FormPageSchema<FormPageSchemaInput<undefined, TSections>>;
@@ -0,0 +1,25 @@
1
+ import { isRecord } from "../utils/path.js";
2
+ export function defineFormPageSection(section) {
3
+ return section;
4
+ }
5
+ export function defineFormPageSchema(schema) {
6
+ if (!isRecord(schema) || !Array.isArray(schema.sections)) {
7
+ return schema;
8
+ }
9
+ return { ...schema, fields: schema.sections.filter(isRecord).map(toSectionCard) };
10
+ }
11
+ const CARD_PROPERTIES = ["condition", "dependencies", "description"];
12
+ function toSectionCard(section) {
13
+ const card = {
14
+ fields: section.fields,
15
+ key: section.key,
16
+ label: section.label,
17
+ type: "card"
18
+ };
19
+ for (const property of CARD_PROPERTIES) {
20
+ if (section[property] !== void 0) {
21
+ card[property] = section[property];
22
+ }
23
+ }
24
+ return card;
25
+ }
@@ -15,6 +15,7 @@ export type * from './layout';
15
15
  export type * from './options';
16
16
  export type * from './options-runtime';
17
17
  export type * from './overlay';
18
+ export type * from './page';
18
19
  export type * from './output';
19
20
  export type * from './runtime';
20
21
  export type * from './schema';
@@ -0,0 +1,172 @@
1
+ import type { FormValue } from './/index.js';
2
+ import type { FormAction } from './actions.js';
3
+ import type { FormApi, FormSubmitHandler } from './api.js';
4
+ import type { FormFieldCallback } from './callbacks.js';
5
+ import type { FormContextData, FormContextDefinition } from './context.js';
6
+ import type { FormField } from './field.js';
7
+ import type { FormLayoutConfig } from './layout.js';
8
+ import type { FormControlsConfig, FormDrawerConfig, FormFullscreenConfig, FormHeaderConfig, FormModalConfig } from './schema.js';
9
+ import type { FormUiConfig } from './ui.js';
10
+ import type { FormText } from './utils.js';
11
+ /**
12
+ * One section of a form page: a card in the page and an entry in its navigation.
13
+ *
14
+ * Every text accepts a function, so it can be translated:
15
+ * `label: () => t('account.sections.identity')`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * export function identitySection() {
20
+ * return defineFormPageSection({
21
+ * key: 'identity',
22
+ * label: 'Identity',
23
+ * description: 'display name, legal entity and official identifiers',
24
+ * fields: [
25
+ * { key: 'name', type: 'text', label: 'Name', required: true },
26
+ * { key: 'legalEntity', type: 'text', label: 'Legal entity', required: true },
27
+ * ],
28
+ * })
29
+ * }
30
+ * ```
31
+ */
32
+ export interface FormPageSection<TContext = NonNullable<unknown>, TFields extends readonly FormField<TContext>[] = readonly FormField<TContext>[]> {
33
+ /**
34
+ * Names the section: its navigation entry, its scroll target, and the URL hash that opens the
35
+ * page on it (`#identity`). Also used as the section element's `id`, so keep it unique on the
36
+ * page. It does not prefix the keys of the section's fields.
37
+ */
38
+ key: string;
39
+ /** Title on the section card and in the navigation. */
40
+ label: FormText;
41
+ /** Supporting copy next to the title. */
42
+ description?: FormText;
43
+ /**
44
+ * Shows the section as optional and leaves it out of the sections left to complete. A section
45
+ * without a required field is optional already; set this when its required fields are filled
46
+ * by their defaults and the user has nothing to do there.
47
+ */
48
+ optional?: boolean;
49
+ /** Grid of the section's fields, merged over the schema `layout`. */
50
+ layout?: FormLayoutConfig;
51
+ /** Raw dependency paths read before evaluating `condition`. */
52
+ dependencies?: readonly (string | readonly [string, string])[];
53
+ /** Hides the section, its navigation entry, and its fields while it returns `false`. */
54
+ condition?: FormFieldCallback<boolean, TContext>;
55
+ /** Fields of the section. They write at the form root, as if the section were not there. */
56
+ fields: TFields;
57
+ }
58
+ /**
59
+ * Side navigation of a form page. Its entries are the page sections.
60
+ */
61
+ export interface FormPageNavigationConfig {
62
+ /** Heading above the entries, e.g. `'Création'`. Accepts a function to translate it. */
63
+ title?: FormText;
64
+ }
65
+ /**
66
+ * Schema accepted by `defineFormPageSchema`: a form schema whose `sections` replace `fields`.
67
+ */
68
+ export interface FormPageSchemaInput<TContext extends FormContextDefinition | undefined, TSections extends readonly FormPageSection<FormContextData<TContext>>[]> {
69
+ /** Stable key used by persistence, diagnostics, and test selectors. */
70
+ formKey?: string;
71
+ /** Page heading: `title`, `description`, and the `eyebrow` above the title. */
72
+ header?: FormHeaderConfig;
73
+ /** Form-scoped data sources exposed to fields as `ctx`. */
74
+ context?: TContext;
75
+ /** Default grid of every section. */
76
+ layout?: FormLayoutConfig;
77
+ /** Form-scoped presentation overrides, merged after app defaults. */
78
+ ui?: FormUiConfig;
79
+ /** Runtime lifecycle and validation controls. `dirtyCheck` rings the modified sections. */
80
+ controls?: FormControlsConfig;
81
+ /** Modal-shell sizing, used when the same schema opens in a modal. */
82
+ modal?: FormModalConfig;
83
+ /** Drawer-shell sizing, used when the same schema opens in a drawer. */
84
+ drawer?: FormDrawerConfig;
85
+ /** Fullscreen-shell behavior, used when the same schema opens fullscreen. */
86
+ fullscreen?: FormFullscreenConfig;
87
+ /** Page actions. Omit to render the built-in submit action. */
88
+ actions?: readonly FormAction[];
89
+ /** Runs after validation and before the external submit handler. Return `false` to cancel submit. */
90
+ onBeforeSubmit?: FormSubmitHandler<FormValue, never>;
91
+ /** Submit lifecycle hook. */
92
+ submit?: (params: {
93
+ value: FormValue;
94
+ api: FormApi;
95
+ ctx: FormContextData<TContext>;
96
+ }) => Promise<void> | void;
97
+ /** Side navigation of the page. */
98
+ navigation?: FormPageNavigationConfig;
99
+ /** Sections of the page, in navigation order. */
100
+ sections: TSections;
101
+ }
102
+ /**
103
+ * Card field generated from a section. It renders the section when the schema opens in an
104
+ * overlay, and its fields keep their root paths because a card adds no path segment.
105
+ */
106
+ export type FormPageSectionCard<TSection> = TSection extends {
107
+ readonly key: infer TKey extends string;
108
+ readonly fields: infer TFields;
109
+ } ? {
110
+ readonly type: 'card';
111
+ readonly key: TKey;
112
+ readonly fields: TFields;
113
+ } : never;
114
+ /** Card fields generated from the sections of a form page, in order. */
115
+ export type FormPageSectionCards<TSections extends readonly FormValue[]> = {
116
+ readonly [TIndex in keyof TSections]: FormPageSectionCard<TSections[TIndex]>;
117
+ };
118
+ /**
119
+ * Schema returned by `defineFormPageSchema`: a normal form schema whose `fields` are the cards
120
+ * generated from `sections`, plus the `sections` and `navigation` that `FormPage` reads.
121
+ */
122
+ export type FormPageSchema<TInput extends {
123
+ readonly sections: readonly FormValue[];
124
+ }> = TInput & {
125
+ readonly fields: FormPageSectionCards<TInput['sections']>;
126
+ };
127
+ /** A section paired with the card generated for it, as the page runtime reads them. */
128
+ export interface FormPageSectionEntry {
129
+ section: FormPageSection;
130
+ card: FormField;
131
+ }
132
+ /** How a form page scrolls to a section. */
133
+ export interface FormPageScrollOptions {
134
+ /** Defaults to `smooth`, or `instant` when the user prefers reduced motion. */
135
+ behavior?: ScrollBehavior;
136
+ /** Records the section in the URL hash. Defaults to the page `hash` option. */
137
+ hash?: boolean;
138
+ /** Moves focus to the section title, for keyboard and screen reader users. */
139
+ focus?: boolean;
140
+ }
141
+ /**
142
+ * Where a section stands:
143
+ *
144
+ * - `invalid`: it shows at least one validation error
145
+ * - `complete`: nothing required is missing and it shows no error; an optional section also
146
+ * needs a value, entered by the user or provided by the form input
147
+ * - `pending`: anything else
148
+ */
149
+ export type FormPageSectionStatus = 'invalid' | 'complete' | 'pending';
150
+ /**
151
+ * Live state of a visible section, as the page navigation and section slots receive it.
152
+ */
153
+ export interface FormPageSectionState {
154
+ /** Section key. */
155
+ key: string;
156
+ /** Position among the visible sections. */
157
+ index: number;
158
+ /** Resolved title. */
159
+ label: string;
160
+ /** Resolved supporting copy. */
161
+ description?: string;
162
+ /** True when the section is declared optional or has no required field. */
163
+ optional: boolean;
164
+ /** Number of required fields without a value. */
165
+ missing: number;
166
+ /** Summary of the section's requirements, see `FormPageSectionStatus`. */
167
+ status: FormPageSectionStatus;
168
+ /** True when a value of the section differs from the form's baseline. */
169
+ dirty: boolean;
170
+ /** Dirty paths inside the section. */
171
+ dirtyPaths: readonly string[];
172
+ }
File without changes
@@ -183,6 +183,64 @@ export interface FormOverlayUi {
183
183
  overlay?: FormUiClass;
184
184
  content?: FormUiClass;
185
185
  }
186
+ /**
187
+ * Slots of a form page and its parts. Data attributes carry the live state, so a slot can
188
+ * restyle one state: `data-active` on the current navigation entry, `data-state`
189
+ * (`complete`, `invalid`, `pending`) on entries and indicators, `data-dirty` on modified
190
+ * sections and entries.
191
+ *
192
+ * Two CSS variables place sections under the pinned header: `--nut-form-page-header` (its
193
+ * measured height, set by the page) and `--nut-form-page-gap` (the room below it, `24px` by
194
+ * default). Set them on an element around the navigation and sections, such as the root.
195
+ */
196
+ export interface FormPageUi {
197
+ /** The `<form>` element, which scrolls on its own and is the container of the page queries. */
198
+ root?: FormUiClass;
199
+ /** Grid holding the navigation and the sections. */
200
+ body?: FormUiClass;
201
+ header?: FormUiClass;
202
+ headerContent?: FormUiClass;
203
+ heading?: FormUiClass;
204
+ eyebrow?: FormUiClass;
205
+ title?: FormUiClass;
206
+ /** Line under the title: the description and the unsaved-changes badge. */
207
+ meta?: FormUiClass;
208
+ unsaved?: FormUiClass;
209
+ actions?: FormUiClass;
210
+ navigation?: FormUiClass;
211
+ /** Holds the navigation title and the entries. */
212
+ navigationGroup?: FormUiClass;
213
+ navigationTitle?: FormUiClass;
214
+ navigationList?: FormUiClass;
215
+ /** List item around each entry. */
216
+ navigationEntry?: FormUiClass;
217
+ navigationItem?: FormUiClass;
218
+ navigationIndicator?: FormUiClass;
219
+ /** Check of a complete section in the indicator. */
220
+ navigationIndicatorIcon?: FormUiClass;
221
+ /** Dot in the indicator of the current section while it is pending. */
222
+ navigationIndicatorMarker?: FormUiClass;
223
+ navigationLabel?: FormUiClass;
224
+ navigationOptional?: FormUiClass;
225
+ navigationDirty?: FormUiClass;
226
+ /** Wrapper under the entries, whose padding insets the summary. */
227
+ navigationFooter?: FormUiClass;
228
+ /** The summary, with its top border: "3 sections left to complete". */
229
+ navigationSummary?: FormUiClass;
230
+ /** The bold "3 sections" of the summary. */
231
+ navigationSummaryCount?: FormUiClass;
232
+ sections?: FormUiClass;
233
+ /** Placeholder card shown while the schema context loads. */
234
+ sectionSkeleton?: FormUiClass;
235
+ section?: FormUiClass;
236
+ sectionHeader?: FormUiClass;
237
+ sectionTitle?: FormUiClass;
238
+ sectionDescription?: FormUiClass;
239
+ sectionOptional?: FormUiClass;
240
+ sectionActions?: FormUiClass;
241
+ sectionReset?: FormUiClass;
242
+ sectionBody?: FormUiClass;
243
+ }
186
244
  export interface FormUiPartConfig<TUi> {
187
245
  ui?: TUi;
188
246
  }
@@ -219,4 +277,6 @@ export interface FormUiConfig {
219
277
  modal?: FormUiPartConfig<FormOverlayUi>;
220
278
  drawer?: FormUiPartConfig<FormOverlayUi>;
221
279
  fullscreen?: FormUiPartConfig<FormOverlayUi>;
280
+ /** Form page rendered by `FormPage` and its parts. */
281
+ page?: FormUiPartConfig<FormPageUi>;
222
282
  }
@@ -0,0 +1,15 @@
1
+ import type { FormUiConfig, FormValidationMode, FormValue } from '../types/index.js';
2
+ /** Reads the schema `controls` object. */
3
+ export declare function getSchemaControls(schema: FormValue): any;
4
+ /** Resolves `controls.syncInput`: `true`, a path list, or no synchronization. */
5
+ export declare function getSchemaSyncInput(schema: FormValue): boolean | readonly string[];
6
+ /** Resolves `controls.validate`, validating everything by default. */
7
+ export declare function getSchemaValidationMode(schema: FormValue): FormValidationMode;
8
+ /** Resolves `controls.autoFocus`: a raw field path, `true` for the first field, or `false`. */
9
+ export declare function getSchemaAutoFocus(schema: FormValue): string | boolean;
10
+ /** True when `controls.dirtyCheck` enables dirty metadata and reset affordances. */
11
+ export declare function getSchemaDirtyCheck(schema: FormValue): boolean;
12
+ /** Reads `controls.confirmNavOnDirty` as authored. */
13
+ export declare function getSchemaDirtyNavigation(schema: FormValue): unknown;
14
+ /** Reads the schema-level `ui` overrides. */
15
+ export declare function getSchemaUi(schema: FormValue): FormUiConfig | undefined;
@@ -0,0 +1,38 @@
1
+ import { isRecord } from "./path.js";
2
+ import { isBoolean, isString, stringArray } from "./predicate.js";
3
+ export function getSchemaControls(schema) {
4
+ if (!isRecord(schema)) {
5
+ return;
6
+ }
7
+ const controls = Object.getOwnPropertyDescriptor(schema, "controls")?.value;
8
+ return isRecord(controls) ? controls : void 0;
9
+ }
10
+ function getSchemaControl(schema, name) {
11
+ const controls = getSchemaControls(schema);
12
+ return controls ? Object.getOwnPropertyDescriptor(controls, name)?.value : void 0;
13
+ }
14
+ export function getSchemaSyncInput(schema) {
15
+ const value = getSchemaControl(schema, "syncInput");
16
+ return isBoolean(value) ? value : stringArray(value);
17
+ }
18
+ export function getSchemaValidationMode(schema) {
19
+ const value = getSchemaControl(schema, "validate");
20
+ return value === false || value === "required" || value === "validators" ? value : true;
21
+ }
22
+ export function getSchemaAutoFocus(schema) {
23
+ const value = getSchemaControl(schema, "autoFocus");
24
+ return isString(value) || isBoolean(value) ? value : false;
25
+ }
26
+ export function getSchemaDirtyCheck(schema) {
27
+ return getSchemaControl(schema, "dirtyCheck") === true;
28
+ }
29
+ export function getSchemaDirtyNavigation(schema) {
30
+ return getSchemaControl(schema, "confirmNavOnDirty");
31
+ }
32
+ export function getSchemaUi(schema) {
33
+ if (!isRecord(schema)) {
34
+ return void 0;
35
+ }
36
+ const value = Object.getOwnPropertyDescriptor(schema, "ui")?.value;
37
+ return isRecord(value) ? value : void 0;
38
+ }
@@ -8,7 +8,9 @@ export function resolveFormLayoutConfig(base, override) {
8
8
  return {
9
9
  columns: override?.columns ?? base?.columns ?? FORM_LAYOUT_DEFAULTS.columns,
10
10
  fieldSpan: override?.fieldSpan ?? base?.fieldSpan ?? FORM_LAYOUT_DEFAULTS.fieldSpan,
11
- gap: override?.gap ?? base?.gap ?? FORM_LAYOUT_DEFAULTS.gap
11
+ gap: override?.gap ?? base?.gap ?? FORM_LAYOUT_DEFAULTS.gap,
12
+ labelPosition: override?.labelPosition ?? base?.labelPosition,
13
+ labelWidth: override?.labelWidth ?? base?.labelWidth
12
14
  };
13
15
  }
14
16
  export function normalizeFormLayoutGap(value) {
@@ -0,0 +1,20 @@
1
+ import type { FormObject, FormPageSectionEntry, FormPageSectionState, FormRuntime, FormValue } from '../types/index.js';
2
+ /** Pairs each section of a page schema with the card `defineFormPageSchema` generated for it. */
3
+ export declare function getFormPageSections(schema: FormValue): readonly FormPageSectionEntry[];
4
+ /** Resolved heading of the page navigation. */
5
+ export declare function getFormPageNavigationTitle(schema: FormValue): string | undefined;
6
+ /** True while the section's `condition` lets it render. */
7
+ export declare function isFormPageSectionVisible(entry: FormPageSectionEntry, runtime: FormRuntime): boolean;
8
+ /**
9
+ * Live state of one visible section: what it still misses, whether it shows errors, and what
10
+ * changed since the baseline.
11
+ */
12
+ export declare function resolveFormPageSectionState(params: {
13
+ entry: FormPageSectionEntry;
14
+ index: number;
15
+ runtime: FormRuntime;
16
+ /** Input the form was opened with: a value it provides counts as filled in. */
17
+ input: FormObject | undefined;
18
+ /** False when the validation mode does not enforce required fields. */
19
+ requiredEnforced: boolean;
20
+ }): FormPageSectionState;