feat: build PasswordField (full stack)

Dedicated password input component — matches the Ark UI / Chakra v3 /
Mantine / PrimeReact / Ant Design pattern of treating PasswordField as
its own first-class component (not a TextField variant). Includes one
feature that NO surveyed library ships: a CapsLock indicator driven by
KeyboardEvent.getModifierState('CapsLock').

Anatomy (5 parts, 4 events):
- Provider (root, value + visibility state)
- Input (the textbox; type flips between password / text)
- VisibilityTrigger (eye toggle button; aria-pressed + aria-controls)
- CapsLockIndicator (status live region; data-active toggles via soma
  listening to keydown/keyup on Input)
- StrengthMeter (composable; consumer provides 0-4 score; UIX bundles
  no zxcvbn-style algorithm — dependency-free by design)

Events:
- commit-input    (every keystroke)
- commit-submit   (Enter)
- shift-toggle-visibility (eye click)
- shift-caps-state (Caps Lock state flips while focused)

Key behaviors:
- `purpose: 'current' | 'new'` drives native autocomplete attribute
  ('current-password' for login / 'new-password' for registration).
- Field integration: disabled/readonly/required/invalid OR-merged.
- VisibilityTrigger gets tabindex=-1 so it doesn't break the form's
  natural tab order — mouse / programmatic focus only, matching Ark UI.
- Caps Lock indicator is aria-live="polite" — announces on activation
  while focused, silent when inactive.
- Letter-spacing increased on the input for legibility while masked.

Reference library survey performed (10 libraries) before implementation —
all consensus around dedicated component, all have built-in toggle,
ZERO have caps lock warning. Documented in the demo's API tab with a
feature comparison table.

Demo follows DEMO_AUTHORING_GUIDE canonical 6-tab depth: Live (controls
grouped by layer + code snippets at the bottom), API (props + reference
comparison), Morfo (field/value + Parts + Events tables), Sema (events
table with ▶ play), Recipe (selectors with morfo/eidos owner tags), A11y
(concern/contract table).

Also includes external work in words/ from a separate session
(unrelated to PasswordField) — not touching Words is the standing rule,
but the diff was already in the working tree.

