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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/api.d.ts +175 -0
  4. package/dist/api.js +1954 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/frameworks/astro/astro.config.snippet.md +23 -0
  7. package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
  8. package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
  9. package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
  10. package/dist/frameworks/next/layout-import.snippet.md +19 -0
  11. package/dist/frameworks/next/next.config.snippet.md +15 -0
  12. package/dist/frameworks/next/postcss.config.js.template +16 -0
  13. package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
  14. package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
  15. package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
  16. package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
  17. package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
  18. package/dist/index.js +2047 -0
  19. package/dist/index.js.map +1 -0
  20. package/package.json +63 -0
  21. package/registry/components/primitives/icon/icon.module.css +63 -0
  22. package/registry/components/primitives/icon/icon.tsx +53 -0
  23. package/registry/components/primitives/modal/modal-context.ts +19 -0
  24. package/registry/components/primitives/modal/modal.module.css +415 -0
  25. package/registry/components/primitives/modal/modal.tsx +550 -0
  26. package/registry/components/primitives/popover/popover-context.ts +28 -0
  27. package/registry/components/primitives/popover/popover.module.css +28 -0
  28. package/registry/components/primitives/popover/popover.tsx +465 -0
  29. package/registry/components/ui/accordion/accordion.module.css +208 -0
  30. package/registry/components/ui/accordion/accordion.tsx +327 -0
  31. package/registry/components/ui/avatar/avatar.module.css +32 -0
  32. package/registry/components/ui/avatar/avatar.tsx +37 -0
  33. package/registry/components/ui/badge/badge.module.css +103 -0
  34. package/registry/components/ui/badge/badge.tsx +29 -0
  35. package/registry/components/ui/button/button.module.css +216 -0
  36. package/registry/components/ui/button/button.tsx +133 -0
  37. package/registry/components/ui/chart/chart.module.css +168 -0
  38. package/registry/components/ui/chart/chart.tsx +1293 -0
  39. package/registry/components/ui/chart/types.ts +210 -0
  40. package/registry/components/ui/chart/utils.ts +59 -0
  41. package/registry/components/ui/copy-button/copy-button.module.css +21 -0
  42. package/registry/components/ui/copy-button/copy-button.tsx +123 -0
  43. package/registry/components/ui/countdown/countdown.module.css +5 -0
  44. package/registry/components/ui/countdown/countdown.tsx +152 -0
  45. package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
  46. package/registry/components/ui/data-table/data-table.module.css +594 -0
  47. package/registry/components/ui/data-table/data-table.tsx +1109 -0
  48. package/registry/components/ui/data-table/filter-bar.tsx +33 -0
  49. package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
  50. package/registry/components/ui/data-table/types.ts +594 -0
  51. package/registry/components/ui/data-table/useDataTable.tsx +573 -0
  52. package/registry/components/ui/data-table/utils.ts +445 -0
  53. package/registry/components/ui/empty-state/empty-state.module.css +26 -0
  54. package/registry/components/ui/empty-state/empty-state.tsx +32 -0
  55. package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
  56. package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
  57. package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
  58. package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
  59. package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
  60. package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
  61. package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
  62. package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
  63. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
  64. package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
  65. package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
  66. package/registry/components/ui/input-fields/common/types.ts +38 -0
  67. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
  68. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
  69. package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
  70. package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
  71. package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
  72. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
  73. package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
  74. package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
  75. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
  76. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
  77. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
  78. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
  79. package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
  80. package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
  81. package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
  82. package/registry/components/ui/input-fields/input/input.tsx +319 -0
  83. package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
  84. package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
  85. package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
  86. package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
  87. package/registry/components/ui/input-fields/media-input/types.ts +129 -0
  88. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
  89. package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
  90. package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
  91. package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
  92. package/registry/components/ui/input-fields/select/select.tsx +118 -0
  93. package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
  94. package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
  95. package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
  96. package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
  97. package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
  98. package/registry/components/ui/loader/loader.module.css +156 -0
  99. package/registry/components/ui/loader/loader.tsx +102 -0
  100. package/registry/components/ui/stepper/stepper.module.css +294 -0
  101. package/registry/components/ui/stepper/stepper.tsx +197 -0
  102. package/registry/components/ui/tabs/tabs.module.css +150 -0
  103. package/registry/components/ui/tabs/tabs.tsx +134 -0
  104. package/registry/components/ui/toast/toast.module.css +159 -0
  105. package/registry/components/ui/toast/toast.tsx +104 -0
  106. package/registry/components/ui/tooltip/tooltip.module.css +82 -0
  107. package/registry/components/ui/tooltip/tooltip.tsx +72 -0
  108. package/registry/global.d.ts +4 -0
  109. package/registry/hooks/useFocusOutside.tsx +33 -0
  110. package/registry/hooks/useResizeObserver.tsx +32 -0
  111. package/registry/lib/dom.ts +25 -0
  112. package/registry/lib/string.ts +23 -0
  113. package/registry/registry.json +719 -0
  114. package/registry/styles/app.css +240 -0
  115. package/registry/styles/components.css +78 -0
  116. package/registry/styles/fonts/geist-regular.woff2 +0 -0
  117. package/registry/styles/globals.css +7 -0
  118. package/registry/styles/media.css +25 -0
  119. package/registry/styles/utilities.css +90 -0
  120. package/registry/themes/colors/blue.css +37 -0
  121. package/registry/themes/colors/green.css +37 -0
  122. package/registry/themes/colors/rose.css +37 -0
  123. package/registry/themes/colors/slate.css +37 -0
  124. package/registry/themes/colors/stone.css +37 -0
  125. package/registry/themes/colors/violet.css +37 -0
  126. package/registry/themes/colors/zinc.css +37 -0
  127. package/registry/themes/radius/pill.css +8 -0
  128. package/registry/themes/radius/rounded.css +8 -0
  129. package/registry/themes/radius/sharp.css +8 -0
  130. package/registry/themes/spacing/compact.css +17 -0
  131. package/registry/themes/spacing/spacious.css +17 -0
  132. 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
+ };