Skip to content

Commit 6377bf1

Browse files
authored
feat: add password input (#2483)
* feat: add password input * chore: add styles * test: add e2e * refactor: api and code * docs: add anatomy icon * docs: update * docs: add showcase
1 parent 8fb6a5d commit 6377bf1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1522
-47
lines changed

.changeset/upset-ghosts-raise.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zag-js/password-input": minor
3+
---
4+
5+
Initial release of password input component

e2e/_utils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,26 @@ export async function swipe(
167167
await page.mouse.move(endX, endY, { steps: Math.max(duration / 10, 1) }) // Smoothness based on duration
168168
await page.mouse.up()
169169
}
170+
171+
export function moveCaret(input: Locator, start: number, end = start) {
172+
return input.evaluate((el) => {
173+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
174+
el.setSelectionRange(start, end)
175+
}
176+
177+
throw new Error("Element is not an input or textarea")
178+
})
179+
}
180+
181+
export function getCaret(input: Locator) {
182+
return input.evaluate((el) => {
183+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
184+
return {
185+
start: el.selectionStart,
186+
end: el.selectionEnd,
187+
}
188+
}
189+
190+
throw new Error("Element is not an input or textarea")
191+
})
192+
}

e2e/models/password-input.model.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { expect, type Page } from "@playwright/test"
2+
import { a11y, getCaret } from "../_utils"
3+
import { Model } from "./model"
4+
5+
export class PasswordInputModel extends Model {
6+
constructor(public page: Page) {
7+
super(page)
8+
}
9+
10+
checkAccessibility() {
11+
return a11y(this.page)
12+
}
13+
14+
goto(url = "/password-input") {
15+
return this.page.goto(url)
16+
}
17+
18+
get input() {
19+
return this.page.locator(`[data-scope=password-input][data-part=input]`)
20+
}
21+
22+
get visibilityTrigger() {
23+
return this.page.locator("[data-scope=password-input][data-part=visibility-trigger]")
24+
}
25+
26+
clickVisibilityTrigger = async () => {
27+
await this.visibilityTrigger.click()
28+
}
29+
30+
canSeePassword = async () => {
31+
await expect(this.input).toHaveAttribute("type", "text")
32+
}
33+
34+
cantSeePassword = async () => {
35+
await expect(this.input).toHaveAttribute("type", "password")
36+
}
37+
38+
caretIsAt = async (start: number, end = start) => {
39+
const caret = await getCaret(this.input)
40+
expect(caret.start).toBe(start)
41+
expect(caret.end).toBe(end)
42+
}
43+
}

e2e/password-input.e2e.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { test } from "@playwright/test"
2+
import { PasswordInputModel } from "./models/password-input.model"
3+
4+
let I: PasswordInputModel
5+
6+
test.beforeEach(async ({ page }) => {
7+
I = new PasswordInputModel(page)
8+
await I.goto()
9+
})
10+
11+
test("should have no accessibility violation", async () => {
12+
await I.checkAccessibility()
13+
})
14+
15+
test("should toggle password visibility", async () => {
16+
await I.clickVisibilityTrigger()
17+
await I.canSeePassword()
18+
await I.clickVisibilityTrigger()
19+
await I.cantSeePassword()
20+
})
21+
22+
test("preserve caret position when toggling password visibility", async () => {
23+
await I.input.pressSequentially("123456")
24+
await I.input.press("ArrowLeft")
25+
await I.clickVisibilityTrigger()
26+
await I.caretIsAt(5)
27+
await I.clickVisibilityTrigger()
28+
await I.caretIsAt(5)
29+
})

examples/next-ts/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@zag-js/navigation-menu": "workspace:*",
5353
"@zag-js/number-input": "workspace:*",
5454
"@zag-js/pagination": "workspace:*",
55+
"@zag-js/password-input": "workspace:*",
5556
"@zag-js/pin-input": "workspace:*",
5657
"@zag-js/popover": "workspace:*",
5758
"@zag-js/popper": "workspace:*",
@@ -107,4 +108,4 @@
107108
"typescript": "5.8.3"
108109
},
109110
"license": "MIT"
110-
}
111+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import * as passwordInput from "@zag-js/password-input"
2+
import { normalizeProps, useMachine } from "@zag-js/react"
3+
import { passwordInputControls } from "@zag-js/shared"
4+
import { EyeIcon, EyeOffIcon } from "lucide-react"
5+
import { useId } from "react"
6+
import { StateVisualizer } from "../components/state-visualizer"
7+
import { Toolbar } from "../components/toolbar"
8+
import { useControls } from "../hooks/use-controls"
9+
10+
export default function Page() {
11+
const controls = useControls(passwordInputControls)
12+
const service = useMachine(passwordInput.machine, {
13+
id: useId(),
14+
...controls.context,
15+
})
16+
17+
const api = passwordInput.connect(service, normalizeProps)
18+
19+
return (
20+
<>
21+
<main className="password-input">
22+
<div {...api.getRootProps()}>
23+
<label {...api.getLabelProps()}>Password</label>
24+
<div {...api.getControlProps()}>
25+
<input {...api.getInputProps()} />
26+
<button {...api.getVisibilityTriggerProps()}>
27+
<span {...api.getIndicatorProps()}>{api.visible ? <EyeIcon /> : <EyeOffIcon />}</span>
28+
</button>
29+
</div>
30+
</div>
31+
</main>
32+
33+
<Toolbar controls={controls.ui}>
34+
<StateVisualizer state={service} />
35+
</Toolbar>
36+
</>
37+
)
38+
}