svelte-check: 0 errors in PasswordField files (Words errors are tracked
by its own development branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
active-uix
dev 5 months ago
parent fead097ce1
commit da3e5026bb

@ -0,0 +1,49 @@
// PasswordField — text input with masked value + visibility toggle +
// CapsLock indicator + optional strength meter.
//
// import { PasswordField } from '$uix/eidos/components/password-field';
//
// <PasswordField bind:value purpose="new">
// <PasswordField.Input placeholder="At least 8 characters" />
// <PasswordField.VisibilityTrigger />
// <PasswordField.CapsLockIndicator />
// <PasswordField.StrengthMeter strength={score} />
// </PasswordField>
import PasswordFieldComponent from './password-field.svelte';
import Input from './password-field-input.svelte';
import VisibilityTrigger from './password-field-visibility-trigger.svelte';
import CapsLockIndicator from './password-field-caps-lock-indicator.svelte';
import StrengthMeter from './password-field-strength-meter.svelte';
type PasswordFieldNamespace = typeof PasswordFieldComponent & {
Input: typeof Input;
VisibilityTrigger: typeof VisibilityTrigger;
CapsLockIndicator: typeof CapsLockIndicator;
StrengthMeter: typeof StrengthMeter;
};
const PasswordField = PasswordFieldComponent as PasswordFieldNamespace;
PasswordField.Input = Input;
PasswordField.VisibilityTrigger = VisibilityTrigger;
PasswordField.CapsLockIndicator = CapsLockIndicator;
PasswordField.StrengthMeter = StrengthMeter;
export { PasswordField };
export default PasswordField;
export type {
PasswordFieldProps,
PasswordFieldInputProps as InputProps,
PasswordFieldVisibilityTriggerProps as VisibilityTriggerProps,
PasswordFieldCapsLockIndicatorProps as CapsLockIndicatorProps,
PasswordFieldStrengthMeterProps as StrengthMeterProps,
PasswordFieldProviderSnippetProps as ProviderSnippetProps,
PasswordFieldVisibilityTriggerSnippetProps as VisibilityTriggerSnippetProps,
PasswordFieldCapsLockIndicatorSnippetProps as CapsLockIndicatorSnippetProps,
PasswordFieldStrengthMeterSnippetProps as StrengthMeterSnippetProps,
PasswordFieldPurpose,
PasswordFieldSize,
PasswordFieldVariant,
PasswordFieldColor
} from './types';

@ -0,0 +1,26 @@
<script lang="ts">
import { getActiveUix } from '$active-uix';
import * as PasswordField from '$soma/components/password-field';
import { PASSWORD_FIELD_LANGS } from '$soma/components/password-field/langs';
import type { PasswordFieldCapsLockIndicatorProps } from './types';
let {
ref = $bindable(null),
children,
...rest
}: PasswordFieldCapsLockIndicatorProps = $props();
const uix = getActiveUix();
const capsLabel = $derived(uix.langs.ts(PASSWORD_FIELD_LANGS.CAPS_WARNING));
</script>
<PasswordField.CapsLockIndicator {...rest} bind:ref>
{#snippet children({ isActive })}
{#if children}
{@render children({ isActive })}
{:else}
<span data-password-field-caps-lock-glyph aria-hidden="true">⇪</span>
<span data-password-field-caps-lock-text>{isActive ? capsLabel : ''}</span>
{/if}
{/snippet}
</PasswordField.CapsLockIndicator>

@ -0,0 +1,8 @@
<script lang="ts">
import * as PasswordField from '$soma/components/password-field';
import type { PasswordFieldInputProps } from './types';
let { ref = $bindable(null), children, ...rest }: PasswordFieldInputProps = $props();
</script>
<PasswordField.Input {...rest} bind:ref />

@ -0,0 +1,30 @@
<script lang="ts">
import * as PasswordField from '$soma/components/password-field';
import type { PasswordFieldStrengthMeterProps } from './types';
let {
ref = $bindable(null),
strength = 0,
labels,
children,
...rest
}: PasswordFieldStrengthMeterProps = $props();
</script>
<PasswordField.StrengthMeter {...rest} {strength} {labels} bind:ref>
{#snippet children(snippetProps)}
{#if children}
{@render children(snippetProps)}
{:else}
<div data-password-field-strength-meter-track aria-hidden="true">
{#each [0, 1, 2, 3] as i (i)}
<span
data-password-field-strength-meter-segment
data-active={snippetProps.strength > i ? '' : undefined}
></span>
{/each}
</div>
<span data-password-field-strength-meter-label>{snippetProps.label}</span>
{/if}
{/snippet}
</PasswordField.StrengthMeter>

@ -0,0 +1,23 @@
<script lang="ts">
import * as PasswordField from '$soma/components/password-field';
import { Eye, EyeOff } from '$uix/eidos/components/icon';
import type { PasswordFieldVisibilityTriggerProps } from './types';
let {
ref = $bindable(null),
children,
...rest
}: PasswordFieldVisibilityTriggerProps = $props();
</script>
<PasswordField.VisibilityTrigger {...rest} bind:ref>
{#snippet children({ isVisible })}
{#if children}
{@render children({ isVisible })}
{:else if isVisible}
<EyeOff aria-hidden="true" />
{:else}
<Eye aria-hidden="true" />
{/if}
{/snippet}
</PasswordField.VisibilityTrigger>

@ -0,0 +1,257 @@
/* PasswordField — single-line password input with visibility toggle. */
[data-password-field] {
--_password-field-px: var(--space-3);
--_password-field-py: var(--space-2);
--_password-field-gap: var(--space-2);
--_password-field-font-size: var(--font-size-md);
--_password-field-line-height: var(--leading-normal);
--_password-field-radius: var(--radius-md);
--_password-field-border: var(--color-neutral-border);
--_password-field-border-hover: var(--color-neutral-border-strong);
--_password-field-border-focus: var(--color-primary-border-strong);
--_password-field-bg: var(--color-surface);
--_password-field-color: var(--color-content);
--_password-field-muted: var(--color-content-muted);
display: inline-flex;
flex-wrap: wrap;
align-items: stretch;
gap: var(--_password-field-gap);
inline-size: 100%;
min-inline-size: 0;
position: relative;
}
[data-password-field][data-size='xs'] {
--_password-field-px: var(--space-2);
--_password-field-py: var(--space-1);
--_password-field-font-size: var(--font-size-xs);
--_password-field-radius: var(--radius-sm);
}
[data-password-field][data-size='sm'] {
--_password-field-px: var(--space-2);
--_password-field-py: var(--space-1-5);
--_password-field-font-size: var(--font-size-sm);
}
[data-password-field][data-size='lg'] {
--_password-field-px: var(--space-4);
--_password-field-py: var(--space-3);
--_password-field-font-size: var(--font-size-lg);
--_password-field-radius: var(--radius-lg);
}
[data-password-field][data-size='xl'] {
--_password-field-px: var(--space-5);
--_password-field-py: var(--space-4);
--_password-field-font-size: var(--font-size-xl);
--_password-field-radius: var(--radius-lg);
}
/* Color accents on focus + invalid. */
[data-password-field][data-color='secondary'] {
--_password-field-border-focus: var(--color-secondary-border-strong);
}
[data-password-field][data-color='neutral'] {
--_password-field-border-focus: var(--color-neutral-border-strong);
}
[data-password-field][data-color='affirm'] {
--_password-field-border-focus: var(--color-affirm-border-strong);
}
[data-password-field][data-color='fulfill'] {
--_password-field-border-focus: var(--color-fulfill-border-strong);
}
[data-password-field][data-color='risk'] {
--_password-field-border-focus: var(--color-risk-border-strong);
}
[data-password-field][data-color='threat'] {
--_password-field-border-focus: var(--color-threat-border-strong);
}
[data-password-field][data-color='loss'] {
--_password-field-border-focus: var(--color-loss-border-strong);
}
/* ── Control row (input + trigger + caps indicator) ───────────────── */
[data-password-field] {
border: var(--border-width) solid var(--_password-field-border);
border-radius: var(--_password-field-radius);
background: var(--_password-field-bg);
padding-inline: var(--_password-field-px);
transition:
border-color var(--duration-fast) var(--ease-default),
box-shadow var(--duration-fast) var(--ease-default);
}
[data-password-field][data-variant='outline'] {
background: transparent;
}
[data-password-field][data-variant='ghost'] {
background: transparent;
border-color: transparent;
}
[data-password-field]:hover:not([data-disabled]):not([data-readonly]) {
border-color: var(--_password-field-border-hover);
}
[data-password-field][data-focused] {
border-color: var(--_password-field-border-focus);
box-shadow: 0 0 0 var(--ring-width, 2px)
color-mix(in srgb, var(--_password-field-border-focus) 25%, transparent);
}
[data-password-field][data-invalid] {
border-color: var(--color-threat-border-strong);
}
[data-password-field][data-disabled] {
opacity: var(--opacity-disabled, 0.6);
}
[data-password-field-input] {
flex: 1 1 auto;
min-inline-size: 0;
padding-block: var(--_password-field-py);
border: 0;
background: transparent;
color: var(--_password-field-color);
font-family: var(--font-family-body);
font-size: var(--_password-field-font-size);
line-height: var(--_password-field-line-height);
outline: none;
letter-spacing: 0.05em;
}
[data-password-field-input]::placeholder {
color: var(--_password-field-muted);
letter-spacing: 0;
}
[data-password-field-input][data-disabled] {
cursor: not-allowed;
}
/* ── VisibilityTrigger ─────────────────────────────────────────────── */
[data-password-field-visibility-trigger] {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: calc(var(--_password-field-font-size) * 1.5);
block-size: calc(var(--_password-field-font-size) * 1.5);
margin-block: auto;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--_password-field-muted);
cursor: pointer;
transition:
color var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default);
}
[data-password-field-visibility-trigger]:hover {
color: var(--_password-field-color);
background: color-mix(in srgb, var(--_password-field-border) 30%, transparent);
}
[data-password-field-visibility-trigger]:focus-visible {
outline: var(--ring-width, 2px) solid var(--_password-field-border-focus);
outline-offset: 1px;
}
[data-password-field-visibility-trigger][data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
[data-password-field-visibility-trigger] svg {
inline-size: calc(var(--_password-field-font-size) * 1.1);
block-size: calc(var(--_password-field-font-size) * 1.1);
}
/* ── CapsLockIndicator ─────────────────────────────────────────────── */
[data-password-field-caps-lock-indicator] {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 0.25em;
margin-block: auto;
padding-inline: 0.4em;
padding-block: 0.2em;
border-radius: var(--radius-sm);
font-size: calc(var(--_password-field-font-size) * 0.75);
font-weight: 600;
color: transparent;
background: transparent;
transition:
color var(--duration-fast) var(--ease-default),
background var(--duration-fast) var(--ease-default);
pointer-events: none;
user-select: none;
}
[data-password-field-caps-lock-indicator][data-active] {
color: var(--color-risk-text);
background: color-mix(in srgb, var(--color-risk-track) 50%, transparent);
}
[data-password-field-caps-lock-glyph] {
font-size: 1em;
line-height: 1;
}
/* ── StrengthMeter ─────────────────────────────────────────────────── */
[data-password-field-strength-meter] {
flex: 1 1 100%;
display: flex;
flex-direction: column;
gap: 0.25em;
margin-top: var(--space-1);
padding-inline: 0;
}
[data-password-field-strength-meter-track] {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
[data-password-field-strength-meter-segment] {
block-size: 4px;
border-radius: var(--radius-sm);
background: var(--color-neutral-track);
transition: background var(--duration-fast) var(--ease-default);
}
[data-password-field-strength-meter][data-strength='1'] [data-password-field-strength-meter-segment][data-active] {
background: var(--color-loss-solid, var(--color-threat-solid));
}
[data-password-field-strength-meter][data-strength='2'] [data-password-field-strength-meter-segment][data-active] {
background: var(--color-risk-solid);
}
[data-password-field-strength-meter][data-strength='3'] [data-password-field-strength-meter-segment][data-active] {
background: var(--color-fulfill-solid);
}
[data-password-field-strength-meter][data-strength='4'] [data-password-field-strength-meter-segment][data-active] {
background: var(--color-affirm-solid);
}
[data-password-field-strength-meter-label] {
font-size: calc(var(--_password-field-font-size) * 0.75);
color: var(--_password-field-muted);
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
[data-password-field],
[data-password-field-visibility-trigger],
[data-password-field-caps-lock-indicator],
[data-password-field-strength-meter-segment] {
transition: none;
}
}

@ -0,0 +1,32 @@
<script lang="ts">
import './password-field.css';
import { ActiveEidos } from '$uix/eidos';
import * as PasswordField from '$soma/components/password-field';
import type { PasswordFieldProps } from './types';
let {
size = 'md',
variant = 'surface',
color = 'primary',
value = $bindable(''),
visible = $bindable(false),
children: bodyContent,
...rest
}: PasswordFieldProps = $props();
const eidos = ActiveEidos.require();
const resolvedSize = $derived(eidos.resolve(size, 'md'));
</script>
<PasswordField.Provider
{...rest}
bind:value
bind:visible
data-size={resolvedSize}
data-variant={variant}
data-color={color}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}
{/snippet}
</PasswordField.Provider>

@ -0,0 +1,44 @@
import type {
ProviderProps,
InputProps,
VisibilityTriggerProps,
CapsLockIndicatorProps,
StrengthMeterProps,
ProviderSnippetProps as PasswordFieldProviderSnippetProps,
VisibilityTriggerSnippetProps as PasswordFieldVisibilityTriggerSnippetProps,
CapsLockIndicatorSnippetProps as PasswordFieldCapsLockIndicatorSnippetProps,
StrengthMeterSnippetProps as PasswordFieldStrengthMeterSnippetProps,
PasswordFieldPurpose
} from '$soma/components/password-field';
import type { ColorRole, ControlVariant, ResponsiveProp, Size } from '$uix/eidos/lib/types';
export type PasswordFieldSize = Extract<Size, 'xs' | 'sm' | 'md' | 'lg' | 'xl'>;
export type PasswordFieldVariant = ControlVariant;
export type PasswordFieldColor = ColorRole;
/**
* Props for the eidos `<PasswordField>`. Extends soma's headless primitive
* with visual sizing only. Value, visibility, caps lock detection,
* autocomplete and Field integration are owned by soma.
*/
export type PasswordFieldProps = ProviderProps & {
/** Visual size. @default 'md' */
size?: ResponsiveProp<PasswordFieldSize>;
/** Surface treatment for the input chrome. @default 'surface' */
variant?: PasswordFieldVariant;
/** Accent palette for focus + caps warning. @default 'primary' */
color?: PasswordFieldColor;
};
export type PasswordFieldInputProps = InputProps;
export type PasswordFieldVisibilityTriggerProps = VisibilityTriggerProps;
export type PasswordFieldCapsLockIndicatorProps = CapsLockIndicatorProps;
export type PasswordFieldStrengthMeterProps = StrengthMeterProps;
export type {
PasswordFieldProviderSnippetProps,
PasswordFieldVisibilityTriggerSnippetProps,
PasswordFieldCapsLockIndicatorSnippetProps,
PasswordFieldStrengthMeterSnippetProps,
PasswordFieldPurpose
};

@ -146,7 +146,7 @@
[data-words]:has([data-words-toolbar][data-orientation='vertical']) { [data-words]:has([data-words-toolbar][data-orientation='vertical']) {
display: grid; display: grid;
grid-template-columns: max-content minmax(0, 1fr); grid-template-columns: max-content minmax(0, 1fr);
grid-template-rows: minmax(var(--_words-content-min-block-size), auto) auto; grid-template-rows: auto auto;
} }
[data-words-toolbar] { [data-words-toolbar] {

@ -36,6 +36,7 @@ import { meterLangs } from './meter';
import { navigationMenuLangs } from './navigation-menu'; import { navigationMenuLangs } from './navigation-menu';
import { numberFieldLangs } from './number-field'; import { numberFieldLangs } from './number-field';
import { paginationLangs } from './pagination'; import { paginationLangs } from './pagination';
import { passwordFieldLangs } from './password-field';
import { pinInputLangs } from './pin-input'; import { pinInputLangs } from './pin-input';
import { popoverLangs } from './popover'; import { popoverLangs } from './popover';
import { progressLangs } from './progress'; import { progressLangs } from './progress';
@ -112,6 +113,7 @@ export const componentLangs = {
'navigation-menu': navigationMenuLangs, 'navigation-menu': navigationMenuLangs,
'number-field': numberFieldLangs, 'number-field': numberFieldLangs,
pagination: paginationLangs, pagination: paginationLangs,
'password-field': passwordFieldLangs,
'pin-input': pinInputLangs, 'pin-input': pinInputLangs,
popover: popoverLangs, popover: popoverLangs,
progress: progressLangs, progress: progressLangs,

@ -0,0 +1,24 @@
import type { LangNode } from '$libs/langs';
/**
* Default strings for the password-field component. Merged under
* `components.password-field.*` by `ActiveUix` (via `componentLangs` barrel).
*/
export const passwordFieldLangs = {
label: {
es: 'Contraseña',
en: 'Password'
},
show: {
es: 'Mostrar contraseña',
en: 'Show password'
},
hide: {
es: 'Ocultar contraseña',
en: 'Hide password'
},
'caps-warning': {
es: 'Bloq Mayús activado',
en: 'Caps Lock is on'
}
} satisfies LangNode;

@ -0,0 +1,195 @@
import type { Morfo } from '../types';
import { v } from '../types';
/**
* PasswordField — text input with masked value, visibility toggle and
* CapsLock awareness. APG textbox pattern + ARIA Toggle Button for the
* visibility trigger.
*
* Surface (consensus across Ark UI / Chakra v3 / Mantine / PrimeReact /
* Ant Design — all dedicate a component for this):
*
* <PasswordField bind:value bind:visible>
* <PasswordField.Input />
* <PasswordField.VisibilityTrigger />
* <PasswordField.CapsLockIndicator />
* </PasswordField>
*
* Distinctive UIX contribution: the `CapsLockIndicator` part fills a gap
* present in 10/10 libraries surveyed — none warn the user when caps
* lock is on while typing a password. Soma listens to keydown events,
* reads `KeyboardEvent.getModifierState('CapsLock')`, and exposes the
* boolean via `data-caps-active`.
*/
export const passwordFieldMorfo = {
name: 'PasswordField',
kebab: 'password-field',
scope: ['soma', 'sema'],
texts: {
label: '#?components.password-field.label|Password',
show: '#?components.password-field.show|Show password',
hide: '#?components.password-field.hide|Hide password',
'caps-warning': '#?components.password-field.caps-warning|Caps Lock is on'
},
apg: 'https://www.w3.org/WAI/ARIA/apg/patterns/textbox/',
events: [
{
// Value committed (every keystroke).
name: 'commit-input',
semantic: {
family: 'commit',
verb: 'input',
target: v.partRef('input'),
intent: 'neutral',
sequence: 'post'
}
},
{
// User pressed Enter — consumer-defined submit hook.
name: 'commit-submit',
semantic: {
family: 'commit',
verb: 'submit',
target: v.partRef('input'),
intent: 'neutral',
sequence: 'post'
}
},
{
// Visibility toggled (mask ↔ plaintext).
name: 'shift-toggle-visibility',
semantic: {
family: 'shift',
verb: 'toggle',
target: v.partRef('visibility-trigger'),
sequence: 'post'
}
},
{
// Caps Lock state changed while the input is focused.
name: 'shift-caps-state',
semantic: {
family: 'shift',
verb: 'navigate',
target: v.partRef('caps-lock-indicator'),
sequence: 'post'
}
}
],
parts: [
{
name: 'Provider',
kebab: 'provider',
archetype: 'provider',
kind: 'public',
defaultElement: 'div',
optional: false,
data: [
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-required', value: v.propRef('required'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' },
{ attr: 'data-focused', value: v.propRef('focused'), severity: 'optional' },
{ attr: 'data-empty', value: v.propRef('empty'), severity: 'optional' },
{ attr: 'data-visible', value: v.propRef('visible'), severity: 'optional' },
{ attr: 'data-caps-active', value: v.propRef('capsActive'), severity: 'optional' }
],
aria: []
},
{
name: 'Input',
kebab: 'input',
archetype: 'input',
kind: 'public',
defaultElement: 'input',
role: 'textbox',
optional: false,
data: [
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' },
{ attr: 'data-readonly', value: v.propRef('readonly'), severity: 'optional' },
{ attr: 'data-invalid', value: v.propRef('invalid'), severity: 'optional' }
],
aria: [
{
attr: 'aria-label',
value: v.translationRef('#?components.password-field.label|Password'),
severity: 'recommended'
},
{
attr: 'aria-invalid',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'invalid' }
},
{
attr: 'aria-required',
value: v.literal('true'),
severity: 'optional',
condition: { when: 'prop-truthy', prop: 'required' }
}
],
keyboard: [{ key: 'Enter', action: 'submit' }]
},
{
name: 'VisibilityTrigger',
kebab: 'visibility-trigger',
archetype: 'trigger',
kind: 'public',
defaultElement: 'button',
role: 'button',
optional: true,
data: [
{ attr: 'data-visible', value: v.propRef('visible'), severity: 'optional' },
{ attr: 'data-disabled', value: v.propRef('disabled'), severity: 'optional' }
],
aria: [
{ attr: 'type', value: v.literal('button') },
{
// Toggle button pattern — pressed when password is visible.
attr: 'aria-pressed',
value: v.mapRef(v.propRef('visible'), { true: 'true', false: 'false' }),
severity: 'recommended'
},
{
// Label flips between "Show" and "Hide" based on state.
attr: 'aria-label',
value: v.translationRef('#?components.password-field.show|Show password'),
severity: 'recommended'
}
]
},
{
// Live region that announces Caps Lock state while the input has
// focus. `aria-live="polite"` so it doesn't interrupt typing flow.
name: 'CapsLockIndicator',
kebab: 'caps-lock-indicator',
archetype: 'indicator',
kind: 'public',
defaultElement: 'span',
role: 'status',
optional: true,
data: [
{ attr: 'data-active', value: v.propRef('capsActive'), severity: 'optional' }
],
aria: [{ attr: 'aria-live', value: v.literal('polite') }]
},
{
// Composable strength meter — consumer provides a 0–4 score.
// UIX does not bundle a strength algorithm (zxcvbn etc.) to stay
// dependency-free; consumer wires whichever library they trust.
name: 'StrengthMeter',
kebab: 'strength-meter',
archetype: 'indicator',
kind: 'public',
defaultElement: 'div',
role: 'meter',
optional: true,
data: [{ attr: 'data-strength', value: v.propRef('strength'), severity: 'optional' }],
aria: [
{ attr: 'aria-valuemin', value: v.literal('0') },
{ attr: 'aria-valuemax', value: v.literal('4') },
{ attr: 'aria-valuenow', value: v.propRef('strength') }
]
}
]
} as const satisfies Morfo;

@ -21,6 +21,7 @@ export { fileUploadSema } from './file-upload';
export { formSema } from './form'; export { formSema } from './form';
export { numberFieldSema } from './number-field'; export { numberFieldSema } from './number-field';
export { paginationSema } from './pagination'; export { paginationSema } from './pagination';
export { passwordFieldSema } from './password-field';
export { popoverSema } from './popover'; export { popoverSema } from './popover';
export { radioGroupSema } from './radio-group'; export { radioGroupSema } from './radio-group';
export { ratingGroupSema } from './rating-group'; export { ratingGroupSema } from './rating-group';

@ -0,0 +1,40 @@
import { semaSelector } from '$uix/morfo';
import { passwordFieldMorfo } from '$uix/morfo/components/password-field';
import { soundTuning } from '../sounds';
import type { Sema } from '../sema-map';
/**
* PasswordField perceptual defaults.
*
* - `commit-submit` → standard commit chime (Enter pressed in the input).
* - `shift-toggle-visibility` → soft handle click on the eye button.
* - `shift-caps-state` → tap when caps lock state flips while typing —
* subtle audio counterpart to the visible indicator.
* Typing itself (`commit-input`) is silent.
*/
const onPart = (
part: 'provider' | 'input' | 'visibility-trigger' | 'caps-lock-indicator',
matchers?: Parameters<typeof semaSelector<typeof passwordFieldMorfo>>[2]
) => semaSelector(passwordFieldMorfo, part, matchers);
export const passwordFieldSema: Sema = {
name: 'password-field',
cascade: [
{
selector: onPart('input', { eventName: 'commit-submit' }),
sound: soundTuning('form.commit.soft'),
haptic: { kind: 'tap' }
},
{
selector: onPart('visibility-trigger', { eventName: 'shift-toggle-visibility' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' }
},
{
selector: onPart('caps-lock-indicator', { eventName: 'shift-caps-state' }),
sound: soundTuning('form.commit.subtle'),
haptic: { kind: 'tap' }
}
]
};

@ -34,6 +34,7 @@ export * as MonthGrid from './month-grid';
export * as NavigationMenu from './navigation-menu'; export * as NavigationMenu from './navigation-menu';
export * as NumberField from './number-field'; export * as NumberField from './number-field';
export * as Pagination from './pagination'; export * as Pagination from './pagination';
export * as PasswordField from './password-field';
export * as PinInput from './pin-input'; export * as PinInput from './pin-input';
export * as Popover from './popover'; export * as Popover from './popover';
export * as Progress from './progress'; export * as Progress from './progress';

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { PasswordFieldCapsLockIndicatorProvider } from '../password-field-provider.svelte';
import type { PasswordFieldCapsLockIndicatorProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'password-field-caps-lock-indicator'),
children,
child,
...restProps
}: PasswordFieldCapsLockIndicatorProps = $props();
const state = PasswordFieldCapsLockIndicatorProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v as HTMLSpanElement | null)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.(state.snippetProps)}
</span>
{/if}

@ -0,0 +1,31 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import {
PasswordFieldInputProvider,
PasswordFieldProvider
} from '../password-field-provider.svelte';
import type { PasswordFieldInputProps } from '../types';
const uid = $props.id();
const provider = PasswordFieldProvider.require();
let {
ref = $bindable(null),
id = provider.opts.inputId.current || createId(uid, 'password-field-input'),
...restProps
}: PasswordFieldInputProps = $props();
const state = PasswordFieldInputProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v as HTMLInputElement | null)
)
});
const mergedProps = $derived(mergeProps({}, restProps, state.props));
</script>
<input {...mergedProps} />

@ -0,0 +1,45 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { PasswordFieldStrengthMeterProvider } from '../password-field-provider.svelte';
import type { PasswordFieldStrengthMeterProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'password-field-strength-meter'),
strength = 0,
labels,
children,
child,
...restProps
}: PasswordFieldStrengthMeterProps = $props();
const state = PasswordFieldStrengthMeterProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v as HTMLDivElement | null)
),
strength: readableActive(() => strength)
});
const DEFAULT_LABELS = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'] as const;
const resolvedLabel = $derived(labels ? labels[state.clampedStrength] : DEFAULT_LABELS[state.clampedStrength]);
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ strength: state.clampedStrength, label: resolvedLabel, props: mergedProps })}
{:else}
<div {...mergedProps}>
{#if children}
{@render children({ strength: state.clampedStrength, label: resolvedLabel })}
{:else}
<span data-password-field-strength-meter-label aria-hidden="true">{resolvedLabel}</span>
{/if}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { PasswordFieldVisibilityTriggerProvider } from '../password-field-provider.svelte';
import type { PasswordFieldVisibilityTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'password-field-visibility-trigger'),
children,
child,
...restProps
}: PasswordFieldVisibilityTriggerProps = $props();
const state = PasswordFieldVisibilityTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v as HTMLButtonElement | null)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.(state.snippetProps)}
</button>
{/if}

