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
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
|
||||
};
|
||||
@ -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,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' }
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -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 @@
|
||||
export * from './exports';
|
||||
@ -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 }>;
|
||||
Loading…
Reference in new issue