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'