aplance-ui 1.0.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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/api.d.ts +175 -0
- package/dist/api.js +1954 -0
- package/dist/api.js.map +1 -0
- package/dist/frameworks/astro/astro.config.snippet.md +23 -0
- package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
- package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
- package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
- package/dist/frameworks/next/layout-import.snippet.md +19 -0
- package/dist/frameworks/next/next.config.snippet.md +15 -0
- package/dist/frameworks/next/postcss.config.js.template +16 -0
- package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
- package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
- package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
- package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
- package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
- package/dist/index.js +2047 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/registry/components/primitives/icon/icon.module.css +63 -0
- package/registry/components/primitives/icon/icon.tsx +53 -0
- package/registry/components/primitives/modal/modal-context.ts +19 -0
- package/registry/components/primitives/modal/modal.module.css +415 -0
- package/registry/components/primitives/modal/modal.tsx +550 -0
- package/registry/components/primitives/popover/popover-context.ts +28 -0
- package/registry/components/primitives/popover/popover.module.css +28 -0
- package/registry/components/primitives/popover/popover.tsx +465 -0
- package/registry/components/ui/accordion/accordion.module.css +208 -0
- package/registry/components/ui/accordion/accordion.tsx +327 -0
- package/registry/components/ui/avatar/avatar.module.css +32 -0
- package/registry/components/ui/avatar/avatar.tsx +37 -0
- package/registry/components/ui/badge/badge.module.css +103 -0
- package/registry/components/ui/badge/badge.tsx +29 -0
- package/registry/components/ui/button/button.module.css +216 -0
- package/registry/components/ui/button/button.tsx +133 -0
- package/registry/components/ui/chart/chart.module.css +168 -0
- package/registry/components/ui/chart/chart.tsx +1293 -0
- package/registry/components/ui/chart/types.ts +210 -0
- package/registry/components/ui/chart/utils.ts +59 -0
- package/registry/components/ui/copy-button/copy-button.module.css +21 -0
- package/registry/components/ui/copy-button/copy-button.tsx +123 -0
- package/registry/components/ui/countdown/countdown.module.css +5 -0
- package/registry/components/ui/countdown/countdown.tsx +152 -0
- package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
- package/registry/components/ui/data-table/data-table.module.css +594 -0
- package/registry/components/ui/data-table/data-table.tsx +1109 -0
- package/registry/components/ui/data-table/filter-bar.tsx +33 -0
- package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
- package/registry/components/ui/data-table/types.ts +594 -0
- package/registry/components/ui/data-table/useDataTable.tsx +573 -0
- package/registry/components/ui/data-table/utils.ts +445 -0
- package/registry/components/ui/empty-state/empty-state.module.css +26 -0
- package/registry/components/ui/empty-state/empty-state.tsx +32 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
- package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
- package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
- package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
- package/registry/components/ui/input-fields/common/types.ts +38 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
- package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
- package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
- package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
- package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
- package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
- package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
- package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
- package/registry/components/ui/input-fields/input/input.tsx +319 -0
- package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
- package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
- package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
- package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
- package/registry/components/ui/input-fields/media-input/types.ts +129 -0
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
- package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
- package/registry/components/ui/input-fields/select/select.tsx +118 -0
- package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
- package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
- package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
- package/registry/components/ui/loader/loader.module.css +156 -0
- package/registry/components/ui/loader/loader.tsx +102 -0
- package/registry/components/ui/stepper/stepper.module.css +294 -0
- package/registry/components/ui/stepper/stepper.tsx +197 -0
- package/registry/components/ui/tabs/tabs.module.css +150 -0
- package/registry/components/ui/tabs/tabs.tsx +134 -0
- package/registry/components/ui/toast/toast.module.css +159 -0
- package/registry/components/ui/toast/toast.tsx +104 -0
- package/registry/components/ui/tooltip/tooltip.module.css +82 -0
- package/registry/components/ui/tooltip/tooltip.tsx +72 -0
- package/registry/global.d.ts +4 -0
- package/registry/hooks/useFocusOutside.tsx +33 -0
- package/registry/hooks/useResizeObserver.tsx +32 -0
- package/registry/lib/dom.ts +25 -0
- package/registry/lib/string.ts +23 -0
- package/registry/registry.json +719 -0
- package/registry/styles/app.css +240 -0
- package/registry/styles/components.css +78 -0
- package/registry/styles/fonts/geist-regular.woff2 +0 -0
- package/registry/styles/globals.css +7 -0
- package/registry/styles/media.css +25 -0
- package/registry/styles/utilities.css +90 -0
- package/registry/themes/colors/blue.css +37 -0
- package/registry/themes/colors/green.css +37 -0
- package/registry/themes/colors/rose.css +37 -0
- package/registry/themes/colors/slate.css +37 -0
- package/registry/themes/colors/stone.css +37 -0
- package/registry/themes/colors/violet.css +37 -0
- package/registry/themes/colors/zinc.css +37 -0
- package/registry/themes/radius/pill.css +8 -0
- package/registry/themes/radius/rounded.css +8 -0
- package/registry/themes/radius/sharp.css +8 -0
- package/registry/themes/spacing/compact.css +17 -0
- package/registry/themes/spacing/spacious.css +17 -0
- package/registry/tsconfig.json +25 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChangeEvent,
|
|
3
|
+
type ClipboardEvent,
|
|
4
|
+
type FocusEvent,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
type Ref,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import clsx from "clsx";
|
|
14
|
+
import sCommon from "../common/input-field-common.module.css";
|
|
15
|
+
import sButton from "../../button/button.module.css";
|
|
16
|
+
import s from "./otp-input.module.css";
|
|
17
|
+
import { FieldMessage } from "../common/field-message/field-message";
|
|
18
|
+
import type { BaseInputFieldProps } from "../common/types";
|
|
19
|
+
|
|
20
|
+
type FormChangeEvent = { target: { name?: string; value: string } };
|
|
21
|
+
|
|
22
|
+
export type OTPInputProps = Omit<
|
|
23
|
+
BaseInputFieldProps,
|
|
24
|
+
"iconStart" | "iconEnd"
|
|
25
|
+
> & {
|
|
26
|
+
length?: number;
|
|
27
|
+
/** Combined string value (controlled). */
|
|
28
|
+
value?: string;
|
|
29
|
+
/** Combined string default value (uncontrolled). */
|
|
30
|
+
defaultValue?: string;
|
|
31
|
+
/** Fires whenever the combined value changes. RHF-compatible shape. */
|
|
32
|
+
onChange?: (event: FormChangeEvent) => void;
|
|
33
|
+
/** Fires once when every cell is filled. */
|
|
34
|
+
onComplete?: (value: string) => void;
|
|
35
|
+
onBlur?: (event: FocusEvent<HTMLInputElement>) => void;
|
|
36
|
+
/** Focus the first empty cell on mount. */
|
|
37
|
+
autoFocus?: boolean;
|
|
38
|
+
/** Per-character validation regex. Default: digits only. */
|
|
39
|
+
charPattern?: RegExp;
|
|
40
|
+
/** Mobile keyboard hint. Default: "numeric". */
|
|
41
|
+
inputMode?: "numeric" | "text";
|
|
42
|
+
/** Render cells as password-style dots. */
|
|
43
|
+
mask?: boolean;
|
|
44
|
+
/** Per-cell placeholder character. */
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Forwarded to the first cell. RHF's `register` ref attaches here so
|
|
48
|
+
* focus-on-error works without extra wiring.
|
|
49
|
+
*/
|
|
50
|
+
ref?: Ref<HTMLInputElement>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const DEFAULT_PATTERN = /^[0-9]$/;
|
|
54
|
+
|
|
55
|
+
const splitToCells = (input: string, length: number): string[] => {
|
|
56
|
+
const cells = new Array(length).fill("");
|
|
57
|
+
for (let i = 0; i < Math.min(input.length, length); i++) {
|
|
58
|
+
cells[i] = input[i] ?? "";
|
|
59
|
+
}
|
|
60
|
+
return cells;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const OTPInput = ({
|
|
64
|
+
length = 6,
|
|
65
|
+
label,
|
|
66
|
+
value,
|
|
67
|
+
defaultValue,
|
|
68
|
+
onChange,
|
|
69
|
+
onComplete,
|
|
70
|
+
onBlur,
|
|
71
|
+
errors,
|
|
72
|
+
info,
|
|
73
|
+
disabled,
|
|
74
|
+
readOnly,
|
|
75
|
+
required,
|
|
76
|
+
name,
|
|
77
|
+
id,
|
|
78
|
+
className,
|
|
79
|
+
style,
|
|
80
|
+
layout = "default",
|
|
81
|
+
layoutDirection = "default",
|
|
82
|
+
buttonStyle = "none",
|
|
83
|
+
autoFocus = false,
|
|
84
|
+
charPattern = DEFAULT_PATTERN,
|
|
85
|
+
inputMode = "numeric",
|
|
86
|
+
mask = false,
|
|
87
|
+
placeholder = "",
|
|
88
|
+
ref,
|
|
89
|
+
}: OTPInputProps) => {
|
|
90
|
+
const hasButtonStyle = buttonStyle !== "none";
|
|
91
|
+
const buttonToneClass = hasButtonStyle
|
|
92
|
+
? sButton[`tone_${buttonStyle}`]
|
|
93
|
+
: undefined;
|
|
94
|
+
const isControlled = value !== undefined;
|
|
95
|
+
|
|
96
|
+
const [internal, setInternal] = useState<string[]>(() =>
|
|
97
|
+
splitToCells(defaultValue ?? "", length),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const cells = isControlled ? splitToCells(value ?? "", length) : internal;
|
|
101
|
+
const inputsRef = useRef<Array<HTMLInputElement | null>>([]);
|
|
102
|
+
const completedRef = useRef(false);
|
|
103
|
+
|
|
104
|
+
const generatedId = useId();
|
|
105
|
+
const inputId = id ?? generatedId;
|
|
106
|
+
const hasErrors = (errors?.length ?? 0) > 0;
|
|
107
|
+
|
|
108
|
+
const focusCell = useCallback(
|
|
109
|
+
(idx: number) => {
|
|
110
|
+
const clamped = Math.max(0, Math.min(length - 1, idx));
|
|
111
|
+
inputsRef.current[clamped]?.focus();
|
|
112
|
+
inputsRef.current[clamped]?.select();
|
|
113
|
+
},
|
|
114
|
+
[length],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const emit = useCallback(
|
|
118
|
+
(next: string[]) => {
|
|
119
|
+
const joined = next.join("");
|
|
120
|
+
if (!isControlled) setInternal(next);
|
|
121
|
+
onChange?.({ target: { name, value: joined } });
|
|
122
|
+
const isComplete = next.length === length && next.every((c) => c !== "");
|
|
123
|
+
if (isComplete && !completedRef.current) {
|
|
124
|
+
completedRef.current = true;
|
|
125
|
+
onComplete?.(joined);
|
|
126
|
+
} else if (!isComplete) {
|
|
127
|
+
completedRef.current = false;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
[isControlled, length, name, onChange, onComplete],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!autoFocus) return;
|
|
135
|
+
const firstEmpty = cells.findIndex((c) => c === "");
|
|
136
|
+
focusCell(firstEmpty >= 0 ? firstEmpty : 0);
|
|
137
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
const distribute = (raw: string, startIdx: number) => {
|
|
141
|
+
const cleaned = Array.from(raw).filter((c) => charPattern.test(c));
|
|
142
|
+
if (cleaned.length === 0) return;
|
|
143
|
+
const next = [...cells];
|
|
144
|
+
let writeIdx = startIdx;
|
|
145
|
+
for (const c of cleaned) {
|
|
146
|
+
if (writeIdx >= length) break;
|
|
147
|
+
next[writeIdx++] = c;
|
|
148
|
+
}
|
|
149
|
+
emit(next);
|
|
150
|
+
focusCell(Math.min(writeIdx, length - 1));
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const handleCellChange =
|
|
154
|
+
(idx: number) => (e: ChangeEvent<HTMLInputElement>) => {
|
|
155
|
+
if (disabled || readOnly) return;
|
|
156
|
+
const raw = e.target.value;
|
|
157
|
+
// SMS autofill / fast paste: a multi-char value lands in one cell.
|
|
158
|
+
if (raw.length > 1) {
|
|
159
|
+
distribute(raw, idx);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (raw !== "" && !charPattern.test(raw)) return;
|
|
163
|
+
const next = [...cells];
|
|
164
|
+
next[idx] = raw;
|
|
165
|
+
emit(next);
|
|
166
|
+
if (raw && idx < length - 1) focusCell(idx + 1);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const handleKeyDown =
|
|
170
|
+
(idx: number) => (e: KeyboardEvent<HTMLInputElement>) => {
|
|
171
|
+
if (disabled || readOnly) return;
|
|
172
|
+
if (e.key === "Backspace") {
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
const next = [...cells];
|
|
175
|
+
if (next[idx]) {
|
|
176
|
+
next[idx] = "";
|
|
177
|
+
emit(next);
|
|
178
|
+
} else if (idx > 0) {
|
|
179
|
+
next[idx - 1] = "";
|
|
180
|
+
emit(next);
|
|
181
|
+
focusCell(idx - 1);
|
|
182
|
+
}
|
|
183
|
+
} else if (e.key === "ArrowLeft") {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
focusCell(idx - 1);
|
|
186
|
+
} else if (e.key === "ArrowRight") {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
focusCell(idx + 1);
|
|
189
|
+
} else if (e.key === "Home") {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
focusCell(0);
|
|
192
|
+
} else if (e.key === "End") {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
focusCell(length - 1);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const handlePaste =
|
|
199
|
+
(idx: number) => (e: ClipboardEvent<HTMLInputElement>) => {
|
|
200
|
+
if (disabled || readOnly) return;
|
|
201
|
+
const text = e.clipboardData.getData("text").trim();
|
|
202
|
+
if (!text) return;
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
distribute(text, idx);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const handleFocus = (e: FocusEvent<HTMLInputElement>) => {
|
|
208
|
+
e.target.select();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Forward external ref to the first cell so RHF's registration ref can
|
|
212
|
+
// focus-on-error and so consumers calling .focus() get sensible behavior.
|
|
213
|
+
const refCallback = useCallback(
|
|
214
|
+
(el: HTMLInputElement | null) => {
|
|
215
|
+
inputsRef.current[0] = el;
|
|
216
|
+
if (!ref) return;
|
|
217
|
+
if (typeof ref === "function") ref(el);
|
|
218
|
+
else (ref as { current: HTMLInputElement | null }).current = el;
|
|
219
|
+
},
|
|
220
|
+
[ref],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className={clsx(sCommon.container, className)} style={style}>
|
|
225
|
+
<div
|
|
226
|
+
className={clsx(
|
|
227
|
+
sCommon.field_container,
|
|
228
|
+
layout !== "default" && sCommon[layout],
|
|
229
|
+
layout !== "default" &&
|
|
230
|
+
layoutDirection !== "default" &&
|
|
231
|
+
sCommon[layoutDirection],
|
|
232
|
+
)}
|
|
233
|
+
>
|
|
234
|
+
{label && (
|
|
235
|
+
<label className={sCommon.label} htmlFor={inputId}>
|
|
236
|
+
{label}
|
|
237
|
+
</label>
|
|
238
|
+
)}
|
|
239
|
+
<div
|
|
240
|
+
className={s.cells}
|
|
241
|
+
role="group"
|
|
242
|
+
aria-label={label ?? "One-time code"}
|
|
243
|
+
>
|
|
244
|
+
{cells.map((cellValue, idx) => (
|
|
245
|
+
<input
|
|
246
|
+
key={idx}
|
|
247
|
+
id={idx === 0 ? inputId : undefined}
|
|
248
|
+
ref={
|
|
249
|
+
idx === 0
|
|
250
|
+
? refCallback
|
|
251
|
+
: (el) => {
|
|
252
|
+
inputsRef.current[idx] = el;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
name={idx === 0 ? name : undefined}
|
|
256
|
+
type={mask ? "password" : "text"}
|
|
257
|
+
inputMode={inputMode}
|
|
258
|
+
autoComplete={idx === 0 ? "one-time-code" : "off"}
|
|
259
|
+
maxLength={1}
|
|
260
|
+
value={cellValue}
|
|
261
|
+
onChange={handleCellChange(idx)}
|
|
262
|
+
onKeyDown={handleKeyDown(idx)}
|
|
263
|
+
onPaste={handlePaste(idx)}
|
|
264
|
+
onFocus={handleFocus}
|
|
265
|
+
onBlur={onBlur}
|
|
266
|
+
disabled={disabled}
|
|
267
|
+
readOnly={readOnly}
|
|
268
|
+
required={required && idx === 0}
|
|
269
|
+
placeholder={placeholder}
|
|
270
|
+
aria-label={`Digit ${idx + 1} of ${length}`}
|
|
271
|
+
aria-invalid={hasErrors || undefined}
|
|
272
|
+
className={clsx(
|
|
273
|
+
!hasButtonStyle && sCommon.field_default,
|
|
274
|
+
hasButtonStyle && [
|
|
275
|
+
sCommon.field_button_variant,
|
|
276
|
+
buttonToneClass,
|
|
277
|
+
sButton.base,
|
|
278
|
+
sButton[buttonStyle],
|
|
279
|
+
sButton.input_focus,
|
|
280
|
+
],
|
|
281
|
+
s.cell,
|
|
282
|
+
)}
|
|
283
|
+
/>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
<FieldMessage errors={errors} info={info} />
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useId, type ComponentProps } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import sCommon from "../common/input-field-common.module.css";
|
|
4
|
+
import sButton from "../../button/button.module.css";
|
|
5
|
+
import s from "../common/input-field-native.module.css";
|
|
6
|
+
import { FieldMessage } from "../common/field-message/field-message";
|
|
7
|
+
import type { BaseInputFieldProps } from "../common/types";
|
|
8
|
+
import { IconChevronDown } from "@tabler/icons-react";
|
|
9
|
+
import { Icon } from "../../../primitives/icon/icon";
|
|
10
|
+
|
|
11
|
+
type BaseProps = BaseInputFieldProps & {
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
options: { value: string; label: string }[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SelectProps = ComponentProps<"select"> & BaseProps;
|
|
17
|
+
|
|
18
|
+
export const Select = ({
|
|
19
|
+
label,
|
|
20
|
+
errors,
|
|
21
|
+
info,
|
|
22
|
+
options,
|
|
23
|
+
placeholder,
|
|
24
|
+
className,
|
|
25
|
+
style,
|
|
26
|
+
id,
|
|
27
|
+
name,
|
|
28
|
+
layout = "default",
|
|
29
|
+
layoutDirection = "default",
|
|
30
|
+
buttonStyle = "none",
|
|
31
|
+
iconStart,
|
|
32
|
+
iconEnd,
|
|
33
|
+
...props
|
|
34
|
+
}: SelectProps) => {
|
|
35
|
+
const generatedId = useId();
|
|
36
|
+
const inputId = id ?? generatedId;
|
|
37
|
+
const startActionCount = iconStart ? 1 : 0;
|
|
38
|
+
const endActionCount = (props.multiple ? 0 : 1) + (iconEnd ? 1 : 0); // 1 for chevron, 1 for iconEnd
|
|
39
|
+
const hasButtonStyle = buttonStyle !== "none";
|
|
40
|
+
const buttonToneClass = hasButtonStyle
|
|
41
|
+
? sButton[`tone_${buttonStyle}`]
|
|
42
|
+
: undefined;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className={clsx(sCommon.container, className)}>
|
|
46
|
+
<div
|
|
47
|
+
className={clsx(
|
|
48
|
+
sCommon.field_container,
|
|
49
|
+
className,
|
|
50
|
+
layout !== "default" && sCommon[layout],
|
|
51
|
+
layout !== "default" &&
|
|
52
|
+
layoutDirection !== "default" &&
|
|
53
|
+
sCommon[layoutDirection],
|
|
54
|
+
)}
|
|
55
|
+
style={style}
|
|
56
|
+
>
|
|
57
|
+
{label && (
|
|
58
|
+
<label className={sCommon.label} htmlFor={inputId}>
|
|
59
|
+
{label}
|
|
60
|
+
</label>
|
|
61
|
+
)}
|
|
62
|
+
<div
|
|
63
|
+
className={clsx(
|
|
64
|
+
sCommon.field_wrapper,
|
|
65
|
+
s.native_field_wrapper,
|
|
66
|
+
hasButtonStyle && sCommon.field_wrapper_variant,
|
|
67
|
+
hasButtonStyle && buttonToneClass,
|
|
68
|
+
)}
|
|
69
|
+
data-start-actions={startActionCount > 0 ? startActionCount : undefined}
|
|
70
|
+
data-end-actions={endActionCount > 0 ? Math.min(3, endActionCount) : undefined}
|
|
71
|
+
>
|
|
72
|
+
{iconStart && <div className={sCommon.icon_start}>{iconStart}</div>}
|
|
73
|
+
<select
|
|
74
|
+
id={inputId}
|
|
75
|
+
name={name}
|
|
76
|
+
className={clsx(
|
|
77
|
+
hasButtonStyle
|
|
78
|
+
? [
|
|
79
|
+
sCommon.field_full_width,
|
|
80
|
+
sCommon.field_cursor_default,
|
|
81
|
+
sCommon.field_button_variant,
|
|
82
|
+
buttonToneClass,
|
|
83
|
+
sButton.base,
|
|
84
|
+
sButton[buttonStyle],
|
|
85
|
+
sButton.input_focus,
|
|
86
|
+
]
|
|
87
|
+
: sCommon.field_default,
|
|
88
|
+
)}
|
|
89
|
+
{...props}
|
|
90
|
+
>
|
|
91
|
+
{placeholder && (
|
|
92
|
+
<option value="" disabled hidden>
|
|
93
|
+
{placeholder}
|
|
94
|
+
</option>
|
|
95
|
+
)}
|
|
96
|
+
{options.map((opt) => (
|
|
97
|
+
<option key={opt.value} value={opt.value}>
|
|
98
|
+
{opt.label}
|
|
99
|
+
</option>
|
|
100
|
+
))}
|
|
101
|
+
</select>
|
|
102
|
+
{endActionCount > 0 && (
|
|
103
|
+
<div className={sCommon.action_end_wrapper} style={{ pointerEvents: "none" }}>
|
|
104
|
+
{!props.multiple && (
|
|
105
|
+
<Icon className={s.native_select_chevron}>
|
|
106
|
+
<IconChevronDown />
|
|
107
|
+
</Icon>
|
|
108
|
+
)}
|
|
109
|
+
{iconEnd}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<FieldMessage info={info} errors={errors} />
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* Switch - toggle variant of input-fields family.
|
|
2
|
+
Uses the family shell from common/input-field-common.module.css;
|
|
3
|
+
this module only styles the track + thumb.
|
|
4
|
+
|
|
5
|
+
Geometry: thumb is anchored on top/bottom/left by --switch-pad and sized
|
|
6
|
+
via aspect-ratio:1/1 - guarantees identical inset on all four sides in the
|
|
7
|
+
off state. Travel = track-w − track-h, which keeps the right-side inset
|
|
8
|
+
identical when checked. Integer px sizing avoids sub-pixel rounding.
|
|
9
|
+
|
|
10
|
+
Theme: border-radius uses --radius-xl on both track and thumb so the
|
|
11
|
+
sharp/rounded/pill radius variants flow through (--radius-full is fixed
|
|
12
|
+
and would mute them).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
.track {
|
|
16
|
+
--switch-w: 38px;
|
|
17
|
+
--switch-h: 22px;
|
|
18
|
+
--switch-border: 1px;
|
|
19
|
+
--switch-pad: 3px;
|
|
20
|
+
|
|
21
|
+
appearance: none;
|
|
22
|
+
-webkit-appearance: none;
|
|
23
|
+
box-sizing: border-box;
|
|
24
|
+
width: var(--switch-w);
|
|
25
|
+
height: var(--switch-h);
|
|
26
|
+
background-color: var(--color-muted);
|
|
27
|
+
border: var(--switch-border) solid var(--color-border);
|
|
28
|
+
border-radius: var(--radius-md);
|
|
29
|
+
position: relative;
|
|
30
|
+
cursor: pointer;
|
|
31
|
+
flex-shrink: 0;
|
|
32
|
+
transition:
|
|
33
|
+
background-color var(--transition-fast),
|
|
34
|
+
border-color var(--transition-fast),
|
|
35
|
+
box-shadow var(--transition-fast);
|
|
36
|
+
|
|
37
|
+
&::after {
|
|
38
|
+
content: "";
|
|
39
|
+
position: absolute;
|
|
40
|
+
top: var(--switch-pad);
|
|
41
|
+
bottom: var(--switch-pad);
|
|
42
|
+
left: var(--switch-pad);
|
|
43
|
+
aspect-ratio: 1 / 1;
|
|
44
|
+
background-color: var(--color-background);
|
|
45
|
+
border-radius: var(--radius-sm);
|
|
46
|
+
box-shadow: var(--shadow-sm);
|
|
47
|
+
transition:
|
|
48
|
+
transform var(--transition-fast),
|
|
49
|
+
background-color var(--transition-fast);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
&:checked {
|
|
53
|
+
background-color: var(--color-primary);
|
|
54
|
+
border-color: var(--color-primary);
|
|
55
|
+
|
|
56
|
+
&::after {
|
|
57
|
+
transform: translateX(calc(var(--switch-w) - var(--switch-h)));
|
|
58
|
+
background-color: var(--color-primary-foreground);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
&:not(:disabled):focus {
|
|
63
|
+
box-shadow: var(--shadow-ring);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
&:disabled {
|
|
67
|
+
cursor: not-allowed;
|
|
68
|
+
opacity: 0.5;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useId, type ComponentProps } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import sCommon from "../common/input-field-common.module.css";
|
|
4
|
+
import s from "./switch.module.css";
|
|
5
|
+
import { FieldMessage } from "../common/field-message/field-message";
|
|
6
|
+
import type { BaseInputFieldProps } from "../common/types";
|
|
7
|
+
|
|
8
|
+
export type SwitchProps = Omit<ComponentProps<"input">, "type" | "size"> &
|
|
9
|
+
Omit<BaseInputFieldProps, "buttonStyle" | "iconStart" | "iconEnd">;
|
|
10
|
+
|
|
11
|
+
export const Switch = ({
|
|
12
|
+
label,
|
|
13
|
+
errors,
|
|
14
|
+
info,
|
|
15
|
+
className,
|
|
16
|
+
style,
|
|
17
|
+
layout = "default",
|
|
18
|
+
layoutDirection = "default",
|
|
19
|
+
id,
|
|
20
|
+
name,
|
|
21
|
+
ref,
|
|
22
|
+
...props
|
|
23
|
+
}: SwitchProps) => {
|
|
24
|
+
const generatedId = useId();
|
|
25
|
+
const inputId = id ?? generatedId;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className={clsx(sCommon.container, className)} style={style}>
|
|
29
|
+
<div
|
|
30
|
+
className={clsx(
|
|
31
|
+
sCommon.field_container,
|
|
32
|
+
layout !== "default" && sCommon[layout],
|
|
33
|
+
layout !== "default" &&
|
|
34
|
+
layoutDirection !== "default" &&
|
|
35
|
+
sCommon[layoutDirection],
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{label && (
|
|
39
|
+
<label className={sCommon.label} htmlFor={inputId}>
|
|
40
|
+
{label}
|
|
41
|
+
</label>
|
|
42
|
+
)}
|
|
43
|
+
<div className={clsx(sCommon.field_wrapper, sCommon.field_wrapper_fit)}>
|
|
44
|
+
<input
|
|
45
|
+
id={inputId}
|
|
46
|
+
name={name}
|
|
47
|
+
type="checkbox"
|
|
48
|
+
role="switch"
|
|
49
|
+
ref={ref}
|
|
50
|
+
className={s.track}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<FieldMessage errors={errors} info={info} />
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type ComponentProps, useId, useState } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import sCommon from "../common/input-field-common.module.css";
|
|
4
|
+
import sButton from "../../button/button.module.css";
|
|
5
|
+
import s from "../common/input-field-native.module.css";
|
|
6
|
+
import { FieldMessage } from "../common/field-message/field-message";
|
|
7
|
+
import type { BaseInputFieldProps } from "../common/types";
|
|
8
|
+
|
|
9
|
+
type BaseProps = Omit<BaseInputFieldProps, "iconStart" | "iconEnd"> & {
|
|
10
|
+
charCount?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type TextareaProps = ComponentProps<"textarea"> & BaseProps;
|
|
14
|
+
|
|
15
|
+
export const Textarea = ({
|
|
16
|
+
label,
|
|
17
|
+
errors,
|
|
18
|
+
info,
|
|
19
|
+
className,
|
|
20
|
+
style,
|
|
21
|
+
value,
|
|
22
|
+
maxLength,
|
|
23
|
+
charCount,
|
|
24
|
+
layout = "default",
|
|
25
|
+
layoutDirection = "default",
|
|
26
|
+
buttonStyle = "none",
|
|
27
|
+
ref,
|
|
28
|
+
onChange,
|
|
29
|
+
...props
|
|
30
|
+
}: TextareaProps) => {
|
|
31
|
+
const [currentLength, setCurrentLength] = useState(0);
|
|
32
|
+
const showCharCount = Boolean(charCount && maxLength);
|
|
33
|
+
const hasButtonStyle = buttonStyle !== "none";
|
|
34
|
+
const buttonToneClass = hasButtonStyle
|
|
35
|
+
? sButton[`tone_${buttonStyle}`]
|
|
36
|
+
: undefined;
|
|
37
|
+
const generatedId = useId();
|
|
38
|
+
const inputId = props.id ?? generatedId;
|
|
39
|
+
|
|
40
|
+
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
41
|
+
if (charCount) {
|
|
42
|
+
setCurrentLength(e.target.value.length);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (onChange) {
|
|
46
|
+
onChange(e);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Callback ref to read initial value and merge with external ref
|
|
51
|
+
const refCallback = (element: HTMLTextAreaElement | null) => {
|
|
52
|
+
if (element && charCount) {
|
|
53
|
+
setCurrentLength(element.value.length);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Merge with external ref (e.g., from RHF)
|
|
57
|
+
if (ref) {
|
|
58
|
+
if (typeof ref === "function") {
|
|
59
|
+
ref(element);
|
|
60
|
+
} else {
|
|
61
|
+
ref.current = element;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={clsx(sCommon.container, className)}>
|
|
68
|
+
<div
|
|
69
|
+
className={clsx(
|
|
70
|
+
sCommon.field_container,
|
|
71
|
+
layout !== "default" && sCommon[layout],
|
|
72
|
+
layout !== "default" &&
|
|
73
|
+
layoutDirection !== "default" &&
|
|
74
|
+
sCommon[layoutDirection],
|
|
75
|
+
)}
|
|
76
|
+
style={style}
|
|
77
|
+
>
|
|
78
|
+
{(label || showCharCount) && (
|
|
79
|
+
<div
|
|
80
|
+
className={clsx(
|
|
81
|
+
showCharCount && sCommon.label_row,
|
|
82
|
+
showCharCount && !label && sCommon.field_justify_end,
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
{label && (
|
|
86
|
+
<label className={sCommon.label} htmlFor={inputId}>
|
|
87
|
+
{label}
|
|
88
|
+
</label>
|
|
89
|
+
)}
|
|
90
|
+
{showCharCount && (
|
|
91
|
+
<small className={sCommon.label_meta}>
|
|
92
|
+
{currentLength} / {maxLength}
|
|
93
|
+
</small>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
<div
|
|
98
|
+
className={clsx(
|
|
99
|
+
sCommon.field_wrapper,
|
|
100
|
+
s.native_field_wrapper,
|
|
101
|
+
hasButtonStyle && sCommon.field_wrapper_variant,
|
|
102
|
+
hasButtonStyle && buttonToneClass,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
<textarea
|
|
106
|
+
id={inputId}
|
|
107
|
+
value={value}
|
|
108
|
+
maxLength={maxLength}
|
|
109
|
+
className={clsx(
|
|
110
|
+
hasButtonStyle
|
|
111
|
+
? [
|
|
112
|
+
sCommon.field_full_width,
|
|
113
|
+
sCommon.field_cursor_text,
|
|
114
|
+
sCommon.field_button_variant,
|
|
115
|
+
buttonToneClass,
|
|
116
|
+
sButton.base,
|
|
117
|
+
sButton[buttonStyle],
|
|
118
|
+
sButton.input_focus,
|
|
119
|
+
sCommon.textarea_button_variant_padding,
|
|
120
|
+
]
|
|
121
|
+
: [sCommon.field_default, sCommon.textarea_padding_y],
|
|
122
|
+
)}
|
|
123
|
+
ref={refCallback}
|
|
124
|
+
{...props}
|
|
125
|
+
onChange={handleTextareaChange}
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<FieldMessage info={info} errors={errors} />
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
};
|