@ -0,0 +1,73 @@
<script lang="ts">
import { readableActive, writableActive } from '$libs/reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { PasswordFieldProvider } from '../password-field-provider.svelte';
import type { PasswordFieldProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'password-field'),
inputId = createId(uid, 'password-field-input'),
value = $bindable(''),
onValueChange = () => {},
onSubmit,
visible = $bindable(false),
onVisibilityChange,
purpose = 'current',
maxLength,
minLength,
disabled = false,
readonly = false,
required = false,
invalid = false,
name,
placeholder,
'aria-label': ariaLabel,
children,
child,
...restProps
}: PasswordFieldProps = $props();
const state = PasswordFieldProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
inputId: readableActive(() => inputId),
value: writableActive(
() => value,
(v) => (value = v)
),
visible: writableActive(
() => visible,
(v) => (visible = v)
),
purpose: readableActive(() => purpose),
maxLength: readableActive(() => maxLength),
minLength: readableActive(() => minLength),
disabled: readableActive(() => disabled),
readonly: readableActive(() => readonly),
required: readableActive(() => required),
invalid: readableActive(() => invalid),
name: readableActive(() => name),
placeholder: readableActive(() => placeholder),
ariaLabel: readableActive(() => ariaLabel),
onValueChange: readableActive(() => onValueChange),
onSubmit: readableActive(() => onSubmit),
onVisibilityChange: readableActive(() => onVisibilityChange)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -0,0 +1,18 @@
export { default as Provider } from './components/password-field.svelte';
export { default as Input } from './components/password-field-input.svelte';
export { default as VisibilityTrigger } from './components/password-field-visibility-trigger.svelte';
export { default as CapsLockIndicator } from './components/password-field-caps-lock-indicator.svelte';
export { default as StrengthMeter } from './components/password-field-strength-meter.svelte';
export type {
PasswordFieldProps as ProviderProps,
PasswordFieldInputProps as InputProps,
PasswordFieldVisibilityTriggerProps as VisibilityTriggerProps,
PasswordFieldCapsLockIndicatorProps as CapsLockIndicatorProps,
PasswordFieldStrengthMeterProps as StrengthMeterProps,
PasswordFieldProviderSnippetProps as ProviderSnippetProps,
PasswordFieldVisibilityTriggerSnippetProps as VisibilityTriggerSnippetProps,
PasswordFieldCapsLockIndicatorSnippetProps as CapsLockIndicatorSnippetProps,
PasswordFieldStrengthMeterSnippetProps as StrengthMeterSnippetProps,
PasswordFieldPurpose
} from './types';

