@luxfi/ui 5.6.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +109 -0
  2. package/package.json +81 -278
  3. package/dist/accordion.cjs +0 -213
  4. package/dist/accordion.js +0 -186
  5. package/dist/alert.cjs +0 -553
  6. package/dist/alert.js +0 -531
  7. package/dist/avatar.cjs +0 -149
  8. package/dist/avatar.js +0 -125
  9. package/dist/badge.cjs +0 -611
  10. package/dist/badge.js +0 -589
  11. package/dist/button.cjs +0 -689
  12. package/dist/button.js +0 -664
  13. package/dist/checkbox.cjs +0 -265
  14. package/dist/checkbox.js +0 -241
  15. package/dist/close-button.cjs +0 -73
  16. package/dist/close-button.js +0 -51
  17. package/dist/collapsible.cjs +0 -702
  18. package/dist/collapsible.js +0 -679
  19. package/dist/color-mode.cjs +0 -96
  20. package/dist/color-mode.js +0 -72
  21. package/dist/dialog.cjs +0 -279
  22. package/dist/dialog.js +0 -246
  23. package/dist/drawer.cjs +0 -207
  24. package/dist/drawer.js +0 -175
  25. package/dist/empty-state.cjs +0 -93
  26. package/dist/empty-state.js +0 -71
  27. package/dist/field.cjs +0 -183
  28. package/dist/field.js +0 -160
  29. package/dist/heading.cjs +0 -46
  30. package/dist/heading.js +0 -40
  31. package/dist/icon-button.cjs +0 -491
  32. package/dist/icon-button.js +0 -470
  33. package/dist/image.cjs +0 -572
  34. package/dist/image.js +0 -551
  35. package/dist/index.cjs +0 -5779
  36. package/dist/index.js +0 -5619
  37. package/dist/input-group.cjs +0 -155
  38. package/dist/input-group.js +0 -133
  39. package/dist/input.cjs +0 -65
  40. package/dist/input.js +0 -59
  41. package/dist/link.cjs +0 -630
  42. package/dist/link.js +0 -606
  43. package/dist/menu.cjs +0 -305
  44. package/dist/menu.js +0 -269
  45. package/dist/pin-input.cjs +0 -182
  46. package/dist/pin-input.js +0 -160
  47. package/dist/popover.cjs +0 -327
  48. package/dist/popover.js +0 -294
  49. package/dist/progress-circle.cjs +0 -152
  50. package/dist/progress-circle.js +0 -128
  51. package/dist/progress.cjs +0 -117
  52. package/dist/progress.js +0 -94
  53. package/dist/provider.cjs +0 -62
  54. package/dist/provider.js +0 -40
  55. package/dist/radio.cjs +0 -177
  56. package/dist/radio.js +0 -153
  57. package/dist/rating.cjs +0 -80
  58. package/dist/rating.js +0 -58
  59. package/dist/select.cjs +0 -791
  60. package/dist/select.js +0 -757
  61. package/dist/separator.cjs +0 -57
  62. package/dist/separator.js +0 -51
  63. package/dist/skeleton.cjs +0 -370
  64. package/dist/skeleton.js +0 -346
  65. package/dist/slider.cjs +0 -138
  66. package/dist/slider.js +0 -115
  67. package/dist/switch.cjs +0 -163
  68. package/dist/switch.js +0 -140
  69. package/dist/table.cjs +0 -1044
  70. package/dist/table.js +0 -1013
  71. package/dist/tabs.cjs +0 -240
  72. package/dist/tabs.js +0 -213
  73. package/dist/tag.cjs +0 -651
  74. package/dist/tag.js +0 -628
  75. package/dist/textarea.cjs +0 -65
  76. package/dist/textarea.js +0 -59
  77. package/dist/toaster.cjs +0 -99
  78. package/dist/toaster.js +0 -96
  79. package/dist/tooltip.cjs +0 -171
  80. package/dist/tooltip.js +0 -148
  81. package/dist/utils.cjs +0 -11
  82. package/dist/utils.js +0 -9
  83. package/src/accordion.tsx +0 -285
  84. package/src/alert.tsx +0 -221
  85. package/src/avatar.tsx +0 -174
  86. package/src/badge.tsx +0 -158
  87. package/src/button.tsx +0 -411
  88. package/src/checkbox.tsx +0 -307
  89. package/src/close-button.tsx +0 -51
  90. package/src/collapsible.tsx +0 -126
  91. package/src/color-mode.tsx +0 -125
  92. package/src/dialog.tsx +0 -356
  93. package/src/drawer.tsx +0 -186
  94. package/src/empty-state.tsx +0 -97
  95. package/src/field.tsx +0 -202
  96. package/src/heading.tsx +0 -55
  97. package/src/icon-button.tsx +0 -192
  98. package/src/image.tsx +0 -280
  99. package/src/index.ts +0 -192
  100. package/src/input-group.tsx +0 -159
  101. package/src/input.tsx +0 -60
  102. package/src/link.tsx +0 -326
  103. package/src/menu.tsx +0 -471
  104. package/src/pin-input.tsx +0 -187
  105. package/src/popover.tsx +0 -400
  106. package/src/progress-circle.tsx +0 -180
  107. package/src/progress.tsx +0 -109
  108. package/src/provider.tsx +0 -12
  109. package/src/radio.tsx +0 -175
  110. package/src/rating.tsx +0 -79
  111. package/src/select.tsx +0 -696
  112. package/src/separator.tsx +0 -59
  113. package/src/skeleton.tsx +0 -302
  114. package/src/slider.tsx +0 -152
  115. package/src/switch.tsx +0 -158
  116. package/src/table.tsx +0 -621
  117. package/src/tabs.tsx +0 -354
  118. package/src/tag.tsx +0 -159
  119. package/src/textarea.tsx +0 -60
  120. package/src/toaster.tsx +0 -117
  121. package/src/tokens.css +0 -438
  122. package/src/tooltip.tsx +0 -184
  123. package/src/utils/cn.ts +0 -7
  124. package/src/utils.ts +0 -6
  125. package/tokens.css +0 -438
