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>
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
Loading…
Reference in new issue