Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
6 changes: 6 additions & 0 deletions src/lib/components/Icon/bundled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export const bundledIcons: BundledIconCollection[] = [
ellipsis: {
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></g>'
},
eye: {
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 19.876 0a1 1 0 0 1 0 .696a10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></g>'
},
'eye-off': {
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575a1 1 0 0 1 0 .696a10.8 10.8 0 0 1-1.444 2.49m-6.41-.679a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151a1 1 0 0 1 0-.696a10.75 10.75 0 0 1 4.446-5.143M2 2l20 20"/></g>'
},
file: {
body: '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/></g>'
},
Expand Down
190 changes: 190 additions & 0 deletions src/lib/components/InputPassword/InputPassword.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<script lang="ts" module>
import type { InputPasswordProps } from './input-password.types.js'

export type Props = InputPasswordProps

const autocompleteFor = {
login: 'current-password',
signup: 'new-password',
change: 'new-password'
} as const
</script>

<script lang="ts">
import { inputPasswordVariants, inputPasswordDefaults } from './input-password.variants.js'
import { getComponentConfig, iconsDefaults } from '../../config.js'
import { inputDefaults } from '../Input/input.variants.js'
import type { InputProps } from '../Input/input.types.js'
import Input from '../Input/Input.svelte'
import Button from '../Button/Button.svelte'
import Icon from '../Icon/Icon.svelte'
import Progress from '../Progress/Progress.svelte'

const config = getComponentConfig('inputPassword', inputPasswordDefaults)
const inputConfig = getComponentConfig('input', inputDefaults)
const icons = getComponentConfig('icons', iconsDefaults)

let {
ref = $bindable(null),
value = $bindable(''),
visible = $bindable(false),
purpose = 'login',
toggle = true,
showIcon = icons.passwordShow,
hideIcon = icons.passwordHide,
showLabel = 'Show password',
hideLabel = 'Hide password',
capsLockWarning = true,
capsLockMessage = 'Caps Lock is on',
strength = null,
strengthLevels = config.strengthLevels,
size,
disabled = false,
loading = false,
footer,
ui,
class: className,
onkeydown,
onkeyup,
onblur,
...restProps
}: Props = $props()

const ownSlots = ['root', 'toggle', 'meter', 'caps', 'capsIcon', 'footer'] as const

const inputUi = $derived.by(() => {
if (!ui) return undefined

const rest = { ...ui } as Record<string, unknown>
for (const slot of ownSlots) delete rest[slot]

return rest as InputProps<string>['ui']
})

let capsLockOn = $state(false)
let announcedLevel = $state<string | null>(null)

const classes = $derived.by(() => {
const slots = inputPasswordVariants()
return {
root: slots.root({ class: [config.slots.root, className, ui?.root] }),
toggle: slots.toggle({ class: [config.slots.toggle, ui?.toggle] }),
meter: slots.meter({ class: [config.slots.meter, ui?.meter] }),
caps: slots.caps({ class: [config.slots.caps, ui?.caps] }),
capsIcon: slots.capsIcon({ class: [config.slots.capsIcon, ui?.capsIcon] }),
footer: slots.footer({ class: [config.slots.footer, ui?.footer] })
}
})

const resolvedSize = $derived(size ?? inputConfig.defaultVariants.size)
const isInert = $derived(disabled || loading)
const hasMeter = $derived(strength !== null && strength !== undefined)
const clampedStrength = $derived(
hasMeter ? Math.max(0, Math.min(strengthLevels.length - 1, strength as number)) : null
)
const meterColor = $derived(
clampedStrength === null
? 'primary'
: (config.strengthColors[Math.min(config.strengthColors.length - 1, clampedStrength)] ??
'primary')
)
const currentLevel = $derived(
clampedStrength === null ? null : (strengthLevels[clampedStrength] ?? null)
)

function readCapsLock(event: KeyboardEvent) {
if (!capsLockWarning) return
if (typeof event.getModifierState !== 'function') return

capsLockOn = event.getModifierState('CapsLock')
}

function handleKeydown(event: KeyboardEvent & { currentTarget: HTMLInputElement }) {
readCapsLock(event)
onkeydown?.(event)
}

function handleKeyup(event: KeyboardEvent & { currentTarget: HTMLInputElement }) {
readCapsLock(event)
onkeyup?.(event)
}

function handleBlur(event: FocusEvent & { currentTarget: HTMLInputElement }) {
capsLockOn = false
onblur?.(event)
}

$effect(() => {
const level = currentLevel
if (level === announcedLevel) return

const id = setTimeout(() => {
announcedLevel = level
}, 400)
return () => clearTimeout(id)
})
</script>

<div class={classes.root}>
{#snippet toggleButton()}
<Button
variant="ghost"
color="surface"
size={resolvedSize}
square
disabled={isInert}
icon={visible ? hideIcon : showIcon}
aria-label={visible ? hideLabel : showLabel}
aria-pressed={visible}
class={classes.toggle}
onclick={() => (visible = !visible)}
/>
{/snippet}

<Input
bind:ref
bind:value
type={visible ? 'text' : 'password'}
autocomplete={autocompleteFor[purpose]}
size={resolvedSize}
{disabled}
{loading}
ui={inputUi}
trailingSlot={toggle ? toggleButton : undefined}
onkeydown={handleKeydown}
onkeyup={handleKeyup}
onblur={handleBlur}
{...restProps}
/>

{#if hasMeter}
<div class={classes.meter}>
<Progress
value={clampedStrength}
max={strengthLevels}
color={meterColor}
size="sm"
aria-label="Password strength"
/>
</div>
{/if}

{#if capsLockWarning && capsLockOn}
<p class={classes.caps}>
<Icon name="lucide:triangle-alert" class={classes.capsIcon} />
{capsLockMessage}
</p>
{/if}

{#if hasMeter}
<span class="sr-only" role="status" aria-live="polite">
{announcedLevel ? `Password strength: ${announcedLevel}` : ''}
</span>
{/if}

{#if footer}
<div class={classes.footer}>
{@render footer({ value: value ?? '', strength: clampedStrength })}
</div>
{/if}
</div>
Loading
Loading