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