You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/web/routes/uix/components/password-field/+page.svelte

1028 lines
38 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script lang="ts">
import {
PasswordField,
type PasswordFieldColor,
type PasswordFieldPurpose,
type PasswordFieldSize,
type PasswordFieldVariant
} from '$uix/eidos/components/password-field';
import { Form } from '$uix/eidos/components/form';
import { Field } from '$uix/eidos/components/field';
import { compileMorfo } from '$uix/morfo';
import { passwordFieldMorfo } from '@/uix/morfo/components/password-field';
import { getActiveUix } from '$active-uix';
import { createForm } from '$libs/forms';
import { object, string, pipe, min, regex, meta, refine } from '$sium/core';
type Tab = 'live' | 'api' | 'morfo' | 'sema' | 'recipe' | 'a11y';
type TraceEntry = { event: string; family: string; intent?: string; at: number };
const uix = getActiveUix();
let tab = $state<Tab>('live');
let trace = $state<TraceEntry[]>([]);
let stageRef = $state<HTMLElement | null>(null);
// soma props
let value = $state('');
let visible = $state(false);
let purpose = $state<PasswordFieldPurpose>('current');
let placeholder = $state('Enter your password');
let maxLength = $state<number | undefined>(64);
let minLength = $state<number | undefined>(8);
let disabled = $state(false);
let readonly = $state(false);
let required = $state(true);
let invalid = $state(false);
let name = $state('password');
// eidos props
let size = $state<PasswordFieldSize>('md');
let variant = $state<PasswordFieldVariant>('surface');
let color = $state<PasswordFieldColor>('primary');
// composition switches
let showVisibilityTrigger = $state(true);
let showCapsLockIndicator = $state(true);
let showStrengthMeter = $state(true);
let lastSubmit = $state<string | null>(null);
const compiled = compileMorfo(passwordFieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
const events = $derived([...compiled.actions.byName.values()]);
const sizes: PasswordFieldSize[] = ['xs', 'sm', 'md', 'lg', 'xl'];
const variants: PasswordFieldVariant[] = ['surface', 'outline', 'ghost'];
const colors: PasswordFieldColor[] = [
'primary',
'secondary',
'neutral',
'affirm',
'fulfill',
'risk',
'threat',
'loss'
];
const purposes: PasswordFieldPurpose[] = ['current', 'new'];
/**
* Trivial strength estimator — for the demo only. Real apps should plug
* in a proper algorithm (zxcvbn, OWASP entropy estimator…). 0–4 scale.
*/
function estimateStrength(pw: string): number {
if (pw.length === 0) return 0;
let score = 0;
if (pw.length >= 8) score++;
if (pw.length >= 12) score++;
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) score++;
if (/\d/.test(pw) && /[^\w\s]/.test(pw)) score++;
return Math.min(4, score);
}
const strength = $derived(estimateStrength(value));
// ── Form + SIUM demos ─────────────────────────────────────────────
// Three real forms that wire PasswordField through Field + Form so the
// validation chain (schema → form.errors → Field.isInvalid →
// PasswordField data-invalid) is visible end-to-end. The schemas use
// the project's SIUM core (Standard Schema implementation).
let loginResult = $state('—');
let registrationResult = $state('—');
let changeResult = $state('—');
// 1) Login — minimal: username + non-empty password.
const loginSchema = object({
username: pipe(string(), min(3), meta({ label: 'Username' })),
password: pipe(string(), min(1), meta({ label: 'Password' }))
});
const loginForm = createForm({
schema: loginSchema,
defaults: { username: '', password: '' },
validationBehaviour: 'onBlur',
onValidSubmit: (v) => {
loginResult = JSON.stringify(v, null, 2);
}
});
// 2) Registration — `purpose="new"` + complexity rules in the schema.
// The PasswordField's strength meter is a visual companion; the schema
// is what blocks submission until the password matches the policy.
const registrationSchema = object({
email: pipe(string(), regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), meta({ label: 'Email' })),
password: pipe(
string(),
min(8),
regex(/[A-Z]/),
regex(/\d/),
regex(/[^\w\s]/),
meta({ label: 'Password' })
)
});
const registrationForm = createForm({
schema: registrationSchema,
defaults: { email: '', password: '' },
validationBehaviour: 'onChange',
onValidSubmit: (v) => {
registrationResult = JSON.stringify(v, null, 2);
}
});
// 3) Password change — three fields with a cross-field refine() guard
// (new password must equal the confirmation). `refine()` runs after the
// object schema; the error lands on the `confirm` path via opts.
const passwordChangeBaseSchema = object({
current: pipe(string(), min(1), meta({ label: 'Current password' })),
next: pipe(string(), min(8), meta({ label: 'New password' })),
confirm: pipe(string(), min(1), meta({ label: 'Confirm new password' }))
});
// Cross-field refine — the error lands at the schema root.
// In a real app, structure the schema differently or display the message
// via `<Form.ErrorSummary>` to surface root-level errors.
const passwordChangeSchema = pipe(
passwordChangeBaseSchema,
refine(
(v) => v.next === v.confirm,
{ code: 'custom', message: "Passwords don't match" }
)
);
const changeForm = createForm({
schema: passwordChangeSchema,
defaults: { current: '', next: '', confirm: '' },
validationBehaviour: 'onBlur',
onValidSubmit: (v) => {
changeResult = JSON.stringify(v, null, 2);
}
});
const registrationStrength = $derived(
estimateStrength(String(registrationForm.values.password ?? ''))
);
$effect(() => {
const el = stageRef;
if (!el) return;
const obs = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.attributeName !== 'data-event') continue;
const target = m.target as Element;
const ev = target.getAttribute('data-event');
if (!ev) continue;
trace = [
{
event: ev,
family: target.getAttribute('data-event-family') ?? '—',
intent: target.getAttribute('data-event-intent') ?? undefined,
at: Date.now()
},
...trace
].slice(0, 6);
}
});
obs.observe(el, { attributes: true, subtree: true, attributeFilter: ['data-event'] });
return () => obs.disconnect();
});
function fmtTime(at: number): string {
const d = new Date(at);
return `${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
const somaSnippet = $derived(
[
"<script lang='ts'>",
" import * as PasswordField from '$soma/components/password-field';",
' let value = $state(\'\');',
' let visible = $state(false);',
'</' + 'script>',
'',
'<PasswordField.Provider',
' bind:value',
' bind:visible',
purpose !== 'current' && ` purpose="${purpose}"`,
minLength !== undefined && ` minLength={${minLength}}`,
maxLength !== undefined && ` maxLength={${maxLength}}`,
required && ' required',
'>',
' <PasswordField.Input placeholder="…" />',
showVisibilityTrigger && ' <PasswordField.VisibilityTrigger />',
showCapsLockIndicator && ' <PasswordField.CapsLockIndicator />',
'</PasswordField.Provider>'
]
.filter(Boolean)
.join('\n')
);
const eidosSnippet = $derived(
[
"<script lang='ts'>",
" import { PasswordField } from '$uix/eidos/components/password-field';",
' let value = $state(\'\');',
' let visible = $state(false);',
" const strength = $derived(estimateStrength(value));",
'</' + 'script>',
'',
'<PasswordField',
' bind:value',
' bind:visible',
purpose !== 'current' && ` purpose="${purpose}"`,
minLength !== undefined && ` minLength={${minLength}}`,
maxLength !== undefined && ` maxLength={${maxLength}}`,
required && ' required',
size !== 'md' && ` size="${size}"`,
variant !== 'surface' && ` variant="${variant}"`,
color !== 'primary' && ` color="${color}"`,
'>',
' <PasswordField.Input placeholder="…" />',
showVisibilityTrigger && ' <PasswordField.VisibilityTrigger />',
showCapsLockIndicator && ' <PasswordField.CapsLockIndicator />',
showStrengthMeter && ' <PasswordField.StrengthMeter {strength} />',
'</PasswordField>'
]
.filter(Boolean)
.join('\n')
);
</script>
<div data-uix-canvas-inner>
<header>
<div data-uix-eyebrow>Forms · PasswordField</div>
<h1 data-uix-page-title>PasswordField</h1>
<p data-uix-page-lede>
Single-line password input with masked value, visibility toggle, CapsLock awareness, and an
optional composable strength meter. Anatomy follows Ark UI; CapsLockIndicator fills a
universal gap (10/10 surveyed libraries warn nothing when Caps Lock is on while typing).
Soma owns value, masking, autocomplete (<code>current-password</code>/<code>new-password</code>)
and Caps Lock detection.
</p>
<div data-uix-page-meta>
<span data-uix-meta-pill>
<span data-uix-meta-key>parts</span>{compiled.parts.order.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>events</span>{events.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>keyboard</span>1
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>sizes</span>{sizes.length}
</span>
<span data-uix-meta-pill>
<span data-uix-meta-key>apg</span>
<a href={passwordFieldMorfo.apg} target="_blank" rel="noreferrer">textbox</a>
</span>
</div>
</header>
<!-- Live preview always rendered (between header and tabs). -->
<div data-uix-stage>
<div data-uix-stage-area bind:this={stageRef} style="padding: var(--uix-space-4);">
<div style="max-inline-size: 24rem; margin-inline: auto;">
<PasswordField
bind:value
bind:visible
{purpose}
{placeholder}
{maxLength}
{minLength}
{disabled}
{readonly}
{required}
{invalid}
{name}
{size}
{variant}
{color}
onSubmit={(v) => (lastSubmit = v)}
>
<PasswordField.Input />
{#if showCapsLockIndicator}
<PasswordField.CapsLockIndicator />
{/if}
{#if showVisibilityTrigger}
<PasswordField.VisibilityTrigger />
{/if}
{#if showStrengthMeter}
<PasswordField.StrengthMeter {strength} />
{/if}
</PasswordField>
</div>
</div>
<div data-uix-stage-trace>
<span data-uix-stage-trace-key>trace</span>
{#if trace.length === 0}
<span>type, press Caps Lock, click the eye to see events</span>
{:else}
{#each trace.slice(0, 3) as entry}
<span>
<span data-uix-stage-trace-event>{entry.event}</span>
· {entry.family}{entry.intent ? ' · ' + entry.intent : ''}
</span>
<span style="color: var(--uix-text-faint)">{fmtTime(entry.at)}</span>
{/each}
{/if}
<span style="margin-inline-start: auto;">
<span data-uix-stage-trace-key>length</span>
{value.length} · <span data-uix-stage-trace-key>strength</span> {strength}/4
</span>
</div>
</div>
<div data-uix-tabs role="tablist">
<button data-uix-tab data-active={tab === 'live'} onclick={() => (tab = 'live')}>Live</button>
<button data-uix-tab data-active={tab === 'api'} onclick={() => (tab = 'api')}>
API <span data-uix-tab-count>14</span>
</button>
<button data-uix-tab data-active={tab === 'morfo'} onclick={() => (tab = 'morfo')}>
<span data-uix-layer-badge="morfo">morfo</span>
<span data-uix-tab-count>{partsList.length}p · {events.length}e</span>
</button>
<button data-uix-tab data-active={tab === 'sema'} onclick={() => (tab = 'sema')}>
<span data-uix-layer-badge="sema">sema</span>
<span data-uix-tab-count>{events.length}</span>
</button>
<button data-uix-tab data-active={tab === 'recipe'} onclick={() => (tab = 'recipe')}>Recipe</button
>
<button data-uix-tab data-active={tab === 'a11y'} onclick={() => (tab = 'a11y')}>A11y</button>
</div>
{#if tab === 'live'}
<section data-uix-section>
<h2 data-uix-section-title>Controls</h2>
<p data-uix-section-desc>
<span data-uix-layer-badge="soma">soma</span> owns value, masking and CapsLock detection.
<span data-uix-layer-badge="eidos">eidos</span> owns size, variant, color and icon
rendering.
</p>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · value &amp; behaviour
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label
>value
<span data-uix-control-hint>{value.length} chars</span>
</span>
<input type="text" bind:value placeholder="(empty)" />
</label>
<label data-uix-control>
<span data-uix-control-label>visible</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={visible} />
<span data-uix-switch-label>{visible ? 'plaintext' : 'masked'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>purpose</span>
<span data-uix-chips role="radiogroup">
{#each purposes as p (p)}
<button data-uix-chip data-active={purpose === p} onclick={() => (purpose = p)}
>{p}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>placeholder</span>
<input type="text" bind:value={placeholder} />
</label>
<label data-uix-control>
<span data-uix-control-label>minLength</span>
<input
type="number"
min="0"
value={minLength ?? ''}
oninput={(e) => {
const v = e.currentTarget.value;
minLength = v === '' ? undefined : Number(v);
}}
/>
</label>
<label data-uix-control>
<span data-uix-control-label>maxLength</span>
<input
type="number"
min="1"
value={maxLength ?? ''}
oninput={(e) => {
const v = e.currentTarget.value;
maxLength = v === '' ? undefined : Number(v);
}}
/>
</label>
<label data-uix-control>
<span data-uix-control-label>name</span>
<input type="text" bind:value={name} />
</label>
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> props · flags
</div>
<div data-uix-controls>
{#each ['disabled', 'readonly', 'required', 'invalid'] as flag}
<label data-uix-control>
<span data-uix-control-label>{flag}</span>
<span data-uix-switch>
{#if flag === 'disabled'}
<input type="checkbox" bind:checked={disabled} />
<span data-uix-switch-label>{disabled ? 'on' : 'off'}</span>
{:else if flag === 'readonly'}
<input type="checkbox" bind:checked={readonly} />
<span data-uix-switch-label>{readonly ? 'on' : 'off'}</span>
{:else if flag === 'required'}
<input type="checkbox" bind:checked={required} />
<span data-uix-switch-label>{required ? 'on' : 'off'}</span>
{:else}
<input type="checkbox" bind:checked={invalid} />
<span data-uix-switch-label>{invalid ? 'on' : 'off'}</span>
{/if}
</span>
</label>
{/each}
</div>
<div data-uix-subsection-head>
<span data-uix-layer-badge="eidos">eidos</span> props · presentation
</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>size</span>
<span data-uix-chips role="radiogroup">
{#each sizes as s (s)}
<button data-uix-chip data-active={size === s} onclick={() => (size = s)}>{s}</button>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>variant</span>
<span data-uix-chips role="radiogroup">
{#each variants as v (v)}
<button data-uix-chip data-active={variant === v} onclick={() => (variant = v)}
>{v}</button
>
{/each}
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>color</span>
<span data-uix-chips role="radiogroup">
{#each colors as c (c)}
<button data-uix-chip data-active={color === c} onclick={() => (color = c)}
>{c}</button
>
{/each}
</span>
</label>
</div>
<div data-uix-subsection-head>Composition · which parts to include</div>
<div data-uix-controls>
<label data-uix-control>
<span data-uix-control-label>VisibilityTrigger</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showVisibilityTrigger} />
<span data-uix-switch-label>{showVisibilityTrigger ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>CapsLockIndicator</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showCapsLockIndicator} />
<span data-uix-switch-label>{showCapsLockIndicator ? 'on' : 'off'}</span>
</span>
</label>
<label data-uix-control>
<span data-uix-control-label>StrengthMeter</span>
<span data-uix-switch>
<input type="checkbox" bind:checked={showStrengthMeter} />
<span data-uix-switch-label>{showStrengthMeter ? 'on' : 'off'}</span>
</span>
</label>
</div>
{#if lastSubmit !== null}
<div data-uix-subsection-head>Last submitted</div>
<pre data-uix-snippet><code>{lastSubmit}</code></pre>
{/if}
<!-- ── Form + SIUM integration ─────────────────────────────────── -->
<div data-uix-subsection-head>
<span data-uix-layer-badge="soma">soma</span> integration · Form + SIUM (Standard Schema)
</div>
<p data-uix-section-desc>
PasswordField has no built-in complexity rules — validation is schema-first via the
project's SIUM core (any Standard Schema implementor: SIUM, Zod, Valibot, ArkType…).
Wrap in <code>&lt;Field.Provider name="…"&gt;</code> inside a
<code>&lt;Form.Provider&gt;</code>; the form runs the schema and propagates
<code>form.errors</code> into <code>Field.isInvalid</code>, which OR-merges into
<code>PasswordField.invalid</code> (red border + <code>aria-invalid</code>). The
<code>Field.ErrorText</code> renders the first error message.
</p>
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--uix-space-4); margin-top: var(--uix-space-3);"
>
<!-- ── Login form ────────────────────────────────────────────── -->
<div data-uix-snippet style="padding: var(--uix-space-4);">
<h3 style="margin: 0 0 var(--uix-space-3); font-size: var(--uix-text-sm);">
Login form · <code>purpose="current"</code>
</h3>
<Form form={loginForm}>
<div style="display: flex; flex-direction: column; gap: var(--uix-space-3);">
<Field name="username" label="Username" required>
<Field.Label>Username</Field.Label>
<Field.Control>
<input
type="text"
autocomplete="username"
bind:value={loginForm.values.username}
/>
</Field.Control>
<Field.ErrorText>{loginForm.errors.username?.[0]}</Field.ErrorText>
</Field>
<Field name="password" label="Password" required>
<Field.Label>Password</Field.Label>
<Field.Control>
<PasswordField bind:value={loginForm.values.password} purpose="current" name="password">
<PasswordField.Input />
<PasswordField.CapsLockIndicator />
<PasswordField.VisibilityTrigger />
</PasswordField>
</Field.Control>
<Field.ErrorText>{loginForm.errors.password?.[0]}</Field.ErrorText>
</Field>
<Form.Submit>Sign in</Form.Submit>
{#if loginResult !== '—'}
<pre style="margin: 0; font-size: var(--uix-text-xs);"><code
>{loginResult}</code
></pre>
{/if}
</div>
</Form>
</div>
<!-- ── Registration form ─────────────────────────────────────── -->
<div data-uix-snippet style="padding: var(--uix-space-4);">
<h3 style="margin: 0 0 var(--uix-space-3); font-size: var(--uix-text-sm);">
Registration · <code>purpose="new"</code> + complexity
</h3>
<Form form={registrationForm}>
<div style="display: flex; flex-direction: column; gap: var(--uix-space-3);">
<Field name="email" label="Email" required>
<Field.Label>Email</Field.Label>
<Field.Control>
<input
type="email"
autocomplete="email"
bind:value={registrationForm.values.email}
/>
</Field.Control>
<Field.ErrorText>{registrationForm.errors.email?.[0]}</Field.ErrorText>
</Field>
<Field name="password" label="Password" required>
<Field.Label>Password</Field.Label>
<Field.Control>
<PasswordField
bind:value={registrationForm.values.password}
purpose="new"
name="password"
>
<PasswordField.Input />
<PasswordField.CapsLockIndicator />
<PasswordField.VisibilityTrigger />
<PasswordField.StrengthMeter strength={registrationStrength} />
</PasswordField>
</Field.Control>
<Field.ErrorText>{registrationForm.errors.password?.[0]}</Field.ErrorText>
</Field>
<Form.Submit>Create account</Form.Submit>
{#if registrationResult !== '—'}
<pre style="margin: 0; font-size: var(--uix-text-xs);"><code
>{registrationResult}</code
></pre>
{/if}
</div>
</Form>
</div>
<!-- ── Password change form ──────────────────────────────────── -->
<div data-uix-snippet style="padding: var(--uix-space-4);">
<h3 style="margin: 0 0 var(--uix-space-3); font-size: var(--uix-text-sm);">
Change · cross-field <code>refine()</code>
</h3>
<Form form={changeForm}>
<div style="display: flex; flex-direction: column; gap: var(--uix-space-3);">
<Field name="current" label="Current password" required>
<Field.Label>Current password</Field.Label>
<Field.Control>
<PasswordField
bind:value={changeForm.values.current}
purpose="current"
name="current-password"
>
<PasswordField.Input />
<PasswordField.VisibilityTrigger />
</PasswordField>
</Field.Control>
<Field.ErrorText>{changeForm.errors.current?.[0]}</Field.ErrorText>
</Field>
<Field name="next" label="New password" required>
<Field.Label>New password</Field.Label>
<Field.Control>
<PasswordField
bind:value={changeForm.values.next}
purpose="new"
name="new-password"
>
<PasswordField.Input />
<PasswordField.VisibilityTrigger />
</PasswordField>
</Field.Control>
<Field.ErrorText>{changeForm.errors.next?.[0]}</Field.ErrorText>
</Field>
<Field name="confirm" label="Confirm new password" required>
<Field.Label>Confirm</Field.Label>
<Field.Control>
<PasswordField
bind:value={changeForm.values.confirm}
purpose="new"
name="confirm-password"
>
<PasswordField.Input />
<PasswordField.VisibilityTrigger />
</PasswordField>
</Field.Control>
<Field.ErrorText>{changeForm.errors.confirm?.[0]}</Field.ErrorText>
</Field>
<Form.Submit>Update password</Form.Submit>
{#if changeResult !== '—'}
<pre style="margin: 0; font-size: var(--uix-text-xs);"><code
>{changeResult}</code
></pre>
{/if}
</div>
</Form>
</div>
</div>
<!-- ── Code snippets per layer ─────────────────────────────────── -->
<div data-uix-code>
<div data-uix-code-head>
<span data-uix-layer-badge="soma">soma</span>
<span>headless · ARIA + behavior only</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{somaSnippet}</code></pre>
</div>
<div data-uix-code style="margin-top: var(--uix-space-3);">
<div data-uix-code-head>
<span data-uix-layer-badge="eidos">eidos</span>
<span>visual · adds icons, chrome + strength meter</span>
<span data-uix-code-lang>svelte</span>
</div>
<pre><code>{eidosSnippet}</code></pre>
</div>
</section>
{/if}
{#if tab === 'api'}
<section data-uix-section>
<h2 data-uix-section-title>API reference</h2>
<p data-uix-section-desc>
PasswordField composes through 5 parts. Root drives value + visibility + caps detection;
every visual part is optional (composition over visibility props).
</p>
<div data-uix-subsection-head>Provider props</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
<tr><td class="name">value</td><td class="type">string</td><td>Bindable. Default <code>''</code>.</td></tr>
<tr><td class="name">visible</td><td class="type">boolean</td><td>Bindable. Toggle plaintext / masked. Default <code>false</code>.</td></tr>
<tr>
<td class="name">purpose</td>
<td class="type">'current' | 'new'</td>
<td>
Drives the native <code>autocomplete</code> attribute. <code>'current'</code> →
<code>current-password</code> (login). <code>'new'</code> →
<code>new-password</code> (registration / change). Default <code>'current'</code>.
</td>
</tr>
<tr><td class="name">minLength / maxLength</td><td class="type">number</td><td>Native constraints + mirrored ARIA. Used by HTML form validation.</td></tr>
<tr><td class="name">disabled / readonly / required / invalid</td><td class="type">boolean</td><td>OR-merged with parent <code>&lt;Field&gt;</code>.</td></tr>
<tr><td class="name">placeholder</td><td class="type">string?</td><td>Native placeholder.</td></tr>
<tr><td class="name">name</td><td class="type">string?</td><td>Form submission name.</td></tr>
<tr><td class="name">aria-label</td><td class="type">string?</td><td>Override translated 'Password' default. Ignored if inside Field.Label.</td></tr>
<tr><td class="name">onValueChange</td><td class="type">(v: string) =&gt; void</td><td>Fires on every keystroke.</td></tr>
<tr><td class="name">onSubmit</td><td class="type">(v: string) =&gt; void</td><td>Fires on Enter.</td></tr>
<tr><td class="name">onVisibilityChange</td><td class="type">(visible: boolean) =&gt; void</td><td>Fires after the eye toggle.</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Eidos visual props</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Prop</th><th>Type</th><th>Default</th></tr></thead>
<tbody>
<tr><td class="name">size</td><td class="type">'xs' | 'sm' | 'md' | 'lg' | 'xl'</td><td><code>'md'</code></td></tr>
<tr><td class="name">variant</td><td class="type">ControlVariant</td><td><code>'surface'</code></td></tr>
<tr><td class="name">color</td><td class="type">ColorRole</td><td><code>'primary'</code></td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Reference comparison</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr><th>Library</th><th>Built-in toggle</th><th>CapsLock</th><th>Strength meter</th></tr>
</thead>
<tbody>
<tr><td class="name">React Aria</td><td class="default">no</td><td class="default">no</td><td class="default">no</td></tr>
<tr><td class="name">Ark UI</td><td>yes</td><td class="default">no</td><td class="default">external</td></tr>
<tr><td class="name">Chakra v3</td><td>yes</td><td class="default">no</td><td>sibling cmp</td></tr>
<tr><td class="name">Mantine</td><td>yes</td><td class="default">no</td><td>compose</td></tr>
<tr><td class="name">PrimeReact</td><td>yes</td><td class="default">no</td><td>built-in popup</td></tr>
<tr><td class="name">Ant Design</td><td>yes (default)</td><td class="default">no</td><td class="default">no</td></tr>
<tr><td class="name">shadcn/ui</td><td class="default">no</td><td class="default">no</td><td class="default">no</td></tr>
<tr><td class="name">Material UI</td><td class="default">no</td><td class="default">no</td><td class="default">no</td></tr>
<tr style="background: color-mix(in srgb, var(--color-affirm-track) 30%, transparent);">
<td class="name"><strong>UIX</strong></td>
<td><strong>yes</strong></td>
<td><strong>yes (universal gap)</strong></td>
<td>composable</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'morfo'}
<section data-uix-section>
<h2 data-uix-section-title>Morfo contract</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>
<tr><td class="name">name</td><td>{passwordFieldMorfo.name}</td></tr>
<tr><td class="name">kebab</td><td><code>{passwordFieldMorfo.kebab}</code></td></tr>
<tr><td class="name">scope</td><td>{passwordFieldMorfo.scope.join(', ')}</td></tr>
<tr><td class="name">apg</td><td><a href={passwordFieldMorfo.apg} target="_blank" rel="noreferrer">textbox</a></td></tr>
<tr><td class="name">parts</td><td>{partsList.length}</td></tr>
<tr><td class="name">events</td><td>{events.length}</td></tr>
</tbody>
</table>
</div>
<div data-uix-subsection-head>Parts</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead>
<tr>
<th>kebab</th>
<th>marker</th>
<th>element</th>
<th>role</th>
<th>archetype</th>
<th>optional</th>
</tr>
</thead>
<tbody>
{#each partsList as part (part.kebab)}
<tr>
<td class="name">{part.kebab}</td>
<td><code data-uix-part-marker>[{part.marker}]</code></td>
<td class="type">&lt;{part.defaultElement}&gt;</td>
<td class="type">{part.role ?? '—'}</td>
<td class="type">{part.archetype ?? '—'}</td>
<td class="default">{part.optional ? 'yes' : 'no'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div data-uix-subsection-head>Events</div>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>target</th><th>intent</th></tr></thead>
<tbody>
{#each events as e (e.name)}
<tr>
<td><code>{e.name}</code></td>
<td>{e.semantic?.family ?? '—'}</td>
<td>{e.semantic?.verb ?? '—'}</td>
<td><code>{e.semantic && 'target' in e.semantic ? e.semantic.target.target : '—'}</code></td>
<td>{e.semantic && 'intent' in e.semantic && typeof e.semantic.intent === 'string' ? e.semantic.intent : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'sema'}
<section data-uix-section>
<h2 data-uix-section-title>
<span data-uix-layer-badge="sema">sema</span> · events
</h2>
<p data-uix-section-desc>
Type a character to fire <code>commit-input</code>. Press Enter to commit
<code>commit-submit</code>. Click the eye to fire
<code>shift-toggle-visibility</code>. Press Caps Lock while the input is focused to fire
<code>shift-caps-state</code>. Use ▶ play to dispatch the event without the gesture.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>name</th><th>family</th><th>verb</th><th>sequence</th><th>intent</th><th></th></tr></thead>
<tbody>
{#each events as action (action.name)}
{@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined}
{@const effectiveIntent =
typeof intentDecl === 'string' ? intentDecl : undefined}
<tr>
<td><code>{action.name}</code></td>
<td>{action.semantic.family}</td>
<td>{action.semantic.verb ?? '—'}</td>
<td>{action.semantic.sequence ?? '—'}</td>
<td>{effectiveIntent ?? '—'}</td>
<td>
<button
data-uix-play
onclick={() => {
const part = action.semantic.target.target;
const target = (stageRef?.querySelector(`[data-password-field-${part}]`) ??
stageRef?.querySelector('[data-password-field]') ??
stageRef) as HTMLElement | null;
if (!target) return;
void uix.events?.emit({
name: action.name,
family: action.semantic.family,
target,
...(effectiveIntent ? { intent: effectiveIntent } : {})
});
}}>▶ play</button
>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'recipe'}
<section data-uix-section>
<h2 data-uix-section-title>Eidos recipe</h2>
<p data-uix-section-desc>
Recipe lives in <code>src/uix/eidos/components/password-field/password-field.css</code>.
Provider is the inline-flex container; tokens are private (<code>--_password-field-*</code>).
CapsLock indicator and StrengthMeter have their own data-* attribute states.
</p>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Selector</th><th>Owner</th><th>Purpose</th></tr></thead>
<tbody>
<tr>
<td class="name"><code>[data-password-field]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Provider marker. Inline-flex container with border + focus ring.</td>
</tr>
<tr>
<td class="name"><code>[data-password-field][data-size='xs'..'xl']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Padding + font-size + radius scale.</td>
</tr>
<tr>
<td class="name"><code>[data-password-field][data-color='primary'..'loss']</code></td>
<td><span data-uix-tag data-kind="eidos">eidos</span></td>
<td>Maps to focus accent token.</td>
</tr>
<tr>
<td class="name"><code>[data-password-field][data-focused]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Focus ring via box-shadow color-mix.</td>
</tr>
<tr>
<td class="name"><code>[data-password-field-input]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Input cell. Letter-spacing increased for legibility while masked.</td>
</tr>
<tr>
<td class="name"><code>[data-password-field-visibility-trigger]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Eye toggle button. Hover background + focus outline.</td>
</tr>
<tr>
<td class="name"><code>[data-password-field-caps-lock-indicator][data-active]</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Risk-tinted pill, hidden when inactive.</td>
</tr>
<tr>
<td class="name"><code>[data-password-field-strength-meter][data-strength='1'..'4']</code></td>
<td><span data-uix-tag data-kind="morfo">morfo</span></td>
<td>Active segments tint per strength bucket (loss / risk / fulfill / affirm).</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
{#if tab === 'a11y'}
<section data-uix-section>
<h2 data-uix-section-title>Accessibility</h2>
<div data-uix-table-wrap>
<table data-uix-table>
<thead><tr><th>Concern</th><th>Contract</th></tr></thead>
<tbody>
<tr>
<td class="name">Role</td>
<td>
Native <code>&lt;input&gt;</code> with <code>type="password"</code> (or
<code>"text"</code> when visible). Implicit <code>role="textbox"</code>.
</td>
</tr>
<tr>
<td class="name">Label</td>
<td>
Defaults to translated 'Password' / 'Contraseña'. When inside a
<code>&lt;Field&gt;</code> the Field.Label wins via <code>aria-labelledby</code>.
</td>
</tr>
<tr>
<td class="name">Autofill</td>
<td>
<code>autocomplete="current-password"</code> (login) or
<code>"new-password"</code> (registration / password change). Drives password
manager behavior.
</td>
</tr>
<tr>
<td class="name">Visibility toggle</td>
<td>
<code>&lt;button type="button" aria-pressed={'{visible}'} aria-label="Show/Hide
password"&gt;</code>. <code>tabindex="-1"</code> so it doesn't break the form's
natural tab order — the user toggles via mouse / programmatic focus instead.
</td>
</tr>
<tr>
<td class="name">Caps Lock detection</td>
<td>
Soma listens to keydown/keyup on the input and reads
<code>KeyboardEvent.getModifierState('CapsLock')</code>. Indicator carries
<code>role="status"</code> + <code>aria-live="polite"</code> so screen readers
announce when Caps Lock turns on while typing.
</td>
</tr>
<tr>
<td class="name">Strength meter</td>
<td>
<code>role="meter"</code> with <code>aria-valuemin=0</code>,
<code>aria-valuemax=4</code>, <code>aria-valuenow</code>. Visual segments are
<code>aria-hidden</code>; only the label is announced.
</td>
</tr>
<tr>
<td class="name">Keyboard</td>
<td>
<code>Enter</code> fires <code>commit-submit</code>. No other keys hijacked —
selection / cursor movement / paste all behave natively.
</td>
</tr>
<tr>
<td class="name">Disabled / readonly</td>
<td>
Mirrored to native attributes AND ARIA. The visibility trigger also disables when
the input is disabled.
</td>
</tr>
<tr>
<td class="name">Reduced motion</td>
<td>All transitions disabled under <code>prefers-reduced-motion: reduce</code>.</td>
</tr>
</tbody>
</table>
</div>
</section>
{/if}
</div>

Powered by TurnKey Linux.