examples/nuxt-ts/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,13 @@
4242
"@zag-js/hover-card": "workspace:*",
4343
"@zag-js/i18n-utils": "workspace:*",
4444
"@zag-js/interact-outside": "workspace:*",
45-
"@zag-js/live-region": "workspace:*",
4645
"@zag-js/listbox": "workspace:*",
46+
"@zag-js/live-region": "workspace:*",
4747
"@zag-js/menu": "workspace:*",
48+
"@zag-js/navigation-menu": "workspace:*",
4849
"@zag-js/number-input": "workspace:*",
4950
"@zag-js/pagination": "workspace:*",
51+
"@zag-js/password-input": "workspace:*",
5052
"@zag-js/pin-input": "workspace:*",
5153
"@zag-js/popover": "workspace:*",
5254
"@zag-js/popper": "workspace:*",
@@ -93,4 +95,4 @@
9395
"@types/node": "22.15.21",
9496
"nuxt": "3.17.4"
9597
}
96-
}
98+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script setup lang="ts">
2+
import * as passwordInput from "@zag-js/password-input"
3+
import { passwordInputControls } from "@zag-js/shared"
4+
import { normalizeProps, useMachine } from "@zag-js/vue"
5+
import { useId } from "vue"
6+
import { EyeIcon, EyeOffIcon } from "lucide-vue-next"
7+
8+
const controls = useControls(passwordInputControls)
9+
10+
const service = useMachine(passwordInput.machine, controls.mergeProps({ id: useId() }))
11+
12+
const api = computed(() => passwordInput.connect(service, normalizeProps))
13+
</script>
14+
15+
<template>
16+
<main class="password-input">
17+
<div v-bind="api.getRootProps()">
18+
<label v-bind="api.getLabelProps()">Password</label>
19+
20+
<div v-bind="api.getControlProps()">
21+
<input v-bind="api.getInputProps()" />
22+
<button v-bind="api.getVisibilityTriggerProps()">
23+
<span v-bind="api.getIndicatorProps()">
24+
<EyeIcon v-if="api.visible" />
25+
<EyeOffIcon v-else />
26+
</span>
27+
</button>
28+
</div>
29+
</div>
30+
</main>
31+
32+
<Toolbar>
33+
<StateVisualizer :state="service" />
34+
<template #controls>
35+
<Controls :control="controls" />
36+
</template>
37+
</Toolbar>
38+
</template>

examples/preact-ts/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@
4141
"@zag-js/hover-card": "workspace:*",
4242
"@zag-js/i18n-utils": "workspace:*",
4343
"@zag-js/interact-outside": "workspace:*",
44+
"@zag-js/listbox": "workspace:*",
4445
"@zag-js/live-region": "workspace:*",
4546
"@zag-js/menu": "workspace:*",
47+
"@zag-js/navigation-menu": "workspace:*",
4648
"@zag-js/number-input": "workspace:*",
4749
"@zag-js/pagination": "workspace:*",
50+
"@zag-js/password-input": "workspace:*",
4851
"@zag-js/pin-input": "workspace:*",
4952
"@zag-js/popover": "workspace:*",
5053
"@zag-js/popper": "workspace:*",
@@ -91,4 +94,4 @@
9194
"typescript": "5.8.3",
9295
"vite": "6.3.5"
9396
}
94-
}
97+
}

examples/solid-ts/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@zag-js/navigation-menu": "workspace:*",
5252
"@zag-js/number-input": "workspace:*",
5353
"@zag-js/pagination": "workspace:*",
54+
"@zag-js/password-input": "workspace:*",
5455
"@zag-js/pin-input": "workspace:*",
5556
"@zag-js/popover": "workspace:*",
5657
"@zag-js/popper": "workspace:*",
@@ -93,4 +94,4 @@
9394
"engines": {
9495
"node": ">=18"
9596
}
96-
}
97+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as passwordInput from "@zag-js/password-input"
2+
import { passwordInputControls } from "@zag-js/shared"
3+
import { normalizeProps, useMachine } from "@zag-js/solid"
4+
import { EyeIcon, EyeOffIcon } from "lucide-solid"
5+
import { createMemo, createUniqueId, Show } from "solid-js"
6+
import { StateVisualizer } from "../components/state-visualizer"
7+
import { Toolbar } from "../components/toolbar"
8+
import { useControls } from "../hooks/use-controls"
9+
10+
export default function Page() {
11+
const controls = useControls(passwordInputControls)
12+
13+
const service = useMachine(
14+
passwordInput.machine,
15+
controls.mergeProps({
16+
id: createUniqueId(),
17+
}),
18+
)
19+
20+
const api = createMemo(() => passwordInput.connect(service, normalizeProps))
21+
22+
return (
23+
<>
24+
<main class="password-input">
25+
<div {...api().getRootProps()}>
26+
<label {...api().getLabelProps()}>Password</label>
27+
<div {...api().getControlProps()}>
28+
<input {...api().getInputProps()} />
29+
<button {...api().getVisibilityTriggerProps()}>
30+
<span {...api().getIndicatorProps()}>
31+
<Show when={api().visible} fallback={<EyeOffIcon />}>
32+
<EyeIcon />
33+
</Show>
34+
</span>
35+
</button>
36+
</div>
37+
</div>
38+
</main>
39+
40+
<Toolbar controls={controls}>
41+
<StateVisualizer state={service} />
42+
</Toolbar>
43+
</>
44+
)
45+
}

