@shadow-js/ui 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +598 -2
- package/dist/cli.mjs +2293 -0
- package/dist/dev/cli/schema.d.ts +73 -0
- package/dist/dev/index.d.ts +6 -0
- package/dist/dev/index.js +5 -0
- package/dist/dev/locales/ar.d.ts +141 -0
- package/dist/dev/locales/ar.js +128 -0
- package/dist/dev/locales/ar.js.map +1 -0
- package/dist/dev/locales/en.d.ts +141 -0
- package/dist/dev/locales/en.js +128 -0
- package/dist/dev/locales/en.js.map +1 -0
- package/dist/dev/registry/utils/utils.d.ts +29 -0
- package/dist/dev/registry/utils/utils.js +27 -0
- package/dist/dev/registry/utils/utils.js.map +1 -0
- package/dist/dev/theme/theme-logic.d.ts +187 -0
- package/dist/dev/theme/theme-logic.js +184 -0
- package/dist/dev/theme/theme-logic.js.map +1 -0
- package/dist/dev/theme/theme-provider.d.ts +90 -0
- package/dist/dev/theme/theme-provider.js +150 -0
- package/dist/dev/theme/theme-provider.js.map +1 -0
- package/dist/prod/index.js +1 -0
- package/dist/prod/locales/ar.js +2 -0
- package/dist/prod/locales/ar.js.map +1 -0
- package/dist/prod/locales/en.js +2 -0
- package/dist/prod/locales/en.js.map +1 -0
- package/dist/prod/registry/utils/utils.js +2 -0
- package/dist/prod/registry/utils/utils.js.map +1 -0
- package/dist/prod/theme/theme-logic.js +2 -0
- package/dist/prod/theme/theme-logic.js.map +1 -0
- package/dist/prod/theme/theme-provider.js +2 -0
- package/dist/prod/theme/theme-provider.js.map +1 -0
- package/package.json +102 -4
- package/registry/accordion/accordion.tsx +210 -0
- package/registry/accordion/item.json +4 -0
- package/registry/alert/alert.tsx +200 -0
- package/registry/alert/item.json +4 -0
- package/registry/alert-dialog/alert-dialog.tsx +530 -0
- package/registry/alert-dialog/item.json +4 -0
- package/registry/aspect-ratio/aspect-ratio.tsx +71 -0
- package/registry/aspect-ratio/item.json +4 -0
- package/registry/attachment/attachment.tsx +459 -0
- package/registry/attachment/item.json +4 -0
- package/registry/avatar/avatar.tsx +385 -0
- package/registry/avatar/item.json +4 -0
- package/registry/badge/badge.tsx +108 -0
- package/registry/badge/item.json +4 -0
- package/registry/breadcrumb/breadcrumb.tsx +354 -0
- package/registry/breadcrumb/item.json +4 -0
- package/registry/bubble/bubble.tsx +260 -0
- package/registry/bubble/item.json +4 -0
- package/registry/button/button.tsx +125 -0
- package/registry/button/item.json +4 -0
- package/registry/button-group/button-group.tsx +189 -0
- package/registry/button-group/item.json +4 -0
- package/registry/calendar/calendar.tsx +348 -0
- package/registry/calendar/item.json +4 -0
- package/registry/card/card.tsx +311 -0
- package/registry/card/item.json +4 -0
- package/registry/carousel/carousel.tsx +851 -0
- package/registry/carousel/item.json +4 -0
- package/registry/chart/chart.tsx +1956 -0
- package/registry/chart/item.json +4 -0
- package/registry/chart-scale/chart-scale.ts +670 -0
- package/registry/chart-scale/item.json +5 -0
- package/registry/checkbox/checkbox.tsx +79 -0
- package/registry/checkbox/item.json +4 -0
- package/registry/code-block/code-block.tsx +470 -0
- package/registry/code-block/item.json +4 -0
- package/registry/collapsible/collapsible.tsx +117 -0
- package/registry/collapsible/item.json +4 -0
- package/registry/combobox/combobox.tsx +977 -0
- package/registry/combobox/item.json +4 -0
- package/registry/command/command.tsx +757 -0
- package/registry/command/item.json +4 -0
- package/registry/context-menu/context-menu.tsx +697 -0
- package/registry/context-menu/item.json +4 -0
- package/registry/data-table/data-table.tsx +437 -0
- package/registry/data-table/item.json +4 -0
- package/registry/date-picker/date-picker.tsx +349 -0
- package/registry/date-picker/item.json +4 -0
- package/registry/dialog/dialog.tsx +412 -0
- package/registry/dialog/item.json +4 -0
- package/registry/direction/direction.tsx +88 -0
- package/registry/direction/item.json +4 -0
- package/registry/drawer/drawer.tsx +787 -0
- package/registry/drawer/item.json +4 -0
- package/registry/dropdown-menu/dropdown-menu.tsx +692 -0
- package/registry/dropdown-menu/item.json +4 -0
- package/registry/empty/empty.tsx +240 -0
- package/registry/empty/item.json +4 -0
- package/registry/field/field.tsx +624 -0
- package/registry/field/item.json +4 -0
- package/registry/form/form.tsx +1112 -0
- package/registry/form/item.json +4 -0
- package/registry/hover-card/hover-card.tsx +144 -0
- package/registry/hover-card/item.json +4 -0
- package/registry/icons/icons.tsx +1691 -0
- package/registry/icons/item.json +4 -0
- package/registry/input/input.tsx +43 -0
- package/registry/input/item.json +4 -0
- package/registry/input-group/input-group.tsx +315 -0
- package/registry/input-group/item.json +4 -0
- package/registry/input-otp/input-otp.tsx +329 -0
- package/registry/input-otp/item.json +4 -0
- package/registry/item/item.json +4 -0
- package/registry/item/item.tsx +439 -0
- package/registry/kbd/item.json +4 -0
- package/registry/kbd/kbd.tsx +65 -0
- package/registry/label/item.json +4 -0
- package/registry/label/label.tsx +60 -0
- package/registry/marker/item.json +4 -0
- package/registry/marker/marker.tsx +175 -0
- package/registry/menubar/item.json +4 -0
- package/registry/menubar/menubar.tsx +694 -0
- package/registry/message/item.json +4 -0
- package/registry/message/message.tsx +280 -0
- package/registry/message-scroller/item.json +4 -0
- package/registry/message-scroller/message-scroller.tsx +262 -0
- package/registry/native-select/item.json +4 -0
- package/registry/native-select/native-select.tsx +113 -0
- package/registry/navigation-menu/item.json +4 -0
- package/registry/navigation-menu/navigation-menu.tsx +362 -0
- package/registry/pagination/item.json +4 -0
- package/registry/pagination/pagination.tsx +460 -0
- package/registry/password-input/item.json +5 -0
- package/registry/password-input/password-input.tsx +494 -0
- package/registry/popover/item.json +4 -0
- package/registry/popover/popover.tsx +284 -0
- package/registry/progress/item.json +4 -0
- package/registry/progress/progress.tsx +190 -0
- package/registry/questionnaire/item.json +4 -0
- package/registry/questionnaire/questionnaire.tsx +569 -0
- package/registry/radio-group/item.json +4 -0
- package/registry/radio-group/radio-group.tsx +101 -0
- package/registry/resizable/item.json +4 -0
- package/registry/resizable/resizable.tsx +455 -0
- package/registry/scroll-area/item.json +4 -0
- package/registry/scroll-area/scroll-area.tsx +149 -0
- package/registry/select/item.json +4 -0
- package/registry/select/select.tsx +571 -0
- package/registry/separator/item.json +4 -0
- package/registry/separator/separator.tsx +52 -0
- package/registry/sheet/item.json +4 -0
- package/registry/sheet/sheet.tsx +476 -0
- package/registry/sidebar/item.json +4 -0
- package/registry/sidebar/sidebar.tsx +1870 -0
- package/registry/skeleton/item.json +4 -0
- package/registry/skeleton/skeleton.tsx +47 -0
- package/registry/slider/item.json +4 -0
- package/registry/slider/slider.tsx +149 -0
- package/registry/spinner/item.json +4 -0
- package/registry/spinner/spinner.tsx +51 -0
- package/registry/switch/item.json +4 -0
- package/registry/switch/switch.tsx +93 -0
- package/registry/table/item.json +4 -0
- package/registry/table/table.tsx +373 -0
- package/registry/tabs/item.json +4 -0
- package/registry/tabs/tabs.tsx +209 -0
- package/registry/textarea/item.json +4 -0
- package/registry/textarea/textarea.tsx +51 -0
- package/registry/toast/item.json +4 -0
- package/registry/toast/toast.tsx +949 -0
- package/registry/toggle/item.json +4 -0
- package/registry/toggle/toggle.tsx +110 -0
- package/registry/toggle-group/item.json +4 -0
- package/registry/toggle-group/toggle-group.tsx +189 -0
- package/registry/tooltip/item.json +4 -0
- package/registry/tooltip/tooltip.tsx +169 -0
- package/registry/trace/item.json +4 -0
- package/registry/trace/trace.tsx +356 -0
- package/registry/ui-strings/item.json +5 -0
- package/registry/ui-strings/ui-strings.tsx +454 -0
- package/registry/use-highlighted-code/item.json +5 -0
- package/registry/use-highlighted-code/use-highlighted-code.tsx +145 -0
- package/registry/use-mobile/item.json +5 -0
- package/registry/use-mobile/use-mobile.ts +46 -0
- package/registry/utils/item.json +5 -0
- package/registry/utils/utils.ts +38 -0
- package/registry.json +3274 -0
- package/styles/animations.css +321 -0
- package/styles/theme-core.css +55 -0
- package/styles/theme.css +12 -0
- package/styles/themes/gray.css +69 -0
- package/styles/themes/neutral.css +69 -0
- package/styles/themes/slate.css +69 -0
- package/styles/themes/stone.css +69 -0
- package/styles/themes/zinc.css +69 -0
- package/styles/typeset.css +251 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { type Accessor, onMount, splitProps, useMemo, useSignal } from "@shadow-js/core";
|
|
2
|
+
import {
|
|
3
|
+
createPartContext,
|
|
4
|
+
mergeElementProps,
|
|
5
|
+
PasswordToggleField,
|
|
6
|
+
useBaseId,
|
|
7
|
+
} from "@shadow-js/primitives";
|
|
8
|
+
import { type ComponentProps, For, type JSX, Show } from "@shadow-js/web";
|
|
9
|
+
import { useUiStrings } from "@/lib/ui-strings";
|
|
10
|
+
import { type ClassValue, cn } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Props of {@link PasswordInput}: the attributes of an `input`, which go to the input, plus the
|
|
14
|
+
* visibility options below. `disabled` disables both the input and the toggle. `class` styles the
|
|
15
|
+
* wrapper, so its width is the width of the field.
|
|
16
|
+
* @since 1.0.0
|
|
17
|
+
* @see {@link PasswordInput}
|
|
18
|
+
*/
|
|
19
|
+
export type PasswordInputProps = Omit<ComponentProps<"input">, "children"> & {
|
|
20
|
+
/**
|
|
21
|
+
* Controlled visibility: `true` shows the password as text. Clicks on the toggle only report the
|
|
22
|
+
* change through `onVisibleChange`.
|
|
23
|
+
* @default none (uncontrolled)
|
|
24
|
+
*/
|
|
25
|
+
readonly visible?: boolean | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Whether the password is shown at first, when uncontrolled.
|
|
28
|
+
* @default false
|
|
29
|
+
*/
|
|
30
|
+
readonly defaultVisible?: boolean | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Called with the new visibility when the user shows or hides the password.
|
|
33
|
+
* @default none
|
|
34
|
+
*/
|
|
35
|
+
readonly onVisibleChange?: ((visible: boolean) => void) | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* The accessible name of the toggle, in the page's language. The name stays the same while the
|
|
38
|
+
* password is shown.
|
|
39
|
+
* @default "Show password"
|
|
40
|
+
*/
|
|
41
|
+
readonly toggleLabel?: string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Shows "Caps Lock is on" under the field while the password is hidden, the field has focus and Caps
|
|
44
|
+
* Lock is on, so a mistyped password is not a mystery. The hint is a polite status and the input's
|
|
45
|
+
* `aria-describedby` names it while it shows. Turn it off with `false`.
|
|
46
|
+
* @default true
|
|
47
|
+
*/
|
|
48
|
+
readonly capsLockHint?: boolean | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* The slot under the field: a {@link PasswordStrength}, or your own part that reads the field with
|
|
51
|
+
* {@link usePasswordInput}.
|
|
52
|
+
* @default none
|
|
53
|
+
*/
|
|
54
|
+
readonly children?: JSX.Element | undefined;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The props as the implementation sees them. */
|
|
58
|
+
type Loose = {
|
|
59
|
+
readonly class?: ClassValue;
|
|
60
|
+
readonly id?: string | undefined;
|
|
61
|
+
readonly visible?: boolean | undefined;
|
|
62
|
+
readonly defaultVisible?: boolean | undefined;
|
|
63
|
+
readonly onVisibleChange?: ((visible: boolean) => void) | undefined;
|
|
64
|
+
readonly toggleLabel?: string | undefined;
|
|
65
|
+
readonly capsLockHint?: boolean | undefined;
|
|
66
|
+
readonly disabled?: boolean | undefined;
|
|
67
|
+
readonly children?: JSX.Element;
|
|
68
|
+
readonly [prop: string]: unknown;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** An eye: the password is hidden, the toggle offers to show it. Inline SVG, as the icons are. */
|
|
72
|
+
function EyeIcon(props: ComponentProps<"svg">): JSX.Element {
|
|
73
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
74
|
+
return (
|
|
75
|
+
<svg
|
|
76
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
77
|
+
width="24"
|
|
78
|
+
height="24"
|
|
79
|
+
viewBox="0 0 24 24"
|
|
80
|
+
fill="none"
|
|
81
|
+
stroke="currentColor"
|
|
82
|
+
stroke-width="2"
|
|
83
|
+
stroke-linecap="round"
|
|
84
|
+
stroke-linejoin="round"
|
|
85
|
+
aria-hidden="true"
|
|
86
|
+
class={cn("size-4", local.class)}
|
|
87
|
+
{...rest}
|
|
88
|
+
>
|
|
89
|
+
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
|
90
|
+
<circle cx="12" cy="12" r="3" />
|
|
91
|
+
</svg>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** An eye with a slash: the password is shown, the toggle offers to hide it. Inline SVG. */
|
|
96
|
+
function EyeOffIcon(props: ComponentProps<"svg">): JSX.Element {
|
|
97
|
+
const [local, rest] = splitProps(props, ["class"]);
|
|
98
|
+
return (
|
|
99
|
+
<svg
|
|
100
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
101
|
+
width="24"
|
|
102
|
+
height="24"
|
|
103
|
+
viewBox="0 0 24 24"
|
|
104
|
+
fill="none"
|
|
105
|
+
stroke="currentColor"
|
|
106
|
+
stroke-width="2"
|
|
107
|
+
stroke-linecap="round"
|
|
108
|
+
stroke-linejoin="round"
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
class={cn("size-4", local.class)}
|
|
111
|
+
{...rest}
|
|
112
|
+
>
|
|
113
|
+
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
|
114
|
+
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
|
115
|
+
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
|
116
|
+
<path d="m2 2 20 20" />
|
|
117
|
+
</svg>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `autocorrect` is an iOS Safari attribute that the JSX types do not list. @internal */
|
|
122
|
+
const SAFARI_ATTRIBUTES: Readonly<Record<string, string>> = { autocorrect: "off" };
|
|
123
|
+
|
|
124
|
+
/** What a password field shares with the parts inside it. @internal */
|
|
125
|
+
interface PasswordInputContextValue {
|
|
126
|
+
readonly value: Accessor<string>;
|
|
127
|
+
readonly visible: Accessor<boolean>;
|
|
128
|
+
/** Adds an element id to the input's `aria-describedby` while the part is mounted. */
|
|
129
|
+
readonly describe: (id: string) => () => void;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const passwordInputContext = createPartContext<PasswordInputContextValue>("PasswordInput");
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What {@link usePasswordInput} returns: the password typed so far and whether it is shown.
|
|
136
|
+
* @see {@link usePasswordInput}
|
|
137
|
+
* @since 1.0.0
|
|
138
|
+
*/
|
|
139
|
+
export interface PasswordInputState {
|
|
140
|
+
/** The password in the field, as typed (or the controlled `value`). Empty on the server. */
|
|
141
|
+
readonly value: Accessor<string>;
|
|
142
|
+
/** Whether the password is shown as text. */
|
|
143
|
+
readonly visible: Accessor<boolean>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Reads the field that the calling part sits in, to build your own parts for the strength meter slot:
|
|
148
|
+
* a list of rules that turn green, a count of characters. Call it inside a {@link PasswordInput}.
|
|
149
|
+
*
|
|
150
|
+
* @returns Accessors of the typed value and the visibility.
|
|
151
|
+
* @throws Error when called outside a {@link PasswordInput}.
|
|
152
|
+
* @example
|
|
153
|
+
* import { PasswordInput, usePasswordInput } from "@/components/ui/password-input";
|
|
154
|
+
*
|
|
155
|
+
* function Length() {
|
|
156
|
+
* const field = usePasswordInput();
|
|
157
|
+
* return <p>{field.value().length} characters</p>;
|
|
158
|
+
* }
|
|
159
|
+
*
|
|
160
|
+
* export function Signup() {
|
|
161
|
+
* return (
|
|
162
|
+
* <PasswordInput aria-label="Password" autocomplete="new-password">
|
|
163
|
+
* <Length />
|
|
164
|
+
* </PasswordInput>
|
|
165
|
+
* );
|
|
166
|
+
* }
|
|
167
|
+
* @see {@link PasswordStrength}
|
|
168
|
+
* @since 1.0.0
|
|
169
|
+
*/
|
|
170
|
+
function usePasswordInput(): PasswordInputState {
|
|
171
|
+
const context = passwordInputContext.use("usePasswordInput");
|
|
172
|
+
return { value: context.value, visible: context.visible };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Passwords that rate as the weakest whatever their length. @internal */
|
|
176
|
+
const COMMON = new Set([
|
|
177
|
+
"12345678",
|
|
178
|
+
"123456789",
|
|
179
|
+
"1234567890",
|
|
180
|
+
"11111111",
|
|
181
|
+
"00000000",
|
|
182
|
+
"password",
|
|
183
|
+
"password1",
|
|
184
|
+
"qwertyui",
|
|
185
|
+
"qwertyuiop",
|
|
186
|
+
"iloveyou",
|
|
187
|
+
"letmein1",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Rates a password from `0` (very weak) to `4` (strong) with a rule of thumb: length counts first (8, 12
|
|
192
|
+
* and 16 characters), and a mix of at least three kinds of characters (lowercase or other letters,
|
|
193
|
+
* uppercase letters, digits, symbols) adds one more level. A password shorter than 8 characters, and a
|
|
194
|
+
* very common one, is `0`. Characters are counted as people see them, so an emoji is one. It is a hint,
|
|
195
|
+
* not a guarantee: for real checks use a library such as zxcvbn and pass it as `evaluate`.
|
|
196
|
+
*
|
|
197
|
+
* @param password The password.
|
|
198
|
+
* @returns The level, from `0` to `4`.
|
|
199
|
+
* @example
|
|
200
|
+
* import { scorePassword } from "@/components/ui/password-input";
|
|
201
|
+
*
|
|
202
|
+
* export const level = scorePassword("Abcdefghijkl123!"); // 4
|
|
203
|
+
* @see {@link PasswordStrength}
|
|
204
|
+
* @since 1.0.0
|
|
205
|
+
*/
|
|
206
|
+
function scorePassword(password: string): 0 | 1 | 2 | 3 | 4 {
|
|
207
|
+
const length = [...password].length;
|
|
208
|
+
if (length < 8 || COMMON.has(password.toLowerCase())) return 0;
|
|
209
|
+
const kinds = [/[\p{Ll}\p{Lo}\p{Lm}]/u, /[\p{Lu}\p{Lt}]/u, /\p{Nd}/u, /[^\p{L}\p{Nd}]/u].filter(
|
|
210
|
+
(kind) => kind.test(password),
|
|
211
|
+
).length;
|
|
212
|
+
const base = length < 12 ? 1 : length < 16 ? 2 : 3;
|
|
213
|
+
return Math.min(4, base + (kinds >= 3 ? 1 : 0)) as 1 | 2 | 3 | 4;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Props of {@link PasswordStrength}: the attributes of the wrapper `div`, plus how to rate and name the
|
|
218
|
+
* levels.
|
|
219
|
+
* @see {@link PasswordStrength}
|
|
220
|
+
* @since 1.0.0
|
|
221
|
+
*/
|
|
222
|
+
export interface PasswordStrengthProps extends ComponentProps<"div"> {
|
|
223
|
+
/**
|
|
224
|
+
* Rates the password from `0` to `4`. Use it to plug in a stronger checker.
|
|
225
|
+
* @default scorePassword
|
|
226
|
+
*/
|
|
227
|
+
readonly evaluate?: ((password: string) => 0 | 1 | 2 | 3 | 4) | undefined;
|
|
228
|
+
/**
|
|
229
|
+
* The words for the five levels, from the weakest to the strongest.
|
|
230
|
+
* @default the UI strings password.level.0 to password.level.4 ("Very weak" to "Strong")
|
|
231
|
+
*/
|
|
232
|
+
readonly labels?: readonly string[] | undefined;
|
|
233
|
+
/**
|
|
234
|
+
* The accessible name of the meter.
|
|
235
|
+
* @default the UI string password.strength ("Password strength")
|
|
236
|
+
*/
|
|
237
|
+
readonly label?: string | undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The strength meter for the slot of a {@link PasswordInput}: four bars that fill with the level of the
|
|
242
|
+
* password, and the level as text. Put it inside the field.
|
|
243
|
+
*
|
|
244
|
+
* Keyboard and ARIA: not interactive. The bars are a `role="meter"` named "Password strength", with
|
|
245
|
+
* `aria-valuemin` 0, `aria-valuemax` 4, `aria-valuenow` and, once something is typed, `aria-valuetext`
|
|
246
|
+
* (the level word). The word is also shown in a polite status that the input's `aria-describedby` names,
|
|
247
|
+
* so the level is read when the field is focused and announced when it changes. An empty field shows
|
|
248
|
+
* level 0 and no word. The bars have forced-colors styles, and the color change is not the only cue: the
|
|
249
|
+
* number of filled bars and the word say the same.
|
|
250
|
+
*
|
|
251
|
+
* @param props See {@link PasswordStrengthProps}.
|
|
252
|
+
* @returns The meter.
|
|
253
|
+
* @throws Error when rendered outside a {@link PasswordInput}.
|
|
254
|
+
* @example
|
|
255
|
+
* import { PasswordInput, PasswordStrength } from "@/components/ui/password-input";
|
|
256
|
+
*
|
|
257
|
+
* export function NewPassword() {
|
|
258
|
+
* return (
|
|
259
|
+
* <PasswordInput aria-label="New password" autocomplete="new-password">
|
|
260
|
+
* <PasswordStrength />
|
|
261
|
+
* </PasswordInput>
|
|
262
|
+
* );
|
|
263
|
+
* }
|
|
264
|
+
* @see {@link scorePassword}
|
|
265
|
+
* @see {@link usePasswordInput}
|
|
266
|
+
* @since 1.0.0
|
|
267
|
+
*/
|
|
268
|
+
function PasswordStrength(props: PasswordStrengthProps): JSX.Element {
|
|
269
|
+
const field = passwordInputContext.use("PasswordStrength");
|
|
270
|
+
const t = useUiStrings();
|
|
271
|
+
const [local, rest] = splitProps(props, ["class", "evaluate", "labels", "label"]);
|
|
272
|
+
const textId = `${useBaseId()()}-strength`;
|
|
273
|
+
onMount(() => field.describe(textId));
|
|
274
|
+
const score = useMemo((): 0 | 1 | 2 | 3 | 4 => {
|
|
275
|
+
const evaluate = local.evaluate as ((password: string) => 0 | 1 | 2 | 3 | 4) | undefined;
|
|
276
|
+
return field.value() === "" ? 0 : (evaluate ?? scorePassword)(field.value());
|
|
277
|
+
});
|
|
278
|
+
const word = (): string => {
|
|
279
|
+
if (field.value() === "") return "";
|
|
280
|
+
const given = local.labels as readonly string[] | undefined;
|
|
281
|
+
if (given?.[score()] !== undefined) return given[score()] as string;
|
|
282
|
+
switch (score()) {
|
|
283
|
+
case 0:
|
|
284
|
+
return t("password.level.0", "Very weak");
|
|
285
|
+
case 1:
|
|
286
|
+
return t("password.level.1", "Weak");
|
|
287
|
+
case 2:
|
|
288
|
+
return t("password.level.2", "Fair");
|
|
289
|
+
case 3:
|
|
290
|
+
return t("password.level.3", "Good");
|
|
291
|
+
default:
|
|
292
|
+
return t("password.level.4", "Strong");
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
return (
|
|
296
|
+
<div
|
|
297
|
+
data-slot="password-strength"
|
|
298
|
+
data-score={score()}
|
|
299
|
+
class={cn("group/strength mt-2 grid gap-1.5", local.class)}
|
|
300
|
+
{...rest}
|
|
301
|
+
>
|
|
302
|
+
<div
|
|
303
|
+
role="meter"
|
|
304
|
+
aria-label={
|
|
305
|
+
(local.label as string | undefined) ?? t("password.strength", "Password strength")
|
|
306
|
+
}
|
|
307
|
+
aria-valuemin={0}
|
|
308
|
+
aria-valuemax={4}
|
|
309
|
+
aria-valuenow={score()}
|
|
310
|
+
aria-valuetext={word() === "" ? undefined : word()}
|
|
311
|
+
class="flex gap-1"
|
|
312
|
+
>
|
|
313
|
+
<For each={[1, 2, 3, 4]}>
|
|
314
|
+
{(level) => (
|
|
315
|
+
<span
|
|
316
|
+
data-slot="password-strength-segment"
|
|
317
|
+
data-active={field.value() !== "" && score() >= level ? "true" : "false"}
|
|
318
|
+
class="h-1.5 flex-1 rounded-full bg-muted transition-colors motion-reduce:transition-none data-[active=true]:bg-destructive group-data-[score=2]/strength:data-[active=true]:bg-chart-4 group-data-[score=3]/strength:data-[active=true]:bg-primary group-data-[score=4]/strength:data-[active=true]:bg-primary forced-colors:data-[active=true]:bg-[Highlight]"
|
|
319
|
+
/>
|
|
320
|
+
)}
|
|
321
|
+
</For>
|
|
322
|
+
</div>
|
|
323
|
+
<p
|
|
324
|
+
data-slot="password-strength-label"
|
|
325
|
+
id={textId}
|
|
326
|
+
role="status"
|
|
327
|
+
aria-live="polite"
|
|
328
|
+
class="min-h-4 text-xs text-muted-foreground"
|
|
329
|
+
>
|
|
330
|
+
{word()}
|
|
331
|
+
</p>
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A password field with a button that shows and hides the password. The input is a `password`
|
|
338
|
+
* field until the toggle shows it as text, and the icon changes with the state. It is built on the
|
|
339
|
+
* `PasswordToggleField` primitive, which owns the visibility and the toggle's ARIA. Name the input
|
|
340
|
+
* with a {@link Label} whose `htmlFor` points at the input's `id`.
|
|
341
|
+
*
|
|
342
|
+
* Children are the strength meter slot: put a {@link PasswordStrength}, or your own part that calls
|
|
343
|
+
* {@link usePasswordInput}, inside, and it is placed under the field. A Caps Lock hint appears under the
|
|
344
|
+
* field when it helps (see `capsLockHint`).
|
|
345
|
+
*
|
|
346
|
+
* Keyboard and ARIA: Tab reaches the toggle, which is a `button` with `type="button"`, so it never
|
|
347
|
+
* submits a form. Enter or Space flips the visibility. The toggle has `aria-pressed` (true while the
|
|
348
|
+
* password is shown) and `aria-controls`, which names the input. Its name is "Show password" unless
|
|
349
|
+
* you pass `toggleLabel`, and it keeps that name while the password is shown. The input has no name
|
|
350
|
+
* of its own: give it a label through `id` and `htmlFor`, or pass `aria-label`. `autocomplete`
|
|
351
|
+
* defaults to `current-password`; pass `new-password` on a sign-up form. The input also turns off
|
|
352
|
+
* `autocapitalize`, `spellcheck` and `autocorrect`, which would capitalize the first letter or send the
|
|
353
|
+
* word to a spelling service; pass your own values to change them. The words of the toggle, the hint
|
|
354
|
+
* and the meter come from the UI strings provider unless a prop sets them.
|
|
355
|
+
*
|
|
356
|
+
* The component does not store the password or send it anywhere: it only switches the input between
|
|
357
|
+
* `password` and `text`, and keeps the typed text in memory for the meter. A form around the field
|
|
358
|
+
* submits the value when the input has a `name`, as it does for any input.
|
|
359
|
+
*
|
|
360
|
+
* @param props The input's attributes, plus `visible`, `defaultVisible`, `onVisibleChange`,
|
|
361
|
+
* `toggleLabel` and `capsLockHint`. `class` styles the wrapper. `children` is the slot.
|
|
362
|
+
* @returns The wrapper `div` with the input and the toggle inside.
|
|
363
|
+
* @example
|
|
364
|
+
* import { Label } from "@/components/ui/label";
|
|
365
|
+
* import { PasswordInput, PasswordStrength } from "@/components/ui/password-input";
|
|
366
|
+
*
|
|
367
|
+
* <div>
|
|
368
|
+
* <Label htmlFor="password">Password</Label>
|
|
369
|
+
* <PasswordInput id="password" name="password" autocomplete="new-password">
|
|
370
|
+
* <PasswordStrength />
|
|
371
|
+
* </PasswordInput>
|
|
372
|
+
* </div>
|
|
373
|
+
* @since 1.0.0
|
|
374
|
+
* @see {@link PasswordInputProps}
|
|
375
|
+
*/
|
|
376
|
+
function PasswordInput(props: PasswordInputProps): JSX.Element {
|
|
377
|
+
const [local, rest] = splitProps(props as unknown as Loose, [
|
|
378
|
+
"class",
|
|
379
|
+
"id",
|
|
380
|
+
"visible",
|
|
381
|
+
"defaultVisible",
|
|
382
|
+
"onVisibleChange",
|
|
383
|
+
"toggleLabel",
|
|
384
|
+
"capsLockHint",
|
|
385
|
+
"disabled",
|
|
386
|
+
"children",
|
|
387
|
+
]);
|
|
388
|
+
const t = useUiStrings();
|
|
389
|
+
const [typed, setTyped] = useSignal("");
|
|
390
|
+
const [shown, setShown] = useSignal(local.defaultVisible === true);
|
|
391
|
+
const [caps, setCaps] = useSignal(false);
|
|
392
|
+
const [described, setDescribed] = useSignal<readonly string[]>([]);
|
|
393
|
+
const baseId = useBaseId()();
|
|
394
|
+
const hintId = `${baseId}-caps-lock`;
|
|
395
|
+
let element: HTMLInputElement | undefined;
|
|
396
|
+
const visible = (): boolean => local.visible ?? shown();
|
|
397
|
+
const value = (): string => (typeof rest["value"] === "string" ? rest["value"] : typed());
|
|
398
|
+
const showHint = (): boolean => local.capsLockHint !== false && caps() && !visible();
|
|
399
|
+
const update = (event: KeyboardEvent): void => {
|
|
400
|
+
setCaps(event.getModifierState?.("CapsLock") === true);
|
|
401
|
+
};
|
|
402
|
+
onMount(() => {
|
|
403
|
+
if (element === undefined) return undefined;
|
|
404
|
+
const input = element;
|
|
405
|
+
setTyped(input.value);
|
|
406
|
+
// A form reset puts the default value back after the event, with no `input` event of its own.
|
|
407
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
408
|
+
const reset = (): void => {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
timer = setTimeout(() => setTyped(input.value), 0);
|
|
411
|
+
};
|
|
412
|
+
input.form?.addEventListener("reset", reset);
|
|
413
|
+
return () => {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
input.form?.removeEventListener("reset", reset);
|
|
416
|
+
};
|
|
417
|
+
});
|
|
418
|
+
const describedBy = (): string | undefined => {
|
|
419
|
+
const ids = [
|
|
420
|
+
rest["aria-describedby"] as string | undefined,
|
|
421
|
+
...described(),
|
|
422
|
+
showHint() ? hintId : undefined,
|
|
423
|
+
].filter((id) => id !== undefined && id !== "");
|
|
424
|
+
return ids.length === 0 ? undefined : ids.join(" ");
|
|
425
|
+
};
|
|
426
|
+
const handlers = mergeElementProps(
|
|
427
|
+
{
|
|
428
|
+
onInput: (event: Event) => setTyped((event.target as HTMLInputElement).value),
|
|
429
|
+
onKeyDown: update,
|
|
430
|
+
onKeyUp: update,
|
|
431
|
+
onBlur: () => setCaps(false),
|
|
432
|
+
ref: (node: HTMLInputElement) => {
|
|
433
|
+
element = node;
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
rest,
|
|
437
|
+
);
|
|
438
|
+
return (
|
|
439
|
+
<passwordInputContext.Provider
|
|
440
|
+
value={{
|
|
441
|
+
value,
|
|
442
|
+
visible,
|
|
443
|
+
describe: (id) => {
|
|
444
|
+
setDescribed((ids) => [...ids, id]);
|
|
445
|
+
return () => setDescribed((ids) => ids.filter((known) => known !== id));
|
|
446
|
+
},
|
|
447
|
+
}}
|
|
448
|
+
>
|
|
449
|
+
<PasswordToggleField
|
|
450
|
+
data-slot="password-input"
|
|
451
|
+
class={cn("relative w-full", local.class)}
|
|
452
|
+
visible={local.visible}
|
|
453
|
+
defaultVisible={local.defaultVisible}
|
|
454
|
+
onVisibleChange={(next: boolean) => {
|
|
455
|
+
setShown(next);
|
|
456
|
+
local.onVisibleChange?.(next);
|
|
457
|
+
}}
|
|
458
|
+
disabled={local.disabled}
|
|
459
|
+
>
|
|
460
|
+
<PasswordToggleField.Input
|
|
461
|
+
autocapitalize="none"
|
|
462
|
+
spellcheck={false}
|
|
463
|
+
{...SAFARI_ATTRIBUTES}
|
|
464
|
+
{...handlers}
|
|
465
|
+
aria-describedby={describedBy()}
|
|
466
|
+
data-slot="password-input-field"
|
|
467
|
+
id={local.id}
|
|
468
|
+
class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent ps-3 pe-10 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40 motion-reduce:transition-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent"
|
|
469
|
+
/>
|
|
470
|
+
<PasswordToggleField.Toggle
|
|
471
|
+
data-slot="password-input-toggle"
|
|
472
|
+
aria-label={local.toggleLabel ?? t("password.show", "Show password")}
|
|
473
|
+
class="group absolute end-0 top-0 flex h-9 w-10 items-center justify-center rounded-e-md text-muted-foreground outline-none transition-[color,box-shadow] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none forced-colors:text-[ButtonText] focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-transparent"
|
|
474
|
+
>
|
|
475
|
+
<EyeIcon class="group-data-[state=visible]:hidden" />
|
|
476
|
+
<EyeOffIcon class="hidden group-data-[state=visible]:block" />
|
|
477
|
+
</PasswordToggleField.Toggle>
|
|
478
|
+
<Show when={showHint()}>
|
|
479
|
+
<p
|
|
480
|
+
data-slot="password-input-caps-lock"
|
|
481
|
+
id={hintId}
|
|
482
|
+
role="status"
|
|
483
|
+
class="mt-2 text-xs text-muted-foreground"
|
|
484
|
+
>
|
|
485
|
+
{t("password.capsLock", "Caps Lock is on")}
|
|
486
|
+
</p>
|
|
487
|
+
</Show>
|
|
488
|
+
{local.children}
|
|
489
|
+
</PasswordToggleField>
|
|
490
|
+
</passwordInputContext.Provider>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export { PasswordInput, PasswordStrength, scorePassword, usePasswordInput };
|