From 97c8bbd6875dea8dd0cea516af56827eb159e397 Mon Sep 17 00:00:00 2001 From: nguyenlongdang0412 Date: Sat, 26 Sep 2026 15:36:10 +0700 Subject: [PATCH] feat(input-password): add a password field with a caller scored meter Requested in #228. The first answer was to decline and document the composition instead, which was wrong on two counts: ThemeModeButton is 89 lines and does less, so thinness is not where this library draws its line, and the two extra bundled icons measure at about half a kilobyte before compression. The component wraps Input rather than reimplementing it the way InputTags and InputNumber do, because those two need to change what sits inside the field box while this one only needs a trailing control and blocks below. FormField and Form integration comes for free as a result: label association, error state and aria-describedby all flow through. What it handles: a visibility toggle whose accessible name follows the next action, purpose mapping to the correct autocomplete value for sign in, sign up and password change, a caps lock warning cleared on release or blur, and a guarantee that pasting is never intercepted. Scoring stays with the caller. strength takes a number and the component renders the meter, picks the colour and debounces the screen reader announcement so it fires on level changes rather than on every keystroke. A naive scorer is worse than no meter: counting character classes rates Password1! as strong while penalising a long passphrase for having no digit, and scoring properly needs a dictionary or a breach list, neither of which belongs in a UI library. Adds passwordShow and passwordHide to the icon defaults and regenerates the bundled set. Closes #228 --- CHANGELOG.md | 1 + src/lib/components/Icon/bundled.ts | 6 + .../InputPassword/InputPassword.svelte | 190 ++++++++++ .../InputPassword.svelte.spec.ts | 349 ++++++++++++++++++ src/lib/components/InputPassword/index.ts | 2 + .../InputPassword/input-password.types.ts | 111 ++++++ .../InputPassword/input-password.variants.ts | 27 ++ src/lib/config.ts | 4 +- src/lib/index.ts | 1 + 9 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/InputPassword/InputPassword.svelte create mode 100644 src/lib/components/InputPassword/InputPassword.svelte.spec.ts create mode 100644 src/lib/components/InputPassword/index.ts create mode 100644 src/lib/components/InputPassword/input-password.types.ts create mode 100644 src/lib/components/InputPassword/input-password.variants.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 62eff29..b911f9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **InputPassword** — password field built on `Input`. A visibility toggle whose accessible name follows the next action, `purpose` mapping to the right `autocomplete` value for sign in, sign up and password change, a caps lock warning, and a guarantee that pasting is never blocked. An optional strength meter renders a score you supply: the library never judges a password itself, since scoring without a dictionary is misleading and a rule set that suits one policy penalises another. The screen reader announcement is debounced and fires on level changes rather than on every keystroke. ([#228](https://github.com/ndlabdev/sv5ui/issues/228)) - **ThemeMode** — dark mode handler for the root layout, replacing the setup that required a second package. ([#224](https://github.com/ndlabdev/sv5ui/issues/224)) - **Exports** — `toggleMode`, `setMode`, `resetMode`, `mode`, `userPrefersMode` and `systemPrefersMode` are re-exported from the package root, alongside `resetConfig`. The docs already referenced these; now they resolve. ([#224](https://github.com/ndlabdev/sv5ui/issues/224)) - **NavigationMenu** — `align` prop (`start` | `center` | `end`, default `center`) places the dropdown under the open trigger. ([#220](https://github.com/ndlabdev/sv5ui/issues/220)) diff --git a/src/lib/components/Icon/bundled.ts b/src/lib/components/Icon/bundled.ts index cbfeafc..5623969 100644 --- a/src/lib/components/Icon/bundled.ts +++ b/src/lib/components/Icon/bundled.ts @@ -36,6 +36,12 @@ export const bundledIcons: BundledIconCollection[] = [ ellipsis: { body: '' }, + eye: { + body: '' + }, + 'eye-off': { + body: '' + }, file: { body: '' }, diff --git a/src/lib/components/InputPassword/InputPassword.svelte b/src/lib/components/InputPassword/InputPassword.svelte new file mode 100644 index 0000000..6cdc3cb --- /dev/null +++ b/src/lib/components/InputPassword/InputPassword.svelte @@ -0,0 +1,190 @@ + + + + +
+ {#snippet toggleButton()} +
diff --git a/src/lib/components/InputPassword/InputPassword.svelte.spec.ts b/src/lib/components/InputPassword/InputPassword.svelte.spec.ts new file mode 100644 index 0000000..5f403d0 --- /dev/null +++ b/src/lib/components/InputPassword/InputPassword.svelte.spec.ts @@ -0,0 +1,349 @@ +import '../../../routes/layout.css' +import { describe, expect, it, vi, afterEach } from 'vitest' +import { render } from 'vitest-browser-svelte' +import { createRawSnippet } from 'svelte' +import { defineConfig, resetConfig } from '../../config.js' +import InputPassword from './InputPassword.svelte' + +afterEach(() => resetConfig()) + +const settle = async (container: Element) => { + await vi.waitFor(() => expect(container.querySelector('input')).not.toBeNull()) +} + +const parts = (container: Element) => ({ + input: container.querySelector('input')!, + toggle: container.querySelector('button'), + meter: container.querySelector('[role="progressbar"]'), + live: container.querySelector('[aria-live]'), + caps: container.querySelector('p') +}) + +describe('InputPassword', () => { + // ==================== RENDERING ==================== + + describe('rendering', () => { + it('should render a password field by default', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(parts(container).input.type).toBe('password') + }) + + it('should render the visibility toggle by default', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(parts(container).toggle).not.toBeNull() + }) + + it('should render no toggle when it is turned off', async () => { + const { container } = render(InputPassword, { toggle: false }) + await settle(container) + + expect(parts(container).toggle).toBeNull() + }) + }) + + // ==================== VISIBILITY TOGGLE ==================== + + describe('visibility toggle', () => { + it('should reveal and hide the value', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { input, toggle } = parts(container) + + toggle!.click() + await vi.waitFor(() => expect(input.type).toBe('text')) + + toggle!.click() + await vi.waitFor(() => expect(input.type).toBe('password')) + }) + + it('should name the toggle for its next action', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { toggle } = parts(container) + + expect(toggle!.getAttribute('aria-label')).toBe('Show password') + toggle!.click() + await vi.waitFor(() => expect(toggle!.getAttribute('aria-label')).toBe('Hide password')) + }) + + it('should report the toggle state through aria-pressed', async () => { + const { container } = render(InputPassword, { visible: true }) + await settle(container) + + expect(parts(container).toggle!.getAttribute('aria-pressed')).toBe('true') + }) + + it('should accept custom labels', async () => { + const { container } = render(InputPassword, { showLabel: 'Reveal' }) + await settle(container) + + expect(parts(container).toggle!.getAttribute('aria-label')).toBe('Reveal') + }) + + it.each([ + ['disabled', { disabled: true }], + ['loading', { loading: true }] + ])('should disable the toggle while %s', async (_label, props) => { + const { container } = render(InputPassword, props) + await settle(container) + + expect(parts(container).toggle!.disabled).toBe(true) + }) + }) + + // ==================== AUTOCOMPLETE ==================== + + describe('autocomplete', () => { + it.each([ + ['login', 'current-password'], + ['signup', 'new-password'], + ['change', 'new-password'] + ] as const)('should map purpose=%s to %s', async (purpose, expected) => { + const { container } = render(InputPassword, { purpose }) + await settle(container) + + expect(parts(container).input.getAttribute('autocomplete')).toBe(expected) + }) + + it('should default to the sign in value', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(parts(container).input.getAttribute('autocomplete')).toBe('current-password') + }) + }) + + // ==================== PASTE ==================== + + describe('paste', () => { + it('should never block pasting into the field', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { input } = parts(container) + + const event = new Event('paste', { bubbles: true, cancelable: true }) + input.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(false) + expect(input.onpaste).toBeNull() + }) + }) + + // ==================== CAPS LOCK ==================== + + describe('caps lock warning', () => { + const keydown = (input: HTMLInputElement, capsLock: boolean) => { + const event = new KeyboardEvent('keydown', { key: 'a', bubbles: true }) + Object.defineProperty(event, 'getModifierState', { + value: (key: string) => key === 'CapsLock' && capsLock + }) + input.dispatchEvent(event) + } + + it('should warn while caps lock is on', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + keydown(parts(container).input, true) + + await vi.waitFor(() => + expect(parts(container).caps?.textContent).toContain('Caps Lock is on') + ) + }) + + it('should stay quiet while caps lock is off', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + keydown(parts(container).input, false) + + await vi.waitFor(() => expect(parts(container).caps).toBeNull()) + }) + + it('should clear the warning once caps lock is released', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { input } = parts(container) + + keydown(input, true) + await vi.waitFor(() => expect(parts(container).caps).not.toBeNull()) + + keydown(input, false) + await vi.waitFor(() => expect(parts(container).caps).toBeNull()) + }) + + it('should clear the warning when the field loses focus', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + const { input } = parts(container) + + keydown(input, true) + await vi.waitFor(() => expect(parts(container).caps).not.toBeNull()) + + input.dispatchEvent(new FocusEvent('blur', { bubbles: true })) + await vi.waitFor(() => expect(parts(container).caps).toBeNull()) + }) + + it('should accept a custom message', async () => { + const { container } = render(InputPassword, { capsLockMessage: 'Caps is active' }) + await settle(container) + keydown(parts(container).input, true) + + await vi.waitFor(() => + expect(parts(container).caps?.textContent).toContain('Caps is active') + ) + }) + + it('should render nothing when the warning is turned off', async () => { + const { container } = render(InputPassword, { capsLockWarning: false }) + await settle(container) + keydown(parts(container).input, true) + + await vi.waitFor(() => expect(parts(container).caps).toBeNull()) + }) + }) + + // ==================== STRENGTH METER ==================== + + describe('strength meter', () => { + it('should render no meter when no score is given', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(parts(container).meter).toBeNull() + }) + + it('should render no meter for a null score', async () => { + const { container } = render(InputPassword, { strength: null }) + await settle(container) + + expect(parts(container).meter).toBeNull() + }) + + it.each([0, 1, 2, 3])('should report score %i on the progressbar', async (score) => { + const { container } = render(InputPassword, { strength: score }) + await settle(container) + + const meter = parts(container).meter! + expect(meter.getAttribute('aria-valuenow')).toBe(String(score)) + expect(meter.getAttribute('aria-valuemax')).toBe('3') + }) + + it('should clamp a score above the last level', async () => { + const { container } = render(InputPassword, { strength: 99 }) + await settle(container) + + expect(parts(container).meter!.getAttribute('aria-valuenow')).toBe('3') + }) + + it('should clamp a negative score', async () => { + const { container } = render(InputPassword, { strength: -5 }) + await settle(container) + + expect(parts(container).meter!.getAttribute('aria-valuenow')).toBe('0') + }) + + it('should follow custom levels', async () => { + const { container } = render(InputPassword, { + strength: 1, + strengthLevels: ['Low', 'High'] + }) + await settle(container) + + expect(parts(container).meter!.getAttribute('aria-valuemax')).toBe('1') + }) + + it('should announce the level in a live region only when a meter exists', async () => { + const without = render(InputPassword, {}) + await settle(without.container) + expect(parts(without.container).live).toBeNull() + + const withMeter = render(InputPassword, { strength: 2 }) + await settle(withMeter.container) + expect(parts(withMeter.container).live).not.toBeNull() + }) + }) + + // ==================== SIZE ==================== + + describe('size', () => { + const padOf = (container: Element) => getComputedStyle(parts(container).toggle!).padding + + it('should keep the toggle in step with the field by default', async () => { + const { container } = render(InputPassword, {}) + await settle(container) + + expect(padOf(container)).toBe('6px') + }) + + it('should follow an explicit size', async () => { + const { container } = render(InputPassword, { size: 'xs' }) + await settle(container) + + expect(padOf(container)).toBe('4px') + }) + + it('should follow the size configured for Input', async () => { + defineConfig({ input: { defaultVariants: { size: 'xl' } } } as never) + const { container } = render(InputPassword, {}) + await settle(container) + + expect(padOf(container)).toBe('10px') + }) + }) + + // ==================== UI OVERRIDES ==================== + + describe('ui overrides', () => { + it('should apply its own root class exactly once', async () => { + const { container } = render(InputPassword, { ui: { root: 'probe-root' } } as never) + await settle(container) + + expect(container.querySelectorAll('.probe-root')).toHaveLength(1) + }) + + it('should still forward slots that belong to the field', async () => { + const { container } = render(InputPassword, { ui: { base: 'probe-base' } } as never) + await settle(container) + + expect(parts(container).input.className).toContain('probe-base') + }) + }) + + // ==================== FOOTER ==================== + + describe('footer', () => { + it('should render footer content below the field', async () => { + const { container } = render(InputPassword, { + footer: createRawSnippet(() => ({ + render: () => '', + setup: () => {} + })) + } as never) + await settle(container) + + expect(container.querySelector('#probe-footer')).not.toBeNull() + }) + }) + + // ==================== REF ==================== + + describe('ref', () => { + it('should expose the underlying input element', async () => { + let el = $state(null) + const { container } = render(InputPassword, { + get ref() { + return el + }, + set ref(v: HTMLInputElement | null) { + el = v + } + } as never) + await settle(container) + + expect(el).toBe(parts(container).input) + }) + }) +}) diff --git a/src/lib/components/InputPassword/index.ts b/src/lib/components/InputPassword/index.ts new file mode 100644 index 0000000..5ccfc29 --- /dev/null +++ b/src/lib/components/InputPassword/index.ts @@ -0,0 +1,2 @@ +export { default as InputPassword } from './InputPassword.svelte' +export type { InputPasswordProps, InputPasswordPurpose } from './input-password.types.js' diff --git a/src/lib/components/InputPassword/input-password.types.ts b/src/lib/components/InputPassword/input-password.types.ts new file mode 100644 index 0000000..fa241ba --- /dev/null +++ b/src/lib/components/InputPassword/input-password.types.ts @@ -0,0 +1,111 @@ +import type { Snippet } from 'svelte' +import type { ClassNameValue } from 'tailwind-merge' +import type { InputProps } from '../Input/input.types.js' +import type { InputPasswordSlots } from './input-password.variants.js' + +/** + * What the field is for, which decides the `autocomplete` value browsers and + * password managers rely on. + * + * - `login` maps to `current-password` + * - `signup` and `change` map to `new-password`, which lets a password manager + * offer to generate a strong one + */ +export type InputPasswordPurpose = 'login' | 'signup' | 'change' + +export type InputPasswordProps = Omit< + InputProps, + 'type' | 'icon' | 'trailing' | 'trailingIcon' | 'trailingSlot' | 'autocomplete' +> & { + /** + * Bindable reference to the underlying input element. + */ + ref?: HTMLInputElement | null + + /** + * What the field is for. Sets `autocomplete` accordingly. + * @default 'login' + */ + purpose?: InputPasswordPurpose + + /** + * Whether the value is currently readable. Bindable, so the caller can + * drive or observe the toggle. + * @default false + */ + visible?: boolean + + /** + * Renders the visibility toggle button. + * @default true + */ + toggle?: boolean + + /** + * Icon for the button that reveals the value. + * @default Uses `icons.passwordShow` from app config + */ + showIcon?: string + + /** + * Icon for the button that hides the value. + * @default Uses `icons.passwordHide` from app config + */ + hideIcon?: string + + /** + * Accessible name of the toggle when the value is hidden. + * @default 'Show password' + */ + showLabel?: string + + /** + * Accessible name of the toggle when the value is visible. + * @default 'Hide password' + */ + hideLabel?: string + + /** + * Warns while caps lock is on, a common cause of failed sign in. + * @default true + */ + capsLockWarning?: boolean + + /** + * Message shown while caps lock is on. + * @default 'Caps Lock is on' + */ + capsLockMessage?: string + + /** + * Current strength, as an index into `strengthLevels`. Scoring is left to + * the caller: this component renders the result and never judges a password + * itself. Pass `null` or leave it out to hide the meter. + */ + strength?: number | null + + /** + * Labels for each strength step, in ascending order. Also decides how many + * steps the meter has. + * @default ['Weak', 'Fair', 'Good', 'Strong'] + */ + strengthLevels?: string[] + + /** + * Extra content rendered below the field, after the meter. Receives the + * current value so a caller can render their own hints. + */ + footer?: Snippet<[{ value: string; strength: number | null }]> + + /** + * Additional CSS classes for the outer wrapper, which holds the field, the + * strength meter, the caps lock warning and the footer. To style the field + * alone, use `ui.base`. + */ + class?: ClassNameValue + + /** + * Override styles for specific slots. + */ + ui?: Partial> & InputProps['ui'] +} diff --git a/src/lib/components/InputPassword/input-password.variants.ts b/src/lib/components/InputPassword/input-password.variants.ts new file mode 100644 index 0000000..e13b6f2 --- /dev/null +++ b/src/lib/components/InputPassword/input-password.variants.ts @@ -0,0 +1,27 @@ +import { tv, type VariantProps } from 'tailwind-variants' + +export const inputPasswordVariants = tv({ + slots: { + root: 'w-full', + toggle: [ + 'text-on-surface-variant/75 transition-colors', + 'hover:bg-transparent hover:text-on-surface', + 'active:bg-transparent active:text-on-surface', + 'focus-visible:bg-transparent focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary' + ], + meter: 'mt-1.5', + caps: 'mt-1.5 flex items-center gap-1.5 text-xs text-warning', + capsIcon: 'size-3.5 shrink-0', + footer: 'mt-1.5' + } +}) + +export type InputPasswordVariantProps = VariantProps +export type InputPasswordSlots = keyof ReturnType + +export const inputPasswordDefaults = { + defaultVariants: inputPasswordVariants.defaultVariants, + slots: {} as Partial>, + strengthLevels: ['Weak', 'Fair', 'Good', 'Strong'], + strengthColors: ['error', 'warning', 'info', 'success'] as const +} diff --git a/src/lib/config.ts b/src/lib/config.ts index 5ffda79..7807b1b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -50,7 +50,9 @@ export const iconsDefaults = { sortDesc: 'lucide:chevron-down', sortDefault: 'lucide:chevrons-up-down', panelLeft: 'lucide:panel-left', - eyeDropper: 'lucide:pipette' + eyeDropper: 'lucide:pipette', + passwordShow: 'lucide:eye', + passwordHide: 'lucide:eye-off' } // ==================== TYPES ==================== diff --git a/src/lib/index.ts b/src/lib/index.ts index 3403bb5..c6ccaa7 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -47,6 +47,7 @@ export * from './components/Form/index.js' export * from './components/Input/index.js' export * from './components/InputNumber/index.js' export * from './components/InputTags/index.js' +export * from './components/InputPassword/index.js' export * from './components/Textarea/index.js' export * from './components/Select/index.js' export * from './components/SelectMenu/index.js'