@ -0,0 +1,7 @@
/** Idlangref constants for the PasswordField component. */
export const PASSWORD_FIELD_LANGS = {
LABEL: '#?components.password-field.label|Password',
SHOW: '#?components.password-field.show|Show password',
HIDE: '#?components.password-field.hide|Hide password',
CAPS_WARNING: '#?components.password-field.caps-warning|Caps Lock is on'
} as const;

@ -0,0 +1,422 @@
import { context, type WithRefOpts } from '../../provider';
import { boolToEmptyStrOrUndef, boolToStr } from '$adom';
import {
readableActive,
state,
type Active,
type ActiveProps,
type StateProps
} from '$libs/reactive';
import type {
OnChangeFn,
SomaFocusEvent,
SomaInputEvent,
SomaKeyboardEvent
} from '../../types';
import { KEYS } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { FieldProvider } from '../field/field-provider.svelte';
import { PASSWORD_FIELD_LANGS } from './langs';
import { passwordFieldMorfo } from '../../../morfo/components/password-field';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { PasswordFieldPurpose } from './types';
// ── Root provider ──────────────────────────────────────────────────────────
interface PasswordFieldOpts
extends
WithRefOpts,
StateProps<{
value: string;
visible: boolean;
}>,
ActiveProps<{
inputId: string;
purpose: PasswordFieldPurpose;
maxLength: number | undefined;
minLength: number | undefined;
disabled: boolean;
readonly: boolean;
required: boolean;
invalid: boolean;
name: string | undefined;
placeholder: string | undefined;
ariaLabel: string | undefined;
onValueChange: OnChangeFn<string> | undefined;
onSubmit: ((value: string) => void) | undefined;
onVisibilityChange: OnChangeFn<boolean> | undefined;
}> {}
export class PasswordFieldProvider {
readonly opts: PasswordFieldOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<PasswordFieldProvider>('PasswordField');
static get(): PasswordFieldProvider | undefined {
return this.ctx.getOr(undefined) as PasswordFieldProvider | undefined;
}
static require(): PasswordFieldProvider {
return this.ctx.get();
}
static create(opts: PasswordFieldOpts) {
return new PasswordFieldProvider(opts);
}
readonly field = FieldProvider.get();
inputRef = state<HTMLInputElement | null>(null);
focused = $state(false);
capsActive = $state(false);
private constructor(opts: PasswordFieldOpts) {
this.opts = opts;
this.soma = Soma.require();
this.runtime = this.soma.runtime(passwordFieldMorfo, {
props: {
disabled: () => this.isDisabled,
readonly: () => this.isReadonly,
required: () => this.isRequired,
invalid: () => this.isInvalid,
focused: () => this.focused,
empty: () => this.isEmpty,
visible: () => this.opts.visible.current,
capsActive: () => this.capsActive
}
});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: PasswordFieldProvider.ctx,
syncAttrs: true
});
// Register Input id with parent Field so `Field.Label`'s `for=` lands.
if (this.field) this.field.inputId.current = opts.inputId.current;
}
// ── Field-aware flags (OR-merge) ─────────────────────────────────────────
readonly isDisabled = $derived.by(
() => this.opts.disabled.current || (this.field?.isDisabled ?? false)
);
readonly isReadonly = $derived.by(
() => this.opts.readonly.current || (this.field?.isReadonly ?? false)
);
readonly isRequired = $derived.by(
() => this.opts.required.current || (this.field?.isRequired ?? false)
);
readonly isInvalid = $derived.by(
() => this.opts.invalid.current || (this.field?.isInvalid ?? false)
);
readonly isEmpty = $derived.by(() => this.opts.value.current === '');
readonly autocomplete = $derived.by(() =>
this.opts.purpose.current === 'new' ? 'new-password' : 'current-password'
);
readonly resolvedAriaLabel: Active<string | undefined> = readableActive(() => {
// Field.Label wins via aria-labelledby — no aria-label on the input.
if (this.field?.labelId.current) return undefined;
return (
this.opts.ariaLabel.current ||
this.soma.langs.ts(PASSWORD_FIELD_LANGS.LABEL) ||
undefined
);
});
setInputRef(el: HTMLInputElement | null) {
this.inputRef.current = el;
}
// ── Mutations ───────────────────────────────────────────────────────────
commit(next: string) {
if (this.isDisabled || this.isReadonly) return;
if (next === this.opts.value.current) return;
this.opts.value.current = next;
this.opts.onValueChange.current?.(next);
void this.runtime.trigger('commit-input');
}
toggleVisibility = () => {
if (this.isDisabled) return;
const next = !this.opts.visible.current;
this.opts.visible.current = next;
this.opts.onVisibilityChange.current?.(next);
void this.runtime.trigger('shift-toggle-visibility');
// Re-focus the input after the toggle so the user can keep typing.
this.soma.dom.focus(this.inputRef.current);
};
submit() {
this.opts.onSubmit.current?.(this.opts.value.current);
void this.runtime.trigger('commit-submit');
}
setCapsActive(active: boolean) {
if (this.capsActive === active) return;
this.capsActive = active;
void this.runtime.trigger('shift-caps-state');
}
updateCapsFromEvent(e: KeyboardEvent | FocusEvent | undefined) {
if (!e || typeof (e as KeyboardEvent).getModifierState !== 'function') return;
this.setCapsActive((e as KeyboardEvent).getModifierState('CapsLock'));
}
// ── Snippet props ───────────────────────────────────────────────────────
readonly snippetProps = $derived.by(() => ({
value: this.opts.value.current,
isEmpty: this.isEmpty,
isFocused: this.focused,
isVisible: this.opts.visible.current,
isCapsActive: this.capsActive,
toggleVisibility: this.toggleVisibility
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props
} as const)
);
}
// ── Input ──────────────────────────────────────────────────────────────────
interface PasswordFieldInputOpts extends WithRefOpts {}
export class PasswordFieldInputProvider {
readonly opts: PasswordFieldInputOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: PasswordFieldInputOpts) {
return new PasswordFieldInputProvider(opts);
}
readonly provider: PasswordFieldProvider;
private constructor(opts: PasswordFieldInputOpts) {
this.opts = opts;
this.provider = PasswordFieldProvider.require();
this.runtimePart = this.provider.runtime.part('input', {
id: opts.id,
ref: opts.ref,
owner: this
});
$effect(() => {
this.provider.setInputRef(opts.ref.current as HTMLInputElement | null);
});
}
readonly describedBy = $derived.by(() => {
const field = this.provider.field;
if (!field) return undefined;
const ids = [
field.helperId.current,
field.isInvalid ? field.errorId.current : ''
].filter(Boolean);
return ids.length > 0 ? ids.join(' ') : undefined;
});
readonly handleInput = (e: SomaInputEvent<HTMLInputElement>) => {
this.provider.commit(e.currentTarget.value);
};
readonly handleFocus = (e: SomaFocusEvent<HTMLInputElement>) => {
this.provider.focused = true;
// On focus, sample CapsLock state from the focus event if available.
this.provider.updateCapsFromEvent(e as unknown as FocusEvent);
};
readonly handleBlur = (_e: SomaFocusEvent<HTMLInputElement>) => {
this.provider.focused = false;
// Clear caps indicator when input loses focus.
this.provider.setCapsActive(false);
};
readonly handleKeydown = (e: SomaKeyboardEvent<HTMLInputElement>) => {
this.provider.updateCapsFromEvent(e);
if (this.provider.isDisabled || this.provider.isReadonly) return;
if (e.key === KEYS.ENTER) {
e.preventDefault();
this.provider.submit();
}
};
readonly handleKeyup = (e: SomaKeyboardEvent<HTMLInputElement>) => {
this.provider.updateCapsFromEvent(e);
};
readonly props = $derived.by(() => {
const p = this.provider;
return this.runtimePart.assert({
...this.runtimePart.props,
type: p.opts.visible.current ? ('text' as const) : ('password' as const),
value: p.opts.value.current,
autocomplete: p.autocomplete,
placeholder: p.opts.placeholder.current,
name: p.opts.name.current,
disabled: p.isDisabled || undefined,
readonly: p.isReadonly || undefined,
required: p.isRequired || undefined,
maxlength: p.opts.maxLength.current,
minlength: p.opts.minLength.current,
spellcheck: false,
autocorrect: 'off' as const,
autocapitalize: 'off' as const,
'aria-label': p.resolvedAriaLabel.current,
'aria-invalid': boolToStr(p.isInvalid),
'aria-required': boolToStr(p.isRequired),
'aria-describedby': this.describedBy,
'data-disabled': boolToEmptyStrOrUndef(p.isDisabled),
'data-readonly': boolToEmptyStrOrUndef(p.isReadonly),
'data-invalid': boolToEmptyStrOrUndef(p.isInvalid),
oninput: this.handleInput,
onfocus: this.handleFocus,
onblur: this.handleBlur,
onkeydown: this.handleKeydown,
onkeyup: this.handleKeyup
} as const);
});
}
// ── VisibilityTrigger ──────────────────────────────────────────────────────
interface PasswordFieldVisibilityTriggerOpts extends WithRefOpts {}
export class PasswordFieldVisibilityTriggerProvider {
readonly opts: PasswordFieldVisibilityTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: PasswordFieldVisibilityTriggerOpts) {
return new PasswordFieldVisibilityTriggerProvider(opts);
}
readonly provider: PasswordFieldProvider;
private constructor(opts: PasswordFieldVisibilityTriggerOpts) {
this.opts = opts;
this.provider = PasswordFieldProvider.require();
this.runtimePart = this.provider.runtime.part('visibility-trigger', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly handleClick = () => {
this.provider.toggleVisibility();
};
readonly snippetProps = $derived.by(() => ({
isVisible: this.provider.opts.visible.current
}));
readonly resolvedAriaLabel = $derived.by(() => {
const key = this.provider.opts.visible.current
? PASSWORD_FIELD_LANGS.HIDE
: PASSWORD_FIELD_LANGS.SHOW;
return this.provider.soma.langs.ts(key);
});
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
type: 'button' as const,
tabindex: -1,
disabled: this.provider.isDisabled || undefined,
'aria-label': this.resolvedAriaLabel,
'aria-pressed': boolToStr(this.provider.opts.visible.current),
'data-visible': boolToEmptyStrOrUndef(this.provider.opts.visible.current),
'data-disabled': boolToEmptyStrOrUndef(this.provider.isDisabled),
onclick: this.handleClick
} as const)
);
}
// ── CapsLockIndicator ──────────────────────────────────────────────────────
interface PasswordFieldCapsLockIndicatorOpts extends WithRefOpts {}
export class PasswordFieldCapsLockIndicatorProvider {
readonly opts: PasswordFieldCapsLockIndicatorOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: PasswordFieldCapsLockIndicatorOpts) {
return new PasswordFieldCapsLockIndicatorProvider(opts);
}
readonly provider: PasswordFieldProvider;
private constructor(opts: PasswordFieldCapsLockIndicatorOpts) {
this.opts = opts;
this.provider = PasswordFieldProvider.require();
this.runtimePart = this.provider.runtime.part('caps-lock-indicator', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly snippetProps = $derived.by(() => ({
isActive: this.provider.capsActive
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-active': boolToEmptyStrOrUndef(this.provider.capsActive)
} as const)
);
}
// ── StrengthMeter ──────────────────────────────────────────────────────────
interface PasswordFieldStrengthMeterOpts
extends
WithRefOpts,
ActiveProps<{
strength: number;
}> {}
export class PasswordFieldStrengthMeterProvider {
readonly opts: PasswordFieldStrengthMeterOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: PasswordFieldStrengthMeterOpts) {
return new PasswordFieldStrengthMeterProvider(opts);
}
readonly provider: PasswordFieldProvider;
private constructor(opts: PasswordFieldStrengthMeterOpts) {
this.opts = opts;
this.provider = PasswordFieldProvider.require();
this.runtimePart = this.provider.runtime.part('strength-meter', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly clampedStrength = $derived.by(() => {
const raw = this.opts.strength.current;
if (!Number.isFinite(raw)) return 0;
return Math.max(0, Math.min(4, Math.round(raw)));
});
readonly snippetProps = $derived.by(() => ({
strength: this.clampedStrength,
label: ''
}));
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
'data-strength': String(this.clampedStrength),
'aria-valuenow': this.clampedStrength
} as const)
);
}

