diff --git a/src/uix/eidos/components/password-field/index.ts b/src/uix/eidos/components/password-field/index.ts new file mode 100644 index 000000000..a2bd49618 --- /dev/null +++ b/src/uix/eidos/components/password-field/index.ts @@ -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'; +// +// +// +// +// +// +// +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'; diff --git a/src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte b/src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte new file mode 100644 index 000000000..079e988de --- /dev/null +++ b/src/uix/eidos/components/password-field/password-field-caps-lock-indicator.svelte @@ -0,0 +1,26 @@ + + + + {#snippet children({ isActive })} + {#if children} + {@render children({ isActive })} + {:else} + + {isActive ? capsLabel : ''} + {/if} + {/snippet} + diff --git a/src/uix/eidos/components/password-field/password-field-input.svelte b/src/uix/eidos/components/password-field/password-field-input.svelte new file mode 100644 index 000000000..9f54e5e0d --- /dev/null +++ b/src/uix/eidos/components/password-field/password-field-input.svelte @@ -0,0 +1,8 @@ + + + diff --git a/src/uix/eidos/components/password-field/password-field-strength-meter.svelte b/src/uix/eidos/components/password-field/password-field-strength-meter.svelte new file mode 100644 index 000000000..9609d2f83 --- /dev/null +++ b/src/uix/eidos/components/password-field/password-field-strength-meter.svelte @@ -0,0 +1,30 @@ + + + + {#snippet children(snippetProps)} + {#if children} + {@render children(snippetProps)} + {:else} + + {snippetProps.label} + {/if} + {/snippet} + diff --git a/src/uix/eidos/components/password-field/password-field-visibility-trigger.svelte b/src/uix/eidos/components/password-field/password-field-visibility-trigger.svelte new file mode 100644 index 000000000..db5938e2a --- /dev/null +++ b/src/uix/eidos/components/password-field/password-field-visibility-trigger.svelte @@ -0,0 +1,23 @@ + + + + {#snippet children({ isVisible })} + {#if children} + {@render children({ isVisible })} + {:else if isVisible} + diff --git a/src/uix/eidos/components/password-field/password-field.css b/src/uix/eidos/components/password-field/password-field.css new file mode 100644 index 000000000..20c68175a --- /dev/null +++ b/src/uix/eidos/components/password-field/password-field.css @@ -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; + } +} diff --git a/src/uix/eidos/components/password-field/password-field.svelte b/src/uix/eidos/components/password-field/password-field.svelte new file mode 100644 index 000000000..7e2313b1f --- /dev/null +++ b/src/uix/eidos/components/password-field/password-field.svelte @@ -0,0 +1,32 @@ + + + + {#snippet children(snippetProps)} + {@render bodyContent?.(snippetProps)} + {/snippet} + diff --git a/src/uix/eidos/components/password-field/types.ts b/src/uix/eidos/components/password-field/types.ts new file mode 100644 index 000000000..ebcb82a6f --- /dev/null +++ b/src/uix/eidos/components/password-field/types.ts @@ -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; +export type PasswordFieldVariant = ControlVariant; +export type PasswordFieldColor = ColorRole; + +/** + * Props for the eidos ``. 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; + /** 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 +}; diff --git a/src/uix/eidos/components/words/words.css b/src/uix/eidos/components/words/words.css index 44e8a08ec..fb34bb027 100644 --- a/src/uix/eidos/components/words/words.css +++ b/src/uix/eidos/components/words/words.css @@ -146,7 +146,7 @@ [data-words]:has([data-words-toolbar][data-orientation='vertical']) { display: grid; grid-template-columns: max-content minmax(0, 1fr); - grid-template-rows: minmax(var(--_words-content-min-block-size), auto) auto; + grid-template-rows: auto auto; } [data-words-toolbar] { diff --git a/src/uix/langs/components/index.ts b/src/uix/langs/components/index.ts index 97ef74c2f..607c48bd9 100644 --- a/src/uix/langs/components/index.ts +++ b/src/uix/langs/components/index.ts @@ -36,6 +36,7 @@ import { meterLangs } from './meter'; import { navigationMenuLangs } from './navigation-menu'; import { numberFieldLangs } from './number-field'; import { paginationLangs } from './pagination'; +import { passwordFieldLangs } from './password-field'; import { pinInputLangs } from './pin-input'; import { popoverLangs } from './popover'; import { progressLangs } from './progress'; @@ -112,6 +113,7 @@ export const componentLangs = { 'navigation-menu': navigationMenuLangs, 'number-field': numberFieldLangs, pagination: paginationLangs, + 'password-field': passwordFieldLangs, 'pin-input': pinInputLangs, popover: popoverLangs, progress: progressLangs, diff --git a/src/uix/langs/components/password-field.ts b/src/uix/langs/components/password-field.ts new file mode 100644 index 000000000..38362d23d --- /dev/null +++ b/src/uix/langs/components/password-field.ts @@ -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; diff --git a/src/uix/morfo/components/password-field.ts b/src/uix/morfo/components/password-field.ts new file mode 100644 index 000000000..2c4d85ceb --- /dev/null +++ b/src/uix/morfo/components/password-field.ts @@ -0,0 +1,195 @@ +import type { Morfo } from '../types'; +import { v } from '../types'; + +/** + * PasswordField — text input with masked value, visibility toggle and + * CapsLock awareness. APG textbox pattern + ARIA Toggle Button for the + * visibility trigger. + * + * Surface (consensus across Ark UI / Chakra v3 / Mantine / PrimeReact / + * Ant Design — all dedicate a component for this): + * + * + * + * + * + * + * + * 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; diff --git a/src/uix/sema/components/index.ts b/src/uix/sema/components/index.ts index b80dc5a4a..73d9d9626 100644 --- a/src/uix/sema/components/index.ts +++ b/src/uix/sema/components/index.ts @@ -21,6 +21,7 @@ export { fileUploadSema } from './file-upload'; export { formSema } from './form'; export { numberFieldSema } from './number-field'; export { paginationSema } from './pagination'; +export { passwordFieldSema } from './password-field'; export { popoverSema } from './popover'; export { radioGroupSema } from './radio-group'; export { ratingGroupSema } from './rating-group'; diff --git a/src/uix/sema/components/password-field.ts b/src/uix/sema/components/password-field.ts new file mode 100644 index 000000000..0cc4ed475 --- /dev/null +++ b/src/uix/sema/components/password-field.ts @@ -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>[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' } + } + ] +}; diff --git a/src/uix/soma/components/index.ts b/src/uix/soma/components/index.ts index 3f2190e3f..7c3e53b8a 100644 --- a/src/uix/soma/components/index.ts +++ b/src/uix/soma/components/index.ts @@ -34,6 +34,7 @@ export * as MonthGrid from './month-grid'; export * as NavigationMenu from './navigation-menu'; export * as NumberField from './number-field'; export * as Pagination from './pagination'; +export * as PasswordField from './password-field'; export * as PinInput from './pin-input'; export * as Popover from './popover'; export * as Progress from './progress'; diff --git a/src/uix/soma/components/password-field/components/password-field-caps-lock-indicator.svelte b/src/uix/soma/components/password-field/components/password-field-caps-lock-indicator.svelte new file mode 100644 index 000000000..af27d4e05 --- /dev/null +++ b/src/uix/soma/components/password-field/components/password-field-caps-lock-indicator.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} + + {@render children?.(state.snippetProps)} + +{/if} diff --git a/src/uix/soma/components/password-field/components/password-field-input.svelte b/src/uix/soma/components/password-field/components/password-field-input.svelte new file mode 100644 index 000000000..cff5705f4 --- /dev/null +++ b/src/uix/soma/components/password-field/components/password-field-input.svelte @@ -0,0 +1,31 @@ + + + diff --git a/src/uix/soma/components/password-field/components/password-field-strength-meter.svelte b/src/uix/soma/components/password-field/components/password-field-strength-meter.svelte new file mode 100644 index 000000000..f2faae084 --- /dev/null +++ b/src/uix/soma/components/password-field/components/password-field-strength-meter.svelte @@ -0,0 +1,45 @@ + + +{#if child} + {@render child({ strength: state.clampedStrength, label: resolvedLabel, props: mergedProps })} +{:else} +
+ {#if children} + {@render children({ strength: state.clampedStrength, label: resolvedLabel })} + {:else} + + {/if} +
+{/if} diff --git a/src/uix/soma/components/password-field/components/password-field-visibility-trigger.svelte b/src/uix/soma/components/password-field/components/password-field-visibility-trigger.svelte new file mode 100644 index 000000000..bb25929c5 --- /dev/null +++ b/src/uix/soma/components/password-field/components/password-field-visibility-trigger.svelte @@ -0,0 +1,35 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} + +{/if} diff --git a/src/uix/soma/components/password-field/components/password-field.svelte b/src/uix/soma/components/password-field/components/password-field.svelte new file mode 100644 index 000000000..032eeae72 --- /dev/null +++ b/src/uix/soma/components/password-field/components/password-field.svelte @@ -0,0 +1,73 @@ + + +{#if child} + {@render child({ ...state.snippetProps, props: mergedProps })} +{:else} +
+ {@render children?.(state.snippetProps)} +
+{/if} diff --git a/src/uix/soma/components/password-field/exports.ts b/src/uix/soma/components/password-field/exports.ts new file mode 100644 index 000000000..f88d79cbc --- /dev/null +++ b/src/uix/soma/components/password-field/exports.ts @@ -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'; diff --git a/src/uix/soma/components/password-field/index.ts b/src/uix/soma/components/password-field/index.ts new file mode 100644 index 000000000..8570a426a --- /dev/null +++ b/src/uix/soma/components/password-field/index.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/src/uix/soma/components/password-field/langs.ts b/src/uix/soma/components/password-field/langs.ts new file mode 100644 index 000000000..59c56b1d6 --- /dev/null +++ b/src/uix/soma/components/password-field/langs.ts @@ -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; diff --git a/src/uix/soma/components/password-field/password-field-provider.svelte.ts b/src/uix/soma/components/password-field/password-field-provider.svelte.ts new file mode 100644 index 000000000..84f32a75d --- /dev/null +++ b/src/uix/soma/components/password-field/password-field-provider.svelte.ts @@ -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 | undefined; + onSubmit: ((value: string) => void) | undefined; + onVisibilityChange: OnChangeFn | undefined; + }> {} + +export class PasswordFieldProvider { + readonly opts: PasswordFieldOpts; + readonly runtimePart: SomaRuntimePart; + readonly soma: Soma; + readonly runtime: SomaRuntime; + static readonly ctx = context('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(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 = 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) => { + this.provider.commit(e.currentTarget.value); + }; + + readonly handleFocus = (e: SomaFocusEvent) => { + 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) => { + this.provider.focused = false; + // Clear caps indicator when input loses focus. + this.provider.setCapsActive(false); + }; + + readonly handleKeydown = (e: SomaKeyboardEvent) => { + 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) => { + 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) + ); +} diff --git a/src/uix/soma/components/password-field/types.ts b/src/uix/soma/components/password-field/types.ts new file mode 100644 index 000000000..a5d176673 --- /dev/null +++ b/src/uix/soma/components/password-field/types.ts @@ -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 `
` container wrapping an `` 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; + /** 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; + + /** + * 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; + +// ── Input ────────────────────────────────────────────────────────────────── + +/** + * Props for `PasswordField.Input` — the real ``. 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 ` + + + + + +
+ + {#if tab === 'live'} +
+

Controls

+

+ soma owns value, masking and CapsLock detection. + eidos owns size, variant, color and icon + rendering. +

+ +
+ soma props · value & behaviour +
+
+ + + + + + + +
+ +
+ soma props · flags +
+
+ {#each ['disabled', 'readonly', 'required', 'invalid'] as flag} + + {/each} +
+ +
+ eidos props · presentation +
+
+ + + +
+ +
Composition · which parts to include
+
+ + + +
+ + {#if lastSubmit !== null} +
Last submitted
+
{lastSubmit}
+ {/if} + + +
+
+ soma + headless · ARIA + behavior only + svelte +
+
{somaSnippet}
+
+ +
+
+ eidos + visual · adds icons, chrome + strength meter + svelte +
+
{eidosSnippet}
+
+
+ {/if} + + {#if tab === 'api'} +
+

API reference

+

+ PasswordField composes through 5 parts. Root drives value + visibility + caps detection; + every visual part is optional (composition over visibility props). +

+ +
Provider props
+
+ + + + + + + + + + + + + + + + + + + +
PropTypeNotes
valuestringBindable. Default ''.
visiblebooleanBindable. Toggle plaintext / masked. Default false.
purpose'current' | 'new' + Drives the native autocomplete attribute. 'current' → + current-password (login). 'new' → + new-password (registration / change). Default 'current'. +
minLength / maxLengthnumberNative constraints + mirrored ARIA. Used by HTML form validation.
disabled / readonly / required / invalidbooleanOR-merged with parent <Field>.
placeholderstring?Native placeholder.
namestring?Form submission name.
aria-labelstring?Override translated 'Password' default. Ignored if inside Field.Label.
onValueChange(v: string) => voidFires on every keystroke.
onSubmit(v: string) => voidFires on Enter.
onVisibilityChange(visible: boolean) => voidFires after the eye toggle.
+
+ +
Eidos visual props
+
+ + + + + + + +
PropTypeDefault
size'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
variantControlVariant'surface'
colorColorRole'primary'
+
+ +
Reference comparison
+
+ + + + + + + + + + + + + + + + + + + + +
LibraryBuilt-in toggleCapsLockStrength meter
React Arianonono
Ark UIyesnoexternal
Chakra v3yesnosibling cmp
Mantineyesnocompose
PrimeReactyesnobuilt-in popup
Ant Designyes (default)nono
shadcn/uinonono
Material UInonono
UIXyesyes (universal gap)composable
+
+
+ {/if} + + {#if tab === 'morfo'} +
+

Morfo contract

+ +
+ + + + + + + + + + +
FieldValue
name{passwordFieldMorfo.name}
kebab{passwordFieldMorfo.kebab}
scope{passwordFieldMorfo.scope.join(', ')}
apgtextbox
parts{partsList.length}
events{events.length}
+
+ +
Parts
+
+ + + + + + + + + + + + + {#each partsList as part (part.kebab)} + + + + + + + + + {/each} + +
kebabmarkerelementrolearchetypeoptional
{part.kebab}[{part.marker}]<{part.defaultElement}>{part.role ?? '—'}{part.archetype ?? '—'}{part.optional ? 'yes' : 'no'}
+
+ +
Events
+
+ + + + {#each events as e (e.name)} + + + + + + + + {/each} + +
namefamilyverbtargetintent
{e.name}{e.semantic?.family ?? '—'}{e.semantic?.verb ?? '—'}{e.semantic && 'target' in e.semantic ? e.semantic.target.target : '—'}{e.semantic && 'intent' in e.semantic && typeof e.semantic.intent === 'string' ? e.semantic.intent : '—'}
+
+
+ {/if} + + {#if tab === 'sema'} +
+

+ sema · events +

+

+ Type a character to fire commit-input. Press Enter to commit + commit-submit. Click the eye to fire + shift-toggle-visibility. Press Caps Lock while the input is focused to fire + shift-caps-state. Use ▶ play to dispatch the event without the gesture. +

+ +
+ + + + {#each events as action (action.name)} + {@const intentDecl = 'intent' in action.semantic ? action.semantic.intent : undefined} + {@const effectiveIntent = + typeof intentDecl === 'string' ? intentDecl : undefined} + + + + + + + + + {/each} + +
namefamilyverbsequenceintent
{action.name}{action.semantic.family}{action.semantic.verb ?? '—'}{action.semantic.sequence ?? '—'}{effectiveIntent ?? '—'} + +
+
+
+ {/if} + + {#if tab === 'recipe'} +
+

Eidos recipe

+

+ Recipe lives in src/uix/eidos/components/password-field/password-field.css. + Provider is the inline-flex container; tokens are private (--_password-field-*). + CapsLock indicator and StrengthMeter have their own data-* attribute states. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SelectorOwnerPurpose
[data-password-field]morfoProvider marker. Inline-flex container with border + focus ring.
[data-password-field][data-size='xs'..'xl']eidosPadding + font-size + radius scale.
[data-password-field][data-color='primary'..'loss']eidosMaps to focus accent token.
[data-password-field][data-focused]morfoFocus ring via box-shadow color-mix.
[data-password-field-input]morfoInput cell. Letter-spacing increased for legibility while masked.
[data-password-field-visibility-trigger]morfoEye toggle button. Hover background + focus outline.
[data-password-field-caps-lock-indicator][data-active]morfoRisk-tinted pill, hidden when inactive.
[data-password-field-strength-meter][data-strength='1'..'4']morfoActive segments tint per strength bucket (loss / risk / fulfill / affirm).
+
+
+ {/if} + + {#if tab === 'a11y'} +
+

Accessibility

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConcernContract
Role + Native <input> with type="password" (or + "text" when visible). Implicit role="textbox". +
Label + Defaults to translated 'Password' / 'Contraseña'. When inside a + <Field> the Field.Label wins via aria-labelledby. +
Autofill + autocomplete="current-password" (login) or + "new-password" (registration / password change). Drives password + manager behavior. +
Visibility toggle + <button type="button" aria-pressed={'{visible}'} aria-label="Show/Hide + password">. tabindex="-1" so it doesn't break the form's + natural tab order — the user toggles via mouse / programmatic focus instead. +
Caps Lock detection + Soma listens to keydown/keyup on the input and reads + KeyboardEvent.getModifierState('CapsLock'). Indicator carries + role="status" + aria-live="polite" so screen readers + announce when Caps Lock turns on while typing. +
Strength meter + role="meter" with aria-valuemin=0, + aria-valuemax=4, aria-valuenow. Visual segments are + aria-hidden; only the label is announced. +
Keyboard + Enter fires commit-submit. No other keys hijacked — + selection / cursor movement / paste all behave natively. +
Disabled / readonly + Mirrored to native attributes AND ARIA. The visibility trigger also disables when + the input is disabled. +
Reduced motionAll transitions disabled under prefers-reduced-motion: reduce.
+
+
+ {/if} + diff --git a/web/routes/uix/uix.css b/web/routes/uix/uix.css index e64e066c2..2201665a2 100644 --- a/web/routes/uix/uix.css +++ b/web/routes/uix/uix.css @@ -1376,236 +1376,115 @@ font-weight: var(--uix-weight-medium); } -/* ── Responsive docs shell ─────────────────────────────────────────── */ -@media (max-width: 900px) { - [data-uix-shell] { - grid-template-columns: minmax(0, 1fr); - } - - [data-uix-topbar] { - max-inline-size: 100vw; - overflow: hidden; - padding-inline: var(--uix-space-3); - gap: var(--uix-space-2); - } - - [data-uix-brand], - [data-uix-topbar-nav], - [data-uix-topbar-tools] { - min-inline-size: 0; - } - - [data-uix-brand-version] { - display: none; - } - - [data-uix-topbar-nav] { - margin-inline-start: 0; - } - - [data-uix-topbar-nav] a { - padding-inline: var(--uix-space-2); - } - - [data-uix-topbar-tools] { - flex: 1 1 auto; - justify-content: flex-end; - overflow-x: auto; - overscroll-behavior-inline: contain; - scrollbar-width: none; - } - - [data-uix-topbar-tools]::-webkit-scrollbar { - display: none; - } - - [data-uix-rail] { - display: none; - } - - [data-uix-canvas] { - grid-column: 1; - padding: var(--uix-space-8) var(--uix-space-4) var(--uix-space-12); - } - - [data-uix-canvas-inner], - [data-uix-canvas-inner][data-with-toc='true'] { - display: block; - max-inline-size: none; - } - - [data-uix-toc] { - display: none; - } -} - -@media (max-width: 560px) { - [data-uix-topbar-nav] a[href='https://github.com/'] { - display: none; - } - - [data-uix-canvas] { - padding: var(--uix-space-6) var(--uix-space-3) var(--uix-space-10); - } - - [data-uix-page-title] { - font-size: var(--uix-text-4xl); - } - - [data-uix-page-lede] { - font-size: var(--uix-text-lg); - } - - [data-uix-page-meta], - [data-uix-tabs], - [data-uix-code-head] { - overflow-x: auto; - overscroll-behavior-inline: contain; - } - - [data-uix-tabs] { - flex-wrap: nowrap; - } - - [data-uix-tab], - [data-uix-meta-pill], - [data-uix-code-head] > * { - flex: 0 0 auto; - } - - [data-uix-stage-area] { - place-items: stretch; - } - - [data-uix-stage-trace] { - align-items: flex-start; - gap: var(--uix-space-2); - } - - [data-uix-controls] { - grid-template-columns: minmax(0, 1fr); - padding: var(--uix-space-4); - } - - [data-uix-part] { - grid-template-columns: minmax(0, 1fr); - gap: var(--uix-space-2); - } -} - -/* ── Responsive docs shell ─────────────────────────────────────────── */ -@media (max-width: 900px) { - [data-uix-shell] { - grid-template-columns: minmax(0, 1fr); - } - - [data-uix-topbar] { - max-inline-size: 100vw; - overflow: hidden; - padding-inline: var(--uix-space-3); - gap: var(--uix-space-2); - } - - [data-uix-brand], - [data-uix-topbar-nav], - [data-uix-topbar-tools] { - min-inline-size: 0; - } - - [data-uix-brand-version] { - display: none; - } - - [data-uix-topbar-nav] { - margin-inline-start: 0; - } - - [data-uix-topbar-nav] a { - padding-inline: var(--uix-space-2); - } - - [data-uix-topbar-tools] { - flex: 1 1 auto; - justify-content: flex-end; - overflow-x: auto; - overscroll-behavior-inline: contain; - scrollbar-width: none; - } - - [data-uix-topbar-tools]::-webkit-scrollbar { - display: none; - } - - [data-uix-rail] { - display: none; - } - - [data-uix-canvas] { - grid-column: 1; - padding: var(--uix-space-8) var(--uix-space-4) var(--uix-space-12); - } - - [data-uix-canvas-inner], - [data-uix-canvas-inner][data-with-toc='true'] { - display: block; - max-inline-size: none; - } - - [data-uix-toc] { - display: none; - } -} - -@media (max-width: 560px) { - [data-uix-topbar-nav] a[href='https://github.com/'] { - display: none; - } - - [data-uix-canvas] { - padding: var(--uix-space-6) var(--uix-space-3) var(--uix-space-10); - } - - [data-uix-page-title] { - font-size: var(--uix-text-4xl); - } - - [data-uix-page-lede] { - font-size: var(--uix-text-lg); - } - - [data-uix-page-meta], - [data-uix-tabs], - [data-uix-code-head] { - overflow-x: auto; - overscroll-behavior-inline: contain; - } - - [data-uix-tabs] { - flex-wrap: nowrap; - } - - [data-uix-tab], - [data-uix-meta-pill], - [data-uix-code-head] > * { - flex: 0 0 auto; - } - - [data-uix-stage-area] { - place-items: stretch; - } - - [data-uix-stage-trace] { - align-items: flex-start; - gap: var(--uix-space-2); - } - - [data-uix-controls] { - grid-template-columns: minmax(0, 1fr); - padding: var(--uix-space-4); - } - - [data-uix-part] { - grid-template-columns: minmax(0, 1fr); - gap: var(--uix-space-2); - } +/* ── Responsive docs shell: driven by ActiveDom breakpoints ────────── */ +[data-uix-docs][data-uix-compact='true'] [data-uix-shell] { + grid-template-columns: minmax(0, 1fr); +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-topbar] { + max-inline-size: 100vi; + overflow: hidden; + padding-inline: var(--uix-space-3); + gap: var(--uix-space-2); +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-brand], +[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-nav], +[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-tools] { + min-inline-size: 0; +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-brand-version] { + display: none; +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-nav] { + margin-inline-start: 0; +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-nav] a { + padding-inline: var(--uix-space-2); +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-tools] { + flex: 1 1 auto; + justify-content: flex-end; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: none; +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-topbar-tools]::-webkit-scrollbar { + display: none; +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-rail] { + display: none; +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-canvas] { + grid-column: 1; + padding: var(--uix-space-8) var(--uix-space-4) var(--uix-space-12); +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-canvas-inner], +[data-uix-docs][data-uix-compact='true'] [data-uix-canvas-inner][data-with-toc='true'] { + display: block; + max-inline-size: none; +} + +[data-uix-docs][data-uix-compact='true'] [data-uix-toc] { + display: none; +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-topbar-nav] a[href='https://github.com/'] { + display: none; +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-canvas] { + padding: var(--uix-space-6) var(--uix-space-3) var(--uix-space-10); +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-page-title] { + font-size: var(--uix-text-4xl); +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-page-lede] { + font-size: var(--uix-text-lg); +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-page-meta], +[data-uix-docs][data-uix-phone='true'] [data-uix-tabs], +[data-uix-docs][data-uix-phone='true'] [data-uix-code-head] { + overflow-x: auto; + overscroll-behavior-inline: contain; +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-tabs] { + flex-wrap: nowrap; +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-tab], +[data-uix-docs][data-uix-phone='true'] [data-uix-meta-pill], +[data-uix-docs][data-uix-phone='true'] [data-uix-code-head] > * { + flex: 0 0 auto; +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-stage-area] { + place-items: stretch; +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-stage-trace] { + align-items: flex-start; + gap: var(--uix-space-2); +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-controls] { + grid-template-columns: minmax(0, 1fr); + padding: var(--uix-space-4); +} + +[data-uix-docs][data-uix-phone='true'] [data-uix-part] { + grid-template-columns: minmax(0, 1fr); + gap: var(--uix-space-2); }