entasis 0.10.1 → 0.11.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 (39) hide show
  1. package/dist/components/Avatar/avatar.theme.js +3 -1
  2. package/dist/components/Avatar/avatarGroup.theme.js +4 -1
  3. package/dist/components/Form/Ask/ask.mcp.d.ts +1 -1
  4. package/dist/components/Form/Ask/ask.mcp.js +1 -1
  5. package/dist/components/Form/Field/Field.svelte +2 -2
  6. package/dist/components/Form/Field/Field.svelte.d.ts +6 -6
  7. package/dist/components/Form/Field/field.d.ts +12 -3
  8. package/dist/components/Form/Field/field.state.svelte.d.ts +8 -37
  9. package/dist/components/Form/Field/field.state.svelte.js +21 -4
  10. package/dist/components/Form/Form/Form.svelte +2 -1
  11. package/dist/components/Form/Form/FormFieldRenderer.svelte +57 -11
  12. package/dist/components/Form/Form/FormFieldRenderer.svelte.d.ts +3 -2
  13. package/dist/components/Form/Form/form.d.ts +52 -8
  14. package/dist/components/Form/Form/form.mcp.d.ts +1 -1
  15. package/dist/components/Form/Form/form.mcp.js +66 -0
  16. package/dist/components/Form/Form/form.registry.d.ts +28 -0
  17. package/dist/components/Form/Form/form.registry.js +62 -0
  18. package/dist/components/Form/Form/form.state.svelte.d.ts +49 -8
  19. package/dist/components/Form/Form/form.state.svelte.js +3 -2
  20. package/dist/components/Form/Form/index.d.ts +2 -1
  21. package/dist/components/Form/Form/index.js +1 -0
  22. package/dist/components/Form/Form/visibility.d.ts +32 -30
  23. package/dist/components/Form/MultiStepForm/multiStepForm.state.svelte.d.ts +28 -0
  24. package/dist/components/Marquee/Marquee.svelte +8 -6
  25. package/dist/components/Marquee/marquee.mcp.d.ts +1 -1
  26. package/dist/components/Marquee/marquee.mcp.js +11 -11
  27. package/dist/components/Marquee/marquee.theme.js +17 -8
  28. package/dist/components/Sidebar/SidebarMenuItem.svelte +9 -1
  29. package/dist/components/Sidebar/SidebarMenuSubItem.svelte +71 -6
  30. package/dist/components/Sidebar/SidebarMenuSubItem.svelte.d.ts +4 -1
  31. package/dist/components/Sidebar/sidebar.mcp.d.ts +1 -1
  32. package/dist/components/Sidebar/sidebar.mcp.js +62 -0
  33. package/dist/components/Sidebar/sidebar.props.d.ts +18 -0
  34. package/dist/components/Sidebar/sidebar.theme.d.ts +60 -0
  35. package/dist/components/Sidebar/sidebar.theme.js +40 -2
  36. package/dist/generated/componentContract.d.ts +1 -1
  37. package/dist/generated/componentContract.js +6 -0
  38. package/dist/generated/componentMcpRegistry.d.ts +4 -4
  39. package/package.json +1 -1
@@ -52,12 +52,14 @@ const defaultAvatarSuffix = cva({
52
52
  size: 'normal'
53
53
  }
54
54
  });