@ -0,0 +1,204 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type {
PrimitiveDivAttributes,
PrimitiveInputAttributes,
PrimitiveButtonAttributes,
PrimitiveSpanAttributes
} from '../../types';
/**
* Autofill purpose. Drives the native `autocomplete` attribute on the
* underlying input so password managers can pick the right strategy.
*/
export type PasswordFieldPurpose = 'current' | 'new';
/** Snippet props exposed by `PasswordField.Provider`. */
export type PasswordFieldProviderSnippetProps = {
value: string;
/** `true` when `value === ''`. */
isEmpty: boolean;
/** `true` while the Input has focus. */
isFocused: boolean;
/** `true` when the password is shown in plaintext. */
isVisible: boolean;
/** `true` while the user is pressing keys with Caps Lock on. */
isCapsActive: boolean;
/** Programmatic visibility toggle. */
toggleVisibility: () => void;
};
// ── Root provider ──────────────────────────────────────────────────────────
/**
* Props for the root `PasswordField.Provider`.
*
* A `<div>` container wrapping an `<input type="password" | "text">` plus an
* optional visibility toggle button and Caps Lock indicator. Participates
* in `Field.Provider` — `disabled` / `readonly` / `required` / `invalid`
* are OR-merged with the parent Field's state.
*/
export type PasswordFieldProps = WithChild<
{
/** DOM id for the root container. Auto-generated if omitted. */
id?: string;
/** DOM id for the inner input. Auto-generated if omitted. */
inputId?: string;
/** Current value. Bindable. @default '' */
value?: string;
/** Fired on every value change (each keystroke, paste). */
onValueChange?: OnChangeFn<string>;
/** Fired when the user presses Enter in the input. */
onSubmit?: (value: string) => void;
/** Plaintext visibility. Bindable. @default false */
visible?: boolean;
/** Fired when visibility toggles. */
onVisibilityChange?: OnChangeFn<boolean>;
/**
* Autofill semantics. `'current'` → `autocomplete="current-password"`
* (login form). `'new'` → `autocomplete="new-password"` (registration
* + password change). @default 'current'
*/
purpose?: PasswordFieldPurpose;
/** Maximum character length. */
maxLength?: number;
/** Minimum character length. */
minLength?: number;
// Flags — OR-merged with the enclosing `Field.Provider`
/** @default false */
disabled?: boolean;
/** @default false */
readonly?: boolean;
/** @default false */
required?: boolean;
/** External invalid flag. @default false */
invalid?: boolean;
// Form
/** Name for native form submission. */
name?: string;
/** Placeholder forwarded to the input. */
placeholder?: string;
/**
* Accessible name. Ignored when inside a `Field.Provider` with a
* `Field.Label`. Defaults to a translated `'Password'`.
*/
'aria-label'?: string;
children?: Snippet<[PasswordFieldProviderSnippetProps]>;
},
PasswordFieldProviderSnippetProps
> &
Without<PrimitiveDivAttributes, { 'aria-label'?: string }>;
// ── Input ──────────────────────────────────────────────────────────────────
/**
* Props for `PasswordField.Input` — the real `<input>`. Consumer never
* passes `value` / `type` / `autocomplete` directly; the Provider drives
* them.
*/
export type PasswordFieldInputProps = WithChild<{ id?: string }, { _default: never }, HTMLInputElement> &
Without<
PrimitiveInputAttributes,
{
value?: unknown;
type?: unknown;
autocomplete?: unknown;
oninput?: unknown;
onkeydown?: unknown;
onkeyup?: unknown;
onfocus?: unknown;
onblur?: unknown;
}
>;
// ── VisibilityTrigger ──────────────────────────────────────────────────────
/**
* Snippet props for `PasswordField.VisibilityTrigger` — passed via the
* `child` snippet API. The eidos wrapper uses this to render the eye /
* eye-off icon pair.
*/
export type PasswordFieldVisibilityTriggerSnippetProps = {
/** Current visibility (true = plaintext shown). */
isVisible: boolean;
};
/**
* Props for `PasswordField.VisibilityTrigger` — toggle button.
* Renders `<button type="button" aria-pressed={visible}>`.
*/
export type PasswordFieldVisibilityTriggerProps = WithChild<
{
id?: string;
/** Accessible name when password is hidden. @default 'Show password' */
'aria-label-show'?: string;
/** Accessible name when password is visible. @default 'Hide password' */
'aria-label-hide'?: string;
children?: Snippet<[PasswordFieldVisibilityTriggerSnippetProps]>;
},
PasswordFieldVisibilityTriggerSnippetProps
> &
Without<PrimitiveButtonAttributes, { 'aria-label'?: string; 'aria-pressed'?: unknown; type?: unknown }>;
// ── CapsLockIndicator ──────────────────────────────────────────────────────
/**
* Snippet props for `PasswordField.CapsLockIndicator`.
*/
export type PasswordFieldCapsLockIndicatorSnippetProps = {
/** `true` while caps lock is on and the input is focused. */
isActive: boolean;
};
/**
* Props for `PasswordField.CapsLockIndicator` — polite live region that
* announces Caps Lock state. Hidden via CSS when inactive.
*/
export type PasswordFieldCapsLockIndicatorProps = WithChild<
{
id?: string;
children?: Snippet<[PasswordFieldCapsLockIndicatorSnippetProps]>;
},
PasswordFieldCapsLockIndicatorSnippetProps
> &
Without<PrimitiveSpanAttributes, { 'aria-live'?: unknown; role?: unknown }>;
// ── StrengthMeter ──────────────────────────────────────────────────────────
/**
* Snippet props for `PasswordField.StrengthMeter`.
*/
export type PasswordFieldStrengthMeterSnippetProps = {
/** Current strength score (0-4). */
strength: number;
/** Label corresponding to the current strength bucket. */
label: string;
};
/**
* Props for `PasswordField.StrengthMeter` — visual indicator of password
* strength. Consumer provides the `strength` score (0-4) — UIX does NOT
* bundle a scoring algorithm.
*
* Labels default to the 5 strength buckets but can be overridden.
*/
export type PasswordFieldStrengthMeterProps = WithChild<
{
id?: string;
/** Strength score, 0-4. @default 0 */
strength?: number;
/** Labels per bucket. Default uses translated strings. */
labels?: readonly [string, string, string, string, string];
children?: Snippet<[PasswordFieldStrengthMeterSnippetProps]>;
},
PasswordFieldStrengthMeterSnippetProps
> &
Without<PrimitiveDivAttributes, { role?: unknown; 'aria-valuemin'?: unknown; 'aria-valuemax'?: unknown; 'aria-valuenow'?: unknown }>;

