Add terra form primitive and field integration

main
dev 6 months ago
parent 29e6168261
commit 9deac478e0

@ -0,0 +1,97 @@
<script lang="ts">
import { Field, Form } from '$uix/terra';
let name = $state('');
let email = $state('');
let pending = $state(false);
let submitted = $state<{ name: string; email: string } | null>(null);
const emailInvalid = $derived(email.length > 0 && !email.includes('@'));
const nameInvalid = $derived(name.length > 0 && name.trim().length < 2);
const formInvalid = $derived(nameInvalid || emailInvalid || !name.trim() || !email.trim());
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (formInvalid) return;
pending = true;
await new Promise((resolve) => setTimeout(resolve, 500));
submitted = { name, email };
pending = false;
}
</script>
<div class="page">
<h1>Form</h1>
<p class="lead">
Primitive inicial para contexto de formulario, campos accesibles y composicion con
Field.
</p>
<Form.Root class="form" {pending} onsubmit={handleSubmit}>
<Form.Field class="field-slot" invalid={nameInvalid} required name="name">
<Field.Root class="field">
<Field.Label class="label">Nombre</Field.Label>
<Field.Control class="control">
<Field.Input bind:value={name} class="input" placeholder="Ada Lovelace" />
</Field.Control>
<Field.HelperText class="help">Minimo 2 caracteres.</Field.HelperText>
{#if nameInvalid}
<Field.Error class="error">El nombre es demasiado corto.</Field.Error>
{/if}
</Field.Root>
</Form.Field>
<Form.Field class="field-slot" invalid={emailInvalid} required name="email">
<Field.Root class="field">
<Field.Label class="label">Email</Field.Label>
<Field.Control class="control">
<Field.Input
bind:value={email}
class="input"
type="email"
placeholder="ada@example.com"
/>
</Field.Control>
<Field.HelperText class="help">Usa un correo valido.</Field.HelperText>
{#if emailInvalid}
<Field.Error class="error">El email debe contener @.</Field.Error>
{/if}
</Field.Root>
</Form.Field>
<Form.Submit class="submit" disabled={formInvalid}>
{pending ? 'Enviando...' : 'Enviar'}
</Form.Submit>
</Form.Root>
{#if submitted}
<pre class="result">{JSON.stringify(submitted, null, 2)}</pre>
{/if}
</div>
<style>
.page { display: grid; gap: 1rem; max-width: 42rem; margin: 0 auto; padding: 2rem; }
.lead { color: #666; }
:global(.form) { display: grid; gap: 1rem; }
:global(.field-slot) { display: grid; }
:global(.field) { display: grid; gap: 0.35rem; }
:global(.label) { font-weight: 600; }
:global(.control) { display: grid; }
:global(.help) { color: #666; font-size: 0.9rem; }
:global(.error) { color: #b42318; font-size: 0.9rem; }
:global(.input) {
width: 100%;
padding: 0.65rem 0.8rem;
border: 1px solid #ccc;
border-radius: 0.5rem;
}
:global(.submit) {
justify-self: start;
padding: 0.7rem 1rem;
border-radius: 0.5rem;
border: 1px solid #111;
background: #111;
color: white;
}
.result { background: #f5f5f5; padding: 1rem; border-radius: 0.5rem; }
</style>

@ -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 @@

@ -1,31 +1,29 @@
<script lang="ts">
import type { FieldControlProps } from "../types";
import { FieldControlState } from "../field.svelte";
import { createId, readableActive, writableActive, mergeProps } from "../../utils";
import { createId, mergeProps, readableActive, writableActive } from '$terra/utils';
import type { FieldControlProps } from '../types';
import { FieldControlState } from '../field-state.svelte';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldControlProps = $props();
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldControlProps = $props();
const controlState = FieldControlState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const controlState = FieldControlState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(controlState.props, restProps));
const mergedProps = $derived(mergeProps(controlState.props, restProps));
</script>
{#if child}
{@render child({ props: mergedProps })}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -1,43 +1,34 @@
<script lang="ts">
import {
readableActive,
writableActive,
mergeProps,
createId
} from "../../utils";
import type { FieldErrorProps } from "../types";
import { FieldErrorState } from "../field.svelte";
import { createId, mergeProps, readableActive, writableActive } from '$terra/utils';
import type { FieldErrorProps } from '../types';
import { FieldErrorState } from '../field-state.svelte';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldErrorProps = $props();
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldErrorProps = $props();
const errorState = FieldErrorState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const errorState = FieldErrorState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(errorState.props, restProps));
const ghostMergedProps = $derived(mergeProps(errorState.ghostProps, restProps));
const mergedProps = $derived(mergeProps(errorState.props, restProps));
const ghostMergedProps = $derived(mergeProps(errorState.ghostProps, restProps));
</script>
{#if errorState.root.isError}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<p {...mergedProps}>
{@render children?.()}
</p>
{/if}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<p {...mergedProps}>
{@render children?.()}
</p>
{/if}
{:else}
<!-- Nodo fantasma: mantiene el id en el DOM para que aria-describedby sea válido -->
<p {...ghostMergedProps}></p>
<p {...ghostMergedProps}></p>
{/if}

@ -1,31 +1,30 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from "../../utils/index";
import type { FieldHelperTextProps } from "../types";
import { FieldHelperTextState } from "../field.svelte";
import { createId } from "../../utils/create-id";
import { readableActive, writableActive, mergeProps } from "$terra/utils";
import type { FieldHelperTextProps } from '../types';
import { FieldHelperTextState } from '../field-state.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldHelperTextProps = $props();
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldHelperTextProps = $props();
const helperState = FieldHelperTextState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const helperState = FieldHelperTextState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(helperState.props, restProps));
const mergedProps = $derived(mergeProps(helperState.props, restProps));
</script>
{#if child}
{@render child({ props: mergedProps })}
{@render child({ props: mergedProps })}
{:else}
<p {...mergedProps}>
{@render children?.()}
</p>
<p {...mergedProps}>
{@render children?.()}
</p>
{/if}

@ -1,36 +1,35 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from "../../utils/index";
import type { FieldInputProps } from "../types";
import { FieldInputState } from "../field.svelte";
import { createId } from "../../utils/create-id";
import { noop } from "../../utils/noop";
import { readableActive, writableActive, mergeProps } from "$terra/utils";
import type { FieldInputProps } from '../types';
import { FieldInputState } from '../field-state.svelte';
import { createId } from '$terra/utils';
import { noop } from '$lib/util/funcs';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
type = "text",
value = $bindable(""),
onValueChange = noop,
child,
children,
...restProps
}: FieldInputProps = $props();
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
type = 'text',
value = $bindable(''),
onValueChange = noop,
child,
children,
...restProps
}: FieldInputProps = $props();
const inputState = FieldInputState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
type: readableActive(() => type),
value: writableActive(() => value, (v) => (value = v)),
onValueChange: readableActive(() => onValueChange),
});
const inputState = FieldInputState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
type: readableActive(() => type),
value: writableActive(() => value, (v) => (value = v)),
onValueChange: readableActive(() => onValueChange),
});
const mergedProps = $derived(mergeProps(inputState.props, restProps));
const mergedProps = $derived(mergeProps(inputState.props, restProps));
</script>
{#if child}
{@render child({ props: mergedProps })}
{@render child({ props: mergedProps })}
{:else}
<input {...mergedProps} />
<input {...mergedProps} />
{/if}

@ -1,29 +1,32 @@
<script lang="ts">
import type { FieldLabelProps } from "../types";
import { FieldRootContext } from "../field.svelte";
import { Label } from "../../label";
import { createId, mergeProps, readableActive, writableActive } from '$terra/utils';
import type { FieldLabelProps } from '../types';
import { FieldLabelState } from '../field-state.svelte';
let {
ref = $bindable(null),
child,
children,
...restProps
}: FieldLabelProps = $props();
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldLabelProps = $props();
const root = FieldRootContext.get();
const labelState = FieldLabelState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(labelState.props, restProps));
</script>
<Label.Root
bind:ref
for={root.inputId}
{...restProps}
>
{#if child}
{@render child({ props: restProps })}
{:else}
{@render children?.()}
{#if root.isRequired}
<span data-field-required-indicator aria-hidden="true">*</span>
{/if}
{/if}
</Label.Root>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<label {...mergedProps}>
{@render children?.()}
{#if labelState.root.isRequired}
<span data-field-required-indicator aria-hidden="true">*</span>
{/if}
</label>
{/if}

@ -1,31 +1,30 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from "../../utils/index";
import type { FieldPrefixProps } from "../types";
import { FieldPrefixState } from "../field.svelte";
import { createId } from "../../utils/create-id";
import { readableActive, writableActive, mergeProps } from "$terra/utils";
import type { FieldPrefixProps } from '../types';
import { FieldPrefixState } from '../field-state.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldPrefixProps = $props();
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldPrefixProps = $props();
const prefixState = FieldPrefixState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const prefixState = FieldPrefixState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(prefixState.props, restProps));
const mergedProps = $derived(mergeProps(prefixState.props, restProps));
</script>
{#if child}
{@render child({ props: mergedProps })}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -1,40 +1,38 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from "../../utils/index";
import type { FieldRootProps } from "../types";
import { FieldRootState } from "../field.svelte";
import { createId } from "../../utils/create-id";
import { noop } from "../../utils/noop";
import { readableActive, writableActive, mergeProps } from "$terra/utils";
import type { FieldRootProps } from '../types';
import { FieldRootState } from '../field-state.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
state = "idle",
disabled = false,
readonly = false,
required = false,
child,
children,
...restProps
}: FieldRootProps = $props();
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
state = 'idle',
disabled = false,
readonly = false,
required = false,
child,
children,
...restProps
}: FieldRootProps = $props();
const rootState = FieldRootState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
state: readableActive(() => state),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
});
const rootState = FieldRootState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
state: readableActive(() => state),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
});
const mergedProps = $derived(mergeProps(rootState.props, restProps));
const mergedProps = $derived(mergeProps(rootState.props, restProps));
</script>
{#if child}
{@render child({ props: mergedProps, ...rootState.snippetProps })}
{@render child({ props: mergedProps, ...rootState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(rootState.snippetProps)}
</div>
<div {...mergedProps}>
{@render children?.(rootState.snippetProps)}
</div>
{/if}

@ -1,31 +1,30 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from "../../utils/index";
import type { FieldSuffixProps } from "../types";
import { FieldSuffixState } from "../field.svelte";
import { createId } from "../../utils/create-id";
import { readableActive, writableActive, mergeProps } from "$terra/utils";
import type { FieldSuffixProps } from '../types';
import { FieldSuffixState } from '../field-state.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldSuffixProps = $props();
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
child,
children,
...restProps
}: FieldSuffixProps = $props();
const suffixState = FieldSuffixState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const suffixState = FieldSuffixState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
});
const mergedProps = $derived(mergeProps(suffixState.props, restProps));
const mergedProps = $derived(mergeProps(suffixState.props, restProps));
</script>
{#if child}
{@render child({ props: mergedProps })}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}

