Skip to content

Commit e2e0559

Browse files
authored
Add new PasswordToggleField primitive (radix-ui#3464)
1 parent 5c0a9be commit e2e0559

File tree

16 files changed

+1207
-0
lines changed

16 files changed

+1207
-0
lines changed

.changeset/wet-otters-hang.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
'@radix-ui/react-password-toggle-field': minor
3+
'radix-ui': minor
4+
---
5+
6+
Introduce new Password Toggle Field primitive
7+
8+
This new primitive provides components for rendering a password input alongside a button to toggle its visibility. Aside from its primary functionality, it also includes:
9+
10+
- Returning focus to the input when toggling with a pointer
11+
- Maintaining focus when toggling with keyboard or virtual navigation
12+
- Resetting visibility to hidden after form submission to prevent accidental storage
13+
- Implicit accessible labeling for icon-based toggle buttons
14+
15+
This API is currently unstable, and we hope you'll help us test it out! Import the primitive using the `unstable_` prefix.
16+
17+
```tsx
18+
import { unstable_PasswordToggleField as PasswordToggleField } from 'radix-ui';
19+
20+
function FieldWithIconToggle() {
21+
return (
22+
<PasswordToggleField.Root>
23+
<PasswordToggleField.Input />
24+
<PasswordToggleField.Toggle>
25+
<PasswordToggleField.Icon visible={<EyeOpenIcon />} hidden={<EyeClosedIcon />} />
26+
</PasswordToggleField.Toggle>
27+
</PasswordToggleField.Root>
28+
);
29+
}
30+
31+
function FieldWithTextToggle() {
32+
return (
33+
<PasswordToggleField.Root>
34+
<PasswordToggleField.Input />
35+
<PasswordToggleField.Toggle>
36+
<PasswordToggleField.Slot visible="Hide password" hidden="Show password" />
37+
</PasswordToggleField.Toggle>
38+
</PasswordToggleField.Root>
39+
);
40+
}
41+
```

apps/ssr-testing/app/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
2525
<Link href="/menubar">Menubar</Link>
2626
<Link href="/navigation-menu">NavigationMenu</Link>
2727
<Link href="/one-time-password-field">OneTimePasswordField</Link>
28+
<Link href="/password-toggle-field">PasswordToggleField</Link>
2829
<Link href="/popover">Popover</Link>
2930
<Link href="/portal">Portal</Link>
3031
<Link href="/progress">Progress</Link>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as React from 'react';
2+
import { unstable_PasswordToggleField as PasswordToggleField } from 'radix-ui';
3+
4+
export default function Page() {
5+
return (
6+
<div style={{ display: 'flex', gap: '1em', flexDirection: 'column' }}>
7+
<div>
8+
<PasswordToggleField.Root>
9+
<PasswordToggleField.Input />
10+
<PasswordToggleField.Toggle>
11+
<PasswordToggleField.Slot visible="Hide" hidden="Show" />
12+
</PasswordToggleField.Toggle>
13+
</PasswordToggleField.Root>
14+
</div>
15+
<div>
16+
<PasswordToggleField.Root>
17+
<PasswordToggleField.Input />
18+
<PasswordToggleField.Toggle>
19+
<PasswordToggleField.Icon visible={<EyeOpenIcon />} hidden={<EyeClosedIcon />} />
20+
</PasswordToggleField.Toggle>
21+
</PasswordToggleField.Root>
22+
</div>
23+
</div>
24+
);
25+
}
26+
27+
function EyeClosedIcon() {
28+
return (
29+
<svg
30+
width="15"
31+
height="15"
32+
viewBox="0 0 15 15"
33+
fill="currentColor"
34+
xmlns="http://www.w3.org/2000/svg"
35+
>
36+
<path
37+
d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 10.9999 7.50001 10.9999C6.99965 10.9999 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.3989 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 9.99991 7.50001 9.99991C10.3078 9.99991 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
38+
fillRule="evenodd"
39+
clipRule="evenodd"
40+
/>
41+
</svg>
42+
);
43+
}
44+
45+
function EyeOpenIcon() {
46+
return (
47+
<svg
48+
width="15"
49+
height="15"
50+
viewBox="0 0 15 15"
51+
fill="currentColor"
52+
xmlns="http://www.w3.org/2000/svg"
53+
>
54+
<path
55+
d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.50001C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
56+
fillRule="evenodd"
57+
clipRule="evenodd"
58+
/>
59+
</svg>
60+
);
61+
}

apps/storybook/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@storybook/addon-webpack5-compiler-swc": "^2.0.0",
2828
"@storybook/blocks": "^8.6.12",
2929
"@storybook/experimental-addon-test": "^8.6.12",
30+
"@storybook/preview-api": "^8.6.12",
3031
"@storybook/react": "^8.6.12",
3132
"@storybook/react-webpack5": "^8.6.12",
3233
"@storybook/manager-api": "^8.6.12",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
.viewport {
2+
--easing: cubic-bezier(0.165, 0.84, 0.44, 1);
3+
display: flex;
4+
flex-direction: column;
5+
height: 100vh;
6+
width: 100vw;
7+
justify-content: center;
8+
align-items: center;
9+
10+
margin: 0;
11+
padding: 24px;
12+
font-family:
13+
system-ui,
14+
-apple-system,
15+
BlinkMacSystemFont,
16+
'Segoe UI',
17+
Roboto,
18+
Oxygen,
19+
Ubuntu,
20+
Cantarell,
21+
'Open Sans',
22+
'Helvetica Neue',
23+
sans-serif;
24+
font-size: 14px;
25+
}
26+
27+
.field {
28+
--_field-height: 1.75rem;
29+
display: flex;
30+
width: 300px;
31+
max-width: 100%;
32+
border: 1px solid #ddd;
33+
height: var(--_field-height);
34+
align-items: center;
35+
justify-content: space-between;
36+
gap: 0.5rem;
37+
}
38+
39+
.field:has(.input:focus) {
40+
outline: 1px solid dodgerblue;
41+
border-color: dodgerblue;
42+
}
43+
44+
.input {
45+
all: unset;
46+
flex: 1 1 auto;
47+
height: 100%;
48+
vertical-align: center;
49+
padding: 0 0.5rem;
50+
}
51+
52+
.toggle {
53+
all: unset;
54+
height: var(--_field-height);
55+
aspect-ratio: 1;
56+
flex: 0 0 var(--_field-height);
57+
display: flex;
58+
align-items: center;
59+
justify-content: center;
60+
}
61+
62+
.toggle:focus-visible {
63+
outline: 2px solid dodgerblue;
64+
outline-offset: -4px;
65+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import * as React from 'react';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
import { useArgs } from '@storybook/preview-api';
4+
import { unstable_PasswordToggleField as PasswordToggleField } from 'radix-ui';
5+
import styles from './password-toggle-field.stories.module.css';
6+
7+
export default {
8+
title: 'Components/PasswordToggleField',
9+
component: PasswordToggleField.Root,
10+
} satisfies Meta<typeof PasswordToggleField.Root>;
11+
12+
type Story = StoryObj<typeof PasswordToggleField.Root>;
13+
type StoryArgs = Exclude<Story['args'], undefined>;
14+
15+
export const Uncontrolled = {
16+
argTypes: {
17+
children: { table: { disable: true } },
18+
defaultVisible: { table: { disable: true } },
19+
visible: { table: { disable: true } },
20+
},
21+
render: function Uncontrolled(args) {
22+
return (
23+
<div className={styles.viewport}>
24+
<PasswordToggleField.Root {...args}>
25+
<div className={styles.field}>
26+
<PasswordToggleField.Input className={styles.input} />
27+
<PasswordToggleField.Toggle className={styles.toggle}>
28+
<PasswordToggleField.Icon
29+
className={styles.toggleIcon}
30+
visible={<EyeOpenIcon />}
31+
hidden={<EyeClosedIcon />}
32+
/>
33+
</PasswordToggleField.Toggle>
34+
</div>
35+
</PasswordToggleField.Root>
36+
</div>
37+
);
38+
},
39+
} satisfies Story;
40+
41+
export const Controlled = {
42+
argTypes: {
43+
children: { table: { disable: true } },
44+
defaultVisible: { table: { disable: true } },
45+
visible: { control: { type: 'boolean' } },
46+
},
47+
args: {
48+
visible: false,
49+
},
50+
render: function Controlled(args) {
51+
const [{ visible }, updateArgs] = useArgs<StoryArgs>();
52+
return (
53+
<div className={styles.viewport}>
54+
<PasswordToggleField.Root
55+
{...args}
56+
visible={visible}
57+
onVisiblityChange={(visible) => updateArgs({ visible })}
58+
>
59+
<div className={styles.field}>
60+
<PasswordToggleField.Input className={styles.input} />
61+
<PasswordToggleField.Toggle className={styles.toggle}>
62+
<PasswordToggleField.Icon
63+
className={styles.toggleIcon}
64+
visible={<EyeOpenIcon />}
65+
hidden={<EyeClosedIcon />}
66+
/>
67+
</PasswordToggleField.Toggle>
68+
</div>
69+
</PasswordToggleField.Root>
70+
</div>
71+
);
72+
},
73+
} satisfies Story;
74+
75+
export const InsideForm = {
76+
argTypes: {
77+
children: { table: { disable: true } },
78+
defaultVisible: { table: { disable: true } },
79+
visible: { control: { type: 'boolean' } },
80+
},
81+
args: {
82+
visible: false,
83+
},
84+
render: function InsideForm(args) {
85+
const [{ visible }, updateArgs] = useArgs<StoryArgs>();
86+
const inputRef = React.useRef<HTMLInputElement>(null);
87+
return (
88+
<div className={styles.viewport}>
89+
<form
90+
onSubmit={(event) => {
91+
event.preventDefault();
92+
// should be reset on submit, so this should always be hidden
93+
window.alert(`Submitted! Field is ${visible ? 'visible' : 'hidden'}`);
94+
inputRef.current?.focus();
95+
}}
96+
>
97+
<PasswordToggleField.Root
98+
visible={visible}
99+
onVisiblityChange={(visible) => updateArgs({ visible })}
100+
{...args}
101+
>
102+
<div className={styles.field}>
103+
<PasswordToggleField.Input ref={inputRef} className={styles.input} />
104+
<PasswordToggleField.Toggle className={styles.toggle}>
105+
<PasswordToggleField.Icon
106+
className={styles.toggleIcon}
107+
visible={<EyeOpenIcon />}
108+
hidden={<EyeClosedIcon />}
109+
/>
110+
</PasswordToggleField.Toggle>
111+
</div>
112+
</PasswordToggleField.Root>
113+
<button>Submit</button>
114+
</form>
115+
</div>
116+
);
117+
},
118+
} satisfies Story;
119+
120+
const EyeClosedIcon = () => (
121+
<svg
122+
width="15"
123+
height="15"
124+
viewBox="0 0 15 15"
125+
fill="currentColor"
126+
xmlns="http://www.w3.org/2000/svg"
127+
>
128+
<path
129+
d="M14.7649 6.07596C14.9991 6.22231 15.0703 6.53079 14.9239 6.76495C14.4849 7.46743 13.9632 8.10645 13.3702 8.66305L14.5712 9.86406C14.7664 10.0593 14.7664 10.3759 14.5712 10.5712C14.3759 10.7664 14.0593 10.7664 13.8641 10.5712L12.6011 9.30817C11.805 9.90283 10.9089 10.3621 9.93375 10.651L10.383 12.3277C10.4544 12.5944 10.2961 12.8685 10.0294 12.94C9.76267 13.0115 9.4885 12.8532 9.41704 12.5865L8.95917 10.8775C8.48743 10.958 8.00036 10.9999 7.50001 10.9999C6.99965 10.9999 6.51257 10.958 6.04082 10.8775L5.58299 12.5864C5.51153 12.8532 5.23737 13.0115 4.97064 12.94C4.7039 12.8686 4.5456 12.5944 4.61706 12.3277L5.06625 10.651C4.09111 10.3621 3.19503 9.90282 2.3989 9.30815L1.1359 10.5712C0.940638 10.7664 0.624058 10.7664 0.428798 10.5712C0.233537 10.3759 0.233537 10.0593 0.428798 9.86405L1.62982 8.66303C1.03682 8.10643 0.515113 7.46742 0.0760677 6.76495C-0.0702867 6.53079 0.000898544 6.22231 0.235065 6.07596C0.469231 5.9296 0.777703 6.00079 0.924058 6.23496C1.40354 7.00213 1.989 7.68057 2.66233 8.2427C2.67315 8.25096 2.6837 8.25972 2.69397 8.26898C4.00897 9.35527 5.65537 9.99991 7.50001 9.99991C10.3078 9.99991 12.6564 8.5063 14.076 6.23495C14.2223 6.00079 14.5308 5.9296 14.7649 6.07596Z"
130+
fillRule="evenodd"
131+
clipRule="evenodd"
132+
/>
133+
</svg>
134+
);
135+
136+
const EyeOpenIcon = () => (
137+
<svg
138+
width="15"
139+
height="15"
140+
viewBox="0 0 15 15"
141+
fill="currentColor"
142+
xmlns="http://www.w3.org/2000/svg"
143+
>
144+
<path
145+
d="M7.5 11C4.80285 11 2.52952 9.62184 1.09622 7.50001C2.52952 5.37816 4.80285 4 7.5 4C10.1971 4 12.4705 5.37816 13.9038 7.50001C12.4705 9.62183 10.1971 11 7.5 11ZM7.5 3C4.30786 3 1.65639 4.70638 0.0760002 7.23501C-0.0253338 7.39715 -0.0253334 7.60288 0.0760014 7.76501C1.65639 10.2936 4.30786 12 7.5 12C10.6921 12 13.3436 10.2936 14.924 7.76501C15.0253 7.60288 15.0253 7.39715 14.924 7.23501C13.3436 4.70638 10.6921 3 7.5 3ZM7.5 9.5C8.60457 9.5 9.5 8.60457 9.5 7.5C9.5 6.39543 8.60457 5.5 7.5 5.5C6.39543 5.5 5.5 6.39543 5.5 7.5C5.5 8.60457 6.39543 9.5 7.5 9.5Z"
146+
fillRule="evenodd"
147+
clipRule="evenodd"
148+
/>
149+
</svg>
150+
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# `react-password-toggle-field`
2+
3+
## Installation
4+
5+
```sh
6+
$ yarn add radix-ui
7+
# or
8+
$ npm install radix-ui
9+
```
10+
11+
## Usage
12+
13+
View docs [here](https://radix-ui.com/primitives/docs/components/password-toggle-field).
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// @ts-check
2+
import { configs } from '@repo/eslint-config/react-package';
3+
4+
export default configs;

0 commit comments

Comments
 (0)