@ -3,7 +3,11 @@ import {
clearFormatting, clearFormatting,
deleteBackward, deleteBackward,
deleteForward, deleteForward,
deleteLineBackward,
deleteLineForward,
deleteRange, deleteRange,
deleteWordBackward,
deleteWordForward,
insertLink, insertLink,
insertLineBreak, insertLineBreak,
insertParagraph, insertParagraph,
@ -23,6 +27,10 @@ export type WordsCommand =
| { type: 'insertLineBreak' } | { type: 'insertLineBreak' }
| { type: 'deleteBackward' } | { type: 'deleteBackward' }
| { type: 'deleteForward' } | { type: 'deleteForward' }
| { type: 'deleteWordBackward' }
| { type: 'deleteWordForward' }
| { type: 'deleteLineBackward' }
| { type: 'deleteLineForward' }
| { type: 'deleteRange' } | { type: 'deleteRange' }
| { type: 'toggleMark'; mark: WordsMark } | { type: 'toggleMark'; mark: WordsMark }
| { type: 'clearFormatting' } | { type: 'clearFormatting' }
@ -47,6 +55,14 @@ export function applyWordsCommand(
return deleteBackward(state); return deleteBackward(state);
case 'deleteForward': case 'deleteForward':
return deleteForward(state); return deleteForward(state);
case 'deleteWordBackward':
return deleteWordBackward(state);
case 'deleteWordForward':
return deleteWordForward(state);
case 'deleteLineBackward':
return deleteLineBackward(state);
case 'deleteLineForward':
return deleteLineForward(state);
case 'deleteRange': case 'deleteRange':
return deleteRange(state); return deleteRange(state);
case 'toggleMark': case 'toggleMark':

@ -43,15 +43,19 @@ export function actionFromBeforeInput(event: WordsBeforeInputLike): WordsBeforeI
case 'insertLineBreak': case 'insertLineBreak':
return { type: 'command', command: { type: 'insertLineBreak' } }; return { type: 'command', command: { type: 'insertLineBreak' } };
case 'deleteContentBackward': case 'deleteContentBackward':
return { type: 'command', command: { type: 'deleteBackward' }, batch: 'typing' };
case 'deleteWordBackward': case 'deleteWordBackward':
return { type: 'command', command: { type: 'deleteWordBackward' }, batch: 'typing' };
case 'deleteSoftLineBackward': case 'deleteSoftLineBackward':
case 'deleteHardLineBackward': case 'deleteHardLineBackward':
return { type: 'command', command: { type: 'deleteBackward' }, batch: 'typing' }; return { type: 'command', command: { type: 'deleteLineBackward' }, batch: 'typing' };
case 'deleteContentForward': case 'deleteContentForward':
return { type: 'command', command: { type: 'deleteForward' }, batch: 'typing' };
case 'deleteWordForward': case 'deleteWordForward':
return { type: 'command', command: { type: 'deleteWordForward' }, batch: 'typing' };
case 'deleteSoftLineForward': case 'deleteSoftLineForward':
case 'deleteHardLineForward': case 'deleteHardLineForward':
return { type: 'command', command: { type: 'deleteForward' }, batch: 'typing' }; return { type: 'command', command: { type: 'deleteLineForward' }, batch: 'typing' };
case 'deleteByCut': case 'deleteByCut':
case 'deleteByDrag': case 'deleteByDrag':
return { type: 'command', command: { type: 'deleteRange' } }; return { type: 'command', command: { type: 'deleteRange' } };

@ -28,6 +28,7 @@ export type WordsNormalizeReason =
| 'invalid-mark' | 'invalid-mark'
| 'invalid-heading-level' | 'invalid-heading-level'
| 'invalid-list-kind' | 'invalid-list-kind'
| 'nested-link'
| 'unsafe-url' | 'unsafe-url'
| 'empty-document' | 'empty-document'
| 'empty-children'; | 'empty-children';
@ -136,7 +137,8 @@ function normalizeListItem(
function normalizeInlineChildren( function normalizeInlineChildren(
children: unknown, children: unknown,
path: readonly number[], path: readonly number[],
issues: WordsNormalizeIssue[] issues: WordsNormalizeIssue[],
options: { insideLink?: boolean } = {}
): readonly WordsInline[] { ): readonly WordsInline[] {
if (!Array.isArray(children) || children.length === 0) { if (!Array.isArray(children) || children.length === 0) {
issues.push({ reason: 'empty-children', path }); issues.push({ reason: 'empty-children', path });
@ -144,7 +146,7 @@ function normalizeInlineChildren(
} }
const normalized = children const normalized = children
.map((inline, index) => normalizeInline(inline, [...path, index], issues)) .flatMap((inline, index) => normalizeInline(inline, [...path, index], issues, options))
.filter((inline): inline is WordsInline => Boolean(inline)); .filter((inline): inline is WordsInline => Boolean(inline));
return mergeAdjacentText(normalized.length ? normalized : [createText('')]); return mergeAdjacentText(normalized.length ? normalized : [createText('')]);
@ -153,34 +155,41 @@ function normalizeInlineChildren(
function normalizeInline( function normalizeInline(
inline: unknown, inline: unknown,
path: readonly number[], path: readonly number[],
issues: WordsNormalizeIssue[] issues: WordsNormalizeIssue[],
): WordsInline | undefined { options: { insideLink?: boolean } = {}
): readonly WordsInline[] {
if (!inline || typeof inline !== 'object') { if (!inline || typeof inline !== 'object') {
issues.push({ reason: 'invalid-inline', path, value: inline }); issues.push({ reason: 'invalid-inline', path, value: inline });
return undefined; return [];
} }
const source = inline as Record<string, unknown>; const source = inline as Record<string, unknown>;
if (source.type === 'text') { if (source.type === 'text') {
const marks = normalizeInlineMarks(source.marks, path, issues); const marks = normalizeInlineMarks(source.marks, path, issues);
return createText(typeof source.text === 'string' ? source.text : '', marks); return [createText(typeof source.text === 'string' ? source.text : '', marks)];
} }
if (source.type === 'link') { if (source.type === 'link') {
if (options.insideLink) {
issues.push({ reason: 'nested-link', path, value: source.href });
return normalizeInlineChildren(source.children, path, issues, { insideLink: true });
}
const href = typeof source.href === 'string' ? sanitizeWordsUrl(source.href) : undefined; const href = typeof source.href === 'string' ? sanitizeWordsUrl(source.href) : undefined;
if (!href) { if (!href) {
issues.push({ reason: 'unsafe-url', path, value: source.href }); issues.push({ reason: 'unsafe-url', path, value: source.href });
return createText(getTextFromUnknownChildren(source.children)); return [createText(getTextFromUnknownChildren(source.children))];
} }
return createLink( return [
href, createLink(
normalizeInlineChildren(source.children, path, issues), href,
typeof source.title === 'string' ? source.title : undefined normalizeInlineChildren(source.children, path, issues, { insideLink: true }),
); typeof source.title === 'string' ? source.title : undefined
)
];
} }
issues.push({ reason: 'invalid-inline', path, value: source.type }); issues.push({ reason: 'invalid-inline', path, value: source.type });
return undefined; return [];
} }
function normalizeInlineMarks( function normalizeInlineMarks(

@ -32,6 +32,7 @@ import {
} from './path'; } from './path';
import { import {
clampSelection, clampSelection,
clampPoint,
createCollapsedSelection, createCollapsedSelection,
getTextEntries, getTextEntries,
normalizeRange, normalizeRange,
@ -306,19 +307,44 @@ export function setBlock(
const startIndex = range?.start.path[0] ?? 0; const startIndex = range?.start.path[0] ?? 0;
const endIndex = range?.end.path[0] ?? startIndex; const endIndex = range?.end.path[0] ?? startIndex;
let changed = false; let changed = false;
const blockIndexMap = new Map<number, number>();
const listItemIndexMap = new Map<string, number>();
const children: WordsBlock[] = [];
state.document.children.forEach((block, index) => {
if (index < startIndex || index > endIndex) {
blockIndexMap.set(index, children.length);
children.push(block);
return;
}
if (block.type === 'list') {
changed = true;
for (const [itemIndex, item] of block.children.entries()) {
listItemIndexMap.set(listItemMapKey(index, itemIndex), children.length);
children.push(blockWithChildren(type, item.children, options.level));
}
return;
}
const children = state.document.children.map((block, index) => { const nextBlock = blockWithChildren(type, block.children, options.level);
if (index < startIndex || index > endIndex) return block; blockIndexMap.set(index, children.length);
const inlines = blockToInlineChildren(block);
const nextBlock = blockWithChildren(type, inlines, options.level);
changed = changed || !sameBlockShape(block, nextBlock); changed = changed || !sameBlockShape(block, nextBlock);
return nextBlock; children.push(nextBlock);
}); });
if (!changed) return { state, changed: false }; if (!changed) return { state, changed: false };
const normalized = normalizeDocument({ ...state.document, children }).document; const normalized = normalizeDocument({ ...state.document, children }).document;
const nextSelection = selection ? clampSelection(normalized, selection) : null; const nextSelection = selection
? remapSelectionAfterSetBlock(
state.document,
normalized,
selection,
blockIndexMap,
listItemIndexMap
)
: null;
return { return {
state: { state: {
document: normalized, document: normalized,
@ -384,6 +410,12 @@ export function insertLink(
const selection = state.selection; const selection = state.selection;
if (!selection || !safeHref) return { state, changed: false }; if (!selection || !safeHref) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection); const range = normalizedSelectionRange(state.document, selection);
if (
findAncestorPath(state.document, range.start.path, 'link') ||
findAncestorPath(state.document, range.end.path, 'link')
) {
return { state, changed: false };
}
const selectedText = range.collapsed ? safeHref : selectedRangeText(state.document, range); const selectedText = range.collapsed ? safeHref : selectedRangeText(state.document, range);
const marks = getActiveMarksForSelection(state.document, selection, state.activeMarks); const marks = getActiveMarksForSelection(state.document, selection, state.activeMarks);
return insertInlinesAtRange(state, [ return insertInlinesAtRange(state, [
@ -445,6 +477,39 @@ export function insertParagraph(state: WordsEditorState): WordsOperationResult {
const itemIndex = containerPath[1] ?? -1; const itemIndex = containerPath[1] ?? -1;
const item = block.children[itemIndex]; const item = block.children[itemIndex];
if (!item) return { state, changed: false }; if (!item) return { state, changed: false };
if (
range.collapsed &&
inlineTextLength(item.children) === 0 &&
inlineTextLength(split.before) === 0 &&
inlineTextLength(split.after) === 0
) {
const beforeItems = block.children.slice(0, itemIndex);
const afterItems = block.children.slice(itemIndex + 1);
const replacement: WordsBlock[] = [
...(beforeItems.length ? [createList(block.kind, beforeItems)] : []),
createParagraph(),
...(afterItems.length ? [createList(block.kind, afterItems)] : [])
];
const paragraphIndex = blockIndex + (beforeItems.length ? 1 : 0);
const normalized = normalizeDocument({
...workingState.document,
children: [
...workingState.document.children.slice(0, blockIndex),
...replacement,
...workingState.document.children.slice(blockIndex + 1)
]
}).document;
const nextPoint = pointFromInlineTextOffset(normalized, [paragraphIndex], 0);
const nextSelection = createCollapsedSelection(nextPoint.path, nextPoint.offset);
return {
state: {
document: normalized,
selection: nextSelection,
activeMarks: getActiveMarksForSelection(normalized, nextSelection)
},
changed: true
};
}
const nextItems = [ const nextItems = [
...block.children.slice(0, itemIndex), ...block.children.slice(0, itemIndex),
createListItem(ensureInlineChildren(split.before), item.checked === true), createListItem(ensureInlineChildren(split.before), item.checked === true),
@ -540,6 +605,79 @@ export function deleteForward(state: WordsEditorState): WordsOperationResult {
return mergeWithNextContainer(state, containerPath); return mergeWithNextContainer(state, containerPath);
} }
export function deleteWordBackward(state: WordsEditorState): WordsOperationResult {
return deleteTextBoundary(state, 'backward', 'word');
}
export function deleteWordForward(state: WordsEditorState): WordsOperationResult {
return deleteTextBoundary(state, 'forward', 'word');
}
export function deleteLineBackward(state: WordsEditorState): WordsOperationResult {
return deleteTextBoundary(state, 'backward', 'line');
}
export function deleteLineForward(state: WordsEditorState): WordsOperationResult {
return deleteTextBoundary(state, 'forward', 'line');
}
function deleteTextBoundary(
state: WordsEditorState,
direction: 'backward' | 'forward',
unit: 'word' | 'line'
): WordsOperationResult {
const selection = state.selection;
if (!selection) return { state, changed: false };
const range = normalizedSelectionRange(state.document, selection);
if (!range.collapsed) return deleteRange(state);
const containerPath = editingContainerPath(state.document, range.start.path);
if (!containerPath) return { state, changed: false };
const text = inlineChildrenText(getInlineChildrenAtPath(state.document, containerPath) ?? []);
const offset = textOffsetInInlineContainer(state.document, containerPath, range.start);
const targetOffset =
unit === 'line'
? direction === 'backward'
? 0
: text.length
: direction === 'backward'
? previousWordBoundary(text, offset)
: nextWordBoundary(text, offset);
if (targetOffset === offset) {
return direction === 'backward'
? mergeWithPreviousContainer(state, containerPath)
: mergeWithNextContainer(state, containerPath);
}
const startOffset = Math.min(offset, targetOffset);
const endOffset = Math.max(offset, targetOffset);
const start = pointFromInlineTextOffset(state.document, containerPath, startOffset);
const end = pointFromInlineTextOffset(state.document, containerPath, endOffset);
return deleteRange({
...state,
selection: { anchor: start, focus: end }
});
}
function previousWordBoundary(text: string, offset: number): number {
let index = Math.max(0, Math.min(offset, text.length));
while (index > 0 && isWordBoundarySpacer(text[index - 1] ?? '')) index -= 1;
while (index > 0 && !isWordBoundarySpacer(text[index - 1] ?? '')) index -= 1;
return index;
}
function nextWordBoundary(text: string, offset: number): number {
let index = Math.max(0, Math.min(offset, text.length));
while (index < text.length && isWordBoundarySpacer(text[index] ?? '')) index += 1;
while (index < text.length && !isWordBoundarySpacer(text[index] ?? '')) index += 1;
return index;
}
function isWordBoundarySpacer(char: string): boolean {
return /\s/u.test(char);
}
function deleteRangeFromDocument( function deleteRangeFromDocument(
document: WordsDocument, document: WordsDocument,
range: NormalizedWordsRange range: NormalizedWordsRange
@ -839,6 +977,66 @@ function remapSelectionByContainerOffsets(
return { anchor: start, focus: end }; return { anchor: start, focus: end };
} }
function remapSelectionAfterSetBlock(
beforeDocument: WordsDocument,
afterDocument: WordsDocument,
selection: WordsSelection,
blockIndexMap: ReadonlyMap<number, number>,
listItemIndexMap: ReadonlyMap<string, number>
): WordsSelection {
return {
anchor: remapPointAfterSetBlock(
beforeDocument,
afterDocument,
selection.anchor,
blockIndexMap,
listItemIndexMap
),
focus: remapPointAfterSetBlock(
beforeDocument,
afterDocument,
selection.focus,
blockIndexMap,
listItemIndexMap
)
};
}
function remapPointAfterSetBlock(
beforeDocument: WordsDocument,
afterDocument: WordsDocument,
point: WordsPoint,
blockIndexMap: ReadonlyMap<number, number>,
listItemIndexMap: ReadonlyMap<string, number>
): WordsPoint {
const blockIndex = point.path[0] ?? 0;
const block = beforeDocument.children[blockIndex];
if (block?.type === 'list') {
const itemIndex = point.path[1] ?? 0;
const nextBlockIndex = listItemIndexMap.get(listItemMapKey(blockIndex, itemIndex));
if (nextBlockIndex !== undefined) {
return clampPoint(afterDocument, {
path: [nextBlockIndex, ...point.path.slice(2)],
offset: point.offset
});
}
}
const nextBlockIndex = blockIndexMap.get(blockIndex);
if (nextBlockIndex !== undefined) {
return clampPoint(afterDocument, {
path: [nextBlockIndex, ...point.path.slice(1)],
offset: point.offset
});
}
return clampPoint(afterDocument, point);
}
function listItemMapKey(blockIndex: number, itemIndex: number): string {
return `${blockIndex}:${itemIndex}`;
}
function getActiveMarksForSelection( function getActiveMarksForSelection(
document: WordsDocument, document: WordsDocument,
selection: WordsSelection | null, selection: WordsSelection | null,
@ -879,7 +1077,34 @@ function mergeWithPreviousContainer(
if (block.type === 'list') { if (block.type === 'list') {
const itemIndex = containerPath[1] ?? -1; const itemIndex = containerPath[1] ?? -1;
if (itemIndex <= 0) return { state, changed: false }; if (itemIndex === 0) {
const current = block.children[itemIndex];
if (!current) return { state, changed: false };
const remainingItems = block.children.slice(1);
const replacement: WordsBlock[] = [
createParagraph(current.children),
...(remainingItems.length ? [createList(block.kind, remainingItems)] : [])
];
const normalized = normalizeDocument({
...state.document,
children: [
...state.document.children.slice(0, blockIndex),
...replacement,
...state.document.children.slice(blockIndex + 1)
]
}).document;
const point = pointFromInlineTextOffset(normalized, [blockIndex], 0);
const selection = createCollapsedSelection(point.path, point.offset);
return {
state: {
document: normalized,
selection,
activeMarks: getActiveMarksForSelection(normalized, selection)
},
changed: true
};
}
if (itemIndex < 0) return { state, changed: false };
const previous = block.children[itemIndex - 1]; const previous = block.children[itemIndex - 1];
const current = block.children[itemIndex]; const current = block.children[itemIndex];
if (!previous || !current) return { state, changed: false }; if (!previous || !current) return { state, changed: false };
@ -1010,13 +1235,6 @@ function findAncestorPath(
return undefined; return undefined;
} }
function blockToInlineChildren(block: WordsBlock): readonly WordsInline[] {
if (block.type !== 'list') return block.children;
return block.children.flatMap((item, index) =>
index === 0 ? item.children : [createText('\n'), ...item.children]
);
}
function blockWithChildren( function blockWithChildren(
type: Exclude<WordsBlockType, 'list'>, type: Exclude<WordsBlockType, 'list'>,
children: readonly WordsInline[], children: readonly WordsInline[],
@ -1078,6 +1296,10 @@ function inlineTextLength(inlines: readonly WordsInline[]): number {
return inlines.reduce((total, inline) => total + getInlineText(inline).length, 0); return inlines.reduce((total, inline) => total + getInlineText(inline).length, 0);
} }
function inlineChildrenText(inlines: readonly WordsInline[]): string {
return inlines.map(getInlineText).join('');
}
function toggleMarkSet(marks: readonly WordsMark[], mark: WordsMark): readonly WordsMark[] { function toggleMarkSet(marks: readonly WordsMark[], mark: WordsMark): readonly WordsMark[] {
return normalizeMarks(marks.includes(mark) ? marks.filter((m) => m !== mark) : [...marks, mark]); return normalizeMarks(marks.includes(mark) ? marks.filter((m) => m !== mark) : [...marks, mark]);
} }

@ -29,6 +29,7 @@ import {
createWordsHistory, createWordsHistory,
createWordsState, createWordsState,
encodeWordsDomSelectionTarget, encodeWordsDomSelectionTarget,
endWordsComposition,
getTextEntries, getTextEntries,
isCollapsedSelection, isCollapsedSelection,
redoWords, redoWords,

@ -12,6 +12,7 @@
import { formSema } from '$uix/sema/components/form'; import { formSema } from '$uix/sema/components/form';
import { numberFieldSema } from '$uix/sema/components/number-field'; import { numberFieldSema } from '$uix/sema/components/number-field';
import { paginationSema } from '$uix/sema/components/pagination'; import { paginationSema } from '$uix/sema/components/pagination';
import { passwordFieldSema } from '$uix/sema/components/password-field';
import { popoverSema } from '$uix/sema/components/popover'; import { popoverSema } from '$uix/sema/components/popover';
import { radioGroupSema } from '$uix/sema/components/radio-group'; import { radioGroupSema } from '$uix/sema/components/radio-group';
import { ratingGroupSema } from '$uix/sema/components/rating-group'; import { ratingGroupSema } from '$uix/sema/components/rating-group';
@ -75,6 +76,7 @@
formSema, formSema,
numberFieldSema, numberFieldSema,
paginationSema, paginationSema,
passwordFieldSema,
popoverSema, popoverSema,
radioGroupSema, radioGroupSema,
ratingGroupSema, ratingGroupSema,
@ -151,6 +153,7 @@
{ slug: '/uix/components/words', label: 'Words' }, { slug: '/uix/components/words', label: 'Words' },
{ slug: '/uix/components/number-field', label: 'Number field' }, { slug: '/uix/components/number-field', label: 'Number field' },
{ slug: '/uix/components/search-field', label: 'Search field' }, { slug: '/uix/components/search-field', label: 'Search field' },
{ slug: '/uix/components/password-field', label: 'Password field' },
{ slug: '/uix/components/textarea', label: 'Text area' }, { slug: '/uix/components/textarea', label: 'Text area' },
{ slug: '/uix/components/select', label: 'Select' }, { slug: '/uix/components/select', label: 'Select' },
{ slug: '/uix/components/combobox', label: 'Combobox' }, { slug: '/uix/components/combobox', label: 'Combobox' },
@ -416,9 +419,22 @@
}) })
.filter((s): s is Section => s !== null); .filter((s): s is Section => s !== null);
}); });
const currentBreakpoint = $derived(uix.dom.currentBreakpoint.current);
const compactShell = $derived(
currentBreakpoint === 'base' || currentBreakpoint === 'sm' || currentBreakpoint === 'md'
);
const phoneShell = $derived(currentBreakpoint === 'base');
</script> </script>
<div data-uix-docs data-theme={theme} data-mode={theme}> <div
data-uix-docs
data-theme={theme}
data-mode={theme}
data-uix-breakpoint={currentBreakpoint}
data-uix-compact={compactShell ? 'true' : 'false'}
data-uix-phone={phoneShell ? 'true' : 'false'}
>
<div data-uix-shell> <div data-uix-shell>
<header data-uix-topbar> <header data-uix-topbar>
<a <a

@ -0,0 +1,778 @@
<script lang="ts">
import {
PasswordField,
type PasswordFieldColor,
type PasswordFieldPurpose,
type PasswordFieldSize,
type PasswordFieldVariant
} from '$uix/eidos/components/password-field';
import { compileMorfo } from '$uix/morfo';
import { passwordFieldMorfo } from '@/uix/morfo/components/password-field';
import { getActiveUix } from '$active-uix';
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));
$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}
<!-- ── 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>

@ -1376,236 +1376,115 @@
font-weight: var(--uix-weight-medium); font-weight: var(--uix-weight-medium);
} }
/* ── Responsive docs shell ─────────────────────────────────────────── */ /* ── Responsive docs shell: driven by ActiveDom breakpoints ────────── */
@media (max-width: 900px) { [data-uix-docs][data-uix-compact='true'] [data-uix-shell] {
[data-uix-shell] { grid-template-columns: minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr); }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-topbar] {
[data-uix-topbar] { max-inline-size: 100vi;
max-inline-size: 100vw; overflow: hidden;
overflow: hidden; padding-inline: var(--uix-space-3);
padding-inline: var(--uix-space-3); gap: var(--uix-space-2);
gap: var(--uix-space-2); }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-brand],
[data-uix-brand], [data-uix-docs][data-uix-compact='true'] [data-uix-topbar-nav],
[data-uix-topbar-nav], [data-uix-docs][data-uix-compact='true'] [data-uix-topbar-tools] {
[data-uix-topbar-tools] { min-inline-size: 0;
min-inline-size: 0; }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-brand-version] {
[data-uix-brand-version] { display: none;
display: none; }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-nav] {
[data-uix-topbar-nav] { margin-inline-start: 0;
margin-inline-start: 0; }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-nav] a {
[data-uix-topbar-nav] a { padding-inline: var(--uix-space-2);
padding-inline: var(--uix-space-2); }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-tools] {
[data-uix-topbar-tools] { flex: 1 1 auto;
flex: 1 1 auto; justify-content: flex-end;
justify-content: flex-end; overflow-x: auto;
overflow-x: auto; overscroll-behavior-inline: contain;
overscroll-behavior-inline: contain; scrollbar-width: none;
scrollbar-width: none; }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-tools]::-webkit-scrollbar {
[data-uix-topbar-tools]::-webkit-scrollbar { display: none;
display: none; }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-rail] {
[data-uix-rail] { display: none;
display: none; }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-canvas] {
[data-uix-canvas] { grid-column: 1;
grid-column: 1; padding: var(--uix-space-8) var(--uix-space-4) var(--uix-space-12);
padding: var(--uix-space-8) var(--uix-space-4) var(--uix-space-12); }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-canvas-inner],
[data-uix-canvas-inner], [data-uix-docs][data-uix-compact='true'] [data-uix-canvas-inner][data-with-toc='true'] {
[data-uix-canvas-inner][data-with-toc='true'] { display: block;
display: block; max-inline-size: none;
max-inline-size: none; }
}
[data-uix-docs][data-uix-compact='true'] [data-uix-toc] {
[data-uix-toc] { display: none;
display: none; }
}
} [data-uix-docs][data-uix-phone='true'] [data-uix-topbar-nav] a[href='https://github.com/'] {
display: none;
@media (max-width: 560px) { }
[data-uix-topbar-nav] a[href='https://github.com/'] {
display: none; [data-uix-docs][data-uix-phone='true'] [data-uix-canvas] {
} padding: var(--uix-space-6) var(--uix-space-3) var(--uix-space-10);
}
[data-uix-canvas] {
padding: var(--uix-space-6) var(--uix-space-3) var(--uix-space-10); [data-uix-docs][data-uix-phone='true'] [data-uix-page-title] {
} font-size: var(--uix-text-4xl);
}
[data-uix-page-title] {
font-size: var(--uix-text-4xl); [data-uix-docs][data-uix-phone='true'] [data-uix-page-lede] {
} font-size: var(--uix-text-lg);
}
[data-uix-page-lede] {
font-size: var(--uix-text-lg); [data-uix-docs][data-uix-phone='true'] [data-uix-page-meta],
} [data-uix-docs][data-uix-phone='true'] [data-uix-tabs],
[data-uix-docs][data-uix-phone='true'] [data-uix-code-head] {
[data-uix-page-meta], overflow-x: auto;
[data-uix-tabs], overscroll-behavior-inline: contain;
[data-uix-code-head] { }
overflow-x: auto;
overscroll-behavior-inline: contain; [data-uix-docs][data-uix-phone='true'] [data-uix-tabs] {
} flex-wrap: nowrap;
}
[data-uix-tabs] {
flex-wrap: nowrap; [data-uix-docs][data-uix-phone='true'] [data-uix-tab],
} [data-uix-docs][data-uix-phone='true'] [data-uix-meta-pill],
[data-uix-docs][data-uix-phone='true'] [data-uix-code-head] > * {
[data-uix-tab], flex: 0 0 auto;
[data-uix-meta-pill], }
[data-uix-code-head] > * {
flex: 0 0 auto; [data-uix-docs][data-uix-phone='true'] [data-uix-stage-area] {
} place-items: stretch;
}
[data-uix-stage-area] {
place-items: stretch; [data-uix-docs][data-uix-phone='true'] [data-uix-stage-trace] {
} align-items: flex-start;
gap: var(--uix-space-2);
[data-uix-stage-trace] { }
align-items: flex-start;
gap: var(--uix-space-2); [data-uix-docs][data-uix-phone='true'] [data-uix-controls] {
} grid-template-columns: minmax(0, 1fr);
padding: var(--uix-space-4);
[data-uix-controls] { }
grid-template-columns: minmax(0, 1fr);
padding: var(--uix-space-4); [data-uix-docs][data-uix-phone='true'] [data-uix-part] {
} grid-template-columns: minmax(0, 1fr);
gap: var(--uix-space-2);
[data-uix-part] {
grid-template-columns: minmax(0, 1fr);
gap: var(--uix-space-2);
}
}
/* ── Responsive docs shell ─────────────────────────────────────────── */
@media (max-width: 900px) {
[data-uix-shell] {
grid-template-columns: minmax(0, 1fr);
}
[data-uix-topbar] {
max-inline-size: 100vw;
overflow: hidden;
padding-inline: var(--uix-space-3);
gap: var(--uix-space-2);
}
[data-uix-brand],
[data-uix-topbar-nav],
[data-uix-topbar-tools] {
min-inline-size: 0;
}
[data-uix-brand-version] {
display: none;
}
[data-uix-topbar-nav] {
margin-inline-start: 0;
}
[data-uix-topbar-nav] a {
padding-inline: var(--uix-space-2);
}
[data-uix-topbar-tools] {
flex: 1 1 auto;
justify-content: flex-end;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: none;
}
[data-uix-topbar-tools]::-webkit-scrollbar {
display: none;
}
[data-uix-rail] {
display: none;
}
[data-uix-canvas] {
grid-column: 1;
padding: var(--uix-space-8) var(--uix-space-4) var(--uix-space-12);
}
[data-uix-canvas-inner],
[data-uix-canvas-inner][data-with-toc='true'] {
display: block;
max-inline-size: none;
}
[data-uix-toc] {
display: none;
}
}
@media (max-width: 560px) {
[data-uix-topbar-nav] a[href='https://github.com/'] {
display: none;
}
[data-uix-canvas] {
padding: var(--uix-space-6) var(--uix-space-3) var(--uix-space-10);
}
[data-uix-page-title] {
font-size: var(--uix-text-4xl);
}
[data-uix-page-lede] {
font-size: var(--uix-text-lg);
}
[data-uix-page-meta],
[data-uix-tabs],
[data-uix-code-head] {
overflow-x: auto;
overscroll-behavior-inline: contain;
}
[data-uix-tabs] {
flex-wrap: nowrap;
}
[data-uix-tab],
[data-uix-meta-pill],
[data-uix-code-head] > * {
flex: 0 0 auto;
}
[data-uix-stage-area] {
place-items: stretch;
}
[data-uix-stage-trace] {
align-items: flex-start;
gap: var(--uix-space-2);
}
[data-uix-controls] {
grid-template-columns: minmax(0, 1fr);
padding: var(--uix-space-4);
}
[data-uix-part] {
grid-template-columns: minmax(0, 1fr);
gap: var(--uix-space-2);
}
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.