diff --git a/src/routes/test/form/+page.svelte b/src/routes/test/form/+page.svelte new file mode 100644 index 000000000..8147d77d1 --- /dev/null +++ b/src/routes/test/form/+page.svelte @@ -0,0 +1,97 @@ + + +
+

Form

+

+ Primitive inicial para contexto de formulario, campos accesibles y composicion con + Field. +

+ + + + + Nombre + + + + Minimo 2 caracteres. + {#if nameInvalid} + El nombre es demasiado corto. + {/if} + + + + + + Email + + + + Usa un correo valido. + {#if emailInvalid} + El email debe contener @. + {/if} + + + + + {pending ? 'Enviando...' : 'Enviar'} + + + + {#if submitted} +
{JSON.stringify(submitted, null, 2)}
+ {/if} +
+ + diff --git a/src/routes/test/terra/+page.svelte b/src/routes/test/terra/+page.svelte index 4f6f7800d..661710710 100644 --- a/src/routes/test/terra/+page.svelte +++ b/src/routes/test/terra/+page.svelte @@ -45,6 +45,8 @@ description: 'Primitivas y compuestos de interacción y formularios.', items: [ { name: 'Checkbox', path: '/test/checkbox', status: 'stable' }, + + { name: 'Form', path: '/test/form', status: 'wip' }, { name: 'Field', path: '/test/field', status: 'wip' }, { name: 'PinInput', path: '/test/pin-input', status: 'stable' }, { name: 'RadioGroup', path: '/test/radio-group', status: 'stable' }, @@ -315,3 +317,5 @@ + + diff --git a/src/uix/terra/field/components/Field.Control.svelte b/src/uix/terra/field/components/Field.Control.svelte index a04c297b9..e7ea6b10e 100644 --- a/src/uix/terra/field/components/Field.Control.svelte +++ b/src/uix/terra/field/components/Field.Control.svelte @@ -1,31 +1,29 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} -
- {@render children?.()} -
+
+ {@render children?.()} +
{/if} - diff --git a/src/uix/terra/field/components/Field.Error.svelte b/src/uix/terra/field/components/Field.Error.svelte index 5b4406f91..89f488629 100644 --- a/src/uix/terra/field/components/Field.Error.svelte +++ b/src/uix/terra/field/components/Field.Error.svelte @@ -1,43 +1,34 @@ {#if errorState.root.isError} - {#if child} - {@render child({ props: mergedProps })} - {:else} -

- {@render children?.()} -

- {/if} + {#if child} + {@render child({ props: mergedProps })} + {:else} +

+ {@render children?.()} +

+ {/if} {:else} - -

+

{/if} - - diff --git a/src/uix/terra/field/components/Field.HelperText.svelte b/src/uix/terra/field/components/Field.HelperText.svelte index 3a41306a6..ff8a437a4 100644 --- a/src/uix/terra/field/components/Field.HelperText.svelte +++ b/src/uix/terra/field/components/Field.HelperText.svelte @@ -1,31 +1,30 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} -

- {@render children?.()} -

+

+ {@render children?.()} +

{/if} - diff --git a/src/uix/terra/field/components/Field.Input.svelte b/src/uix/terra/field/components/Field.Input.svelte index d8771e9d0..fe56ded1c 100644 --- a/src/uix/terra/field/components/Field.Input.svelte +++ b/src/uix/terra/field/components/Field.Input.svelte @@ -1,36 +1,35 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} - + {/if} - diff --git a/src/uix/terra/field/components/Field.Label.svelte b/src/uix/terra/field/components/Field.Label.svelte index 43486e8b5..b305bc8bf 100644 --- a/src/uix/terra/field/components/Field.Label.svelte +++ b/src/uix/terra/field/components/Field.Label.svelte @@ -1,29 +1,32 @@ - - - {#if child} - {@render child({ props: restProps })} - {:else} - {@render children?.()} - {#if root.isRequired} - - {/if} - {/if} - \ No newline at end of file + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/field/components/Field.Prefix.svelte b/src/uix/terra/field/components/Field.Prefix.svelte index b9ac35fd9..b5d67deca 100644 --- a/src/uix/terra/field/components/Field.Prefix.svelte +++ b/src/uix/terra/field/components/Field.Prefix.svelte @@ -1,31 +1,30 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} - - {@render children?.()} - + + {@render children?.()} + {/if} - diff --git a/src/uix/terra/field/components/Field.Root.svelte b/src/uix/terra/field/components/Field.Root.svelte index d2e47e2f9..405f5c7c1 100644 --- a/src/uix/terra/field/components/Field.Root.svelte +++ b/src/uix/terra/field/components/Field.Root.svelte @@ -1,40 +1,38 @@ {#if child} - {@render child({ props: mergedProps, ...rootState.snippetProps })} + {@render child({ props: mergedProps, ...rootState.snippetProps })} {:else} -
- {@render children?.(rootState.snippetProps)} -
+
+ {@render children?.(rootState.snippetProps)} +
{/if} - diff --git a/src/uix/terra/field/components/Field.Suffix.svelte b/src/uix/terra/field/components/Field.Suffix.svelte index d908e3c72..c8cbbeb71 100644 --- a/src/uix/terra/field/components/Field.Suffix.svelte +++ b/src/uix/terra/field/components/Field.Suffix.svelte @@ -1,31 +1,30 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} - - {@render children?.()} - + + {@render children?.()} + {/if} - diff --git a/src/uix/terra/field/field-state.svelte.ts b/src/uix/terra/field/field-state.svelte.ts new file mode 100644 index 000000000..7c73e94ae --- /dev/null +++ b/src/uix/terra/field/field-state.svelte.ts @@ -0,0 +1,349 @@ +import { Context } from 'runed'; +import type { ActiveProps, OnChangeFn, RefAttachment, StateProps, WithRefOpts } from '../utils'; +import { attachRef } from '../utils'; +import { boolToEmptyStrOrUndef, createTerraAttrs } from '$terra/utils/attrs'; +import { FormFieldContext, type FormFieldState } from '../form/form.svelte'; +import type { FieldInputType, FieldState } from './types'; + +const fieldAttrs = createTerraAttrs({ + component: 'field', + parts: ['root', 'label', 'control', 'input', 'helper-text', 'error', 'prefix', 'suffix'] as const, +}); + +export const FieldRootContext = new Context('Field.Root'); + +function getOptionalFormField(): FormFieldState | null { + try { + return FormFieldContext.get(); + } catch { + return null; + } +} + +interface FieldRootStateOpts + extends WithRefOpts, + ActiveProps<{ + state: FieldState; + disabled: boolean; + readonly: boolean; + required: boolean; + }> {} + +export class FieldRootState { + static create(opts: FieldRootStateOpts): FieldRootState { + return FieldRootContext.set(new FieldRootState(opts, getOptionalFormField())); + } + + readonly opts: FieldRootStateOpts; + readonly formField: FormFieldState | null; + readonly attachment: RefAttachment; + + constructor(opts: FieldRootStateOpts, formField: FormFieldState | null) { + this.opts = opts; + this.formField = formField; + this.attachment = attachRef(opts.ref as any); + } + + readonly inputId = $derived.by(() => this.formField?.controlId ?? `${this.opts.id.current}-input`); + readonly labelId = $derived.by(() => this.formField?.labelId ?? `${this.opts.id.current}-label`); + readonly errorId = $derived.by(() => this.formField?.errorId ?? `${this.opts.id.current}-error`); + readonly helperId = $derived.by( + () => this.formField?.descriptionId ?? `${this.opts.id.current}-helper` + ); + + readonly isPending = $derived.by(() => this.formField?.root.isPending ?? false); + readonly isDisabled = $derived.by( + () => + this.isPending || + this.opts.disabled.current || + this.opts.state.current === 'disabled' || + (this.formField?.isDisabled ?? false) + ); + readonly isReadonly = $derived.by( + () => + this.opts.readonly.current || + this.opts.state.current === 'readonly' || + (this.formField?.isReadonly ?? false) + ); + readonly isError = $derived.by( + () => this.opts.state.current === 'error' || (this.formField?.isInvalid ?? false) + ); + readonly isLoading = $derived.by( + () => this.opts.state.current === 'loading' || (this.formField?.isPending ?? false) + ); + readonly isRequired = $derived.by( + () => this.opts.required.current || (this.formField?.isRequired ?? false) + ); + readonly effectiveState = $derived.by((): FieldState => { + if (this.opts.state.current !== 'idle') return this.opts.state.current; + if (this.isLoading) return 'loading'; + if (this.isError) return 'error'; + if (this.isDisabled) return 'disabled'; + if (this.isReadonly) return 'readonly'; + return 'idle'; + }); + + readonly snippetProps = $derived.by(() => ({ + state: this.effectiveState, + isError: this.isError, + isDisabled: this.isDisabled, + isReadonly: this.isReadonly, + isLoading: this.isLoading, + isRequired: this.isRequired, + })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + 'data-state': this.effectiveState, + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), + 'data-required': boolToEmptyStrOrUndef(this.isRequired), + 'data-error': boolToEmptyStrOrUndef(this.isError), + 'data-pending': boolToEmptyStrOrUndef(this.isPending), + [fieldAttrs.root]: '', + ...this.attachment, + }) as const + ); +} + +interface FieldLabelStateOpts extends WithRefOpts {} + +export class FieldLabelState { + static create(opts: FieldLabelStateOpts): FieldLabelState { + return new FieldLabelState(opts, FieldRootContext.get()); + } + + readonly opts: FieldLabelStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldLabelStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.root.labelId, + for: this.root.inputId, + 'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled), + 'data-error': boolToEmptyStrOrUndef(this.root.isError), + 'data-required': boolToEmptyStrOrUndef(this.root.isRequired), + [fieldAttrs.label]: '', + ...this.attachment, + }) as const + ); +} + +interface FieldControlStateOpts extends WithRefOpts {} + +export class FieldControlState { + static create(opts: FieldControlStateOpts): FieldControlState { + return new FieldControlState(opts, FieldRootContext.get()); + } + + readonly opts: FieldControlStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldControlStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + 'data-state': this.root.effectiveState, + 'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled), + [fieldAttrs.control]: '', + ...this.attachment, + }) as const + ); +} + +interface FieldInputStateOpts + extends WithRefOpts, + ActiveProps<{ + type: FieldInputType; + onValueChange: OnChangeFn; + }>, + StateProps<{ value: string }> {} + +export class FieldInputState { + static create(opts: FieldInputStateOpts): FieldInputState { + return new FieldInputState(opts, FieldRootContext.get()); + } + + readonly opts: FieldInputStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldInputStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly describedBy = $derived.by(() => + [this.root.helperId, this.root.isError ? this.root.errorId : null].filter(Boolean).join(' ') + ); + readonly fieldName = $derived.by(() => this.root.formField?.opts.name.current); + + oninput = (e: Event & { currentTarget: HTMLInputElement }) => { + if (this.root.isDisabled || this.root.isReadonly) return; + const value = e.currentTarget.value; + this.opts.value.current = value; + this.opts.onValueChange.current(value); + }; + + readonly props = $derived.by( + () => + ({ + id: this.root.inputId, + name: this.fieldName, + type: this.opts.type.current, + value: this.opts.value.current, + disabled: this.root.isDisabled || undefined, + readonly: this.root.isReadonly || undefined, + required: this.root.isRequired || undefined, + 'aria-labelledby': this.root.labelId, + 'aria-invalid': this.root.isError ? ('true' as const) : undefined, + 'aria-describedby': this.describedBy || undefined, + 'aria-busy': this.root.isLoading ? ('true' as const) : undefined, + 'data-state': this.root.effectiveState, + oninput: this.oninput, + [fieldAttrs.input]: '', + ...this.attachment, + }) as const + ); +} + +interface FieldHelperTextStateOpts extends WithRefOpts {} + +export class FieldHelperTextState { + static create(opts: FieldHelperTextStateOpts): FieldHelperTextState { + return new FieldHelperTextState(opts, FieldRootContext.get()); + } + + readonly opts: FieldHelperTextStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldHelperTextStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.root.helperId, + 'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled), + [fieldAttrs['helper-text']]: '', + ...this.attachment, + }) as const + ); +} + +interface FieldErrorStateOpts extends WithRefOpts {} + +export class FieldErrorState { + static create(opts: FieldErrorStateOpts): FieldErrorState { + return new FieldErrorState(opts, FieldRootContext.get()); + } + + readonly opts: FieldErrorStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldErrorStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.root.errorId, + role: 'alert' as const, + 'aria-live': 'polite' as const, + 'data-error': '', + [fieldAttrs.error]: '', + ...this.attachment, + }) as const + ); + + readonly ghostProps = $derived.by( + () => + ({ + id: this.root.errorId, + hidden: true, + 'aria-hidden': 'true' as const, + }) as const + ); +} + +interface FieldPrefixStateOpts extends WithRefOpts {} + +export class FieldPrefixState { + static create(opts: FieldPrefixStateOpts): FieldPrefixState { + return new FieldPrefixState(opts, FieldRootContext.get()); + } + + readonly opts: FieldPrefixStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldPrefixStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + 'aria-hidden': 'true' as const, + [fieldAttrs.prefix]: '', + ...this.attachment, + }) as const + ); +} + +interface FieldSuffixStateOpts extends WithRefOpts {} + +export class FieldSuffixState { + static create(opts: FieldSuffixStateOpts): FieldSuffixState { + return new FieldSuffixState(opts, FieldRootContext.get()); + } + + readonly opts: FieldSuffixStateOpts; + readonly root: FieldRootState; + readonly attachment: RefAttachment; + + constructor(opts: FieldSuffixStateOpts, root: FieldRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + 'aria-hidden': 'true' as const, + [fieldAttrs.suffix]: '', + ...this.attachment, + }) as const + ); +} diff --git a/src/uix/terra/field/field.svelte.ts b/src/uix/terra/field/field.svelte.ts index 57768bc9b..c17c5baca 100644 --- a/src/uix/terra/field/field.svelte.ts +++ b/src/uix/terra/field/field.svelte.ts @@ -8,13 +8,10 @@ import type { } from "../utils"; import { attachRef, - createTerraAttrs, - boolToEmptyStrOrUndef + } from "../utils"; -import type { - FieldState, - FieldInputType -} from "./types"; +import type { FieldState, FieldInputType } from './types'; +import { boolToEmptyStrOrUndef, createTerraAttrs } from '$terra/utils/attrs'; // ─── Attrs ──────────────────────────────────────────────────────────────────── @@ -27,7 +24,7 @@ const fieldAttrs = createTerraAttrs({ export const FieldRootContext = new Context("Field.Root"); -// ─── Root ───────────────────────────────────────────────────────────────────── +// ─── VirtualList ───────────────────────────────────────────────────────────────────── interface FieldRootStateOpts extends WithRefOpts, @@ -92,18 +89,18 @@ export class FieldRootState { // ─── Label ──────────────────────────────────────────────────────────────────── -interface FieldLabelStateOpts extends WithRefOpts {} +interface FieldLabe$stateOpts extends WithRefOpts {} -export class FieldLabelState { - static create(opts: FieldLabelStateOpts): FieldLabelState { - return new FieldLabelState(opts, FieldRootContext.get()); +export class FieldLabe$state { + static create(opts: FieldLabe$stateOpts): FieldLabe$state { + return new FieldLabe$state(opts, FieldRootContext.get()); } - readonly opts: FieldLabelStateOpts; + readonly opts: FieldLabe$stateOpts; readonly root: FieldRootState; readonly attachment: RefAttachment; - constructor(opts: FieldLabelStateOpts, root: FieldRootState) { + constructor(opts: FieldLabe$stateOpts, root: FieldRootState) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref as any); @@ -125,18 +122,18 @@ export class FieldLabelState { // ─── Control ────────────────────────────────────────────────────────────────── -interface FieldControlStateOpts extends WithRefOpts {} +interface FieldContro$stateOpts extends WithRefOpts {} -export class FieldControlState { - static create(opts: FieldControlStateOpts): FieldControlState { - return new FieldControlState(opts, FieldRootContext.get()); +export class FieldContro$state { + static create(opts: FieldContro$stateOpts): FieldContro$state { + return new FieldContro$state(opts, FieldRootContext.get()); } - readonly opts: FieldControlStateOpts; + readonly opts: FieldContro$stateOpts; readonly root: FieldRootState; readonly attachment: RefAttachment; - constructor(opts: FieldControlStateOpts, root: FieldRootState) { + constructor(opts: FieldContro$stateOpts, root: FieldRootState) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref); diff --git a/src/uix/terra/field/types.ts b/src/uix/terra/field/types.ts index 4dd55aa27..c2434a7d9 100644 --- a/src/uix/terra/field/types.ts +++ b/src/uix/terra/field/types.ts @@ -1,11 +1,11 @@ -import type { WithChild, Without, OnChangeFn } from "../utils/types"; +import type { WithChild, Without, OnChangeFn } from '$terra/utils'; import type { TerraPrimitiveDivAttributes, TerraPrimitiveLabelAttributes, TerraPrimitiveInputAttributes, TerraPrimitiveParagraphAttributes, TerraPrimitiveSpanAttributes, -} from "../utils/attributes"; +} from '$terra/utils'; // ─── Shared ─────────────────────────────────────────────────────────────────── @@ -19,7 +19,7 @@ export type FieldInputType = | "tel" | "url"; -// ─── Root ───────────────────────────────────────────────────────────────────── +// ─── VirtualList ───────────────────────────────────────────────────────────────────── export type FieldRootSnippetProps = { state: FieldState; diff --git a/src/uix/terra/form/components/Form.Control.svelte b/src/uix/terra/form/components/Form.Control.svelte new file mode 100644 index 000000000..d440b081a --- /dev/null +++ b/src/uix/terra/form/components/Form.Control.svelte @@ -0,0 +1,19 @@ + + +{#if child} + {@render child({ props: controlState.controlProps, controlProps: controlState.controlProps, wrapperProps: mergedWrapperProps })} +{:else} +
+ {@render children?.({ controlProps: controlState.controlProps, wrapperProps: mergedWrapperProps })} +
+{/if} diff --git a/src/uix/terra/form/components/Form.Description.svelte b/src/uix/terra/form/components/Form.Description.svelte new file mode 100644 index 000000000..8c92a8472 --- /dev/null +++ b/src/uix/terra/form/components/Form.Description.svelte @@ -0,0 +1,17 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +

{@render children?.()}

+{/if} diff --git a/src/uix/terra/form/components/Form.Error.svelte b/src/uix/terra/form/components/Form.Error.svelte new file mode 100644 index 000000000..7fea8520d --- /dev/null +++ b/src/uix/terra/form/components/Form.Error.svelte @@ -0,0 +1,17 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +

{@render children?.()}

+{/if} diff --git a/src/uix/terra/form/components/Form.Field.svelte b/src/uix/terra/form/components/Form.Field.svelte new file mode 100644 index 000000000..728b393d5 --- /dev/null +++ b/src/uix/terra/form/components/Form.Field.svelte @@ -0,0 +1,41 @@ + + +{#if child} + {@render child({ props: mergedProps, ...fieldState.snippetProps })} +{:else} +
+ {@render children?.(fieldState.snippetProps)} +
+{/if} diff --git a/src/uix/terra/form/components/Form.Label.svelte b/src/uix/terra/form/components/Form.Label.svelte new file mode 100644 index 000000000..efc837eba --- /dev/null +++ b/src/uix/terra/form/components/Form.Label.svelte @@ -0,0 +1,17 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/form/components/Form.Root.svelte b/src/uix/terra/form/components/Form.Root.svelte new file mode 100644 index 000000000..3cd7e1ed5 --- /dev/null +++ b/src/uix/terra/form/components/Form.Root.svelte @@ -0,0 +1,37 @@ + + +{#if child} + {@render child({ props: mergedProps, ...rootState.snippetProps })} +{:else} +
+ {@render children?.(rootState.snippetProps)} +
+{/if} diff --git a/src/uix/terra/form/components/Form.Submit.svelte b/src/uix/terra/form/components/Form.Submit.svelte new file mode 100644 index 000000000..857eec988 --- /dev/null +++ b/src/uix/terra/form/components/Form.Submit.svelte @@ -0,0 +1,17 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/terra/form/exports.ts b/src/uix/terra/form/exports.ts new file mode 100644 index 000000000..4b65f6d98 --- /dev/null +++ b/src/uix/terra/form/exports.ts @@ -0,0 +1,20 @@ +export { default as Root } from './components/Form.Root.svelte'; +export { default as Field } from './components/Form.Field.svelte'; +export { default as Label } from './components/Form.Label.svelte'; +export { default as Description } from './components/Form.Description.svelte'; +export { default as Error } from './components/Form.Error.svelte'; +export { default as Control } from './components/Form.Control.svelte'; +export { default as Submit } from './components/Form.Submit.svelte'; + +export type { + FormRootProps as RootProps, + FormFieldProps as FieldProps, + FormLabelProps as LabelProps, + FormDescriptionProps as DescriptionProps, + FormErrorProps as ErrorProps, + FormControlProps as ControlProps, + FormSubmitProps as SubmitProps, + FormRootSnippetProps, + FormFieldSnippetProps, + FormControlSnippetProps, +} from './types'; diff --git a/src/uix/terra/form/form.svelte.ts b/src/uix/terra/form/form.svelte.ts new file mode 100644 index 000000000..392fbcb14 --- /dev/null +++ b/src/uix/terra/form/form.svelte.ts @@ -0,0 +1,295 @@ +import { Context } from 'runed'; +import type { ActiveProps, RefAttachment, WithRefOpts } from '../utils'; +import { attachRef } from '../utils'; +import { boolToEmptyStrOrUndef, createTerraAttrs } from '$terra/utils/attrs'; + +const formAttrs = createTerraAttrs({ + component: 'form', + parts: ['root', 'field', 'label', 'description', 'error', 'control', 'submit'] as const, +}); + +export const FormRootContext = new Context('Form.Root'); +export const FormFieldContext = new Context('Form.Field'); + +interface FormRootStateOpts + extends WithRefOpts, + ActiveProps<{ + disabled: boolean; + readonly: boolean; + pending: boolean; + }> {} + +export class FormRootState { + static create(opts: FormRootStateOpts) { + return FormRootContext.set(new FormRootState(opts)); + } + + readonly opts: FormRootStateOpts; + readonly attachment: RefAttachment; + + constructor(opts: FormRootStateOpts) { + this.opts = opts; + this.attachment = attachRef(opts.ref as any); + } + + readonly isDisabled = $derived.by(() => this.opts.disabled.current); + readonly isReadonly = $derived.by(() => this.opts.readonly.current); + readonly isPending = $derived.by(() => this.opts.pending.current); + + readonly snippetProps = $derived.by(() => ({ + isDisabled: this.isDisabled, + isReadonly: this.isReadonly, + isPending: this.isPending, + })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + 'aria-busy': this.isPending ? ('true' as const) : undefined, + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), + 'data-pending': boolToEmptyStrOrUndef(this.isPending), + [formAttrs.root]: '', + ...this.attachment, + }) as const + ); +} + +interface FormFieldStateOpts + extends WithRefOpts, + ActiveProps<{ + invalid: boolean; + disabled: boolean; + readonly: boolean; + required: boolean; + name: string | undefined; + }> {} + +export class FormFieldState { + static create(opts: FormFieldStateOpts) { + return FormFieldContext.set(new FormFieldState(opts, FormRootContext.get())); + } + + readonly opts: FormFieldStateOpts; + readonly root: FormRootState; + readonly attachment: RefAttachment; + + constructor(opts: FormFieldStateOpts, root: FormRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly controlId = $derived.by(() => `${this.opts.id.current}-control`); + readonly labelId = $derived.by(() => `${this.opts.id.current}-label`); + readonly descriptionId = $derived.by(() => `${this.opts.id.current}-description`); + readonly errorId = $derived.by(() => `${this.opts.id.current}-error`); + + readonly isInvalid = $derived.by(() => this.opts.invalid.current); + readonly isPending = $derived.by(() => this.root.isPending); + readonly isDisabled = $derived.by( + () => this.root.isDisabled || this.opts.disabled.current || this.root.isPending + ); + readonly isReadonly = $derived.by(() => this.root.isReadonly || this.opts.readonly.current); + readonly isRequired = $derived.by(() => this.opts.required.current); + readonly describedBy = $derived.by(() => + [this.descriptionId, this.isInvalid ? this.errorId : null].filter(Boolean).join(' ') || undefined + ); + + readonly snippetProps = $derived.by(() => ({ + isDisabled: this.isDisabled, + isReadonly: this.isReadonly, + isPending: this.isPending, + isInvalid: this.isInvalid, + isRequired: this.isRequired, + controlId: this.controlId, + descriptionId: this.descriptionId, + errorId: this.errorId, + labelId: this.labelId, + })); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), + 'data-required': boolToEmptyStrOrUndef(this.isRequired), + 'data-pending': boolToEmptyStrOrUndef(this.isPending), + [formAttrs.field]: '', + ...this.attachment, + }) as const + ); + + readonly controlProps = $derived.by( + () => + ({ + id: this.controlId, + name: this.opts.name.current, + disabled: this.isDisabled || undefined, + readonly: this.isReadonly || undefined, + required: this.isRequired || undefined, + 'aria-labelledby': this.labelId, + 'aria-describedby': this.describedBy, + 'aria-invalid': this.isInvalid ? ('true' as const) : undefined, + 'aria-busy': this.isPending ? ('true' as const) : undefined, + 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + 'data-readonly': boolToEmptyStrOrUndef(this.isReadonly), + 'data-required': boolToEmptyStrOrUndef(this.isRequired), + 'data-pending': boolToEmptyStrOrUndef(this.isPending), + }) as const + ); + + readonly controlWrapperProps = $derived.by( + () => + ({ + 'data-invalid': boolToEmptyStrOrUndef(this.isInvalid), + 'data-disabled': boolToEmptyStrOrUndef(this.isDisabled), + [formAttrs.control]: '', + }) as const + ); +} + +export class FormLabelState { + static create(opts: WithRefOpts) { + return new FormLabelState(opts, FormFieldContext.get()); + } + + readonly opts: WithRefOpts; + readonly field: FormFieldState; + readonly attachment: RefAttachment; + + constructor(opts: WithRefOpts, field: FormFieldState) { + this.opts = opts; + this.field = field; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.field.labelId, + for: this.field.controlId, + 'data-invalid': boolToEmptyStrOrUndef(this.field.isInvalid), + 'data-disabled': boolToEmptyStrOrUndef(this.field.isDisabled), + 'data-required': boolToEmptyStrOrUndef(this.field.isRequired), + [formAttrs.label]: '', + ...this.attachment, + }) as const + ); +} + +export class FormDescriptionState { + static create(opts: WithRefOpts) { + return new FormDescriptionState(opts, FormFieldContext.get()); + } + + readonly opts: WithRefOpts; + readonly field: FormFieldState; + readonly attachment: RefAttachment; + + constructor(opts: WithRefOpts, field: FormFieldState) { + this.opts = opts; + this.field = field; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.field.descriptionId, + 'data-disabled': boolToEmptyStrOrUndef(this.field.isDisabled), + [formAttrs.description]: '', + ...this.attachment, + }) as const + ); +} + +export class FormErrorState { + static create(opts: WithRefOpts) { + return new FormErrorState(opts, FormFieldContext.get()); + } + + readonly opts: WithRefOpts; + readonly field: FormFieldState; + readonly attachment: RefAttachment; + + constructor(opts: WithRefOpts, field: FormFieldState) { + this.opts = opts; + this.field = field; + this.attachment = attachRef(opts.ref as any); + } + + readonly props = $derived.by( + () => + ({ + id: this.field.errorId, + 'aria-live': 'polite', + 'data-invalid': boolToEmptyStrOrUndef(this.field.isInvalid), + [formAttrs.error]: '', + ...this.attachment, + }) as const + ); +} + +export class FormControlState { + static create(opts: WithRefOpts) { + return new FormControlState(opts, FormFieldContext.get()); + } + + readonly opts: WithRefOpts; + readonly field: FormFieldState; + readonly attachment: RefAttachment; + + constructor(opts: WithRefOpts, field: FormFieldState) { + this.opts = opts; + this.field = field; + this.attachment = attachRef(opts.ref as any); + } + + readonly wrapperProps = $derived.by( + () => + ({ + id: this.opts.id.current, + ...this.field.controlWrapperProps, + ...this.attachment, + }) as const + ); + + readonly controlProps = $derived.by(() => this.field.controlProps); +} + +export class FormSubmitState { + static create(opts: WithRefOpts) { + return new FormSubmitState(opts, FormRootContext.get()); + } + + readonly opts: WithRefOpts; + readonly root: FormRootState; + readonly attachment: RefAttachment; + + constructor(opts: WithRefOpts, root: FormRootState) { + this.opts = opts; + this.root = root; + this.attachment = attachRef(opts.ref as any); + } + + readonly isDisabled = $derived.by(() => this.root.isDisabled || this.root.isPending); + + readonly props = $derived.by( + () => + ({ + id: this.opts.id.current, + type: 'submit', + disabled: this.isDisabled || undefined, + 'aria-busy': this.root.isPending ? ('true' as const) : undefined, + 'data-disabled': boolToEmptyStrOrUndef(this.root.isDisabled), + 'data-pending': boolToEmptyStrOrUndef(this.root.isPending), + [formAttrs.submit]: '', + ...this.attachment, + }) as const + ); +} diff --git a/src/uix/terra/form/index.ts b/src/uix/terra/form/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/terra/form/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/terra/form/types.ts b/src/uix/terra/form/types.ts new file mode 100644 index 000000000..22f30d60d --- /dev/null +++ b/src/uix/terra/form/types.ts @@ -0,0 +1,80 @@ +import type { WithChild, Without } from '$terra/utils'; +import type { + TerraPrimitiveButtonAttributes, + TerraPrimitiveDivAttributes, + TerraPrimitiveElementAttributes, + TerraPrimitiveLabelAttributes, + TerraPrimitiveParagraphAttributes, +} from '$terra/utils'; + +export type FormRootSnippetProps = { + isDisabled: boolean; + isReadonly: boolean; + isPending: boolean; +}; + +export type FormFieldSnippetProps = FormRootSnippetProps & { + isInvalid: boolean; + isRequired: boolean; + controlId: string; + descriptionId: string; + errorId: string; + labelId: string; +}; + +export type FormControlSnippetProps = { + controlProps: Record; + wrapperProps: Record; +}; + +type BaseFormRootProps = { + disabled?: boolean; + readonly?: boolean; + pending?: boolean; +}; + +export type FormRootPropsWithoutHTML = WithChild< + BaseFormRootProps, + FormRootSnippetProps, + HTMLFormElement +>; + +export type FormRootProps = FormRootPropsWithoutHTML & + Without; + +type BaseFormFieldProps = { + invalid?: boolean; + disabled?: boolean; + readonly?: boolean; + required?: boolean; + name?: string; +}; + +export type FormFieldPropsWithoutHTML = WithChild< + BaseFormFieldProps, + FormFieldSnippetProps, + HTMLDivElement +>; + +export type FormFieldProps = FormFieldPropsWithoutHTML & + Without; + +export type FormLabelPropsWithoutHTML = WithChild<{}, {}, HTMLLabelElement>; +export type FormLabelProps = FormLabelPropsWithoutHTML & + Without; + +export type FormDescriptionPropsWithoutHTML = WithChild<{}, {}, HTMLParagraphElement>; +export type FormDescriptionProps = FormDescriptionPropsWithoutHTML & + Without; + +export type FormErrorPropsWithoutHTML = WithChild<{}, {}, HTMLParagraphElement>; +export type FormErrorProps = FormErrorPropsWithoutHTML & + Without; + +export type FormControlPropsWithoutHTML = WithChild<{}, FormControlSnippetProps, HTMLDivElement>; +export type FormControlProps = FormControlPropsWithoutHTML & + Without; + +export type FormSubmitPropsWithoutHTML = WithChild<{}, {}, HTMLButtonElement>; +export type FormSubmitProps = FormSubmitPropsWithoutHTML & + Without; diff --git a/src/uix/terra/index.ts b/src/uix/terra/index.ts index 35faa530e..90db3ce00 100644 --- a/src/uix/terra/index.ts +++ b/src/uix/terra/index.ts @@ -7,7 +7,7 @@ export * as AspectRatio from './aspect-ratio'; export * as Avatar from './avatar'; -export type { AvatarImageLoadingStatus } from './avatar'; +export type { ImageLoadingStatus } from './avatar'; export * as Accordion from './accordion'; export * as Button from './button'; export * as Checkbox from './checkbox'; @@ -42,6 +42,8 @@ export * as Command from './command'; export * as AlertDialog from './alert-dialog'; export * as Dialog from './dialog'; export * as Drawer from './drawer'; +export * as Field from './field'; +export * as Form from './form'; export { TerraConfig } from './base/config/exports'; export type {