examples/svelte-ts/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"version": "0.0.0",
55
"type": "module",
66
"scripts": {
7-
"dev": "vite",
7+
"dev": "vite --port 3000",
88
"build": "vite build",
99
"preview": "vite preview",
1010
"check": "svelte-check --tsconfig ./tsconfig.json"
@@ -43,12 +43,13 @@
4343
"@zag-js/hover-card": "workspace:*",
4444
"@zag-js/i18n-utils": "workspace:*",
4545
"@zag-js/interact-outside": "workspace:*",
46-
"@zag-js/live-region": "workspace:*",
4746
"@zag-js/listbox": "workspace:*",
47+
"@zag-js/live-region": "workspace:*",
4848
"@zag-js/menu": "workspace:*",
4949
"@zag-js/navigation-menu": "workspace:*",
5050
"@zag-js/number-input": "workspace:*",
5151
"@zag-js/pagination": "workspace:*",
52+
"@zag-js/password-input": "workspace:*",
5253
"@zag-js/pin-input": "workspace:*",
5354
"@zag-js/popover": "workspace:*",
5455
"@zag-js/popper": "workspace:*",

examples/svelte-ts/src/App.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@
2121
import FloatingPanel from "./routes/floating-panel.svelte"
2222
import HoverCard from "./routes/hover-card.svelte"
2323
import Index from "./routes/index.svelte"
24+
import ListboxGrid from "./routes/listbox-grid.svelte"
25+
import Listbox from "./routes/listbox.svelte"
2426
import NestedMenu from "./routes/menu-nested.svelte"
2527
import MenuOptions from "./routes/menu-options.svelte"
2628
import Menu from "./routes/menu.svelte"
2729
import NumberInput from "./routes/number-input.svelte"
2830
import Pagination from "./routes/pagination.svelte"
31+
import PasswordInput from "./routes/password-input.svelte"
2932
import PinInput from "./routes/pin-input.svelte"
3033
import Popover from "./routes/popover.svelte"
3134
import Popper from "./routes/popper.svelte"
@@ -53,8 +56,6 @@
5356
import Tooltip from "./routes/tooltip.svelte"
5457
import Tour from "./routes/tour.svelte"
5558
import TreeView from "./routes/tree-view.svelte"
56-
import Listbox from "./routes/listbox.svelte"
57-
import ListboxGrid from "./routes/listbox-grid.svelte"
5859
5960
const sortedRoutes = routesData.sort((a, b) => a.label.localeCompare(b.label))
6061
@@ -112,6 +113,7 @@
112113
{ path: "/steps", component: Steps },
113114
{ path: "/listbox", component: Listbox },
114115
{ path: "/listbox-grid", component: ListboxGrid },
116+
{ path: "/password-input", component: PasswordInput },
115117
]
116118
</script>
117119

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script lang="ts">
2+
import StateVisualizer from "$lib/components/state-visualizer.svelte"
3+
import Toolbar from "$lib/components/toolbar.svelte"
4+
import { useControls } from "$lib/use-controls.svelte"
5+
import * as passwordInput from "@zag-js/password-input"
6+
import { passwordInputControls } from "@zag-js/shared"
7+
import { normalizeProps, useMachine } from "@zag-js/svelte"
8+
import { EyeIcon, EyeOffIcon } from "lucide-svelte"
9+
10+
const controls = useControls(passwordInputControls)
11+
12+
const id = $props.id()
13+
const service = useMachine(passwordInput.machine, { id })
14+
15+
const api = $derived(passwordInput.connect(service, normalizeProps))
16+
</script>
17+
18+
<main class="password-input">
19+
<div {...api.getRootProps()}>
20+
<label {...api.getLabelProps()}>Password</label>
21+
<div {...api.getControlProps()}>
22+
<input {...api.getInputProps()} />
23+
<button {...api.getVisibilityTriggerProps()}>
24+
<span {...api.getIndicatorProps()}>
25+
{#if api.visible}
26+
<EyeIcon />
27+
{:else}
28+
<EyeOffIcon />
29+
{/if}
30+
</span>
31+
</button>
32+
</div>
33+
</div>
34+
</main>
35+
36+
<Toolbar {controls}>
37+
<StateVisualizer state={service} />
38+
</Toolbar>

0 commit comments

Comments
 (0)