@ -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<FieldRootState>('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<HTMLDivElement>;
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<HTMLLabelElement>;
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<HTMLDivElement>;
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<string>;
}>,
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<HTMLInputElement>;
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<HTMLParagraphElement>;
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<HTMLParagraphElement>;
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<HTMLSpanElement>;
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<HTMLSpanElement>;
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
);
}

@ -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<FieldRootState>("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<HTMLLabelElement>;
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);

@ -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;

@ -0,0 +1,19 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from '$terra/utils';
import type { FormControlProps } from '../types';
import { FormControlState } from '../form.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let { ref = $bindable(null), id = createId(uid), children, child, ...restProps }: FormControlProps = $props();
const controlState = FormControlState.create({ id: readableActive(() => id), ref: writableActive(() => ref, (v) => (ref = v)) });
const mergedWrapperProps = $derived(mergeProps(restProps, controlState.wrapperProps));
</script>
{#if child}
{@render child({ props: controlState.controlProps, controlProps: controlState.controlProps, wrapperProps: mergedWrapperProps })}
{:else}
<div {...mergedWrapperProps}>
{@render children?.({ controlProps: controlState.controlProps, wrapperProps: mergedWrapperProps })}
</div>
{/if}

@ -0,0 +1,17 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from '$terra/utils';
import type { FormDescriptionProps } from '../types';
import { FormDescriptionState } from '../form.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let { ref = $bindable(null), id = createId(uid), children, child, ...restProps }: FormDescriptionProps = $props();
const descriptionState = FormDescriptionState.create({ id: readableActive(() => id), ref: writableActive(() => ref, (v) => (ref = v)) });
const mergedProps = $derived(mergeProps(restProps, descriptionState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<p {...mergedProps}>{@render children?.()}</p>
{/if}

@ -0,0 +1,17 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from '$terra/utils';
import type { FormErrorProps } from '../types';
import { FormErrorState } from '../form.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let { ref = $bindable(null), id = createId(uid), children, child, ...restProps }: FormErrorProps = $props();
const errorState = FormErrorState.create({ id: readableActive(() => id), ref: writableActive(() => ref, (v) => (ref = v)) });
const mergedProps = $derived(mergeProps(restProps, errorState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<p {...mergedProps}>{@render children?.()}</p>
{/if}

@ -0,0 +1,41 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from '$terra/utils';
import type { FormFieldProps } from '../types';
import { FormFieldState } from '../form.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
invalid = false,
disabled = false,
readonly = false,
required = false,
name = undefined,
children,
child,
...restProps
}: FormFieldProps = $props();
const fieldState = FormFieldState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
invalid: readableActive(() => invalid),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
name: readableActive(() => name),
});
const mergedProps = $derived(mergeProps(restProps, fieldState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...fieldState.snippetProps })}
{:else}
<div {...mergedProps}>
{@render children?.(fieldState.snippetProps)}
</div>
{/if}

@ -0,0 +1,17 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from '$terra/utils';
import type { FormLabelProps } from '../types';
import { FormLabelState } from '../form.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let { ref = $bindable(null), id = createId(uid), children, child, ...restProps }: FormLabelProps = $props();
const labelState = FormLabelState.create({ id: readableActive(() => id), ref: writableActive(() => ref, (v) => (ref = v)) });
const mergedProps = $derived(mergeProps(restProps, labelState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<label {...mergedProps}>{@render children?.()}</label>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from '$terra/utils';
import type { FormRootProps } from '../types';
import { FormRootState } from '../form.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid),
disabled = false,
readonly = false,
pending = false,
children,
child,
...restProps
}: FormRootProps = $props();
const rootState = FormRootState.create({
id: readableActive(() => id),
ref: writableActive(() => ref, (v) => (ref = v)),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
pending: readableActive(() => pending),
});
const mergedProps = $derived(mergeProps(restProps, rootState.props));
</script>
{#if child}
{@render child({ props: mergedProps, ...rootState.snippetProps })}
{:else}
<form {...mergedProps}>
{@render children?.(rootState.snippetProps)}
</form>
{/if}

@ -0,0 +1,17 @@
<script lang="ts">
import { readableActive, writableActive, mergeProps } from '$terra/utils';
import type { FormSubmitProps } from '../types';
import { FormSubmitState } from '../form.svelte';
import { createId } from '$terra/utils';
const uid = $props.id();
let { ref = $bindable(null), id = createId(uid), children, child, ...restProps }: FormSubmitProps = $props();
const submitState = FormSubmitState.create({ id: readableActive(() => id), ref: writableActive(() => ref, (v) => (ref = v)) });
const mergedProps = $derived(mergeProps(restProps, submitState.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>{@render children?.()}</button>
{/if}

@ -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';

@ -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<FormRootState>('Form.Root');
export const FormFieldContext = new Context<FormFieldState>('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<HTMLFormElement>;
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<HTMLDivElement>;
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<HTMLLabelElement>;
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<HTMLParagraphElement>;
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<HTMLParagraphElement>;
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<HTMLDivElement>;
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<HTMLButtonElement>;
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
);
}

@ -0,0 +1 @@
export * from './exports';

@ -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<string, unknown>;
wrapperProps: Record<string, unknown>;
};
type BaseFormRootProps = {
disabled?: boolean;
readonly?: boolean;
pending?: boolean;
};
export type FormRootPropsWithoutHTML = WithChild<
BaseFormRootProps,
FormRootSnippetProps,
HTMLFormElement
>;
export type FormRootProps = FormRootPropsWithoutHTML &
Without<TerraPrimitiveElementAttributes, FormRootPropsWithoutHTML>;
type BaseFormFieldProps = {
invalid?: boolean;
disabled?: boolean;
readonly?: boolean;
required?: boolean;
name?: string;
};
export type FormFieldPropsWithoutHTML = WithChild<
BaseFormFieldProps,
FormFieldSnippetProps,
HTMLDivElement
>;
export type FormFieldProps = FormFieldPropsWithoutHTML &
Without<TerraPrimitiveDivAttributes, FormFieldPropsWithoutHTML>;
export type FormLabelPropsWithoutHTML = WithChild<{}, {}, HTMLLabelElement>;
export type FormLabelProps = FormLabelPropsWithoutHTML &
Without<TerraPrimitiveLabelAttributes, FormLabelPropsWithoutHTML>;
export type FormDescriptionPropsWithoutHTML = WithChild<{}, {}, HTMLParagraphElement>;
export type FormDescriptionProps = FormDescriptionPropsWithoutHTML &
Without<TerraPrimitiveParagraphAttributes, FormDescriptionPropsWithoutHTML>;
export type FormErrorPropsWithoutHTML = WithChild<{}, {}, HTMLParagraphElement>;
export type FormErrorProps = FormErrorPropsWithoutHTML &
Without<TerraPrimitiveParagraphAttributes, FormErrorPropsWithoutHTML>;
export type FormControlPropsWithoutHTML = WithChild<{}, FormControlSnippetProps, HTMLDivElement>;
export type FormControlProps = FormControlPropsWithoutHTML &
Without<TerraPrimitiveDivAttributes, FormControlPropsWithoutHTML>;
export type FormSubmitPropsWithoutHTML = WithChild<{}, {}, HTMLButtonElement>;
export type FormSubmitProps = FormSubmitPropsWithoutHTML &
Without<TerraPrimitiveButtonAttributes, FormSubmitPropsWithoutHTML>;

@ -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 {

Loading…
Cancel
Save

Powered by TurnKey Linux.