55
+ // Small initials sit on the type scale's smallest step, so they tighten their tracking instead:
56
+ // two bold capitals then fill the 24px circle in the same proportion the larger sizes do.
55
57
  const defaultAvatarInitials = cva({
56
58
  base: 'absolute bg-surface-floating bottom-0 w-full h-full text-center left-0 rounded-full flex items-center justify-center uppercase font-bold text-sm',
57
59
  variants: {
58
60
  size: {
59
61
  normal: 'text-sm',
60
- small: 'text-xs',
62
+ small: 'text-xs tracking-tight',
61
63
  large: 'text-base'
62
64
  }
63
65
  },
@@ -1,11 +1,14 @@
1
1
  import { cva } from '../../utils/cva/index.js';
2
2
  import { setComponentTheme, useComponentTheme } from '../../utils/cva/index.js';
3
+ // Each size overlaps its neighbour by about a fifth of an avatar (7.2/32, 4.8/24, 8/40px), so a
4
+ // stack reads the same at every size. Small used to overlap 0.4rem — over a quarter of its 24px —
5
+ // which slid each circle across the end of the previous avatar's two-letter initials.
3
6
  const defaultAvatarGroup = cva({
4
7
  base: 'flex gap-[-0.25rem] items-center isolate ml-[0.3rem]',
5
8
  variants: {
6
9
  size: {
7
10
  normal: '[&>[data-avatar]]:ml-[-0.45rem]',
8
- small: '[&>[data-avatar]]:ml-[-0.4rem]',
11
+ small: '[&>[data-avatar]]:ml-[-0.3rem]',
9
12
  large: '[&>[data-avatar]]:ml-[-0.5rem]'
10
13
  }
11
14
  },
@@ -1 +1 @@
1
- export declare const askDescription = "\n# Ask\n\nImport from `entasis/ask`.\n\nMount Ask to host programmatic requests created by ask. Requests use the existing Form inputs and validation boundary inside Dialog. AskResult reports submitted and value; cancellation preserves the current form value. Submission failures reject the request. Mount the host before requesting a form.\n";
1
+ export declare const askDescription = "\n# Ask\n\nImport from `entasis/ask`.\n\nMount Ask to host programmatic requests created by ask. Requests use the existing Form inputs and validation boundary inside Dialog, including input types the app registered with registerFormInputs (see Form), so a request can arrive as plain data. AskResult reports submitted and value; cancellation preserves the current form value. Submission failures reject the request. Mount the host before requesting a form.\n";
@@ -3,5 +3,5 @@ export const askDescription = `
3
3
 
4
4
  Import from \`entasis/ask\`.
5
5
 
6
- Mount Ask to host programmatic requests created by ask. Requests use the existing Form inputs and validation boundary inside Dialog. AskResult reports submitted and value; cancellation preserves the current form value. Submission failures reject the request. Mount the host before requesting a form.
6
+ Mount Ask to host programmatic requests created by ask. Requests use the existing Form inputs and validation boundary inside Dialog, including input types the app registered with registerFormInputs (see Form), so a request can arrive as plain data. AskResult reports submitted and value; cancellation preserves the current form value. Submission failures reject the request. Mount the host before requesting a form.
7
7
  `;
@@ -1,6 +1,6 @@
1
- <script lang="ts" generics="Type extends InputType">
1
+ <script lang="ts" generics="Type extends FieldType">
2
2
  import Slot from '../../Slot/Slot.svelte';
3
- import type { InputType, FieldProps } from './field.js';
3
+ import type { FieldType, FieldProps } from './field.js';
4
4
  import { useFieldTheme } from './field.theme.js';
5
5
 
6
6
  let {
@@ -1,12 +1,12 @@
1
- import type { InputType, FieldProps } from './field.js';
2
- declare function $$render<Type extends InputType>(): {
1
+ import type { FieldType, FieldProps } from './field.js';
2
+ declare function $$render<Type extends FieldType>(): {
3
3
  props: FieldProps<Type>;
4
4
  exports: {};
5
5
  bindings: "";
6
6
  slots: {};
7
7
  events: {};
8
8
  };
9
- declare class __sveltets_Render<Type extends InputType> {
9
+ declare class __sveltets_Render<Type extends FieldType> {
10
10
  props(): ReturnType<typeof $$render<Type>>['props'];
11
11
  events(): ReturnType<typeof $$render<Type>>['events'];
12
12
  slots(): ReturnType<typeof $$render<Type>>['slots'];
@@ -14,12 +14,12 @@ declare class __sveltets_Render<Type extends InputType> {
14
14
  exports(): {};
15
15
  }
16
16
  interface $$IsomorphicComponent {
17
- new <Type extends InputType>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Type>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Type>['props']>, ReturnType<__sveltets_Render<Type>['events']>, ReturnType<__sveltets_Render<Type>['slots']>> & {
17
+ new <Type extends FieldType>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Type>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Type>['props']>, ReturnType<__sveltets_Render<Type>['events']>, ReturnType<__sveltets_Render<Type>['slots']>> & {
18
18
  $$bindings?: ReturnType<__sveltets_Render<Type>['bindings']>;
19
19
  } & ReturnType<__sveltets_Render<Type>['exports']>;
20
- <Type extends InputType>(internal: unknown, props: ReturnType<__sveltets_Render<Type>['props']> & {}): ReturnType<__sveltets_Render<Type>['exports']>;
20
+ <Type extends FieldType>(internal: unknown, props: ReturnType<__sveltets_Render<Type>['props']> & {}): ReturnType<__sveltets_Render<Type>['exports']>;
21
21
  z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
22
  }
23
23
  declare const Field: $$IsomorphicComponent;
24
- type Field<Type extends InputType> = InstanceType<typeof Field<Type>>;
24
+ type Field<Type extends FieldType> = InstanceType<typeof Field<Type>>;
25
25
  export default Field;
@@ -4,6 +4,7 @@ import type { HTMLAttributes } from 'svelte/elements';
4
4
  import type { FieldState } from './field.state.svelte.js';
5
5
  import type { FieldThemeProps } from './field.theme.js';
6
6
  import type { Density, Sizes } from '../../../types/theme.js';
7
+ import type { FormInputRegistry } from '../Form/form.js';
7
8
  export type KeyValuePair = {
8
9
  key: string;
9
10
  value: string;
@@ -29,8 +30,15 @@ export type ColorInputType = 'color';
29
30
  export type FieldLabelPosition = 'top' | 'left';
30
31
  export type FieldAttributes = Omit<HTMLAttributes<HTMLElement>, 'class' | 'id'>;
31
32
  export type InputType = FileInputType | SliderRangeInputType | DateInputType | ColorInputType | NumberInputType | RatingInputType | VoiceInputType | TimeInputType | TextInputType | RichTextInputType | BooleanInputType | MultipleChoiceInputType | TagInputType | TagGroupInputType | KeyValueInputType | PinInputType | SingleOptionInputType | CalendarInputType;
32
- export type FieldValue<T extends InputType> = T extends 'file' ? File : T extends 'files' ? File[] : T extends DateInputType ? Date : T extends SliderRangeInputType ? number[] : T extends NumberInputType ? number : T extends RatingInputType ? number : T extends VoiceInputType ? Blob : T extends TimeInputType ? number : T extends TextInputType ? string : T extends RichTextInputType ? string : T extends PinInputType ? string : T extends BooleanInputType ? boolean : T extends MultipleChoiceInputType ? string[] : T extends TagInputType ? string[] : T extends TagGroupInputType ? string | string[] | null : T extends KeyValueInputType ? KeyValuePair[] : T extends SingleOptionInputType ? string : T extends 'calendar' ? Date : T extends 'calendar-range' ? [Date | null, Date | null] : T extends ColorInputType ? string : never;
33
- export type InputProps<T extends InputType> = WithSlot<{
33
+ /** Input types an app registered: the keys of its `FormInputRegistry` augmentation. */
34
+ export type RegisteredInputType = Extract<keyof FormInputRegistry, string>;
35
+ /** Every type a field can carry: the built-in inputs and the ones an app registered. */
36
+ export type FieldType = InputType | RegisteredInputType;
37
+ type RegisteredInputValue<T extends RegisteredInputType> = FormInputRegistry[T] extends {
38
+ value: infer Value;
39
+ } ? Value : unknown;
40
+ export type FieldValue<T extends FieldType> = T extends RegisteredInputType ? RegisteredInputValue<T> : T extends 'file' ? File : T extends 'files' ? File[] : T extends DateInputType ? Date : T extends SliderRangeInputType ? number[] : T extends NumberInputType ? number : T extends RatingInputType ? number : T extends VoiceInputType ? Blob : T extends TimeInputType ? number : T extends TextInputType ? string : T extends RichTextInputType ? string : T extends PinInputType ? string : T extends BooleanInputType ? boolean : T extends MultipleChoiceInputType ? string[] : T extends TagInputType ? string[] : T extends TagGroupInputType ? string | string[] | null : T extends KeyValueInputType ? KeyValuePair[] : T extends SingleOptionInputType ? string : T extends 'calendar' ? Date : T extends 'calendar-range' ? [Date | null, Date | null] : T extends ColorInputType ? string : never;
41
+ export type InputProps<T extends FieldType> = WithSlot<{
34
42
  /** Form field name, used as the key when the input is part of a Form. */
35
43
  name?: string;
36
44
  /** Marks the field as required for validation and shows the required indicator. */
@@ -64,7 +72,7 @@ export type InputProps<T extends InputType> = WithSlot<{
64
72
  /** Whether the field currently holds focus, bindable with `bind:focused`. */
65
73
  focused?: boolean;
66
74
  }, 'header' | 'label' | 'suffix' | 'prefix' | 'actions' | 'description' | 'helper' | 'footer' | 'error' | 'errorsContainer'>;
67
- export type FieldProps<T extends InputType> = Omit<InputProps<T>, 'type' | 'name' | 'required' | 'disabled' | 'visible' | 'onValidate' | 'onValueChange' | 'defaultValue' | 'value' | 'errors' | 'focused'> & {
75
+ export type FieldProps<T extends FieldType> = Omit<InputProps<T>, 'type' | 'name' | 'required' | 'disabled' | 'visible' | 'onValidate' | 'onValueChange' | 'defaultValue' | 'value' | 'errors' | 'focused'> & {
68
76
  /** Native wrapper element; use fieldset for a group of controls. */
69
77
  as?: string;
70
78
  /** Control ID associated with the label, or false for an aria-labelledby label. */
@@ -76,3 +84,4 @@ export type FieldProps<T extends InputType> = Omit<InputProps<T>, 'type' | 'name
76
84
  };
77
85
  export declare const fieldStructure = "\n<Field>\n\t<Header>\n\t\t<Label />\n\t\t<Actions />\n\t</Header>\n\t<InputContainer>\n\t\t<Prefix />\n\t\t<Input />\n\t\t<Suffix />\n\t</InputContainer>\n\t<Footer>\n\t\t<Description />\n\t\t<Helper />\n\t</Footer>\n <ErrorsContainer>\n <Error />\n </ErrorsContainer>\n</Field>\n";
78
86
  export declare const llmDescription = "\nThe field component is a versatile wrapper component for form inputs.\nThe field component is used to wrap form inputs and provide a consistent look and feel.\nIt's also used to provide a consistent way to handle errors and validation.\nUse it directly with createFieldState for standalone custom controls, or use a Form\ntype: 'field' entry to receive a registered controller in a snippet.\n\nThe field component is composed of the following parts:\n- Header\n- InputContainer\n- Footer\n- ErrorsContainer\n- Error\n- Description\n- Helper\n\nTo customize the field component, you can use the following snippets:\n\n- header: it is the wrapper for the label and actions.\n- label: it is the label for the field.\n- actions: it is the actions for the field.\n- footer: it is the wrapper for the description and helper.\n- errorsContainer: it is the wrapper for the error.\n- error: it is the error for the field.\n- description: it is the description for the field.\n- helper: it is the helper for the field.\n\nCompose snippets with the field controller captured in the parent component.\n\n";
87
+ export {};
@@ -1,10 +1,10 @@
1
- import type { FieldValue, InputType } from './field.js';
1
+ import type { FieldType, FieldValue } from './field.js';
2
2
  import * as v from 'valibot';
3
3
  import type { Attachment } from 'svelte/attachments';
4
4
  import type { FormState } from '../Form/form.state.svelte.js';
5
5
  import type { Density, Sizes } from '../../../types/theme.js';
6
6
  export type FieldValidationResult = string | string[] | boolean | null | undefined;
7
- type FieldStateStaticOptions<T extends InputType> = {
7
+ type FieldStateStaticOptions<T extends FieldType> = {
8
8
  type: T;
9
9
  name?: string;
10
10
  required?: boolean;
@@ -16,12 +16,12 @@ type FieldStateStaticOptions<T extends InputType> = {
16
16
  onValueChange?: (value: FieldValue<T> | null) => void;
17
17
  id: string;
18
18
  };
19
- type FieldStateBindableOptions<T extends InputType> = {
19
+ type FieldStateBindableOptions<T extends FieldType> = {
20
20
  value?: FieldValue<T> | null;
21
21
  errors: string[] | boolean;
22
22
  focused: boolean;
23
23
  };
24
- export type FieldState<T extends InputType> = ReturnType<typeof createFieldState<T>>;
24
+ export type FieldState<T extends FieldType> = ReturnType<typeof createFieldState<T>>;
25
25
  export type FieldControlAttributes = {
26
26
  id: string;
27
27
  name: string;
@@ -30,7 +30,7 @@ export type FieldControlAttributes = {
30
30
  'aria-invalid': 'true' | undefined;
31
31
  'aria-describedby': string | undefined;
32
32
  };
33
- export declare const createFieldState: <T extends InputType>(options: FieldStateBindableOptions<T> & FieldStateStaticOptions<T>) => {
33
+ export declare const createFieldState: <T extends FieldType>(options: FieldStateBindableOptions<T> & FieldStateStaticOptions<T>) => {
34
34
  name: string;
35
35
  size: Sizes;
36
36
  density: Density;
@@ -47,38 +47,9 @@ export declare const createFieldState: <T extends InputType>(options: FieldState
47
47
  setValue: (nextValue: FieldValue<T> | null | undefined) => void;
48
48
  /** @internal Applies parent/Form state without echoing a control change callback. */
49
49
  syncValue: (nextValue: FieldValue<T> | null | undefined) => void;
50
- checkSchema(value?: FieldValue<T> | null | undefined): v.SafeParseResult<{
51
- number: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
52
- select: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
53
- color: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
54
- date: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
55
- file: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
56
- files: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
57
- "slider-range": v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
58
- datetime: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
59
- slider: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
60
- rating: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
61
- voice: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
62
- time: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
63
- text: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
64
- password: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
65
- email: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
66
- url: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
67
- textarea: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
68
- phone: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
69
- "rich-text": v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
70
- switch: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
71
- checkbox: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
72
- checkboxes: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
73
- tag: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
74
- "tag-group": v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
75
- keyvalue: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
76
- pin: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
77
- radio: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
78
- combobox: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
79
- calendar: v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
80
- "calendar-range": v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>;
81
- }[T]>;
50
+ /** The registered definition behind an app-defined input type, if this field has one. */
51
+ get definition(): import("../Form/form.registry.js").RegisteredFormInputDefinition | undefined;
52
+ checkSchema(value?: FieldValue<T> | null | undefined): v.SafeParseResult<v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>;
82
53
  get isValid(): boolean;
83
54
  validate: (value?: FieldValue<T> | null | undefined) => readonly [boolean, unknown];
84
55
  value?: FieldValue<T> | null | undefined;
@@ -1,6 +1,7 @@
1
1
  import { createBindableStateClass } from '../../../utils/state.svelte.js';
2
2
  import * as v from 'valibot';
3
3
  import { schemas } from './schemas.js';
4
+ import { getFormInputDefinition, getRegisteredInputSchema } from '../Form/form.registry.js';
4
5
  import { getContext, onDestroy, untrack } from 'svelte';
5
6
  const normalizeErrors = (validation) => {
6
7
  if (!validation)
@@ -116,8 +117,15 @@ export const createFieldState = (options) => {
116
117
  options.value = nextValue;
117
118
  localValue = options.value;
118
119
  };
120
+ /** The registered definition behind an app-defined input type, if this field has one. */
121
+ get definition() {
122
+ return getFormInputDefinition(this.type);
123
+ }
119
124
  checkSchema(value) {
120
- const schema = schemas[this.required ? 'required' : 'optional'][this.type];
125
+ const definition = this.definition;
126
+ const schema = definition
127
+ ? getRegisteredInputSchema(definition, Boolean(this.required))
128
+ : schemas[this.required ? 'required' : 'optional'][this.type];
121
129
  return v.safeParse(schema, value);
122
130
  }
123
131
  get isValid() {
@@ -131,9 +139,18 @@ export const createFieldState = (options) => {
131
139
  return [true, parseResult.output];
132
140
  }
133
141
  // We should only call onValidate if the value is not null or undefined and not an empty string when the field is not required
134
- const shouldCallOnValidate = this.required || (value !== null && value !== undefined && value !== '');
135
- if (this.onValidate && shouldCallOnValidate) {
136
- this.errors = normalizeErrors(this.onValidate(value));
142
+ const definition = this.definition;
143
+ const shouldCallOnValidate = definition
144
+ ? !definition.isEmpty(value)
145
+ : this.required || (value !== null && value !== undefined && value !== '');
146
+ if (shouldCallOnValidate) {
147
+ // A registered type's own rule runs first; the entry's onValidate adds to it.
148
+ const errors = [
149
+ ...normalizeErrors(definition?.validate?.(value)),
150
+ ...normalizeErrors(this.onValidate?.(value))
151
+ ];
152
+ if (errors.length > 0)
153
+ this.errors = errors;
137
154
  }
138
155
  return [this.hasError, parseResult.output];
139
156
  };
@@ -11,6 +11,7 @@
11
11
  import type { FormProps } from './form.props.js';
12
12
  import { useFormTheme } from './form.theme.js';
13
13
  import { useForm } from './form.state.svelte.js';
14
+ import { isRegisteredFormInput } from './form.registry.js';
14
15
  import { formCardSurfaceContextKey, type FormCardSurfaceContext } from './form.context.js';
15
16
  let {
16
17
  inputs,
@@ -118,7 +119,7 @@
118
119
  className: [input.class, itemClass].filter(Boolean).join(' ')
119
120
  })}
120
121
  />
121
- {:else if input.type === 'field'}
122
+ {:else if input.type === 'field' || isRegisteredFormInput(input)}
122
123
  <FormFieldRenderer {name} {input} {size} {density} {labelPosition} {itemClass} />
123
124
  {:else}
124
125
  <FormInputRenderer {name} {input} {size} {density} {labelPosition} {itemClass} />
@@ -2,13 +2,46 @@
2
2
  import { untrack, type Snippet } from 'svelte';
3
3
  import type { Density, Sizes } from '../../../types/theme.js';
4
4
  import Field from '../Field/Field.svelte';
5
- import type { FieldLabelPosition, FieldValue, InputType } from '../Field/field.js';
5
+ import type { FieldLabelPosition, FieldType, FieldValue } from '../Field/field.js';
6
6
  import {
7
7
  createFieldState,
8
8
  type FieldState,
9
9
  type FieldValidationResult
10
10
  } from '../Field/field.state.svelte.js';
11
- import type { FormFieldEntry } from './form.js';
11
+ import type { FormFieldEntry, FormRegisteredInput } from './form.js';
12
+ import { getFormInputDefinition } from './form.registry.js';
13
+
14
+ // What the Field wrapper and the field controller consume from an entry; everything else on
15
+ // a registered entry belongs to its component.
16
+ const FIELD_ENTRY_KEYS = new Set([
17
+ 'type',
18
+ 'visible',
19
+ 'class',
20
+ 'name',
21
+ 'required',
22
+ 'disabled',
23
+ 'size',
24
+ 'density',
25
+ 'labelPosition',
26
+ 'onValidate',
27
+ 'onValueChange',
28
+ 'fieldAttrs',
29
+ 'theme',
30
+ 'value',
31
+ 'defaultValue',
32
+ 'errors',
33
+ 'focused',
34
+ 'header',
35
+ 'label',
36
+ 'actions',
37
+ 'description',
38
+ 'helper',
39
+ 'footer',
40
+ 'error',
41
+ 'errorsContainer',
42
+ 'prefix',
43
+ 'suffix'
44
+ ]);
12
45
 
13
46
  let {
14
47
  name,
@@ -19,7 +52,8 @@
19
52
  itemClass
20
53
  }: {
21
54
  name: string;
22
- input: FormFieldEntry;
55
+ /** A `type: 'field'` entry with its snippet, or an entry of an app-registered type. */
56
+ input: FormFieldEntry | FormRegisteredInput;
23
57
  size: Sizes;
24
58
  density: Density;
25
59
  labelPosition?: FieldLabelPosition;
@@ -28,18 +62,18 @@
28
62
 
29
63
  const id = $props.id();
30
64
  const initialInput = untrack(() => input);
31
- let value = $state<FieldValue<InputType> | null>(
65
+ let value = $state<FieldValue<FieldType> | null>(
32
66
  (initialInput.value === undefined
33
67
  ? (initialInput.defaultValue ?? null)
34
- : initialInput.value) as FieldValue<InputType> | null
68
+ : initialInput.value) as FieldValue<FieldType> | null
35
69
  );
36
70
  let errors = $state<string[] | boolean>(initialInput.errors ?? []);
37
71
  let focused = $state(initialInput.focused ?? false);
38
72
 
39
- const field = createFieldState<InputType>({
73
+ const field = createFieldState<FieldType>({
40
74
  id,
41
75
  get type() {
42
- return input.fieldType;
76
+ return input.type === 'field' ? input.fieldType : input.type;
43
77
  },
44
78
  get name() {
45
79
  return name;
@@ -76,10 +110,10 @@
76
110
  },
77
111
  get onValidate() {
78
112
  return input.onValidate as
79
- ((value: FieldValue<InputType>) => FieldValidationResult) | undefined;
113
+ ((value: FieldValue<FieldType>) => FieldValidationResult) | undefined;
80
114
  },
81
115
  get onValueChange() {
82
- return input.onValueChange as ((value: FieldValue<InputType> | null) => void) | undefined;
116
+ return input.onValueChange as ((value: FieldValue<FieldType> | null) => void) | undefined;
83
117
  },
84
118
  visible: true
85
119
  });
@@ -87,7 +121,15 @@
87
121
  const resolvedSize = $derived(input.size ?? size);
88
122
  const resolvedLabelPosition = $derived(input.labelPosition ?? labelPosition);
89
123
  const className = $derived([input.class, itemClass].filter(Boolean).join(' ') || undefined);
90
- const controlSnippet = $derived(input.snippet as Snippet<[field: FieldState<InputType>]>);
124
+ const controlSnippet = $derived(
125
+ input.type === 'field' ? (input.snippet as Snippet<[field: FieldState<FieldType>]>) : undefined
126
+ );
127
+ const Control = $derived(
128
+ input.type === 'field' ? undefined : getFormInputDefinition(input.type)?.component
129
+ );
130
+ const controlProps = $derived(
131
+ Object.fromEntries(Object.entries(input).filter(([key]) => !FIELD_ENTRY_KEYS.has(key)))
132
+ );
91
133
  </script>
92
134
 
93
135
  <Field
@@ -109,5 +151,9 @@
109
151
  prefix={input.prefix}
110
152
  suffix={input.suffix}
111
153
  >
112
- {@render controlSnippet(field)}
154
+ {#if controlSnippet}
155
+ {@render controlSnippet(field)}
156
+ {:else if Control}
157
+ <Control {...controlProps} {field} />
158
+ {/if}
113
159
  </Field>
@@ -1,9 +1,10 @@
1
1
  import type { Density, Sizes } from '../../../types/theme.js';
2
2
  import type { FieldLabelPosition } from '../Field/field.js';
3
- import type { FormFieldEntry } from './form.js';
3
+ import type { FormFieldEntry, FormRegisteredInput } from './form.js';
4
4
  type $$ComponentProps = {
5
5
  name: string;
6
- input: FormFieldEntry;
6
+ /** A `type: 'field'` entry with its snippet, or an entry of an app-registered type. */
7
+ input: FormFieldEntry | FormRegisteredInput;
7
8
  size: Sizes;
8
9
  density: Density;
9
10
  labelPosition?: FieldLabelPosition;
@@ -1,5 +1,5 @@
1
- import type { FieldLabelPosition, FieldValue, InputProps, InputType } from '../Field/field.js';
2
- import type { FieldState } from '../Field/field.state.svelte.js';
1
+ import type { FieldLabelPosition, FieldType, FieldValue, InputProps, InputType, RegisteredInputType } from '../Field/field.js';
2
+ import type { FieldState, FieldValidationResult } from '../Field/field.state.svelte.js';
3
3
  import type { TextInputProps } from '../TextInput/textInput.props.js';
4
4
  import type { NumberInputProps } from '../NumberInput/numberInput.props.js';
5
5
  import type { RatingInputProps } from '../RatingInput/ratingInput.props.js';
@@ -28,10 +28,50 @@ import type { KeyValueInputProps } from '../KeyValueInput/keyValueInput.props.js
28
28
  import type { PinInputProps } from '../PinInput/pinInput.props.js';
29
29
  import type { Slot } from '../../Slot/slot.js';
30
30
  import type { ButtonProps } from '../../Button/index.js';
31
- import type { Snippet } from 'svelte';
31
+ import type { Component, Snippet } from 'svelte';
32
32
  import type { FormState } from './form.state.svelte.js';
33
33
  export type MaybePromise<T> = T | Promise<T>;
34
- export type FormValueRecord = Record<string, FieldValue<InputType> | null | undefined>;
34
+ /**
35
+ * Input types an app adds to Form, ask() and every other Form-driven surface. Empty in the
36
+ * library; an app augments it once per custom input, naming the value the input holds and the
37
+ * props its entries take:
38
+ *
39
+ * ```ts
40
+ * declare module 'entasis/form' {
41
+ * interface FormInputRegistry {
42
+ * money: { value: { amount: number; currency: string }; props: { currencies?: string[] } };
43
+ * }
44
+ * }
45
+ * ```
46
+ *
47
+ * Entries then read `{ type: 'money', label: 'Price', currencies: ['EUR'] }`, plain data that
48
+ * serializes, and the form value infers `money`'s value. Pair each key with
49
+ * `registerFormInputs`, which supplies the component that renders it.
50
+ */
51
+ export interface FormInputRegistry {
52
+ }
53
+ type RegisteredInputProps<T extends RegisteredInputType> = FormInputRegistry[T] extends {
54
+ props: infer Props;
55
+ } ? Props : Record<never, never>;
56
+ /** Props of the component that renders a registered input: its entry props and its field. */
57
+ export type FormInputComponentProps<T extends RegisteredInputType> = RegisteredInputProps<T> & {
58
+ /** The field Form registered for the entry: value, errors, and control attributes. */
59
+ field: FieldState<T>;
60
+ };
61
+ /** How a registered input renders and validates; see `registerFormInputs`. */
62
+ export type FormInputDefinition<T extends RegisteredInputType = RegisteredInputType> = {
63
+ /**
64
+ * Renders the control. Form wraps it in Field (label, description, errors), so it renders
65
+ * the control alone: spread `field.controlAttrs`, attach `field.control`, and report edits
66
+ * with `field.setValue`.
67
+ */
68
+ component: Component<FormInputComponentProps<T>>;
69
+ /** What `required` rejects. Defaults to null, undefined, an empty string, and an empty array. */
70
+ isEmpty?: (value: FieldValue<T>) => boolean;
71
+ /** Validation every entry of this type gets, before the entry's own `onValidate`. */
72
+ validate?: (value: FieldValue<T>) => FieldValidationResult;
73
+ };
74
+ export type FormValueRecord = Record<string, FieldValue<FieldType> | null | undefined>;
35
75
  type FormVisibility = boolean | ((value: FormValueRecord) => boolean);
36
76
  type FormButtonAction<State> = ButtonProps & {
37
77
  /** Called with the live form state when the action is activated. */
@@ -152,8 +192,12 @@ export type FormFieldEntry<T extends InputType = InputType> = T extends InputTyp
152
192
  /** Renders the control with its registered, bindable FieldState controller. */
153
193
  snippet: Snippet<[field: FormFieldController<T>]>;
154
194
  } : never;
155
- export type FormValueInput = FormFieldInput | FormFieldEntry;
156
- export type FormRenderableInput = FormFieldInput | FormFieldEntry | FormActionInput | FormCustomInput;
195
+ /** An entry of a type the app registered through `FormInputRegistry` and `registerFormInputs`. */
196
+ export type FormRegisteredInput<T extends RegisteredInputType = RegisteredInputType> = T extends RegisteredInputType ? BaseFormRenderableInput & Omit<InputProps<T>, 'class' | 'name' | 'visible'> & RegisteredInputProps<T> & {
197
+ type: T;
198
+ } : never;
199
+ export type FormValueInput = FormFieldInput | FormFieldEntry | FormRegisteredInput;
200
+ export type FormRenderableInput = FormFieldInput | FormFieldEntry | FormRegisteredInput | FormActionInput | FormCustomInput;
157
201
  export type FormGroupInputs = Record<string, FormRenderableInput>;
158
202
  export type FormGroupColumns = 1 | 2 | 3 | 4;
159
203
  export type FormGroup<I extends FormGroupInputs = FormGroupInputs> = {
@@ -196,7 +240,7 @@ type BindFormInputState<Input, I extends FormInputs> = Input extends {
196
240
  export type FormInputsWithState<I extends FormInputs> = string extends keyof I ? I : {
197
241
  [K in keyof I]: BindFormInputState<I[K], I>;
198
242
  };
199
- type FormValueInputType<T extends FormValueInput> = T extends FormFieldEntry<infer Input> ? Input : T extends FormFieldInput ? T['type'] : never;
243
+ type FormValueInputType<T extends FormValueInput> = T extends FormFieldEntry<infer Input> ? Input : T extends FormFieldInput | FormRegisteredInput ? T['type'] : never;
200
244
  type InferInputValue<T extends FormValueInput> = T['required'] extends true ? NonNullable<FieldValue<FormValueInputType<T>>> : FieldValue<FormValueInputType<T>> | null;
201
245
  type UnionToIntersection<U> = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void ? I : never;
202
246
  type Simplify<T> = {
@@ -240,6 +284,6 @@ export type FlatFormField = {
240
284
  path: string;
241
285
  };
242
286
  export declare function flattenFormInputs(inputs: FormInputs): FlatFormField[];
243
- export declare const getFormValueInputType: (input: FormValueInput) => InputType;
287
+ export declare const getFormValueInputType: (input: FormValueInput) => FieldType;
244
288
  export type FormSubmitHandler<T extends FormInputs> = (value: InferFormValue<T>) => MaybePromise<unknown | void>;
245
289
  export {};
@@ -1 +1 @@
1
- export declare const formDescription = "\n# Form\n\nForm renders configured entasis fields inside a div, owns their state and validation, and submits programmatically. It provides managed Enter-key navigation and submission, but not native form events, browser constraint validation, or FormData behavior.\n\n## Basic usage\n\n```svelte\n<script lang=\"ts\">\n\timport {\n\t\tForm,\n\t\ttype FormState,\n\t\ttype InferFormValue,\n\t\ttype LiveFormValue\n\t} from 'entasis/form';\n\n\tconst inputs = {\n\t\tidentity: {\n\t\t\ttype: 'group',\n\t\t\tlabel: 'Identity',\n\t\t\tdescription: 'Core account details',\n\t\t\tcolumns: 2,\n\t\t\tinputs: {\n\t\t\t\tname: { type: 'text', label: 'Name', required: true },\n\t\t\t\temail: { type: 'email', label: 'Email', required: true }\n\t\t\t}\n\t\t}\n\t} as const;\n\n\tlet value = $state<LiveFormValue<typeof inputs>>({ name: 'Ada' });\n\tlet form = $state<FormState<typeof inputs>>();\n\tlet submitted = $state<InferFormValue<typeof inputs> | null>(null);\n</script>\n\n<Form\n\t{inputs}\n\tbind:value\n\tbind:form\n\tvariant=\"card\"\n\tlayout=\"horizontal\"\n\tsize=\"small\"\n\tdensity=\"normal\"\n\tactions={[\n\t\t{\n\t\t\tchildren: 'Save',\n\t\t\tonAction: (payload) => payload.submit()\n\t\t}\n\t]}\n\tonSubmit={(validatedValue) => {\n\t\tsubmitted = validatedValue;\n\t}}\n/>\n```\n\n## Props\n\n- **inputs** (required): ordered object keyed by entry name. Entries may be value-bearing fields, visual groups, action-button rows, or custom snippets. Only fields contribute keys to the live and validated values.\n- **value** (bindable): controlled partial live value. Visible mounted fields are published; hidden fields are omitted. A supplied top-level value takes precedence over an input's configured value.\n- **defaultValue**: initial partial live value when value is omitted.\n- **onValueChange**: called once when a user changes the visible live value. Parent value updates do not emit it.\n- **form** (bindable): the FormState instance.\n- **onSubmit**: called with the validated visible payload. Its return value is ignored; rejected promises propagate.\n- **size**: small, normal (default), or large. Controls Form typography and becomes the fallback size for fields and actions. An explicit field or action size takes precedence.\n- **density**: compact, normal (default), or comfortable. Controls gaps between the Form header, fields, footer, and actions independently from size.\n- **variant**: plain (default), sectioned, or card. All variants consume the shared Card typography. Plain remains transparent without separators. Sectioned stays transparent and adds edge-to-edge separators between the header, visible top-level fields or groups, and footer. Card adds the same separators inside the Card surface and inset; Card header/footer padding applies only to that variant. Separators do not appear between fields inside a group.\n- **layout**: vertical (default) or horizontal. Horizontal keeps one field per row and places labels to the left once each field is at least 32rem wide; fields remain stacked in narrower forms.\n- **actions**: optional array of Button props. Each action's onAction receives the live FormState, so a submit action calls form.submit(). Actions are disabled while the form is submitting; their own loading and disabled props are composed with that protection.\n- **class**: additional classes on the root div.\n- **theme**: Form theme overrides for root, header, title, description, group, group label/description/fields, top-level items, custom entries, footer, and actions. Global Card typography and section-rhythm changes flow into all Form variants before Form-specific overrides are composed; Card surface changes apply only to the card variant.\n\nThe **header**, **title**, **description**, **children**, and **footer** snippets each receive the FormState instance directly. No-argument snippets remain valid.\n\nField wrapper attributes and native control attributes have separate owners. Use fieldAttrs for the shared Field wrapper. Controls expose a typed bag for their actual native element, such as inputAttrs on TextInput, textareaAttrs on TextArea, and triggerAttrs on the custom Select's combobox button.\n\n## FormState\n\n- **value**: current visible partial value.\n- **loading**: true while the active submission handler is pending.\n- **hasError**: true after validation fails; reset after successful validation.\n- **validate()**: returns the validated visible payload, or false.\n- **submit()**: returns a promise of the validated visible payload, or false. Concurrent calls share the active promise.\n\n## Enter-key behavior\n\nOn an enabled, editable single-line input, Enter validates the current field. A valid field moves focus to the next visible enabled field, while an invalid field remains focused and displays its errors. Enter on a valid final navigable field calls form.submit(). Multiline editors and controls that already own Enter, such as selects and combobox menus, keep their native component behavior. Modified, repeated, and IME-composition Enter events are ignored.\n\n## Programmatic Dialog forms\n\nMount one Ask host near the application root, then call ask() from any client-side event. Both exports come from entasis/form:\n\n```svelte\n<script lang=\"ts\">\n\timport { Ask, ask } from 'entasis/form';\n\n\tconst inputs = {\n\t\tname: { type: 'text', label: 'Name', required: true },\n\t\temail: { type: 'email', label: 'Email', required: true }\n\t} as const;\n\n\tasync function editProfile() {\n\t\tconst outcome = await ask({\n\t\t\ttitle: 'Edit profile',\n\t\t\tdescription: 'Review the account details.',\n\t\t\tinputs,\n\t\t\tvalue: { name: 'Ada' },\n\t\t\tconfirm: 'Save',\n\t\t\tcancel: 'Cancel'\n\t\t});\n\n\t\tif (outcome.submitted) {\n\t\t\t// outcome.value is InferFormValue<typeof inputs>\n\t\t} else {\n\t\t\t// outcome.value is the partial, visible live value at cancellation\n\t\t}\n\t}\n</script>\n\n<Ask />\n```\n\nAskOptions accepts inputs, value, onSubmit, class, size, density, variant, layout, and theme with the same contracts as Form. title is required for the Dialog's accessible name. confirm and cancel accept a string or Button presentation props plus text. Their click, loading, link, and native type behavior is owned by Ask. Set type on the mounted Ask host for its default Dialog type; a request's optional dialog object may override type and also accepts responsive, size, and scroll.\n\nValidation failures keep the Dialog open and focus the first invalid field. A successful submission resolves with the validated visible payload. Cancel, the close button, and Escape resolve with submitted: false and the current partial visible value. Outside-click and swipe dismissal are disabled. While submission is active, all close paths and actions are protected. Rejected onSubmit promises reject ask() after the Dialog closes. Concurrent ask() calls render as independent stacked Dialogs. Calling ask() during SSR or without one mounted host rejects clearly.\n\n## Visibility and values\n\nThe visible prop may be a boolean or a predicate receiving the complete private value cache:\n\n```svelte\n<Form\n\tinputs={{\n\t\tcontactMethod: {\n\t\t\ttype: 'select',\n\t\t\tlabel: 'Contact method',\n\t\t\titems: [\n\t\t\t\t{ label: 'Email', value: 'email' },\n\t\t\t\t{ label: 'Phone', value: 'phone' }\n\t\t\t]\n\t\t},\n\t\tphone: {\n\t\t\ttype: 'phone',\n\t\t\tlabel: 'Phone',\n\t\t\tvisible: (value) => value.contactMethod === 'phone'\n\t\t}\n\t}}\n/>\n```\n\nHidden field values are preserved privately and restored when shown again. Hidden fields are excluded from value, validation, and submission. Removing an input definition removes its cached value.\n\n## Field label position\n\nAll configured inputs inherit the Form layout through the shared Field wrapper. Set labelPosition: 'top' or 'left' on an individual input to override the Form layout without affecting value inference:\n\n```ts\nexport const inputs = {\n\tname: { type: 'text', label: 'Name' },\n\tnotes: { type: 'textarea', label: 'Notes', labelPosition: 'top' }\n} as const;\n```\n\nThe left position becomes a two-column label/control layout once the field is at least 32rem wide and remains stacked in narrower forms. The breakpoint is a container query against the field, so a form in a narrow drawer stacks on any screen. Form uses layout rather than orientation so controls such as Slider retain their own orientation prop.\n\n## Visual groups\n\nA group renders a transparent semantic fieldset with a required legend label and an optional description. Its label and description use the same size scale as ordinary fields; grouping does not add another card or padded container:\n\n```ts\nimport type { InferFormValue } from 'entasis/form';\n\nexport const inputs = {\n\tcontact: {\n\t\ttype: 'group',\n\t\tlabel: 'Contact details',\n\t\tdescription: 'How we can reach you',\n\t\tcolumns: 1,\n\t\tinputs: {\n\t\t\temail: { type: 'email', label: 'Email', required: true },\n\t\t\tphone: { type: 'phone', label: 'Phone' }\n\t\t}\n\t}\n} as const;\n\ntype Value = InferFormValue<typeof inputs>;\n// { email: string; phone: string | null }\n```\n\nThe group key is visual only and never appears in value or submission output. Descendant field names remain top-level, so duplicate names across groups are rejected. Group visibility hides all descendants, preserves field values in the private cache, and makes inferred field keys optional. In a vertical form layout, set columns to 1, 2, 3, or 4 to control the equal-width grid; the count is a container query against the form width (2 columns from 42rem, 3 from 56rem, 4 from 72rem), and groups collapse to one column in narrower forms. Horizontal form layouts ignore columns and always render group entries in one column. A child class may span tracks when an entry needs more room. Groups may contain fields, action rows, and custom snippets; nested groups are intentionally unsupported.\n\n## Custom value fields\n\nUse type: 'field' when Form must own a value but a custom control must render it. fieldType selects the existing FieldState schema and inferred value type. The snippet receives that registered FieldState controller:\n\n```svelte\n{#snippet handleControl(field)}\n\t<input\n\t\t{...field.controlAttrs}\n\t\t{@attach field.control}\n\t\tbind:value={field.value}\n\t/>\n{/snippet}\n\n<Form\n\tinputs={{\n\t\thandle: {\n\t\t\ttype: 'field',\n\t\t\tfieldType: 'text',\n\t\t\tlabel: 'Handle',\n\t\t\trequired: true,\n\t\t\tsnippet: handleControl\n\t\t}\n\t}}\n/>\n```\n\nThe controller exposes value, disabled, required, validation state, accessible ids, controlAttrs, and the control attachment. The entry participates in bind:value, visibility, validation, submission, and InferFormValue through the same FieldState owner as built-in controls.\n\n## Non-value entries\n\nUse type: 'action' to place a labelled row of buttons in the ordered inputs flow. It accepts optional label and description content plus the same Form-aware action objects as the top-level actions prop. Its label position follows the Form layout unless labelPosition overrides it. Form size is the default button size, action density follows the Form, and loading or disabled protection cannot be overridden while the Form is submitting.\n\nUse type: 'custom' to place a snippet in the flow. The snippet receives the live public FormState and owns its own surface and padding; Form adds neither. Both entry types support class and visible, may appear at the top level or inside a group, and are omitted from bind:value, validation, and inferred submission output:\n\n```svelte\n{#snippet accountStatus(form)}\n\t<p>{Object.keys(form.value).length} visible values</p>\n{/snippet}\n\n<Form\n\tinputs={{\n\t\tname: { type: 'text', label: 'Name', required: true },\n\t\tstatus: { type: 'custom', snippet: accountStatus },\n\t\tcontrols: {\n\t\t\ttype: 'action',\n\t\t\tlabel: 'Account actions',\n\t\t\tdescription: 'Validate or save this account.',\n\t\t\tactions: [\n\t\t\t\t{ children: 'Validate', variant: 'soft', onAction: (payload) => payload.validate() },\n\t\t\t\t{ children: 'Save', onAction: (payload) => payload.submit() }\n\t\t\t]\n\t\t}\n\t}}\n/>\n```\n\nFor this configuration, InferFormValue contains name only; status and controls are presentation keys.\n\n## Validation\n\nBuilt-in validation uses the field schema and preserves its issue messages. onValidate may return:\n\n- false, null, undefined, an empty string, or an empty array for valid input;\n- true for a generic error;\n- a string for one error;\n- a string array for multiple errors.\n\nValidation runs on submission. After a field has an error, it revalidates while edited. Failed submission scrolls to and focuses the first invalid control.\n\n## Supported input types\n\ntext, email, url, password, number, rating, voice, slider, slider-range, textarea, rich-text, select, combobox, radio, checkboxes, checkbox, switch, phone, calendar, calendar-range, date, datetime, color, file, files, tag-group, tag, keyvalue, pin, and time.\n\nAlternative displays are display: 'selector' for date and calendar-range, and display: 'picker' for color.\n\nfield is the custom value-bearing entry. group, action, and custom are structural visual nodes rather than value-bearing input types.\n";
1
+ export declare const formDescription = "\n# Form\n\nForm renders configured entasis fields inside a div, owns their state and validation, and submits programmatically. It provides managed Enter-key navigation and submission, but not native form events, browser constraint validation, or FormData behavior.\n\n## Basic usage\n\n```svelte\n<script lang=\"ts\">\n\timport {\n\t\tForm,\n\t\ttype FormState,\n\t\ttype InferFormValue,\n\t\ttype LiveFormValue\n\t} from 'entasis/form';\n\n\tconst inputs = {\n\t\tidentity: {\n\t\t\ttype: 'group',\n\t\t\tlabel: 'Identity',\n\t\t\tdescription: 'Core account details',\n\t\t\tcolumns: 2,\n\t\t\tinputs: {\n\t\t\t\tname: { type: 'text', label: 'Name', required: true },\n\t\t\t\temail: { type: 'email', label: 'Email', required: true }\n\t\t\t}\n\t\t}\n\t} as const;\n\n\tlet value = $state<LiveFormValue<typeof inputs>>({ name: 'Ada' });\n\tlet form = $state<FormState<typeof inputs>>();\n\tlet submitted = $state<InferFormValue<typeof inputs> | null>(null);\n</script>\n\n<Form\n\t{inputs}\n\tbind:value\n\tbind:form\n\tvariant=\"card\"\n\tlayout=\"horizontal\"\n\tsize=\"small\"\n\tdensity=\"normal\"\n\tactions={[\n\t\t{\n\t\t\tchildren: 'Save',\n\t\t\tonAction: (payload) => payload.submit()\n\t\t}\n\t]}\n\tonSubmit={(validatedValue) => {\n\t\tsubmitted = validatedValue;\n\t}}\n/>\n```\n\n## Props\n\n- **inputs** (required): ordered object keyed by entry name. Entries may be value-bearing fields, visual groups, action-button rows, or custom snippets. Only fields contribute keys to the live and validated values.\n- **value** (bindable): controlled partial live value. Visible mounted fields are published; hidden fields are omitted. A supplied top-level value takes precedence over an input's configured value.\n- **defaultValue**: initial partial live value when value is omitted.\n- **onValueChange**: called once when a user changes the visible live value. Parent value updates do not emit it.\n- **form** (bindable): the FormState instance.\n- **onSubmit**: called with the validated visible payload. Its return value is ignored; rejected promises propagate.\n- **size**: small, normal (default), or large. Controls Form typography and becomes the fallback size for fields and actions. An explicit field or action size takes precedence.\n- **density**: compact, normal (default), or comfortable. Controls gaps between the Form header, fields, footer, and actions independently from size.\n- **variant**: plain (default), sectioned, or card. All variants consume the shared Card typography. Plain remains transparent without separators. Sectioned stays transparent and adds edge-to-edge separators between the header, visible top-level fields or groups, and footer. Card adds the same separators inside the Card surface and inset; Card header/footer padding applies only to that variant. Separators do not appear between fields inside a group.\n- **layout**: vertical (default) or horizontal. Horizontal keeps one field per row and places labels to the left once each field is at least 32rem wide; fields remain stacked in narrower forms.\n- **actions**: optional array of Button props. Each action's onAction receives the live FormState, so a submit action calls form.submit(). Actions are disabled while the form is submitting; their own loading and disabled props are composed with that protection.\n- **class**: additional classes on the root div.\n- **theme**: Form theme overrides for root, header, title, description, group, group label/description/fields, top-level items, custom entries, footer, and actions. Global Card typography and section-rhythm changes flow into all Form variants before Form-specific overrides are composed; Card surface changes apply only to the card variant.\n\nThe **header**, **title**, **description**, **children**, and **footer** snippets each receive the FormState instance directly. No-argument snippets remain valid.\n\nField wrapper attributes and native control attributes have separate owners. Use fieldAttrs for the shared Field wrapper. Controls expose a typed bag for their actual native element, such as inputAttrs on TextInput, textareaAttrs on TextArea, and triggerAttrs on the custom Select's combobox button.\n\n## FormState\n\n- **value**: current visible partial value.\n- **loading**: true while the active submission handler is pending.\n- **hasError**: true after validation fails; reset after successful validation.\n- **validate()**: returns the validated visible payload, or false.\n- **submit()**: returns a promise of the validated visible payload, or false. Concurrent calls share the active promise.\n\n## Enter-key behavior\n\nOn an enabled, editable single-line input, Enter validates the current field. A valid field moves focus to the next visible enabled field, while an invalid field remains focused and displays its errors. Enter on a valid final navigable field calls form.submit(). Multiline editors and controls that already own Enter, such as selects and combobox menus, keep their native component behavior. Modified, repeated, and IME-composition Enter events are ignored.\n\n## Programmatic Dialog forms\n\nMount one Ask host near the application root, then call ask() from any client-side event. Both exports come from entasis/form:\n\n```svelte\n<script lang=\"ts\">\n\timport { Ask, ask } from 'entasis/form';\n\n\tconst inputs = {\n\t\tname: { type: 'text', label: 'Name', required: true },\n\t\temail: { type: 'email', label: 'Email', required: true }\n\t} as const;\n\n\tasync function editProfile() {\n\t\tconst outcome = await ask({\n\t\t\ttitle: 'Edit profile',\n\t\t\tdescription: 'Review the account details.',\n\t\t\tinputs,\n\t\t\tvalue: { name: 'Ada' },\n\t\t\tconfirm: 'Save',\n\t\t\tcancel: 'Cancel'\n\t\t});\n\n\t\tif (outcome.submitted) {\n\t\t\t// outcome.value is InferFormValue<typeof inputs>\n\t\t} else {\n\t\t\t// outcome.value is the partial, visible live value at cancellation\n\t\t}\n\t}\n</script>\n\n<Ask />\n```\n\nAskOptions accepts inputs, value, onSubmit, class, size, density, variant, layout, and theme with the same contracts as Form. title is required for the Dialog's accessible name. confirm and cancel accept a string or Button presentation props plus text. Their click, loading, link, and native type behavior is owned by Ask. Set type on the mounted Ask host for its default Dialog type; a request's optional dialog object may override type and also accepts responsive, size, and scroll.\n\nValidation failures keep the Dialog open and focus the first invalid field. A successful submission resolves with the validated visible payload. Cancel, the close button, and Escape resolve with submitted: false and the current partial visible value. Outside-click and swipe dismissal are disabled. While submission is active, all close paths and actions are protected. Rejected onSubmit promises reject ask() after the Dialog closes. Concurrent ask() calls render as independent stacked Dialogs. Calling ask() during SSR or without one mounted host rejects clearly.\n\n## Visibility and values\n\nThe visible prop may be a boolean or a predicate receiving the complete private value cache:\n\n```svelte\n<Form\n\tinputs={{\n\t\tcontactMethod: {\n\t\t\ttype: 'select',\n\t\t\tlabel: 'Contact method',\n\t\t\titems: [\n\t\t\t\t{ label: 'Email', value: 'email' },\n\t\t\t\t{ label: 'Phone', value: 'phone' }\n\t\t\t]\n\t\t},\n\t\tphone: {\n\t\t\ttype: 'phone',\n\t\t\tlabel: 'Phone',\n\t\t\tvisible: (value) => value.contactMethod === 'phone'\n\t\t}\n\t}}\n/>\n```\n\nHidden field values are preserved privately and restored when shown again. Hidden fields are excluded from value, validation, and submission. Removing an input definition removes its cached value.\n\n## Field label position\n\nAll configured inputs inherit the Form layout through the shared Field wrapper. Set labelPosition: 'top' or 'left' on an individual input to override the Form layout without affecting value inference:\n\n```ts\nexport const inputs = {\n\tname: { type: 'text', label: 'Name' },\n\tnotes: { type: 'textarea', label: 'Notes', labelPosition: 'top' }\n} as const;\n```\n\nThe left position becomes a two-column label/control layout once the field is at least 32rem wide and remains stacked in narrower forms. The breakpoint is a container query against the field, so a form in a narrow drawer stacks on any screen. Form uses layout rather than orientation so controls such as Slider retain their own orientation prop.\n\n## Visual groups\n\nA group renders a transparent semantic fieldset with a required legend label and an optional description. Its label and description use the same size scale as ordinary fields; grouping does not add another card or padded container:\n\n```ts\nimport type { InferFormValue } from 'entasis/form';\n\nexport const inputs = {\n\tcontact: {\n\t\ttype: 'group',\n\t\tlabel: 'Contact details',\n\t\tdescription: 'How we can reach you',\n\t\tcolumns: 1,\n\t\tinputs: {\n\t\t\temail: { type: 'email', label: 'Email', required: true },\n\t\t\tphone: { type: 'phone', label: 'Phone' }\n\t\t}\n\t}\n} as const;\n\ntype Value = InferFormValue<typeof inputs>;\n// { email: string; phone: string | null }\n```\n\nThe group key is visual only and never appears in value or submission output. Descendant field names remain top-level, so duplicate names across groups are rejected. Group visibility hides all descendants, preserves field values in the private cache, and makes inferred field keys optional. In a vertical form layout, set columns to 1, 2, 3, or 4 to control the equal-width grid; the count is a container query against the form width (2 columns from 42rem, 3 from 56rem, 4 from 72rem), and groups collapse to one column in narrower forms. Horizontal form layouts ignore columns and always render group entries in one column. A child class may span tracks when an entry needs more room. Groups may contain fields, action rows, and custom snippets; nested groups are intentionally unsupported.\n\n## Custom value fields\n\nUse type: 'field' when Form must own a value but a custom control must render it. fieldType selects the existing FieldState schema and inferred value type. The snippet receives that registered FieldState controller:\n\n```svelte\n{#snippet handleControl(field)}\n\t<input\n\t\t{...field.controlAttrs}\n\t\t{@attach field.control}\n\t\tbind:value={field.value}\n\t/>\n{/snippet}\n\n<Form\n\tinputs={{\n\t\thandle: {\n\t\t\ttype: 'field',\n\t\t\tfieldType: 'text',\n\t\t\tlabel: 'Handle',\n\t\t\trequired: true,\n\t\t\tsnippet: handleControl\n\t\t}\n\t}}\n/>\n```\n\nThe controller exposes value, disabled, required, validation state, accessible ids, controlAttrs, and the control attachment. The entry participates in bind:value, visibility, validation, submission, and InferFormValue through the same FieldState owner as built-in controls.\n\n## App-defined input types\n\nAn app can add its own input types to every Form and ask() dialog, by name, so entries stay plain data that serializes (JSON from a server, a tool call, a database). Two steps, once per app.\n\nType it: augment `FormInputRegistry` with the value the input holds and the props its entries take.\n\n```ts\n// doc-fence: skip (imports a sibling component; tooling/component-contract/consumer-form-input.ts checks this exact setup against the package)\nimport { registerFormInputs } from 'entasis/form';\nimport MoneyInput from './MoneyInput.svelte';\n\nexport type Money = { amount: number; currency: string };\n\ndeclare module 'entasis/form' {\n\tinterface FormInputRegistry {\n\t\tmoney: { value: Money; props: { currencies?: string[] } };\n\t}\n}\n\nregisterFormInputs({\n\tmoney: {\n\t\tcomponent: MoneyInput,\n\t\tisEmpty: (value) => value?.amount === undefined,\n\t\tvalidate: (value) => (value.amount < 0 ? 'Must not be negative' : null)\n\t}\n});\n```\n\nRegister it: call registerFormInputs at the top level of a module the server and the browser both load before a form renders, such as one the root layout imports. Its keys are checked against the registry; built-in type names are refused, and registering a type again replaces it.\n\nThe component renders the control alone. Form wraps it in Field (label, description, errors) and hands it the field it registered, typed from the registry, plus the entry's own props:\n\n```svelte\n<!-- doc-fence: skip -->\n<!-- Needs the money registration above; tooling/component-contract/consumer-money-input.svelte is the checked copy. -->\n<script lang=\"ts\">\n\timport type { FormInputComponentProps } from 'entasis/form';\n\n\tlet { field, currencies = ['EUR'] }: FormInputComponentProps<'money'> = $props();\n\tconst currency = $derived(field.value?.currency ?? currencies[0] ?? 'EUR');\n</script>\n\n<input\n\ttype=\"number\"\n\t{...field.controlAttrs}\n\t{@attach field.control}\n\tvalue={field.value?.amount ?? ''}\n\toninput={(event) => field.setValue({ amount: Number(event.currentTarget.value), currency })}\n/>\n```\n\nEntries then use the type like a built-in one, in Form inputs, groups, and ask():\n\n```ts\n// doc-fence: skip (needs the money registration above)\nconst result = await ask({\n\ttitle: 'Set a price',\n\tconfirm: 'Save',\n\tcancel: 'Cancel',\n\tinputs: { price: { type: 'money', label: 'Price', required: true, currencies: ['EUR', 'USD'] } }\n});\nif (result.submitted) result.value.price; // Money\n```\n\nEvery Field entry option applies (label, description, required, disabled, size, density, visible, defaultValue, onValidate, onValueChange, slots); any other key goes to the component. required rejects what isEmpty reports empty (by default null, undefined, an empty string, or an empty array); validate runs for every entry of the type, before the entry's own onValidate. The value takes part in bind:value, visibility, validation, submission, and InferFormValue like a built-in input's. An entry whose type is neither built-in nor registered throws when the form reads it.\n\n## Non-value entries\n\nUse type: 'action' to place a labelled row of buttons in the ordered inputs flow. It accepts optional label and description content plus the same Form-aware action objects as the top-level actions prop. Its label position follows the Form layout unless labelPosition overrides it. Form size is the default button size, action density follows the Form, and loading or disabled protection cannot be overridden while the Form is submitting.\n\nUse type: 'custom' to place a snippet in the flow. The snippet receives the live public FormState and owns its own surface and padding; Form adds neither. Both entry types support class and visible, may appear at the top level or inside a group, and are omitted from bind:value, validation, and inferred submission output:\n\n```svelte\n{#snippet accountStatus(form)}\n\t<p>{Object.keys(form.value).length} visible values</p>\n{/snippet}\n\n<Form\n\tinputs={{\n\t\tname: { type: 'text', label: 'Name', required: true },\n\t\tstatus: { type: 'custom', snippet: accountStatus },\n\t\tcontrols: {\n\t\t\ttype: 'action',\n\t\t\tlabel: 'Account actions',\n\t\t\tdescription: 'Validate or save this account.',\n\t\t\tactions: [\n\t\t\t\t{ children: 'Validate', variant: 'soft', onAction: (payload) => payload.validate() },\n\t\t\t\t{ children: 'Save', onAction: (payload) => payload.submit() }\n\t\t\t]\n\t\t}\n\t}}\n/>\n```\n\nFor this configuration, InferFormValue contains name only; status and controls are presentation keys.\n\n## Validation\n\nBuilt-in validation uses the field schema and preserves its issue messages. onValidate may return:\n\n- false, null, undefined, an empty string, or an empty array for valid input;\n- true for a generic error;\n- a string for one error;\n- a string array for multiple errors.\n\nValidation runs on submission. After a field has an error, it revalidates while edited. Failed submission scrolls to and focuses the first invalid control.\n\n## Supported input types\n\ntext, email, url, password, number, rating, voice, slider, slider-range, textarea, rich-text, select, combobox, radio, checkboxes, checkbox, switch, phone, calendar, calendar-range, date, datetime, color, file, files, tag-group, tag, keyvalue, pin, and time.\n\nAlternative displays are display: 'selector' for date and calendar-range, and display: 'picker' for color.\n\nfield is the custom value-bearing entry. group, action, and custom are structural visual nodes rather than value-bearing input types.\n";