gbs-add-block 1.2.13 → 1.2.14
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/README.md +4 -12
- package/index.cjs +4 -0
- package/package.json +1 -1
- package/source/beta-components/dialog/README.md +39 -0
- package/source/beta-components/dialog/__tests__/core.test.ts +86 -0
- package/source/beta-components/dialog/core/api.ts +34 -0
- package/source/beta-components/dialog/core/dialog.ts +11 -0
- package/source/beta-components/dialog/core/index.ts +7 -0
- package/source/beta-components/dialog/core/store.ts +81 -0
- package/source/beta-components/dialog/core/types.ts +59 -0
- package/source/beta-components/dialog/index.ts +7 -0
- package/source/beta-components/dialog/react/Dialog.tsx +279 -0
- package/source/beta-components/dialog/react/DialogHost.tsx +45 -0
- package/source/beta-components/dialog/react/icons.tsx +51 -0
- package/source/beta-components/dialog/react/locale.ts +8 -0
- package/source/beta-components/dialog/react/props.ts +13 -0
- package/source/beta-components/dialog/styles.css +278 -0
- package/source/beta-components/input/README.md +44 -0
- package/source/beta-components/input/__tests__/core.test.ts +75 -0
- package/source/beta-components/input/core/count.ts +14 -0
- package/source/beta-components/input/core/index.ts +11 -0
- package/source/beta-components/input/core/otp.ts +64 -0
- package/source/beta-components/input/core/types.ts +14 -0
- package/source/beta-components/input/index.ts +8 -0
- package/source/beta-components/input/react/Input.tsx +219 -0
- package/source/beta-components/input/react/OtpInput.tsx +256 -0
- package/source/beta-components/input/react/dom.ts +10 -0
- package/source/beta-components/input/react/icons.tsx +35 -0
- package/source/beta-components/input/react/locale.ts +9 -0
- package/source/beta-components/input/react/props.ts +6 -0
- package/source/beta-components/input/styles.css +296 -0
- package/source/beta-components/modal/README.md +51 -0
- package/source/beta-components/modal/__tests__/core.test.ts +55 -0
- package/source/beta-components/modal/core/dismiss.ts +29 -0
- package/source/beta-components/modal/core/index.ts +3 -0
- package/source/beta-components/modal/core/types.ts +27 -0
- package/source/beta-components/modal/index.ts +5 -0
- package/source/beta-components/modal/react/Modal.tsx +238 -0
- package/source/beta-components/modal/react/icons.tsx +19 -0
- package/source/beta-components/modal/react/locale.ts +5 -0
- package/source/beta-components/modal/react/props.ts +17 -0
- package/source/beta-components/modal/styles.css +235 -0
- package/source/beta-components/textarea/README.md +39 -0
- package/source/beta-components/textarea/__tests__/core.test.ts +38 -0
- package/source/beta-components/textarea/core/count.ts +14 -0
- package/source/beta-components/textarea/core/index.ts +4 -0
- package/source/beta-components/textarea/core/size.ts +23 -0
- package/source/beta-components/textarea/core/types.ts +17 -0
- package/source/beta-components/textarea/index.ts +5 -0
- package/source/beta-components/textarea/react/Textarea.tsx +209 -0
- package/source/beta-components/textarea/react/locale.ts +5 -0
- package/source/beta-components/textarea/react/props.ts +4 -0
- package/source/beta-components/textarea/styles.css +159 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useSyncExternalStore, type CSSProperties } from "react";
|
|
4
|
+
import { dialogStore } from "../core/dialog";
|
|
5
|
+
import type { DialogStore } from "../core/store";
|
|
6
|
+
import type { DialogLocaleText, DialogRequest } from "../core/types";
|
|
7
|
+
import { Dialog } from "./Dialog";
|
|
8
|
+
import type { DialogSlot } from "./props";
|
|
9
|
+
|
|
10
|
+
export interface DialogHostProps {
|
|
11
|
+
/** A store from `createDialogStore()`. Defaults to the one behind `dialog`. */
|
|
12
|
+
store?: DialogStore;
|
|
13
|
+
/** Applied to every dialog this host shows. */
|
|
14
|
+
className?: string;
|
|
15
|
+
classNames?: Partial<Record<DialogSlot, string>>;
|
|
16
|
+
style?: CSSProperties;
|
|
17
|
+
localeText?: Partial<DialogLocaleText>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Mount once. Shows the dialogs requested through `dialog.alert/confirm/prompt`, one at a time. */
|
|
21
|
+
export function DialogHost({ store = dialogStore, ...shared }: DialogHostProps) {
|
|
22
|
+
const { queue } = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);
|
|
23
|
+
const current = queue[0];
|
|
24
|
+
|
|
25
|
+
// Keep the last dialog rendered while it animates out.
|
|
26
|
+
const [shown, setShown] = useState<DialogRequest | undefined>(current);
|
|
27
|
+
if (current && current !== shown) setShown(current);
|
|
28
|
+
|
|
29
|
+
if (!shown) return null;
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Dialog
|
|
33
|
+
key={shown.id}
|
|
34
|
+
{...shown.options}
|
|
35
|
+
{...shared}
|
|
36
|
+
open={shown === current}
|
|
37
|
+
kind={shown.kind}
|
|
38
|
+
onConfirm={shown.options.onConfirm}
|
|
39
|
+
onClose={(action, value) => store.resolve(shown.id, action === "confirm", value)}
|
|
40
|
+
onExited={() => {
|
|
41
|
+
if (store.getSnapshot().queue.length === 0) setShown(undefined);
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SVGProps } from "react";
|
|
2
|
+
|
|
3
|
+
type IconProps = SVGProps<SVGSVGElement>;
|
|
4
|
+
|
|
5
|
+
function Svg(props: IconProps) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
width="20"
|
|
9
|
+
height="20"
|
|
10
|
+
viewBox="0 0 24 24"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="currentColor"
|
|
13
|
+
strokeWidth="2"
|
|
14
|
+
strokeLinecap="round"
|
|
15
|
+
strokeLinejoin="round"
|
|
16
|
+
aria-hidden="true"
|
|
17
|
+
focusable="false"
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const InfoIcon = (p: IconProps) => (
|
|
24
|
+
<Svg {...p}>
|
|
25
|
+
<circle cx="12" cy="12" r="9" />
|
|
26
|
+
<path d="M12 11v5M12 7.5h.01" />
|
|
27
|
+
</Svg>
|
|
28
|
+
);
|
|
29
|
+
export const SuccessIcon = (p: IconProps) => (
|
|
30
|
+
<Svg {...p}>
|
|
31
|
+
<circle cx="12" cy="12" r="9" />
|
|
32
|
+
<path d="m8 12.5 2.5 2.5L16 9.5" />
|
|
33
|
+
</Svg>
|
|
34
|
+
);
|
|
35
|
+
export const WarningIcon = (p: IconProps) => (
|
|
36
|
+
<Svg {...p}>
|
|
37
|
+
<path d="M10.3 3.9 2.4 18a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
|
|
38
|
+
<path d="M12 9v4M12 17h.01" />
|
|
39
|
+
</Svg>
|
|
40
|
+
);
|
|
41
|
+
export const DangerIcon = (p: IconProps) => (
|
|
42
|
+
<Svg {...p}>
|
|
43
|
+
<circle cx="12" cy="12" r="9" />
|
|
44
|
+
<path d="M12 7.5v5.5M12 16.5h.01" />
|
|
45
|
+
</Svg>
|
|
46
|
+
);
|
|
47
|
+
export const SpinnerIcon = (p: IconProps) => (
|
|
48
|
+
<Svg width={14} height={14} {...p} className="dl-spinner">
|
|
49
|
+
<path d="M12 3a9 9 0 1 0 9 9" />
|
|
50
|
+
</Svg>
|
|
51
|
+
);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type DialogSlot =
|
|
2
|
+
| "root"
|
|
3
|
+
| "body"
|
|
4
|
+
| "icon"
|
|
5
|
+
| "title"
|
|
6
|
+
| "description"
|
|
7
|
+
| "input"
|
|
8
|
+
| "actions"
|
|
9
|
+
| "confirm"
|
|
10
|
+
| "cancel";
|
|
11
|
+
|
|
12
|
+
export const cx = (...names: (string | false | null | undefined)[]) =>
|
|
13
|
+
names.filter(Boolean).join(" ");
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Dialog styles (alert, confirm and prompt).
|
|
3
|
+
*
|
|
4
|
+
* Tokens read the DataGrid's --dg-* variables when they are set on an ancestor
|
|
5
|
+
* (set them on :root to share one theme) and fall back to the same palette
|
|
6
|
+
* otherwise. Rules live in the `components` layer, so utility classes passed
|
|
7
|
+
* through `className` / `classNames` override them.
|
|
8
|
+
*/
|
|
9
|
+
@layer theme, base, components, utilities;
|
|
10
|
+
|
|
11
|
+
@layer components {
|
|
12
|
+
.dl-root {
|
|
13
|
+
--dl-bg: var(--dg-bg, light-dark(#ffffff, #0b0b0e));
|
|
14
|
+
--dl-fg: var(--dg-fg, light-dark(#18181b, #f4f4f5));
|
|
15
|
+
--dl-muted: var(--dg-muted, light-dark(#71717a, #a1a1aa));
|
|
16
|
+
--dl-border: var(--dg-border, light-dark(#e4e4e7, #27272a));
|
|
17
|
+
--dl-hover: var(--dg-hover, light-dark(#f4f4f5, #1f1f23));
|
|
18
|
+
--dl-input-bg: var(--dg-input-bg, light-dark(#ffffff, #121216));
|
|
19
|
+
--dl-accent: var(--dg-accent, light-dark(#2563eb, #60a5fa));
|
|
20
|
+
--dl-accent-fg: var(--dg-accent-fg, light-dark(#ffffff, #0b1220));
|
|
21
|
+
--dl-focus: var(--dg-focus, light-dark(#2563eb, #60a5fa));
|
|
22
|
+
--dl-danger: var(--dg-danger, light-dark(#dc2626, #f87171));
|
|
23
|
+
--dl-danger-fg: light-dark(#ffffff, #1c0606);
|
|
24
|
+
--dl-success: light-dark(#16a34a, #4ade80);
|
|
25
|
+
--dl-warning: light-dark(#d97706, #fbbf24);
|
|
26
|
+
--dl-info: var(--dg-accent, light-dark(#2563eb, #60a5fa));
|
|
27
|
+
--dl-shadow: 0 24px 64px -16px light-dark(rgb(0 0 0 / 0.3), rgb(0 0 0 / 0.8));
|
|
28
|
+
--dl-backdrop: light-dark(rgb(9 9 11 / 0.45), rgb(0 0 0 / 0.65));
|
|
29
|
+
--dl-radius: var(--dg-radius, 12px);
|
|
30
|
+
--dl-font-size: var(--dg-font-size, 13px);
|
|
31
|
+
--dl-width: 420px;
|
|
32
|
+
--dl-intent: transparent;
|
|
33
|
+
--dl-duration: 200ms;
|
|
34
|
+
|
|
35
|
+
color-scheme: inherit;
|
|
36
|
+
box-sizing: border-box;
|
|
37
|
+
width: min(var(--dl-width), 100vw - 32px);
|
|
38
|
+
max-width: none;
|
|
39
|
+
max-height: calc(100dvh - 32px);
|
|
40
|
+
margin: auto;
|
|
41
|
+
padding: 0;
|
|
42
|
+
overflow: auto;
|
|
43
|
+
border: 1px solid var(--dl-border);
|
|
44
|
+
border-radius: var(--dl-radius);
|
|
45
|
+
background: var(--dl-bg);
|
|
46
|
+
color: var(--dl-fg);
|
|
47
|
+
box-shadow: var(--dl-shadow);
|
|
48
|
+
font-size: var(--dl-font-size);
|
|
49
|
+
line-height: 1.5;
|
|
50
|
+
opacity: 0;
|
|
51
|
+
scale: 0.96;
|
|
52
|
+
transition:
|
|
53
|
+
opacity var(--dl-duration) ease,
|
|
54
|
+
scale var(--dl-duration) ease,
|
|
55
|
+
overlay var(--dl-duration) allow-discrete,
|
|
56
|
+
display var(--dl-duration) allow-discrete;
|
|
57
|
+
}
|
|
58
|
+
.dl-root[open] {
|
|
59
|
+
opacity: 1;
|
|
60
|
+
scale: 1;
|
|
61
|
+
}
|
|
62
|
+
@starting-style {
|
|
63
|
+
.dl-root[open] {
|
|
64
|
+
opacity: 0;
|
|
65
|
+
scale: 0.96;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.dl-root::backdrop {
|
|
70
|
+
background: var(--dl-backdrop, rgb(9 9 11 / 0.45));
|
|
71
|
+
opacity: 0;
|
|
72
|
+
transition:
|
|
73
|
+
opacity var(--dl-duration) ease,
|
|
74
|
+
overlay var(--dl-duration) allow-discrete,
|
|
75
|
+
display var(--dl-duration) allow-discrete;
|
|
76
|
+
}
|
|
77
|
+
.dl-root[open]::backdrop {
|
|
78
|
+
opacity: 1;
|
|
79
|
+
}
|
|
80
|
+
@starting-style {
|
|
81
|
+
.dl-root[open]::backdrop {
|
|
82
|
+
opacity: 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
:where(.dark, [data-theme="dark"]) .dl-root {
|
|
87
|
+
color-scheme: dark;
|
|
88
|
+
}
|
|
89
|
+
:where(.light, [data-theme="light"]) .dl-root {
|
|
90
|
+
color-scheme: light;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
:where(.dl-root) *,
|
|
94
|
+
:where(.dl-root) *::before,
|
|
95
|
+
:where(.dl-root) *::after {
|
|
96
|
+
box-sizing: border-box;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
:root:has(.dl-root[open]) {
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.dl-root[data-size="md"] {
|
|
104
|
+
--dl-width: 540px;
|
|
105
|
+
}
|
|
106
|
+
.dl-root[data-intent="info"] {
|
|
107
|
+
--dl-intent: var(--dl-info);
|
|
108
|
+
}
|
|
109
|
+
.dl-root[data-intent="success"] {
|
|
110
|
+
--dl-intent: var(--dl-success);
|
|
111
|
+
}
|
|
112
|
+
.dl-root[data-intent="warning"] {
|
|
113
|
+
--dl-intent: var(--dl-warning);
|
|
114
|
+
}
|
|
115
|
+
.dl-root[data-intent="danger"] {
|
|
116
|
+
--dl-intent: var(--dl-danger);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ------------------------------------------------------------ content */
|
|
120
|
+
|
|
121
|
+
.dl-body {
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: flex-start;
|
|
124
|
+
gap: 14px;
|
|
125
|
+
padding: 20px 20px 16px;
|
|
126
|
+
}
|
|
127
|
+
.dl-icon {
|
|
128
|
+
display: grid;
|
|
129
|
+
flex: none;
|
|
130
|
+
place-items: center;
|
|
131
|
+
width: 38px;
|
|
132
|
+
height: 38px;
|
|
133
|
+
border-radius: 50%;
|
|
134
|
+
background: color-mix(in oklab, var(--dl-intent), transparent 86%);
|
|
135
|
+
color: var(--dl-intent);
|
|
136
|
+
}
|
|
137
|
+
.dl-text {
|
|
138
|
+
display: flex;
|
|
139
|
+
flex: 1 1 auto;
|
|
140
|
+
flex-direction: column;
|
|
141
|
+
gap: 6px;
|
|
142
|
+
min-width: 0;
|
|
143
|
+
}
|
|
144
|
+
.dl-title {
|
|
145
|
+
margin: 0;
|
|
146
|
+
font-size: 1.15em;
|
|
147
|
+
font-weight: 600;
|
|
148
|
+
line-height: 1.35;
|
|
149
|
+
overflow-wrap: anywhere;
|
|
150
|
+
}
|
|
151
|
+
.dl-description {
|
|
152
|
+
color: var(--dl-muted);
|
|
153
|
+
overflow-wrap: anywhere;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.dl-field {
|
|
157
|
+
display: flex;
|
|
158
|
+
flex-direction: column;
|
|
159
|
+
gap: 4px;
|
|
160
|
+
margin-top: 6px;
|
|
161
|
+
}
|
|
162
|
+
.dl-label {
|
|
163
|
+
font-weight: 500;
|
|
164
|
+
}
|
|
165
|
+
.dl-input {
|
|
166
|
+
width: 100%;
|
|
167
|
+
height: 36px;
|
|
168
|
+
padding: 0 10px;
|
|
169
|
+
border: 1px solid var(--dl-border);
|
|
170
|
+
border-radius: 8px;
|
|
171
|
+
background: var(--dl-input-bg);
|
|
172
|
+
color: inherit;
|
|
173
|
+
font: inherit;
|
|
174
|
+
}
|
|
175
|
+
.dl-input:focus-visible {
|
|
176
|
+
outline: 2px solid var(--dl-focus);
|
|
177
|
+
outline-offset: -1px;
|
|
178
|
+
}
|
|
179
|
+
.dl-input[aria-invalid="true"] {
|
|
180
|
+
border-color: var(--dl-danger);
|
|
181
|
+
outline-color: var(--dl-danger);
|
|
182
|
+
}
|
|
183
|
+
.dl-error {
|
|
184
|
+
margin: 0;
|
|
185
|
+
color: var(--dl-danger);
|
|
186
|
+
font-size: 0.92em;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ------------------------------------------------------------ actions */
|
|
190
|
+
|
|
191
|
+
.dl-actions {
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-wrap: wrap;
|
|
194
|
+
justify-content: flex-end;
|
|
195
|
+
gap: 8px;
|
|
196
|
+
padding: 12px 20px;
|
|
197
|
+
border-top: 1px solid var(--dl-border);
|
|
198
|
+
background: var(--dg-header-bg, light-dark(#fafafa, #111114));
|
|
199
|
+
}
|
|
200
|
+
.dl-button {
|
|
201
|
+
display: inline-flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
justify-content: center;
|
|
204
|
+
gap: 6px;
|
|
205
|
+
min-width: 80px;
|
|
206
|
+
height: 34px;
|
|
207
|
+
padding: 0 14px;
|
|
208
|
+
border: 1px solid var(--dl-border);
|
|
209
|
+
border-radius: 8px;
|
|
210
|
+
background: var(--dl-bg);
|
|
211
|
+
color: inherit;
|
|
212
|
+
font: inherit;
|
|
213
|
+
font-weight: 500;
|
|
214
|
+
white-space: nowrap;
|
|
215
|
+
cursor: pointer;
|
|
216
|
+
}
|
|
217
|
+
.dl-button:hover:not([aria-disabled="true"]) {
|
|
218
|
+
background: var(--dl-hover);
|
|
219
|
+
}
|
|
220
|
+
.dl-button[data-variant="primary"] {
|
|
221
|
+
border-color: var(--dl-accent);
|
|
222
|
+
background: var(--dl-accent);
|
|
223
|
+
color: var(--dl-accent-fg);
|
|
224
|
+
}
|
|
225
|
+
.dl-button[data-variant="danger"] {
|
|
226
|
+
border-color: var(--dl-danger);
|
|
227
|
+
background: var(--dl-danger);
|
|
228
|
+
color: var(--dl-danger-fg);
|
|
229
|
+
}
|
|
230
|
+
.dl-button[data-variant="primary"]:hover:not([aria-disabled="true"]) {
|
|
231
|
+
background: color-mix(in oklab, var(--dl-accent), var(--dl-fg) 12%);
|
|
232
|
+
}
|
|
233
|
+
.dl-button[data-variant="danger"]:hover:not([aria-disabled="true"]) {
|
|
234
|
+
background: color-mix(in oklab, var(--dl-danger), var(--dl-fg) 12%);
|
|
235
|
+
}
|
|
236
|
+
.dl-button:focus-visible {
|
|
237
|
+
outline: 2px solid var(--dl-focus);
|
|
238
|
+
outline-offset: 2px;
|
|
239
|
+
}
|
|
240
|
+
.dl-button[aria-disabled="true"] {
|
|
241
|
+
opacity: 0.65;
|
|
242
|
+
cursor: progress;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.dl-spinner {
|
|
246
|
+
animation: dl-spin 0.7s linear infinite;
|
|
247
|
+
}
|
|
248
|
+
@keyframes dl-spin {
|
|
249
|
+
to {
|
|
250
|
+
transform: rotate(360deg);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
@media (max-width: 480px) {
|
|
255
|
+
.dl-actions {
|
|
256
|
+
flex-direction: column-reverse;
|
|
257
|
+
}
|
|
258
|
+
.dl-button {
|
|
259
|
+
width: 100%;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
@media (prefers-reduced-motion: reduce) {
|
|
264
|
+
.dl-root,
|
|
265
|
+
.dl-root::backdrop {
|
|
266
|
+
transition-duration: 0s;
|
|
267
|
+
}
|
|
268
|
+
.dl-spinner {
|
|
269
|
+
animation-duration: 2s;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
@media (forced-colors: active) {
|
|
274
|
+
.dl-root {
|
|
275
|
+
border: 1px solid CanvasText;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Input and OtpInput
|
|
2
|
+
|
|
3
|
+
Text fields for React 19, styled to match the rest of the library. No runtime
|
|
4
|
+
dependencies besides React.
|
|
5
|
+
|
|
6
|
+
- **`<Input>`** — label, hint, error, leading/trailing adornments, clear button,
|
|
7
|
+
password reveal and a character counter. Every other prop goes to the `<input>`.
|
|
8
|
+
- **`<OtpInput>`** — one-time codes. A single real `<input>` drawn as cells, so SMS
|
|
9
|
+
autofill (`autocomplete="one-time-code"`), paste and screen readers work as for
|
|
10
|
+
one field.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { Input, OtpInput } from "@/components/input";
|
|
16
|
+
import "@/components/input/styles.css";
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
<Input label="Email" type="email" value={email} onValueChange={setEmail} clearable />
|
|
23
|
+
<Input label="Password" type="password" name="password" required />
|
|
24
|
+
<OtpInput label="Code" groups={[3, 3]} onComplete={verify} />
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Props
|
|
28
|
+
|
|
29
|
+
`Input`: all `<input>` attributes plus `value`, `defaultValue`, `onValueChange`,
|
|
30
|
+
`label`, `description`, `error`, `size` (`sm` `md` `lg`), `leading`, `trailing`,
|
|
31
|
+
`clearable`, `revealPassword` (default `true`), `showCount`, `classNames`
|
|
32
|
+
(`root` `label` `control` `input` `description` `error` `count`), `localeText`,
|
|
33
|
+
and `ref` (the `<input>`).
|
|
34
|
+
|
|
35
|
+
`OtpInput`: `length` (6), `mode` (`numeric` `alphanumeric` `alphabetic`),
|
|
36
|
+
`uppercase`, `value`, `defaultValue`, `onValueChange`, `onComplete`, `groups`,
|
|
37
|
+
`mask`, `label`, `description`, `error`, `size`, `name`, `required`, `disabled`,
|
|
38
|
+
`classNames` (`root` `label` `cells` `cell` `separator` `description` `error`),
|
|
39
|
+
`localeText`, and `ref` (the `<input>`).
|
|
40
|
+
|
|
41
|
+
## Headless use
|
|
42
|
+
|
|
43
|
+
`sanitizeOtp`, `otpPattern`, `otpInputMode`, `separatorsAfter`, `activeCell`,
|
|
44
|
+
`cellAt` and `countCharacters` are exported from the framework-free `core`.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { countCharacters } from "../core/count";
|
|
3
|
+
import {
|
|
4
|
+
activeCell,
|
|
5
|
+
cellAt,
|
|
6
|
+
otpInputMode,
|
|
7
|
+
otpPattern,
|
|
8
|
+
sanitizeOtp,
|
|
9
|
+
separatorsAfter,
|
|
10
|
+
} from "../core/otp";
|
|
11
|
+
|
|
12
|
+
describe("character count", () => {
|
|
13
|
+
it("counts what a person sees, not UTF-16 units", () => {
|
|
14
|
+
expect(countCharacters("")).toBe(0);
|
|
15
|
+
expect(countCharacters("hello")).toBe(5);
|
|
16
|
+
expect("👍🏽".length).toBe(4);
|
|
17
|
+
expect(countCharacters("👍🏽")).toBe(1);
|
|
18
|
+
expect(countCharacters("é")).toBe(1);
|
|
19
|
+
expect(countCharacters("👨👩👧 ok")).toBe(4);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("otp sanitizing", () => {
|
|
24
|
+
it("keeps digits only, up to the length", () => {
|
|
25
|
+
expect(sanitizeOtp("12a3", 6)).toBe("123");
|
|
26
|
+
expect(sanitizeOtp("Your code: 123-456", 6)).toBe("123456");
|
|
27
|
+
expect(sanitizeOtp("1234567890", 6)).toBe("123456");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("supports letters, with optional uppercasing", () => {
|
|
31
|
+
expect(sanitizeOtp("ab-12 cd", 6, "alphanumeric")).toBe("ab12cd");
|
|
32
|
+
expect(sanitizeOtp("ab-12 cd", 6, "alphanumeric", true)).toBe("AB12CD");
|
|
33
|
+
expect(sanitizeOtp("a1b2c3", 6, "alphabetic")).toBe("abc");
|
|
34
|
+
expect(sanitizeOtp("é1", 6, "alphanumeric")).toBe("1");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("describes the field for keyboards and native validation", () => {
|
|
38
|
+
expect(otpInputMode("numeric")).toBe("numeric");
|
|
39
|
+
expect(otpInputMode("alphanumeric")).toBe("text");
|
|
40
|
+
expect(otpPattern("numeric", 6)).toBe("\\d{6}");
|
|
41
|
+
expect(new RegExp(`^${otpPattern("alphanumeric", 4)}$`).test("aZ09")).toBe(true);
|
|
42
|
+
expect(new RegExp(`^${otpPattern("alphabetic", 4)}$`).test("ab1c")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("otp layout", () => {
|
|
47
|
+
it("places separators between groups", () => {
|
|
48
|
+
expect([...separatorsAfter([3, 3], 6)]).toEqual([2]);
|
|
49
|
+
expect([...separatorsAfter([2, 2, 2], 6)]).toEqual([1, 3]);
|
|
50
|
+
expect([...separatorsAfter(undefined, 6)]).toEqual([]);
|
|
51
|
+
expect([...separatorsAfter([6], 6)]).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("puts the caret where the next character goes, or on the last cell when full", () => {
|
|
55
|
+
expect(activeCell(0, 6, 0)).toBe(0);
|
|
56
|
+
expect(activeCell(3, 6, 3)).toBe(3);
|
|
57
|
+
expect(activeCell(3, 6, 1)).toBe(1);
|
|
58
|
+
expect(activeCell(3, 6, 5)).toBe(3);
|
|
59
|
+
expect(activeCell(6, 6, 6)).toBe(5);
|
|
60
|
+
expect(activeCell(6, 6, 2)).toBe(2);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("finds the cell under the pointer", () => {
|
|
64
|
+
const cells = [
|
|
65
|
+
{ left: 0, right: 40 },
|
|
66
|
+
{ left: 48, right: 88 },
|
|
67
|
+
{ left: 96, right: 136 },
|
|
68
|
+
];
|
|
69
|
+
expect(cellAt(cells, 10)).toBe(0);
|
|
70
|
+
expect(cellAt(cells, 44)).toBe(1);
|
|
71
|
+
expect(cellAt(cells, 120)).toBe(2);
|
|
72
|
+
expect(cellAt(cells, 500)).toBe(2);
|
|
73
|
+
expect(cellAt([], 10)).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
let segmenter: Intl.Segmenter | null | undefined;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Characters as a person counts them. `"👍🏽".length` is 4 and `"é"` can be 2,
|
|
5
|
+
* but each is one character here. Falls back to code points where
|
|
6
|
+
* `Intl.Segmenter` is missing.
|
|
7
|
+
*/
|
|
8
|
+
export function countCharacters(value: string): number {
|
|
9
|
+
if (!value) return 0;
|
|
10
|
+
if (segmenter === undefined) {
|
|
11
|
+
segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter() : null;
|
|
12
|
+
}
|
|
13
|
+
return segmenter ? Array.from(segmenter.segment(value)).length : Array.from(value).length;
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Framework-free: character counting and the OTP rules the components use.
|
|
2
|
+
export { countCharacters } from "./count";
|
|
3
|
+
export {
|
|
4
|
+
activeCell,
|
|
5
|
+
cellAt,
|
|
6
|
+
otpInputMode,
|
|
7
|
+
otpPattern,
|
|
8
|
+
sanitizeOtp,
|
|
9
|
+
separatorsAfter,
|
|
10
|
+
} from "./otp";
|
|
11
|
+
export type * from "./types";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { OtpMode } from "./types";
|
|
2
|
+
|
|
3
|
+
const ALLOWED: Record<OtpMode, RegExp> = {
|
|
4
|
+
numeric: /^\d$/,
|
|
5
|
+
alphanumeric: /^[a-z0-9]$/i,
|
|
6
|
+
alphabetic: /^[a-z]$/i,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keeps the characters a code may contain, up to `length`. Pasting
|
|
11
|
+
* `"Your code: 123-456"` into a numeric field gives `"123456"`.
|
|
12
|
+
*/
|
|
13
|
+
export function sanitizeOtp(
|
|
14
|
+
input: string,
|
|
15
|
+
length: number,
|
|
16
|
+
mode: OtpMode = "numeric",
|
|
17
|
+
uppercase = false,
|
|
18
|
+
): string {
|
|
19
|
+
let result = "";
|
|
20
|
+
for (const char of input) {
|
|
21
|
+
if (result.length >= length) break;
|
|
22
|
+
if (ALLOWED[mode].test(char)) result += uppercase ? char.toUpperCase() : char;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Numeric codes get the phone's number pad. */
|
|
28
|
+
export const otpInputMode = (mode: OtpMode): "numeric" | "text" =>
|
|
29
|
+
mode === "numeric" ? "numeric" : "text";
|
|
30
|
+
|
|
31
|
+
/** A `pattern` attribute, so native form validation knows a complete code. */
|
|
32
|
+
export function otpPattern(mode: OtpMode, length: number): string {
|
|
33
|
+
const characters =
|
|
34
|
+
mode === "numeric" ? "\\d" : mode === "alphabetic" ? "[A-Za-z]" : "[A-Za-z0-9]";
|
|
35
|
+
return `${characters}{${length}}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Cell indexes followed by a separator, from group sizes such as `[3, 3]`. */
|
|
39
|
+
export function separatorsAfter(groups: readonly number[] | undefined, length: number): Set<number> {
|
|
40
|
+
const result = new Set<number>();
|
|
41
|
+
if (!groups) return result;
|
|
42
|
+
let position = 0;
|
|
43
|
+
for (const size of groups.slice(0, -1)) {
|
|
44
|
+
position += size;
|
|
45
|
+
if (position > 0 && position < length) result.add(position - 1);
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The cell showing the caret: where the next character goes, or, once the code
|
|
52
|
+
* is full, the character that typing will replace.
|
|
53
|
+
*/
|
|
54
|
+
export function activeCell(valueLength: number, length: number, caret: number): number {
|
|
55
|
+
return Math.max(0, Math.min(caret, valueLength, length - 1));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The cell under a horizontal position, given each cell's left and right edges. */
|
|
59
|
+
export function cellAt(bounds: readonly { left: number; right: number }[], x: number): number {
|
|
60
|
+
for (let index = 0; index < bounds.length; index++) {
|
|
61
|
+
if (x <= bounds[index].right) return index;
|
|
62
|
+
}
|
|
63
|
+
return Math.max(0, bounds.length - 1);
|
|
64
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type FieldSize = "sm" | "md" | "lg";
|
|
2
|
+
|
|
3
|
+
/** Which characters an OTP cell accepts. */
|
|
4
|
+
export type OtpMode = "numeric" | "alphanumeric" | "alphabetic";
|
|
5
|
+
|
|
6
|
+
export interface InputLocaleText {
|
|
7
|
+
clear: string;
|
|
8
|
+
showPassword: string;
|
|
9
|
+
hidePassword: string;
|
|
10
|
+
/** `max` is set when the field has a `maxLength`. */
|
|
11
|
+
characterCount(count: string, max?: string): string;
|
|
12
|
+
/** Accessible name of the code field when no `label` is given. */
|
|
13
|
+
otpLabel: string;
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Input } from "./react/Input";
|
|
2
|
+
export type { InputProps } from "./react/Input";
|
|
3
|
+
export { OtpInput } from "./react/OtpInput";
|
|
4
|
+
export type { OtpInputProps } from "./react/OtpInput";
|
|
5
|
+
export { defaultInputText } from "./react/locale";
|
|
6
|
+
export { setNativeValue } from "./react/dom";
|
|
7
|
+
export type { InputSlot, OtpSlot } from "./react/props";
|
|
8
|
+
export * from "./core";
|