@usefui/components 1.5.1
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 +233 -0
- package/LICENSE +21 -0
- package/README.md +0 -0
- package/babel.config.js +12 -0
- package/dist/index.d.mts +1299 -0
- package/dist/index.d.ts +1299 -0
- package/dist/index.js +3701 -0
- package/dist/index.mjs +3586 -0
- package/package.json +44 -0
- package/src/__tests__/Accordion.test.tsx +106 -0
- package/src/__tests__/Avatar.test.tsx +89 -0
- package/src/__tests__/Badge.test.tsx +58 -0
- package/src/__tests__/Button.test.tsx +88 -0
- package/src/__tests__/Checkbox.test.tsx +106 -0
- package/src/__tests__/Collapsible.test.tsx +79 -0
- package/src/__tests__/Dialog.test.tsx +109 -0
- package/src/__tests__/Dropdown.test.tsx +159 -0
- package/src/__tests__/Field.test.tsx +100 -0
- package/src/__tests__/OTPField.test.tsx +199 -0
- package/src/__tests__/Overlay.test.tsx +70 -0
- package/src/__tests__/Page.test.tsx +98 -0
- package/src/__tests__/Portal.test.tsx +28 -0
- package/src/__tests__/Sheet.test.tsx +125 -0
- package/src/__tests__/Switch.test.tsx +90 -0
- package/src/__tests__/Tabs.test.tsx +129 -0
- package/src/__tests__/Toggle.test.tsx +67 -0
- package/src/__tests__/Toolbar.test.tsx +147 -0
- package/src/__tests__/Tooltip.test.tsx +88 -0
- package/src/accordion/Accordion.stories.tsx +89 -0
- package/src/accordion/hooks/index.tsx +39 -0
- package/src/accordion/index.tsx +170 -0
- package/src/avatar/Avatar.stories.tsx +62 -0
- package/src/avatar/index.tsx +90 -0
- package/src/avatar/styles/index.ts +79 -0
- package/src/badge/Badge.stories.tsx +60 -0
- package/src/badge/index.tsx +58 -0
- package/src/badge/styles/index.ts +109 -0
- package/src/button/Button.stories.tsx +47 -0
- package/src/button/index.tsx +79 -0
- package/src/button/styles/index.ts +180 -0
- package/src/checkbox/Checkbox.stories.tsx +100 -0
- package/src/checkbox/hooks/index.tsx +40 -0
- package/src/checkbox/index.tsx +147 -0
- package/src/checkbox/styles/index.ts +139 -0
- package/src/collapsible/Collapsible.stories.tsx +95 -0
- package/src/collapsible/hooks/index.tsx +50 -0
- package/src/collapsible/index.tsx +137 -0
- package/src/dialog/Dialog.stories.tsx +73 -0
- package/src/dialog/hooks/index.tsx +35 -0
- package/src/dialog/index.tsx +221 -0
- package/src/dialog/styles/index.ts +72 -0
- package/src/divider/index.ts +10 -0
- package/src/dropdown/Dropdown.stories.tsx +100 -0
- package/src/dropdown/hooks/index.tsx +64 -0
- package/src/dropdown/index.tsx +316 -0
- package/src/dropdown/styles/index.ts +90 -0
- package/src/field/Field.stories.tsx +146 -0
- package/src/field/hooks/index.tsx +28 -0
- package/src/field/index.tsx +183 -0
- package/src/field/styles/index.ts +166 -0
- package/src/index.ts +33 -0
- package/src/otp-field/OTPField.stories.tsx +50 -0
- package/src/otp-field/hooks/index.tsx +13 -0
- package/src/otp-field/index.tsx +234 -0
- package/src/otp-field/styles/index.ts +33 -0
- package/src/otp-field/types/index.ts +23 -0
- package/src/overlay/Overlay.stories.tsx +59 -0
- package/src/overlay/index.tsx +58 -0
- package/src/overlay/styles/index.ts +26 -0
- package/src/page/Page.stories.tsx +85 -0
- package/src/page/index.tsx +265 -0
- package/src/page/styles/index.ts +59 -0
- package/src/portal/Portal.stories.tsx +27 -0
- package/src/portal/index.tsx +36 -0
- package/src/scrollarea/Scrollarea.stories.tsx +99 -0
- package/src/scrollarea/index.tsx +27 -0
- package/src/scrollarea/styles/index.ts +71 -0
- package/src/sheet/Sheet.stories.tsx +86 -0
- package/src/sheet/hooks/index.tsx +47 -0
- package/src/sheet/index.tsx +190 -0
- package/src/sheet/styles/index.ts +69 -0
- package/src/switch/Switch.stories.tsx +96 -0
- package/src/switch/hooks/index.tsx +33 -0
- package/src/switch/index.tsx +122 -0
- package/src/switch/styles/index.ts +118 -0
- package/src/table/index.tsx +138 -0
- package/src/table/styles/index.ts +48 -0
- package/src/tabs/Tabs.stories.tsx +87 -0
- package/src/tabs/hooks/index.tsx +35 -0
- package/src/tabs/index.tsx +161 -0
- package/src/tabs/styles/index.ts +9 -0
- package/src/toggle/Toggle.stories.tsx +118 -0
- package/src/toggle/index.tsx +55 -0
- package/src/toggle/styles/index.ts +0 -0
- package/src/toolbar/Toolbar.stories.tsx +89 -0
- package/src/toolbar/hooks/index.tsx +35 -0
- package/src/toolbar/index.tsx +243 -0
- package/src/toolbar/styles/index.ts +129 -0
- package/src/tooltip/Tooltip.stories.tsx +60 -0
- package/src/tooltip/index.tsx +177 -0
- package/src/tooltip/styles/index.ts +38 -0
- package/src/utils/index.ts +2 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { FieldProvider, useField } from "./hooks";
|
|
5
|
+
import { Fieldset, Sup, Input, Label, Def } from "./styles";
|
|
6
|
+
import {
|
|
7
|
+
IReactChildren,
|
|
8
|
+
IComponentStyling,
|
|
9
|
+
ComponentSizeEnum,
|
|
10
|
+
IComponentSize,
|
|
11
|
+
ComponentVariantEnum,
|
|
12
|
+
IComponentVariant,
|
|
13
|
+
} from "../../../../types";
|
|
14
|
+
|
|
15
|
+
export enum MetaVariantEnum {
|
|
16
|
+
Default = "default",
|
|
17
|
+
Hint = "hint",
|
|
18
|
+
Emphasis = "emphasis",
|
|
19
|
+
Error = "error",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type TMetaVariant = "default" | "hint" | "emphasis" | "error";
|
|
23
|
+
|
|
24
|
+
export interface IField
|
|
25
|
+
extends React.ComponentProps<"input">,
|
|
26
|
+
IComponentSize,
|
|
27
|
+
IComponentVariant,
|
|
28
|
+
IComponentStyling {
|
|
29
|
+
hint?: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface IFieldLabel
|
|
33
|
+
extends React.ComponentProps<"label">,
|
|
34
|
+
IComponentStyling {
|
|
35
|
+
optional?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface IFieldMeta
|
|
38
|
+
extends React.ComponentProps<"small">,
|
|
39
|
+
IComponentStyling {
|
|
40
|
+
variant?: TMetaVariant;
|
|
41
|
+
}
|
|
42
|
+
export interface IFieldComposition {
|
|
43
|
+
Root: typeof FieldRoot;
|
|
44
|
+
Wrapper: typeof FieldWrapper;
|
|
45
|
+
Label: typeof FieldLabel;
|
|
46
|
+
Meta: typeof FieldMeta;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fields are input element that provides additional functionality such as error and hint messages.
|
|
51
|
+
*
|
|
52
|
+
* **Best practices:**
|
|
53
|
+
*
|
|
54
|
+
* - Provide clear and descriptive labels for all input elements.
|
|
55
|
+
* - Ensure that error and hint messages are visible and easily identifiable by users.
|
|
56
|
+
*
|
|
57
|
+
* @param {IField} props - The props for the Field component.
|
|
58
|
+
* @param {boolean} props.raw - Define whether the component is styled or not.
|
|
59
|
+
* @param {ComponentSizeEnum} props.sizing - The size of the component. Defaults to `medium`.
|
|
60
|
+
* @param {string} props.variant - The style definition used by the component.
|
|
61
|
+
* @param {string} props.error - The error message to display.
|
|
62
|
+
* @param {string} props.hint - The hint message to display.
|
|
63
|
+
* @returns {ReactElement} The Field component.
|
|
64
|
+
*/
|
|
65
|
+
const Field = (props: IField) => {
|
|
66
|
+
const {
|
|
67
|
+
raw,
|
|
68
|
+
sizing = ComponentSizeEnum.Medium,
|
|
69
|
+
variant = ComponentVariantEnum.Primary,
|
|
70
|
+
error,
|
|
71
|
+
hint,
|
|
72
|
+
...restProps
|
|
73
|
+
} = props;
|
|
74
|
+
|
|
75
|
+
const metaId = React.useId();
|
|
76
|
+
const { id } = useField();
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<>
|
|
80
|
+
<Input
|
|
81
|
+
id={id}
|
|
82
|
+
aria-invalid={!!error}
|
|
83
|
+
aria-describedby={metaId}
|
|
84
|
+
aria-errormessage={error}
|
|
85
|
+
data-error={Boolean(error)}
|
|
86
|
+
data-variant={variant}
|
|
87
|
+
data-size={sizing}
|
|
88
|
+
data-raw={Boolean(raw)}
|
|
89
|
+
{...restProps}
|
|
90
|
+
/>
|
|
91
|
+
{(error ?? hint) && (
|
|
92
|
+
<FieldMeta
|
|
93
|
+
raw={raw}
|
|
94
|
+
data-variant={error ? MetaVariantEnum.Error : MetaVariantEnum.Hint}
|
|
95
|
+
>
|
|
96
|
+
{error ?? hint}
|
|
97
|
+
</FieldMeta>
|
|
98
|
+
)}
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
Field.displayName = "Field";
|
|
103
|
+
|
|
104
|
+
const FieldRoot = ({ children }: IReactChildren) => {
|
|
105
|
+
return <FieldProvider>{children}</FieldProvider>;
|
|
106
|
+
};
|
|
107
|
+
FieldRoot.displayName = "Field.Root";
|
|
108
|
+
|
|
109
|
+
const FieldWrapper = ({
|
|
110
|
+
children,
|
|
111
|
+
...restProps
|
|
112
|
+
}: IReactChildren & React.ComponentProps<"fieldset">) => {
|
|
113
|
+
return <Fieldset {...restProps}>{children}</Fieldset>;
|
|
114
|
+
};
|
|
115
|
+
FieldWrapper.displayName = "Field.Wrapper";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Labels are component used to describe the expected value of an input.
|
|
119
|
+
*
|
|
120
|
+
* **Best practices:**
|
|
121
|
+
*
|
|
122
|
+
* - Provide a clear and descriptive label for each input.
|
|
123
|
+
* - The `required` criteria of an input must be reflected in the label.
|
|
124
|
+
*
|
|
125
|
+
* @param {IFieldLabel} props - The props for the Field.Label component.
|
|
126
|
+
* @param {boolean} props.raw - Define whether the component is styled or not.
|
|
127
|
+
* @param {boolean} props.optional - Whether the form field is required or not.
|
|
128
|
+
* @param {string} props.children - The label text.
|
|
129
|
+
* @returns {ReactElement} The Field.Label component.
|
|
130
|
+
*/
|
|
131
|
+
const FieldLabel = (props: IFieldLabel) => {
|
|
132
|
+
const { raw, optional = false, children, ...restProps } = props;
|
|
133
|
+
const { id } = useField();
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<Label htmlFor={id} data-raw={Boolean(raw)} {...restProps}>
|
|
137
|
+
{children}
|
|
138
|
+
{!optional && <Sup>*</Sup>}
|
|
139
|
+
</Label>
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
FieldLabel.displayName = "Field.Label";
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Meta are component used to bring more context about an input's usage.
|
|
146
|
+
*
|
|
147
|
+
* @param {IFieldMeta} props - The props for the Field.Meta component.
|
|
148
|
+
* @param {boolean} props.raw - Define whether the component is styled or not.
|
|
149
|
+
* @param {TMetaVariant} props.variant - The style definition used by the component.
|
|
150
|
+
* @param {string} props.children - The meta text.
|
|
151
|
+
* @returns {ReactElement} The Field.Meta component.
|
|
152
|
+
*/
|
|
153
|
+
const FieldMeta = (props: IFieldMeta) => {
|
|
154
|
+
const {
|
|
155
|
+
raw,
|
|
156
|
+
variant = MetaVariantEnum.Emphasis,
|
|
157
|
+
children,
|
|
158
|
+
...restProps
|
|
159
|
+
} = props;
|
|
160
|
+
|
|
161
|
+
const metaId = React.useId();
|
|
162
|
+
const { id } = useField();
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<Def
|
|
166
|
+
id={metaId}
|
|
167
|
+
aria-details={id}
|
|
168
|
+
data-variant={variant}
|
|
169
|
+
data-raw={Boolean(raw)}
|
|
170
|
+
{...restProps}
|
|
171
|
+
>
|
|
172
|
+
{children}
|
|
173
|
+
</Def>
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
FieldMeta.displayName = "Field.Meta";
|
|
177
|
+
|
|
178
|
+
Field.Root = FieldRoot;
|
|
179
|
+
Field.Wrapper = FieldWrapper;
|
|
180
|
+
Field.Label = FieldLabel;
|
|
181
|
+
Field.Meta = FieldMeta;
|
|
182
|
+
|
|
183
|
+
export { Field, FieldRoot, FieldWrapper, FieldLabel, FieldMeta };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import styled, { css } from "styled-components";
|
|
2
|
+
|
|
3
|
+
const FieldDefaultStyles = css`
|
|
4
|
+
outline: none;
|
|
5
|
+
cursor: pointer;
|
|
6
|
+
display: flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
|
|
10
|
+
font-size: var(--fontsize-small-80);
|
|
11
|
+
font-weight: 500;
|
|
12
|
+
line-height: 1.1;
|
|
13
|
+
letter-spacing: calc(
|
|
14
|
+
var(--fontsize-small-10) - ((var(--fontsize-small-10) * 1.066))
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
border: var(--measurement-small-10) solid transparent;
|
|
18
|
+
border-radius: var(--measurement-medium-30);
|
|
19
|
+
backdrop-filter: blur(var(--measurement-small-10));
|
|
20
|
+
color: var(--font-color-alpha-60);
|
|
21
|
+
width: fit-content;
|
|
22
|
+
height: fit-content;
|
|
23
|
+
|
|
24
|
+
transition: all ease-in-out 0.2s;
|
|
25
|
+
|
|
26
|
+
svg,
|
|
27
|
+
span,
|
|
28
|
+
img {
|
|
29
|
+
opacity: 0.6;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
&:hover,
|
|
33
|
+
&:focus,
|
|
34
|
+
&:active {
|
|
35
|
+
color: var(--font-color);
|
|
36
|
+
|
|
37
|
+
svg,
|
|
38
|
+
span,
|
|
39
|
+
img {
|
|
40
|
+
opacity: 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
&::placeholder {
|
|
44
|
+
color: var(--font-color-alpha-30);
|
|
45
|
+
}
|
|
46
|
+
&:disabled {
|
|
47
|
+
cursor: not-allowed;
|
|
48
|
+
opacity: 0.6;
|
|
49
|
+
}
|
|
50
|
+
`;
|
|
51
|
+
const FieldVariantsStyles = css`
|
|
52
|
+
&[data-variant="primary"] {
|
|
53
|
+
background-color: var(--font-color-alpha-10);
|
|
54
|
+
|
|
55
|
+
&[data-error="true"] {
|
|
56
|
+
color: var(--color-red);
|
|
57
|
+
background-color: var(--alpha-red-10);
|
|
58
|
+
border-color: var(--alpha-red-10);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
&[data-variant="secondary"] {
|
|
63
|
+
background-color: transparent;
|
|
64
|
+
border-color: var(--font-color-alpha-10);
|
|
65
|
+
|
|
66
|
+
&:hover,
|
|
67
|
+
&:focus,
|
|
68
|
+
&:active {
|
|
69
|
+
background-color: var(--font-color-alpha-10);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
&[data-error="true"] {
|
|
73
|
+
color: var(--color-red);
|
|
74
|
+
border-color: var(--alpha-red-10);
|
|
75
|
+
|
|
76
|
+
&:hover,
|
|
77
|
+
&:focus,
|
|
78
|
+
&:active {
|
|
79
|
+
background-color: var(--alpha-red-10);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
&[data-variant="ghost"] {
|
|
85
|
+
padding: 0;
|
|
86
|
+
border: none;
|
|
87
|
+
background-color: transparent;
|
|
88
|
+
min-width: fit-content;
|
|
89
|
+
min-height: var(--measurement-medium-60);
|
|
90
|
+
color: var(--font-color-alpha-60);
|
|
91
|
+
|
|
92
|
+
&:hover,
|
|
93
|
+
&:focus,
|
|
94
|
+
&:active {
|
|
95
|
+
color: var(--font-color);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&[data-error="true"] {
|
|
99
|
+
color: var(--color-red);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
const FieldSizeStyles = css`
|
|
104
|
+
&[data-size="small"] {
|
|
105
|
+
gap: var(--measurement-medium-10);
|
|
106
|
+
padding: 0 var(--measurement-medium-30);
|
|
107
|
+
min-width: var(--measurement-medium-60);
|
|
108
|
+
min-height: var(--measurement-medium-80);
|
|
109
|
+
}
|
|
110
|
+
&[data-size="medium"] {
|
|
111
|
+
gap: var(--measurement-medium-30);
|
|
112
|
+
padding: 0 var(--measurement-medium-30);
|
|
113
|
+
min-width: var(--measurement-medium-90);
|
|
114
|
+
min-height: var(--measurement-medium-90);
|
|
115
|
+
width: fit-content;
|
|
116
|
+
}
|
|
117
|
+
&[data-size="large"] {
|
|
118
|
+
padding: var(--measurement-medium-20) var(--measurement-medium-40);
|
|
119
|
+
min-width: var(--measurement-medium-90);
|
|
120
|
+
min-height: var(--measurement-medium-90);
|
|
121
|
+
}
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
export const Fieldset = styled.fieldset<any>`
|
|
125
|
+
all: unset;
|
|
126
|
+
display: grid;
|
|
127
|
+
gap: var(--measurement-medium-10);
|
|
128
|
+
`;
|
|
129
|
+
export const Sup = styled.sup`
|
|
130
|
+
color: var(--color-red);
|
|
131
|
+
`;
|
|
132
|
+
export const Input = styled.input<any>`
|
|
133
|
+
&[data-raw="false"] {
|
|
134
|
+
${FieldDefaultStyles}
|
|
135
|
+
${FieldVariantsStyles}
|
|
136
|
+
${FieldSizeStyles}
|
|
137
|
+
|
|
138
|
+
&[data-error="true"] {
|
|
139
|
+
&::placeholder {
|
|
140
|
+
color: var(--alpha-red-30);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
`;
|
|
145
|
+
export const Label = styled.label<any>`
|
|
146
|
+
&[data-raw="false"] {
|
|
147
|
+
font-weight: 500;
|
|
148
|
+
line-height: 1.1;
|
|
149
|
+
letter-spacing: calc(
|
|
150
|
+
var(--fontsize-small-10) - ((var(--fontsize-small-10) * 1.066))
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
`;
|
|
154
|
+
export const Def = styled.dfn<any>`
|
|
155
|
+
&[data-raw="false"] {
|
|
156
|
+
font-style: normal;
|
|
157
|
+
font-size: var(--fontsize-medium-10);
|
|
158
|
+
|
|
159
|
+
&[data-variant="hint"] {
|
|
160
|
+
color: var(--font-color-alpha-30);
|
|
161
|
+
}
|
|
162
|
+
&[data-variant="error"] {
|
|
163
|
+
color: var(--color-red);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
`;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export * from "./accordion";
|
|
2
|
+
export * from "./avatar";
|
|
3
|
+
export * from "./badge";
|
|
4
|
+
export * from "./button";
|
|
5
|
+
export * from "./checkbox";
|
|
6
|
+
export * from "./collapsible";
|
|
7
|
+
export * from "./dialog";
|
|
8
|
+
export * from "./divider";
|
|
9
|
+
export * from "./dropdown";
|
|
10
|
+
export * from "./field";
|
|
11
|
+
export * from "./otp-field";
|
|
12
|
+
export * from "./overlay";
|
|
13
|
+
export * from "./page";
|
|
14
|
+
export * from "./portal";
|
|
15
|
+
export * from "./sheet";
|
|
16
|
+
export * from "./scrollarea";
|
|
17
|
+
export * from "./switch";
|
|
18
|
+
export * from "./table";
|
|
19
|
+
export * from "./tabs";
|
|
20
|
+
export * from "./toggle";
|
|
21
|
+
export * from "./toolbar";
|
|
22
|
+
export * from "./tooltip";
|
|
23
|
+
|
|
24
|
+
export { useAccordion } from "./accordion/hooks";
|
|
25
|
+
export { useCheckbox } from "./checkbox/hooks";
|
|
26
|
+
export { useCollapsible } from "./collapsible/hooks";
|
|
27
|
+
export { useDialog } from "./dialog/hooks";
|
|
28
|
+
export { useDropdownMenu } from "./dropdown/hooks";
|
|
29
|
+
export { useField } from "./field/hooks";
|
|
30
|
+
export { useSheet } from "./sheet/hooks";
|
|
31
|
+
export { useSwitch } from "./switch/hooks";
|
|
32
|
+
export { useTabs } from "./tabs/hooks";
|
|
33
|
+
export { useToolbar } from "./toolbar/hooks";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
|
|
4
|
+
import { Field, OTPField, Page } from "..";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Components/OTPField",
|
|
8
|
+
component: OTPField,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
decorators: [
|
|
11
|
+
(Story) => (
|
|
12
|
+
<div className="m-medium-30">
|
|
13
|
+
<Story />
|
|
14
|
+
</div>
|
|
15
|
+
),
|
|
16
|
+
],
|
|
17
|
+
} satisfies Meta<typeof OTPField>;
|
|
18
|
+
export default meta;
|
|
19
|
+
|
|
20
|
+
export const Default = {
|
|
21
|
+
args: {},
|
|
22
|
+
argTypes: {},
|
|
23
|
+
render: () => {
|
|
24
|
+
const [value, setValue] = React.useState("");
|
|
25
|
+
|
|
26
|
+
const handleComplete = React.useCallback((value: string) => {
|
|
27
|
+
setValue(value.trim());
|
|
28
|
+
}, []);
|
|
29
|
+
return (
|
|
30
|
+
<Page>
|
|
31
|
+
<Page.Content>
|
|
32
|
+
<form aria-label="story-form">
|
|
33
|
+
<Field.Wrapper className="w-100">
|
|
34
|
+
<Field.Label>Confirmation code</Field.Label>
|
|
35
|
+
<OTPField length={6} onComplete={handleComplete}>
|
|
36
|
+
<OTPField.Group>
|
|
37
|
+
{Array.from({ length: 6 }).map((_, index) => (
|
|
38
|
+
<OTPField.Slot key={index} index={index} />
|
|
39
|
+
))}
|
|
40
|
+
</OTPField.Group>
|
|
41
|
+
</OTPField>
|
|
42
|
+
|
|
43
|
+
{value}
|
|
44
|
+
</Field.Wrapper>
|
|
45
|
+
</form>
|
|
46
|
+
</Page.Content>
|
|
47
|
+
</Page>
|
|
48
|
+
);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import type { OTPFieldContextType } from "../types";
|
|
4
|
+
|
|
5
|
+
export const OTPFieldContext = React.createContext<OTPFieldContextType | null>(
|
|
6
|
+
null
|
|
7
|
+
);
|
|
8
|
+
export const useOTPField = () => {
|
|
9
|
+
const context = React.useContext(OTPFieldContext);
|
|
10
|
+
|
|
11
|
+
if (!context) return null;
|
|
12
|
+
return context;
|
|
13
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
import { useOTPField, OTPFieldContext } from "./hooks";
|
|
6
|
+
import { OTPCell } from "./styles";
|
|
7
|
+
|
|
8
|
+
import type { OTPFieldProps, OTPFieldSlotProps } from "./types";
|
|
9
|
+
|
|
10
|
+
export interface IOTPFieldComposition {
|
|
11
|
+
Slot: typeof OTPFieldSlot;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A field for entering one-time passwords.
|
|
16
|
+
* This component holds the state and logic for the entire OTP input group.
|
|
17
|
+
*
|
|
18
|
+
* @param {OTPFieldProps} props - The props for the OTPField component.
|
|
19
|
+
* @param {ReactNode} props.children - The content to be rendered, typically OTPField.Group.
|
|
20
|
+
* @param {number} [props.length=6] - The number of characters in the OTP.
|
|
21
|
+
* @param {function} [props.onComplete] - Callback fired when all inputs are filled.
|
|
22
|
+
* @returns {ReactElement} The OTPField component.
|
|
23
|
+
*/
|
|
24
|
+
const OTPField = ({ children, length, onComplete }: OTPFieldProps) => {
|
|
25
|
+
const defaultLength = length ?? 6;
|
|
26
|
+
|
|
27
|
+
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
|
|
28
|
+
const [otp, setOtp] = React.useState<string[]>(
|
|
29
|
+
Array.from({ length: defaultLength }, () => "")
|
|
30
|
+
);
|
|
31
|
+
const [activeIndex, setActiveIndex] = React.useState<number>(0);
|
|
32
|
+
|
|
33
|
+
const handleChange = (index: number, value: string) => {
|
|
34
|
+
const newOtp = [...otp];
|
|
35
|
+
newOtp[index] = value.substring(value.length - 1);
|
|
36
|
+
setOtp(newOtp);
|
|
37
|
+
|
|
38
|
+
if (value && index < defaultLength - 1) {
|
|
39
|
+
setActiveIndex(index + 1);
|
|
40
|
+
inputRefs.current[index + 1]?.focus();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
|
|
45
|
+
/**
|
|
46
|
+
* Keyboard Navigation Behavior for OTP Field:
|
|
47
|
+
*
|
|
48
|
+
* Backspace/Delete:
|
|
49
|
+
* - If current slot has a value: clear it and stay in current slot
|
|
50
|
+
* - If current slot is empty: move to previous slot and clear its value
|
|
51
|
+
*
|
|
52
|
+
* Arrow Keys, Home, End:
|
|
53
|
+
* - Prevent navigation when current slot is empty
|
|
54
|
+
* - Allow navigation only when current slot has a value
|
|
55
|
+
*
|
|
56
|
+
* Tab:
|
|
57
|
+
* - Prevent forward navigation (Tab) when current slot is empty
|
|
58
|
+
* - Allow backward navigation (Shift+Tab) regardless of slot state
|
|
59
|
+
**/
|
|
60
|
+
|
|
61
|
+
const enabledBehaviorKeys = ["Backspace", "Delete"];
|
|
62
|
+
const disabledBehaviorKeys = [
|
|
63
|
+
"Tab",
|
|
64
|
+
"ArrowLeft",
|
|
65
|
+
"ArrowRight",
|
|
66
|
+
"Home",
|
|
67
|
+
"End",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
if (enabledBehaviorKeys.includes(e.key)) {
|
|
71
|
+
if (otp[index]) {
|
|
72
|
+
const newOtp = [...otp];
|
|
73
|
+
newOtp[index] = "";
|
|
74
|
+
setOtp(newOtp);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (index > 0) {
|
|
79
|
+
setActiveIndex(index - 1);
|
|
80
|
+
inputRefs.current[index - 1]?.focus();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (disabledBehaviorKeys.includes(e.key) && !otp[index]) {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const handleFocus = (index: number) => setActiveIndex(index);
|
|
92
|
+
|
|
93
|
+
const handlePaste = (e: React.ClipboardEvent) => {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
const pasteData = e.clipboardData.getData("text");
|
|
96
|
+
const pasteArray = pasteData.slice(0, defaultLength).split("");
|
|
97
|
+
|
|
98
|
+
const newOtp = [...otp];
|
|
99
|
+
pasteArray.forEach((char, index) => {
|
|
100
|
+
if (index < defaultLength) newOtp[index] = char;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
setOtp(newOtp);
|
|
104
|
+
|
|
105
|
+
const nextIndex = Math.min(pasteArray.length, defaultLength - 1);
|
|
106
|
+
setActiveIndex(nextIndex);
|
|
107
|
+
inputRefs.current[nextIndex]?.focus();
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleClick = () => {
|
|
111
|
+
const firstEmptyIndex = otp.findIndex((digit) => digit === "");
|
|
112
|
+
const targetIndex =
|
|
113
|
+
firstEmptyIndex === -1 ? defaultLength - 1 : firstEmptyIndex;
|
|
114
|
+
|
|
115
|
+
setActiveIndex(targetIndex);
|
|
116
|
+
inputRefs.current[targetIndex]?.focus();
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* setTimeout is used to prevent the input from being selected
|
|
120
|
+
* when the focus is set and is set to 0 to select the correct slot immediately
|
|
121
|
+
*/
|
|
122
|
+
const timeout = setTimeout(
|
|
123
|
+
() => inputRefs.current[targetIndex]?.select(),
|
|
124
|
+
0
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
return () => clearTimeout(timeout);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
React.useEffect(() => {
|
|
131
|
+
const otpString = otp.join("");
|
|
132
|
+
if (otpString.length === defaultLength && onComplete) {
|
|
133
|
+
onComplete(otpString);
|
|
134
|
+
}
|
|
135
|
+
}, [otp, defaultLength, onComplete]);
|
|
136
|
+
|
|
137
|
+
const contextValue = React.useMemo(() => {
|
|
138
|
+
return {
|
|
139
|
+
otp,
|
|
140
|
+
activeIndex,
|
|
141
|
+
inputRefs,
|
|
142
|
+
length: defaultLength,
|
|
143
|
+
handleChange,
|
|
144
|
+
handleKeyDown,
|
|
145
|
+
handleFocus,
|
|
146
|
+
handleClick,
|
|
147
|
+
handlePaste,
|
|
148
|
+
};
|
|
149
|
+
}, [
|
|
150
|
+
otp,
|
|
151
|
+
activeIndex,
|
|
152
|
+
inputRefs,
|
|
153
|
+
defaultLength,
|
|
154
|
+
handleChange,
|
|
155
|
+
handleKeyDown,
|
|
156
|
+
handleFocus,
|
|
157
|
+
handleClick,
|
|
158
|
+
handlePaste,
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<OTPFieldContext.Provider value={contextValue}>
|
|
163
|
+
{children}
|
|
164
|
+
</OTPFieldContext.Provider>
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
OTPField.displayName = "OTPField";
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A container for the OTP input slots.
|
|
171
|
+
*
|
|
172
|
+
* @param {React.HTMLAttributes<HTMLSpanElement>} props - The props for the OTPField.Group component.
|
|
173
|
+
* @returns {ReactElement} The OTPField.Group component.
|
|
174
|
+
*/
|
|
175
|
+
const OTPFieldGroup = React.forwardRef<
|
|
176
|
+
HTMLSpanElement,
|
|
177
|
+
React.HTMLAttributes<HTMLSpanElement>
|
|
178
|
+
>(({ ...props }, ref) => {
|
|
179
|
+
return (
|
|
180
|
+
<span ref={ref} className="flex g-medium-10 align-center" {...props} />
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
OTPFieldGroup.displayName = "OTPField.Group";
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Represents a single character input slot in the OTP field.
|
|
187
|
+
*
|
|
188
|
+
* @param {OTPFieldSlotProps & React.InputHTMLAttributes<HTMLInputElement>} props - The props for the OTPField.Slot component.
|
|
189
|
+
* @param {number} props.index - The zero-based index of the slot.
|
|
190
|
+
* @returns {ReactElement} The OTPField.Slot component.
|
|
191
|
+
*/
|
|
192
|
+
const OTPFieldSlot = ({
|
|
193
|
+
index,
|
|
194
|
+
...props
|
|
195
|
+
}: OTPFieldSlotProps & React.InputHTMLAttributes<HTMLInputElement>) => {
|
|
196
|
+
const context = useOTPField();
|
|
197
|
+
if (!context) return null;
|
|
198
|
+
|
|
199
|
+
const {
|
|
200
|
+
otp,
|
|
201
|
+
activeIndex,
|
|
202
|
+
inputRefs,
|
|
203
|
+
handleChange,
|
|
204
|
+
handleKeyDown,
|
|
205
|
+
handleFocus,
|
|
206
|
+
handleClick,
|
|
207
|
+
handlePaste,
|
|
208
|
+
} = context;
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<OTPCell
|
|
212
|
+
ref={(el) => (inputRefs.current[index] = el)}
|
|
213
|
+
type="text"
|
|
214
|
+
data-testid="otp-field-slot"
|
|
215
|
+
data-active={activeIndex === index}
|
|
216
|
+
autoComplete="one-time-code"
|
|
217
|
+
maxLength={1}
|
|
218
|
+
value={otp[index] || ""}
|
|
219
|
+
placeholder={props.placeholder || "-"}
|
|
220
|
+
onChange={(e) => handleChange(index, e.target.value)}
|
|
221
|
+
onKeyDown={(e) => handleKeyDown(index, e)}
|
|
222
|
+
onFocus={() => handleFocus(index)}
|
|
223
|
+
onMouseDown={() => handleClick(index)}
|
|
224
|
+
onClick={() => handleClick(index)}
|
|
225
|
+
onPaste={handlePaste}
|
|
226
|
+
{...props}
|
|
227
|
+
/>
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
OTPFieldSlot.displayName = "OTPField.Slot";
|
|
231
|
+
|
|
232
|
+
OTPField.Group = OTPFieldGroup;
|
|
233
|
+
OTPField.Slot = OTPFieldSlot;
|
|
234
|
+
export { OTPField, OTPFieldSlot };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
|
|
3
|
+
export const OTPCell = styled.input`
|
|
4
|
+
width: var(--measurement-medium-90);
|
|
5
|
+
height: var(--measurement-medium-90);
|
|
6
|
+
border: var(--measurement-small-10) solid var(--font-color-alpha-10);
|
|
7
|
+
|
|
8
|
+
border-radius: var(--measurement-medium-30);
|
|
9
|
+
backdrop-filter: blur(var(--measurement-small-10));
|
|
10
|
+
|
|
11
|
+
text-align: center;
|
|
12
|
+
font-size: var(--fontsize-medium-10);
|
|
13
|
+
font-weight: 500;
|
|
14
|
+
|
|
15
|
+
color: var(--font-color-alpha-10);
|
|
16
|
+
text-shadow: 0 0 0 var(--font-color);
|
|
17
|
+
|
|
18
|
+
background-color: transparent;
|
|
19
|
+
transition: all 0.2s ease-in-out;
|
|
20
|
+
outline: none;
|
|
21
|
+
|
|
22
|
+
&:focus:not(:active) {
|
|
23
|
+
background-color: var(--font-color-alpha-10);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
&:hover:not(:active) {
|
|
27
|
+
border-color: var(--font-color-alpha-20);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&::placeholder {
|
|
31
|
+
opacity: var(--opacity-default-10);
|
|
32
|
+
}
|
|
33
|
+
`;
|