package/src/menu.tsx DELETED
@@ -1,471 +0,0 @@
1
- 'use client';
2
-
3
- import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
4
- import * as React from 'react';
5
- import { LuCheck, LuChevronRight } from 'react-icons/lu';
6
-
7
- import { cn } from './utils';
8
-
9
- // --- Utility: map Chakra-style placement string to Radix side + align ---
10
-
11
- type Side = 'top' | 'right' | 'bottom' | 'left';
12
- type Align = 'start' | 'center' | 'end';
13
-
14
- interface PlacementMapping {
15
- readonly side: Side;
16
- readonly align: Align;
17
- }
18
-
19
- function parsePlacement(placement: string | undefined): PlacementMapping {
20
- if (!placement) {
21
- return { side: 'bottom', align: 'start' };
22
- }
23
-
24
- const parts = placement.split('-');
25
- const side = (parts[0] as Side) ?? 'bottom';
26
- const alignPart = parts[1];
27
-
28
- let align: Align = 'center';
29
- if (alignPart === 'start') {
30
- align = 'start';
31
- } else if (alignPart === 'end') {
32
- align = 'end';
33
- }
34
-
35
- return { side, align };
36
- }
37
-
38
- // --- Positioning context (mirrors popover.tsx pattern) ---
39
-
40
- interface Positioning {
41
- readonly placement?: string;
42
- readonly offset?: {
43
- readonly mainAxis?: number;
44
- readonly crossAxis?: number;
45
- };
46
- }
47
-
48
- interface MenuPositioning {
49
- readonly side: Side;
50
- readonly align: Align;
51
- readonly sideOffset: number;
52
- readonly alignOffset: number;
53
- }
54
-
55
- const PositioningContext = React.createContext<MenuPositioning>({
56
- side: 'bottom',
57
- align: 'start',
58
- sideOffset: 4,
59
- alignOffset: 0,
60
- });
61
-
62
- // --- MenuRoot ---
63
-
64
- export interface MenuRootProps {
65
- readonly children?: React.ReactNode;
66
- readonly open?: boolean;
67
- readonly defaultOpen?: boolean;
68
- readonly onOpenChange?: (details: { open: boolean }) => void;
69
- readonly positioning?: Positioning;
70
- readonly lazyMount?: boolean;
71
- readonly unmountOnExit?: boolean;
72
- readonly modal?: boolean;
73
- }
74
-
75
- export const MenuRoot = (props: MenuRootProps): React.ReactElement => {
76
- const {
77
- children,
78
- open,
79
- defaultOpen,
80
- onOpenChange,
81
- positioning,
82
- modal = true,
83
- // lazyMount / unmountOnExit have no direct Radix equivalent; Radix handles
84
- // mount/unmount automatically.
85
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
86
- lazyMount: _lazyMount,
87
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
88
- unmountOnExit: _unmountOnExit,
89
- } = props;
90
-
91
- const mergedPositioning: Positioning = {
92
- placement: 'bottom-start',
93
- ...positioning,
94
- offset: {
95
- mainAxis: 4,
96
- ...positioning?.offset,
97
- },
98
- };
99
-
100
- const { side, align } = parsePlacement(mergedPositioning.placement);
101
-
102
- const positioningValue = React.useMemo<MenuPositioning>(() => ({
103
- side,
104
- align,
105
- sideOffset: mergedPositioning.offset?.mainAxis ?? 4,
106
- alignOffset: mergedPositioning.offset?.crossAxis ?? 0,
107
- }), [ side, align, mergedPositioning.offset?.mainAxis, mergedPositioning.offset?.crossAxis ]);
108
-
109
- const handleOpenChange = React.useCallback((isOpen: boolean) => {
110
- onOpenChange?.({ open: isOpen });
111
- }, [ onOpenChange ]);
112
-
113
- return (
114
- <PositioningContext.Provider value={ positioningValue }>
115
- <DropdownMenu.Root
116
- open={ open }
117
- defaultOpen={ defaultOpen }
118
- onOpenChange={ handleOpenChange }
119
- modal={ modal }
120
- >
121
- { children }
122
- </DropdownMenu.Root>
123
- </PositioningContext.Provider>
124
- );
125
- };
126
-
127
- // --- MenuTrigger ---
128
-
129
- export interface MenuTriggerProps extends React.ComponentPropsWithoutRef<'button'> {
130
- readonly asChild?: boolean;
131
- }
132
-
133
- export const MenuTrigger = React.forwardRef<
134
- HTMLButtonElement,
135
- MenuTriggerProps
136
- >(function MenuTrigger(props, ref) {
137
- const { asChild = false, ...rest } = props;
138
- return <DropdownMenu.Trigger asChild={ asChild } ref={ ref } { ...rest }/>;
139
- });
140
-
141
- // --- MenuContent ---
142
-
143
- export interface MenuContentProps extends React.ComponentPropsWithoutRef<'div'> {
144
- readonly portalled?: boolean;
145
- readonly portalRef?: React.RefObject<HTMLElement>;
146
-
147
- /** Legacy Chakra zIndex prop - mapped to inline style */
148
- readonly zIndex?: string | number;
149
-
150
- /** Legacy Chakra minW prop - mapped to inline style */
151
- readonly minW?: string | number;
152
- }
153
-
154
- export const MenuContent = React.forwardRef<
155
- HTMLDivElement,
156
- MenuContentProps
157
- >(function MenuContent(props, ref) {
158
- const { portalled = true, portalRef, className, zIndex, minW, style, ...rest } = props;
159
- const positioning = React.useContext(PositioningContext);
160
-
161
- const mergedStyle: React.CSSProperties = {
162
- ...style,
163
- ...(zIndex !== undefined ? { zIndex: typeof zIndex === 'string' ? `var(--z-index-${ zIndex }, ${ zIndex })` : zIndex } : {}),
164
- ...(minW !== undefined ? { minWidth: minW } : {}),
165
- };
166
-
167
- const content = (
168
- <DropdownMenu.Content
169
- ref={ ref }
170
- side={ positioning.side }
171
- align={ positioning.align }
172
- sideOffset={ positioning.sideOffset }
173
- alignOffset={ positioning.alignOffset }
174
- className={ cn(
175
- 'z-50 min-w-[8rem] overflow-hidden rounded-lg p-1',
176
- 'border border-[var(--color-popover-border,var(--color-border-divider))]',
177
- 'bg-[var(--color-popover-bg,var(--color-dialog-bg))]',
178
- 'shadow-[0_4px_12px_var(--color-popover-shadow)]',
179
- 'outline-none',
180
- 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
181
- 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
182
- 'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
183
- 'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
184
- className,
185
- ) }
186
- style={ Object.keys(mergedStyle).length > 0 ? mergedStyle : undefined }
187
- { ...rest }
188
- />
189
- );
190
-
191
- if (!portalled) {
192
- return content;
193
- }
194
-
195
- return (
196
- <DropdownMenu.Portal container={ portalRef?.current ?? undefined }>
197
- { content }
198
- </DropdownMenu.Portal>
199
- );
200
- });
201
-
202
- // --- MenuItem ---
203
-
204
- export interface MenuItemProps extends React.ComponentPropsWithoutRef<'div'> {
205
-
206
- /** Informational value identifier (not used by Radix but kept for API compat) */
207
- readonly value?: string;
208
- readonly disabled?: boolean;
209
- readonly asChild?: boolean;
210
-
211
- /** Chakra compat - accepted but not used by Radix */
212
- readonly closeOnSelect?: boolean;
213
- }
214
-
215
- export const MenuItem = React.forwardRef<
216
- HTMLDivElement,
217
- MenuItemProps
218
- >(function MenuItem(props, ref) {
219
- const { className, value: _value, asChild, closeOnSelect: _closeOnSelect, disabled, children, onClick, ...rest } = props;
220
- return (
221
- <DropdownMenu.Item
222
- ref={ ref }
223
- asChild={ asChild }
224
- disabled={ disabled }
225
- onClick={ onClick }
226
- className={ cn(
227
- 'relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm',
228
- 'outline-none transition-colors',
229
- 'text-[var(--color-text-primary,inherit)]',
230
- 'data-[highlighted]:bg-[var(--color-popover-item-hover,rgba(0,0,0,0.04))]',
231
- 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
232
- className,
233
- ) }
234
- { ...(rest as React.ComponentPropsWithoutRef<typeof DropdownMenu.Item>) }
235
- >
236
- { children }
237
- </DropdownMenu.Item>
238
- );
239
- });
240
-
241
- // --- MenuItemText ---
242
-
243
- export interface MenuItemTextProps extends React.ComponentPropsWithoutRef<'span'> {}
244
-
245
- export const MenuItemText = React.forwardRef<
246
- HTMLSpanElement,
247
- MenuItemTextProps
248
- >(function MenuItemText(props, ref) {
249
- const { className, ...rest } = props;
250
- return <span ref={ ref } className={ cn('flex-1', className) } { ...rest }/>;
251
- });
252
-
253
- // --- MenuItemCommand ---
254
-
255
- export interface MenuItemCommandProps extends React.ComponentPropsWithoutRef<'span'> {}
256
-
257
- export const MenuItemCommand = React.forwardRef<
258
- HTMLSpanElement,
259
- MenuItemCommandProps
260
- >(function MenuItemCommand(props, ref) {
261
- const { className, ...rest } = props;
262
- return (
263
- <span
264
- ref={ ref }
265
- className={ cn('ml-auto text-xs tracking-widest opacity-60', className) }
266
- { ...rest }
267
- />
268
- );
269
- });
270
-
271
- // --- MenuSeparator ---
272
-
273
- export interface MenuSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {}
274
-
275
- export const MenuSeparator = React.forwardRef<
276
- HTMLDivElement,
277
- MenuSeparatorProps
278
- >(function MenuSeparator(props, ref) {
279
- const { className, ...rest } = props;
280
- return (
281
- <DropdownMenu.Separator
282
- ref={ ref }
283
- className={ cn('-mx-1 my-1 h-px bg-[var(--color-border-divider)]', className) }
284
- { ...rest }
285
- />
286
- );
287
- });
288
-
289
- // --- MenuItemGroup ---
290
-
291
- export interface MenuItemGroupProps extends React.ComponentPropsWithoutRef<'div'> {
292
- readonly title?: string;
293
- }
294
-
295
- export const MenuItemGroup = React.forwardRef<
296
- HTMLDivElement,
297
- MenuItemGroupProps
298
- >(function MenuItemGroup(props, ref) {
299
- const { title, children, className, ...rest } = props;
300
- return (
301
- <DropdownMenu.Group ref={ ref } className={ className } { ...rest }>
302
- { title && (
303
- <DropdownMenu.Label className="px-2 py-1.5 text-xs font-semibold select-none opacity-60">
304
- { title }
305
- </DropdownMenu.Label>
306
- ) }
307
- { children }
308
- </DropdownMenu.Group>
309
- );
310
- });
311
-
312
- // --- MenuArrow ---
313
-
314
- export interface MenuArrowProps extends React.ComponentPropsWithoutRef<'svg'> {}
315
-
316
- export const MenuArrow = React.forwardRef<
317
- SVGSVGElement,
318
- MenuArrowProps
319
- >(function MenuArrow(props, ref) {
320
- const { className, ...rest } = props;
321
- return (
322
- <DropdownMenu.Arrow
323
- ref={ ref }
324
- className={ cn('fill-[var(--color-popover-bg,var(--color-dialog-bg))]', className) }
325
- { ...rest }
326
- />
327
- );
328
- });
329
-
330
- // --- MenuCheckboxItem ---
331
-
332
- export interface MenuCheckboxItemProps extends React.ComponentPropsWithoutRef<'div'> {
333
- readonly checked?: boolean;
334
- readonly onCheckedChange?: (checked: boolean) => void;
335
- }
336
-
337
- export const MenuCheckboxItem = React.forwardRef<
338
- HTMLDivElement,
339
- MenuCheckboxItemProps
340
- >(function MenuCheckboxItem(props, ref) {
341
- const { className, children, checked, onCheckedChange, onClick, ...rest } = props;
342
- return (
343
- <DropdownMenu.CheckboxItem
344
- ref={ ref }
345
- checked={ checked }
346
- onCheckedChange={ onCheckedChange }
347
- onClick={ onClick }
348
- className={ cn(
349
- 'relative flex cursor-pointer select-none items-center rounded-md py-1.5 pr-2 pl-8 text-sm',
350
- 'outline-none transition-colors',
351
- 'data-[highlighted]:bg-[var(--color-popover-item-hover,rgba(0,0,0,0.04))]',
352
- 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
353
- className,
354
- ) }
355
- { ...(rest as React.ComponentPropsWithoutRef<typeof DropdownMenu.CheckboxItem>) }
356
- >
357
- <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
358
- <DropdownMenu.ItemIndicator>
359
- <LuCheck className="h-4 w-4"/>
360
- </DropdownMenu.ItemIndicator>
361
- </span>
362
- { children }
363
- </DropdownMenu.CheckboxItem>
364
- );
365
- });
366
-
367
- // --- MenuRadioItemGroup ---
368
-
369
- export interface MenuRadioItemGroupProps extends React.ComponentPropsWithoutRef<'div'> {
370
- readonly value?: string;
371
- readonly onValueChange?: (value: string) => void;
372
- }
373
-
374
- export const MenuRadioItemGroup = React.forwardRef<
375
- HTMLDivElement,
376
- MenuRadioItemGroupProps
377
- >(function MenuRadioItemGroup(props, ref) {
378
- const { value, onValueChange, ...rest } = props;
379
- return (
380
- <DropdownMenu.RadioGroup
381
- ref={ ref }
382
- value={ value }
383
- onValueChange={ onValueChange }
384
- { ...rest }
385
- />
386
- );
387
- });
388
-
389
- // --- MenuRadioItem ---
390
-
391
- export interface MenuRadioItemProps extends React.ComponentPropsWithoutRef<'div'> {
392
- readonly value: string;
393
- }
394
-
395
- export const MenuRadioItem = React.forwardRef<
396
- HTMLDivElement,
397
- MenuRadioItemProps
398
- >(function MenuRadioItem(props, ref) {
399
- const { className, children, value, onClick, ...rest } = props;
400
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
401
- const { value: _v, ...radixRest } = rest as Record<string, unknown>;
402
- return (
403
- <DropdownMenu.RadioItem
404
- ref={ ref }
405
- value={ value }
406
- onClick={ onClick }
407
- className={ cn(
408
- 'relative flex cursor-pointer select-none items-center rounded-md py-1.5 pr-2 pl-8 text-sm',
409
- 'outline-none transition-colors',
410
- 'data-[highlighted]:bg-[var(--color-popover-item-hover,rgba(0,0,0,0.04))]',
411
- 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
412
- className,
413
- ) }
414
- >
415
- <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
416
- <DropdownMenu.ItemIndicator>
417
- <LuCheck className="h-4 w-4"/>
418
- </DropdownMenu.ItemIndicator>
419
- </span>
420
- <span>{ children }</span>
421
- </DropdownMenu.RadioItem>
422
- );
423
- });
424
-
425
- // --- MenuContextTrigger ---
426
- // Radix DropdownMenu does not have a native context-menu trigger.
427
- // For API compatibility, export a no-op wrapper. Real context-menu
428
- // support would require @radix-ui/react-context-menu.
429
-
430
- export interface MenuContextTriggerProps extends React.ComponentPropsWithoutRef<'span'> {
431
- readonly asChild?: boolean;
432
- }
433
-
434
- export const MenuContextTrigger = React.forwardRef<
435
- HTMLSpanElement,
436
- MenuContextTriggerProps
437
- >(function MenuContextTrigger(props, ref) {
438
- const { asChild: _asChild, ...rest } = props;
439
- return <span ref={ ref } { ...rest }/>;
440
- });
441
-
442
- // --- MenuTriggerItem ---
443
-
444
- export interface MenuTriggerItemProps extends React.ComponentPropsWithoutRef<'div'> {
445
- readonly startIcon?: React.ReactNode;
446
- }
447
-
448
- export const MenuTriggerItem = React.forwardRef<
449
- HTMLDivElement,
450
- MenuTriggerItemProps
451
- >(function MenuTriggerItem(props, ref) {
452
- const { startIcon, children, className, ...rest } = props;
453
- return (
454
- <DropdownMenu.Sub>
455
- <DropdownMenu.SubTrigger
456
- ref={ ref }
457
- className={ cn(
458
- 'relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm',
459
- 'outline-none transition-colors',
460
- 'data-[highlighted]:bg-[var(--color-popover-item-hover,rgba(0,0,0,0.04))]',
461
- className,
462
- ) }
463
- { ...rest }
464
- >
465
- { startIcon }
466
- <span className="flex-1">{ children }</span>
467
- <LuChevronRight className="h-4 w-4"/>
468
- </DropdownMenu.SubTrigger>
469
- </DropdownMenu.Sub>
470
- );
471
- });
package/src/pin-input.tsx DELETED
@@ -1,187 +0,0 @@
1
- import * as React from 'react';
2
-
3
- import { cn } from './utils';
4
-
5
- export interface PinInputProps {
6
- readonly rootRef?: React.Ref<HTMLDivElement>;
7
- readonly count?: number;
8
- readonly inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
9
- readonly attached?: boolean;
10
- readonly placeholder?: string;
11
- readonly value?: Array<string>;
12
- readonly onValueChange?: (details: { value: Array<string> }) => void;
13
- readonly onValueComplete?: (details: { value: Array<string> }) => void;
14
- readonly disabled?: boolean;
15
- readonly invalid?: boolean;
16
- readonly otp?: boolean;
17
- readonly name?: string;
18
- readonly bgColor?: string;
19
- readonly className?: string;
20
- }
21
-
22
- const INPUT_BASE = [
23
- 'w-10 h-10',
24
- 'text-center text-sm font-medium',
25
- 'border-2 rounded-md',
26
- 'outline-none appearance-none',
27
- 'bg-[var(--color-input-bg)] text-[var(--color-input-fg)]',
28
- 'border-[var(--color-input-border)]',
29
- 'placeholder:text-[var(--color-input-placeholder)]',
30
- 'hover:border-[var(--color-input-border-hover)]',
31
- 'focus:border-[var(--color-input-border-focus)] focus:shadow-md',
32
- 'disabled:opacity-40',
33
- ].join(' ');
34
-
35
- const INPUT_FILLED = 'border-[var(--color-input-border)]';
36
- const INPUT_INVALID = 'border-[var(--color-border-error)] hover:border-[var(--color-border-error)]';
37
-
38
- export const PinInput = React.forwardRef<HTMLInputElement, PinInputProps>(
39
- function PinInput(props, ref) {
40
- const {
41
- count = 6,
42
- inputProps,
43
- rootRef,
44
- attached,
45
- placeholder = ' ',
46
- value: controlledValue,
47
- onValueChange,
48
- onValueComplete,
49
- disabled,
50
- invalid,
51
- otp,
52
- name,
53
- bgColor,
54
- className,
55
- } = props;
56
-
57
- const inputRefs = React.useRef<Array<HTMLInputElement | null>>([]);
58
-
59
- const values = React.useMemo(
60
- () => controlledValue ?? Array.from<string>({ length: count }).fill(''),
61
- [ controlledValue, count ],
62
- );
63
-
64
- const updateValue = React.useCallback((index: number, char: string): void => {
65
- const next = [ ...values ];
66
- next[index] = char;
67
- onValueChange?.({ value: next });
68
-
69
- if (next.every((v) => v.length > 0)) {
70
- onValueComplete?.({ value: next });
71
- }
72
- }, [ values, onValueChange, onValueComplete ]);
73
-
74
- const focusInput = React.useCallback((index: number): void => {
75
- const clamped = Math.max(0, Math.min(index, count - 1));
76
- inputRefs.current[clamped]?.focus();
77
- }, [ count ]);
78
-
79
- const handleInput = React.useCallback((index: number, e: React.FormEvent<HTMLInputElement>): void => {
80
- const target = e.currentTarget;
81
- const char = target.value.slice(-1);
82
- updateValue(index, char);
83
-
84
- if (char && index < count - 1) {
85
- focusInput(index + 1);
86
- }
87
- }, [ count, updateValue, focusInput ]);
88
-
89
- const handleKeyDown = React.useCallback((index: number, e: React.KeyboardEvent<HTMLInputElement>): void => {
90
- if (e.key === 'Backspace') {
91
- if (values[index]) {
92
- updateValue(index, '');
93
- } else if (index > 0) {
94
- updateValue(index - 1, '');
95
- focusInput(index - 1);
96
- }
97
- e.preventDefault();
98
- } else if (e.key === 'ArrowLeft' && index > 0) {
99
- focusInput(index - 1);
100
- e.preventDefault();
101
- } else if (e.key === 'ArrowRight' && index < count - 1) {
102
- focusInput(index + 1);
103
- e.preventDefault();
104
- }
105
- }, [ count, values, updateValue, focusInput ]);
106
-
107
- const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLInputElement>): void => {
108
- e.preventDefault();
109
- const pasted = e.clipboardData.getData('text/plain').trim();
110
- if (!pasted) {
111
- return;
112
- }
113
- const chars = pasted.slice(0, count).split('');
114
- const next = [ ...values ];
115
- chars.forEach((char, i) => {
116
- next[i] = char;
117
- });
118
- onValueChange?.({ value: next });
119
-
120
- if (next.every((v) => v.length > 0)) {
121
- onValueComplete?.({ value: next });
122
- }
123
-
124
- focusInput(Math.min(chars.length, count - 1));
125
- }, [ count, values, onValueChange, onValueComplete, focusInput ]);
126
-
127
- const handleFocus = React.useCallback((e: React.FocusEvent<HTMLInputElement>): void => {
128
- e.currentTarget.select();
129
- }, []);
130
-
131
- const handleNoop = React.useCallback((): void => { /* noop */ }, []);
132
-
133
- const setInputRef = React.useCallback((index: number) => (el: HTMLInputElement | null): void => {
134
- inputRefs.current[index] = el;
135
- }, []);
136
-
137
- const onInputAtIndex = React.useCallback((index: number) => (e: React.FormEvent<HTMLInputElement>): void => {
138
- handleInput(index, e);
139
- }, [ handleInput ]);
140
-
141
- const onKeyDownAtIndex = React.useCallback((index: number) => (e: React.KeyboardEvent<HTMLInputElement>): void => {
142
- handleKeyDown(index, e);
143
- }, [ handleKeyDown ]);
144
-
145
- const bgStyle = bgColor ? { backgroundColor: `var(--color-${ bgColor.replace(/\./g, '-') })` } : undefined;
146
-
147
- return (
148
- <div ref={ rootRef } className={ cn('inline-flex items-center', attached ? 'gap-0' : 'gap-2', className) }>
149
- { /* Hidden input for form submission */ }
150
- <input
151
- ref={ ref }
152
- type="hidden"
153
- name={ name }
154
- value={ values.join('') }
155
- { ...inputProps }
156
- />
157
- { Array.from({ length: count }).map((_, index) => (
158
- <input
159
- key={ index }
160
- ref={ setInputRef(index) }
161
- type="text"
162
- inputMode="numeric"
163
- autoComplete={ otp ? 'one-time-code' : 'off' }
164
- pattern="[0-9]*"
165
- maxLength={ 1 }
166
- placeholder={ placeholder }
167
- disabled={ disabled }
168
- aria-invalid={ invalid || undefined }
169
- value={ values[index] || '' }
170
- className={ cn(
171
- INPUT_BASE,
172
- values[index] && INPUT_FILLED,
173
- invalid && INPUT_INVALID,
174
- attached && index > 0 && '-ml-0.5',
175
- ) }
176
- style={ bgStyle }
177
- onInput={ onInputAtIndex(index) }
178
- onKeyDown={ onKeyDownAtIndex(index) }
179
- onPaste={ handlePaste }
180
- onFocus={ handleFocus }
181
- onChange={ handleNoop }
182
- />
183
- )) }
184
- </div>
185
- );
186
- },
187
- );