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 @@
+
+
+
+
+
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'