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