@altruist03/ui 0.0.0-stage → 0.2.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 (205) hide show
  1. package/assets/images/noData.webp +0 -0
  2. package/dist/index.js +15318 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/noData-XPRHKMFR.webp +0 -0
  5. package/package.json +51 -4
  6. package/src/ThemeProvider/ThemeContext.tsx +75 -0
  7. package/src/ThemeProvider/index.ts +7 -0
  8. package/src/assets/images/noData.webp +0 -0
  9. package/src/assets.d.ts +10 -0
  10. package/src/components/Accordion/Accordion.stories.tsx +64 -0
  11. package/src/components/Accordion/Accordion.tsx +219 -0
  12. package/src/components/Accordion/Accordion.types.ts +16 -0
  13. package/src/components/Avatar/Avatar.stories.tsx +304 -0
  14. package/src/components/Avatar/Avatar.tsx +211 -0
  15. package/src/components/Avatar/Avatar.types.ts +33 -0
  16. package/src/components/Badge/Badge.stories.tsx +143 -0
  17. package/src/components/Badge/Badge.tsx +138 -0
  18. package/src/components/Badge/Badge.types.ts +24 -0
  19. package/src/components/Breadcrumb/Breadcrumb.stories.tsx +114 -0
  20. package/src/components/Breadcrumb/Breadcrumb.tsx +120 -0
  21. package/src/components/Breadcrumb/Breadcrumb.types.ts +22 -0
  22. package/src/components/Button/Button.stories.tsx +457 -0
  23. package/src/components/Button/Button.tsx +202 -0
  24. package/src/components/Button/Button.types.ts +18 -0
  25. package/src/components/Checkbox/Checkbox.stories.tsx +254 -0
  26. package/src/components/Checkbox/Checkbox.tsx +352 -0
  27. package/src/components/Checkbox/Checkbox.types.ts +123 -0
  28. package/src/components/CloseButton/CloseButton.stories.tsx +100 -0
  29. package/src/components/CloseButton/CloseButton.tsx +117 -0
  30. package/src/components/CloseButton/CloseButton.types.ts +15 -0
  31. package/src/components/CollapsibleSearch/CollapsibleSearch.tsx +120 -0
  32. package/src/components/DataTable/DataTable.stories.tsx +233 -0
  33. package/src/components/DataTable/DataTable.tsx +968 -0
  34. package/src/components/DataTable/DataTable.types.ts +49 -0
  35. package/src/components/DatePicker/DatePicker.stories.tsx +341 -0
  36. package/src/components/DatePicker/DatePicker.tsx +669 -0
  37. package/src/components/DatePicker/DatePicker.types.ts +105 -0
  38. package/src/components/DatePicker/DateTimePicker.tsx +35 -0
  39. package/src/components/DateRangePicker/DateRangePicker.tsx +179 -0
  40. package/src/components/DateRangePicker/DateRangePicker.types.ts +34 -0
  41. package/src/components/Drawer/Drawer.stories.tsx +243 -0
  42. package/src/components/Drawer/Drawer.tsx +196 -0
  43. package/src/components/Drawer/Drawer.types.ts +60 -0
  44. package/src/components/FileUpload/FileUpload.stories.tsx +162 -0
  45. package/src/components/FileUpload/FileUpload.tsx +216 -0
  46. package/src/components/FileUpload/FileUpload.types.ts +80 -0
  47. package/src/components/FilterPopover/FilterPopover.stories.tsx +173 -0
  48. package/src/components/FilterPopover/FilterPopover.tsx +310 -0
  49. package/src/components/FilterPopover/FilterPopover.types.ts +26 -0
  50. package/src/components/ImageViewer/ImageViewerToolbar.jsx +81 -0
  51. package/src/components/ImageViewer/useImagePanRotate.js +102 -0
  52. package/src/components/IpWhitelistField/IpWhitelistField.stories.tsx +138 -0
  53. package/src/components/IpWhitelistField/IpWhitelistField.tsx +164 -0
  54. package/src/components/IpWhitelistField/IpWhitelistField.types.ts +42 -0
  55. package/src/components/Loader/Loader.stories.tsx +95 -0
  56. package/src/components/Loader/Loader.tsx +132 -0
  57. package/src/components/Loader/Loader.types.ts +6 -0
  58. package/src/components/Menu/Menu.tsx +143 -0
  59. package/src/components/Menu/Menu.types.ts +28 -0
  60. package/src/components/MenuButton/MenuButton.stories.tsx +140 -0
  61. package/src/components/MenuButton/MenuButton.tsx +45 -0
  62. package/src/components/MenuButton/MenuButton.types.ts +21 -0
  63. package/src/components/Modal/Modal.stories.tsx +266 -0
  64. package/src/components/Modal/Modal.tsx +399 -0
  65. package/src/components/Modal/index.ts +2 -0
  66. package/src/components/Modal/types.ts +126 -0
  67. package/src/components/Pagination/Pagination.stories.tsx +167 -0
  68. package/src/components/Pagination/Pagination.tsx +266 -0
  69. package/src/components/Pagination/Pagination.types.ts +20 -0
  70. package/src/components/PeriodSelector/PeriodSelector.stories.tsx +50 -0
  71. package/src/components/PeriodSelector/PeriodSelector.tsx +47 -0
  72. package/src/components/PeriodSelector/PeriodSelector.types.ts +10 -0
  73. package/src/components/PhoneField/PhoneField.stories.tsx +141 -0
  74. package/src/components/PhoneField/PhoneField.tsx +284 -0
  75. package/src/components/PhoneField/PhoneField.types.ts +89 -0
  76. package/src/components/PhoneField/countryCodes.ts +236 -0
  77. package/src/components/Progress/Progress.stories.tsx +83 -0
  78. package/src/components/Progress/Progress.tsx +91 -0
  79. package/src/components/Progress/Progress.types.ts +22 -0
  80. package/src/components/RadioButton/RadioButton.stories.tsx +332 -0
  81. package/src/components/RadioButton/RadioButton.tsx +427 -0
  82. package/src/components/RadioButton/RadioButton.types.ts +117 -0
  83. package/src/components/ReportCard/ReportCard.tsx +183 -0
  84. package/src/components/ReportCard/ReportCard.types.ts +17 -0
  85. package/src/components/SectionIcon/SectionIcon.tsx +87 -0
  86. package/src/components/SectionIcon/SectionIcon.types.ts +23 -0
  87. package/src/components/SegmentedInputField/SegmentedInputField.stories.tsx +165 -0
  88. package/src/components/SegmentedInputField/SegmentedInputField.tsx +174 -0
  89. package/src/components/SegmentedInputField/SegmentedInputField.types.ts +51 -0
  90. package/src/components/SelectScroll/SelectScroll.stories.tsx +336 -0
  91. package/src/components/SelectScroll/SelectScroll.tsx +1119 -0
  92. package/src/components/SelectScroll/SelectScroll.types.ts +60 -0
  93. package/src/components/Selector/Selector.stories.tsx +382 -0
  94. package/src/components/Selector/Selector.tsx +1310 -0
  95. package/src/components/Selector/Selector.types.ts +71 -0
  96. package/src/components/Skeleton/Skeleton.stories.tsx +133 -0
  97. package/src/components/Skeleton/Skeleton.tsx +20 -0
  98. package/src/components/Skeleton/Skeleton.types.ts +3 -0
  99. package/src/components/Spinner/Spinner.stories.tsx +84 -0
  100. package/src/components/Spinner/Spinner.tsx +79 -0
  101. package/src/components/Spinner/Spinner.types.ts +8 -0
  102. package/src/components/StatCard/StatCard.stories.tsx +125 -0
  103. package/src/components/StatCard/StatCard.tsx +167 -0
  104. package/src/components/StatCard/StatCard.types.ts +20 -0
  105. package/src/components/StatusBadge/StatusBadge.stories.tsx +179 -0
  106. package/src/components/StatusBadge/StatusBadge.tsx +74 -0
  107. package/src/components/StatusBadge/StatusBadge.types.ts +23 -0
  108. package/src/components/Stepper/Stepper.stories.tsx +85 -0
  109. package/src/components/Stepper/Stepper.tsx +138 -0
  110. package/src/components/Stepper/Stepper.types.ts +16 -0
  111. package/src/components/SubTabs/SubTabs.stories.tsx +128 -0
  112. package/src/components/SubTabs/SubTabs.tsx +279 -0
  113. package/src/components/SubTabs/SubTabs.types.ts +44 -0
  114. package/src/components/SummaryCard/SummaryCard.stories.tsx +100 -0
  115. package/src/components/SummaryCard/SummaryCard.tsx +141 -0
  116. package/src/components/SummaryCard/SummaryCard.types.ts +22 -0
  117. package/src/components/Switch/Switch.stories.tsx +59 -0
  118. package/src/components/Switch/Switch.tsx +217 -0
  119. package/src/components/Switch/Switch.types.ts +22 -0
  120. package/src/components/Tabs/Tabs.stories.tsx +178 -0
  121. package/src/components/Tabs/Tabs.tsx +197 -0
  122. package/src/components/Tabs/Tabs.types.ts +34 -0
  123. package/src/components/TextField/TextField.stories.tsx +249 -0
  124. package/src/components/TextField/TextField.tsx +462 -0
  125. package/src/components/TextField/TextField.types.ts +141 -0
  126. package/src/components/Toast/Toast.stories.tsx +237 -0
  127. package/src/components/Toast/Toast.tsx +246 -0
  128. package/src/components/Toast/Toast.types.ts +31 -0
  129. package/src/components/ToggleButton/ToggleButton.stories.tsx +105 -0
  130. package/src/components/ToggleButton/ToggleButton.tsx +267 -0
  131. package/src/components/ToggleButton/ToggleButton.types.ts +19 -0
  132. package/src/components/UploadDropzone/UploadDropzone.jsx +78 -0
  133. package/src/components/UploadDropzone2/UploadDropzone.stories.tsx +94 -0
  134. package/src/components/UploadDropzone2/UploadDropzone.tsx +222 -0
  135. package/src/components/UploadDropzone2/UploadDropzone.types.ts +54 -0
  136. package/src/components/ViewEditToggle/ViewEditToggle.stories.tsx +40 -0
  137. package/src/components/ViewEditToggle/ViewEditToggle.tsx +239 -0
  138. package/src/components/ViewEditToggle/ViewEditToggle.types.ts +27 -0
  139. package/src/components/index.ts +157 -0
  140. package/src/icons/ReportDollarIcon.tsx +71 -0
  141. package/src/icons/index.ts +27 -0
  142. package/src/index.ts +214 -0
  143. package/src/storybook/preview.tsx +64 -0
  144. package/src/theme/index.ts +4 -0
  145. package/src/theme/types.d.ts +1 -0
  146. package/src/themes/cpc.ts +4 -0
  147. package/src/themes/firebird.ts +4 -0
  148. package/src/tokens/brands/cpc/themes/dark.json +82 -0
  149. package/src/tokens/brands/cpc/themes/light.json +1 -0
  150. package/src/tokens/brands/default/brand.json +20 -0
  151. package/src/tokens/brands/firebird/themes/dark.json +28 -0
  152. package/src/tokens/brands/firebird/themes/light.json +20 -0
  153. package/src/tokens/button.ts +24 -0
  154. package/src/tokens/components/accordion.json +52 -0
  155. package/src/tokens/components/avatar.json +40 -0
  156. package/src/tokens/components/badge.json +41 -0
  157. package/src/tokens/components/breadcrumb.json +13 -0
  158. package/src/tokens/components/button.json +67 -0
  159. package/src/tokens/components/card.json +39 -0
  160. package/src/tokens/components/checkbox.json +64 -0
  161. package/src/tokens/components/closebutton.json +55 -0
  162. package/src/tokens/components/datatable.json +40 -0
  163. package/src/tokens/components/datepicker.json +99 -0
  164. package/src/tokens/components/drawer.json +23 -0
  165. package/src/tokens/components/field.json +29 -0
  166. package/src/tokens/components/fileupload.json +57 -0
  167. package/src/tokens/components/filterpopover.json +29 -0
  168. package/src/tokens/components/input.json +31 -0
  169. package/src/tokens/components/ipwhitelistfield.json +16 -0
  170. package/src/tokens/components/loader.json +15 -0
  171. package/src/tokens/components/menu.json +21 -0
  172. package/src/tokens/components/modal.json +25 -0
  173. package/src/tokens/components/pagination.json +45 -0
  174. package/src/tokens/components/phonefield.json +41 -0
  175. package/src/tokens/components/progress.json +12 -0
  176. package/src/tokens/components/radio.json +87 -0
  177. package/src/tokens/components/reportcard.json +17 -0
  178. package/src/tokens/components/selector.json +87 -0
  179. package/src/tokens/components/selectscroll.json +65 -0
  180. package/src/tokens/components/skeleton.json +6 -0
  181. package/src/tokens/components/stepper.json +38 -0
  182. package/src/tokens/components/subtabs.json +45 -0
  183. package/src/tokens/components/switch.json +55 -0
  184. package/src/tokens/components/tabs.json +38 -0
  185. package/src/tokens/components/textfield.json +59 -0
  186. package/src/tokens/components/togglebutton.json +48 -0
  187. package/src/tokens/components/uploaddropzone.json +61 -0
  188. package/src/tokens/drawer.ts +37 -0
  189. package/src/tokens/foundation/breakpoints.json +10 -0
  190. package/src/tokens/foundation/colors.json +122 -0
  191. package/src/tokens/foundation/opacity.json +16 -0
  192. package/src/tokens/foundation/radius.json +14 -0
  193. package/src/tokens/foundation/size.json +38 -0
  194. package/src/tokens/foundation/spacing.json +19 -0
  195. package/src/tokens/foundation/typography.json +43 -0
  196. package/src/tokens/index.ts +3665 -0
  197. package/src/tokens/modal.ts +42 -0
  198. package/src/tokens/semantic/color.json +148 -0
  199. package/src/tokens/semantic/field.json +28 -0
  200. package/src/tokens/semantic/opacity.json +5 -0
  201. package/src/tokens/semantic/size.json +722 -0
  202. package/src/tokens/semantic/spacing.json +27 -0
  203. package/src/tokens/semantic/typography.json +87 -0
  204. package/src/utils/scrollLock.ts +44 -0
  205. package/README.md +0 -3
@@ -0,0 +1,183 @@
1
+ import React from 'react';
2
+ import Icon from '@mui/material/Icon';
3
+ import type { ReportCardProps } from './ReportCard.types';
4
+ import { getReportCardTokensFromResolved } from '../../tokens';
5
+ import { useTokens } from '../../ThemeProvider/ThemeContext';
6
+
7
+ export const ReportCard = ({
8
+ title,
9
+ icon,
10
+ items,
11
+ onItemClick,
12
+ highlightedItemId,
13
+ style,
14
+ className,
15
+ }: ReportCardProps) => {
16
+ const resolvedTokens = useTokens();
17
+ const t = getReportCardTokensFromResolved(resolvedTokens);
18
+ const renderedIcon =
19
+ typeof icon === 'string' ?
20
+ <Icon
21
+ style={{
22
+ color: t.headerColor,
23
+ fontSize: 16,
24
+ display: 'block',
25
+ }}
26
+ >
27
+ {icon}
28
+ </Icon>
29
+ : icon;
30
+
31
+ return (
32
+ <div
33
+ className={className}
34
+ style={{
35
+ backgroundColor: t.bodyBackground,
36
+ borderRadius: t.borderRadius,
37
+ overflow: 'hidden',
38
+ boxShadow: t.shadow,
39
+ ...style,
40
+ }}
41
+ >
42
+ {/* Header */}
43
+ <div
44
+ style={{
45
+ backgroundColor: t.headerBackground,
46
+ display: 'flex',
47
+ alignItems: 'center',
48
+ gap: 10,
49
+ padding: '14px 18px',
50
+ }}
51
+ >
52
+ <span
53
+ style={{
54
+ display: 'flex',
55
+ alignItems: 'center',
56
+ justifyContent: 'center',
57
+ color: t.headerColor,
58
+ background: `color-mix(in srgb, ${t.headerColor} 15%, transparent)`,
59
+ padding: 4,
60
+ borderRadius: 6,
61
+ fontSize: 16,
62
+ lineHeight: 1,
63
+ }}
64
+ >
65
+ {renderedIcon}
66
+ </span>
67
+ <span
68
+ style={{
69
+ fontSize: 15,
70
+ fontWeight: 600,
71
+ color: t.headerColor,
72
+ letterSpacing: 0.2,
73
+ }}
74
+ >
75
+ {title}
76
+ </span>
77
+ </div>
78
+
79
+ {/* Body */}
80
+ <div style={{ display: 'flex', padding: '16px 18px' }}>
81
+ {/* Left vertical divider */}
82
+ <div
83
+ style={{
84
+ width: 2,
85
+ backgroundColor: t.dividerColor,
86
+ borderRadius: 2,
87
+ flexShrink: 0,
88
+ marginRight: 16,
89
+ alignSelf: 'stretch',
90
+ minHeight: '100%',
91
+ }}
92
+ />
93
+ {/* Items */}
94
+ <ul
95
+ style={{
96
+ listStyle: 'none',
97
+ margin: 0,
98
+ padding: 0,
99
+ display: 'flex',
100
+ flexDirection: 'column',
101
+ gap: 12,
102
+ }}
103
+ >
104
+ {items.map((item) => (
105
+ <ReportCardItem
106
+ key={item.id}
107
+ label={item.label}
108
+ bulletColor={t.bulletColor}
109
+ itemColor={t.itemColor}
110
+ itemHoverColor={t.itemHoverColor}
111
+ highlighted={
112
+ highlightedItemId != null && String(item.id) === String(highlightedItemId)
113
+ }
114
+ onClick={onItemClick ? () => onItemClick(item) : undefined}
115
+ />
116
+ ))}
117
+ </ul>
118
+ </div>
119
+ </div>
120
+ );
121
+ };
122
+
123
+ function ReportCardItem({
124
+ label,
125
+ bulletColor,
126
+ itemColor,
127
+ itemHoverColor,
128
+ highlighted,
129
+ onClick,
130
+ }: {
131
+ label: string;
132
+ bulletColor: string;
133
+ itemColor: string;
134
+ itemHoverColor: string;
135
+ highlighted?: boolean;
136
+ onClick?: () => void;
137
+ }) {
138
+ const [hovered, setHovered] = React.useState(false);
139
+
140
+ return (
141
+ <li
142
+ onClick={onClick}
143
+ onMouseEnter={() => setHovered(true)}
144
+ onMouseLeave={() => setHovered(false)}
145
+ style={{
146
+ display: 'flex',
147
+ alignItems: 'center',
148
+ gap: 10,
149
+ cursor: onClick ? 'pointer' : 'default',
150
+ borderRadius: 6,
151
+ padding: highlighted ? '2px 6px' : '2px 6px',
152
+ margin: highlighted ? '0 -6px' : '0 -6px',
153
+ backgroundColor: highlighted ? 'rgba(82, 118, 10, 0.18)' : 'transparent',
154
+ transition: 'background-color 0.4s',
155
+ }}
156
+ >
157
+ <span
158
+ style={{
159
+ width: 8,
160
+ height: 8,
161
+ borderRadius: '50%',
162
+ backgroundColor: bulletColor,
163
+ flexShrink: 0,
164
+ display: 'inline-block',
165
+ }}
166
+ />
167
+ <span
168
+ style={{
169
+ fontSize: 13.5,
170
+ color:
171
+ highlighted ? '#52760A'
172
+ : hovered ? itemHoverColor
173
+ : itemColor,
174
+ lineHeight: 1.4,
175
+ transition: 'color 0.15s',
176
+ fontWeight: highlighted ? 600 : undefined,
177
+ }}
178
+ >
179
+ {label}
180
+ </span>
181
+ </li>
182
+ );
183
+ }
@@ -0,0 +1,17 @@
1
+ import type React from 'react';
2
+
3
+ export interface ReportCardItem {
4
+ id: string;
5
+ label: string;
6
+ [key: string]: unknown;
7
+ }
8
+
9
+ export interface ReportCardProps {
10
+ title: string;
11
+ icon: React.ReactNode;
12
+ items: ReportCardItem[];
13
+ onItemClick?: (item: ReportCardItem) => void;
14
+ highlightedItemId?: string | null;
15
+ style?: React.CSSProperties;
16
+ className?: string;
17
+ }
@@ -0,0 +1,87 @@
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ isValidElement,
5
+ type CSSProperties,
6
+ type ReactElement,
7
+ } from 'react';
8
+ import type { SectionIconProps, SectionIconSize, SectionIconVariant } from './SectionIcon.types';
9
+
10
+ const SIZE_TOKENS: Record<SectionIconSize, { box: number; icon: number; radius: number }> = {
11
+ sm: { box: 24, icon: 14, radius: 6 },
12
+ md: { box: 32, icon: 16, radius: 8 },
13
+ lg: { box: 40, icon: 20, radius: 10 },
14
+ xl: { box: 56, icon: 28, radius: 14 },
15
+ };
16
+
17
+ const VARIANT_TOKENS: Record<SectionIconVariant, { background: string; color: string }> = {
18
+ green: { background: '#E6F4E0', color: '#52760A' },
19
+ blue: { background: '#E8EEFF', color: '#3B5BD9' },
20
+ grey: { background: '#F1F2F5', color: '#4F5560' },
21
+ red: { background: '#FCE6E6', color: '#C53030' },
22
+ yellow: { background: '#FFF4D6', color: '#B27800' },
23
+ };
24
+
25
+ /**
26
+ * SectionIcon
27
+ *
28
+ * A small decorative icon tile (rounded square) used in section headers
29
+ * (e.g. "View Receipt Report" header on the fuel page). Renders any
30
+ * `ReactNode` icon inside a coloured background, with the icon coloured
31
+ * to match the chosen variant.
32
+ */
33
+ export function SectionIcon({
34
+ icon,
35
+ variant = 'green',
36
+ size = 'md',
37
+ backgroundColor,
38
+ color,
39
+ borderRadius,
40
+ ariaLabel,
41
+ className,
42
+ style,
43
+ }: SectionIconProps) {
44
+ const sizeTokens = SIZE_TOKENS[size];
45
+ const variantTokens = VARIANT_TOKENS[variant];
46
+ const resolvedBackground = backgroundColor ?? variantTokens.background;
47
+ const resolvedColor = color ?? variantTokens.color;
48
+ const resolvedRadius = borderRadius ?? sizeTokens.radius;
49
+
50
+ const containerStyle: CSSProperties = {
51
+ display: 'inline-flex',
52
+ alignItems: 'center',
53
+ justifyContent: 'center',
54
+ width: sizeTokens.box,
55
+ height: sizeTokens.box,
56
+ minWidth: sizeTokens.box,
57
+ borderRadius: resolvedRadius,
58
+ background: resolvedBackground,
59
+ color: resolvedColor,
60
+ ...style,
61
+ };
62
+
63
+ const renderIcon = () => {
64
+ if (!isValidElement(icon)) return icon;
65
+ const onlyChild = Children.only(icon) as ReactElement<{
66
+ sx?: Record<string, unknown>;
67
+ style?: CSSProperties;
68
+ }>;
69
+ const existingSx = (onlyChild.props.sx ?? {}) as Record<string, unknown>;
70
+ return cloneElement(onlyChild, {
71
+ sx: { fontSize: sizeTokens.icon, ...existingSx },
72
+ style: { color: resolvedColor, ...(onlyChild.props.style ?? {}) },
73
+ });
74
+ };
75
+
76
+ return (
77
+ <span
78
+ role={ariaLabel ? 'img' : undefined}
79
+ aria-label={ariaLabel}
80
+ aria-hidden={ariaLabel ? undefined : true}
81
+ className={className}
82
+ style={containerStyle}
83
+ >
84
+ {renderIcon()}
85
+ </span>
86
+ );
87
+ }
@@ -0,0 +1,23 @@
1
+ import type { CSSProperties, ReactNode } from 'react';
2
+
3
+ export type SectionIconVariant = 'green' | 'blue' | 'grey' | 'red' | 'yellow';
4
+ export type SectionIconSize = 'sm' | 'md' | 'lg' | 'xl';
5
+
6
+ export interface SectionIconProps {
7
+ /** Icon node to render inside the tile (e.g. an MUI icon). */
8
+ icon: ReactNode;
9
+ /** Visual variant — controls background and icon colour. Defaults to "green". */
10
+ variant?: SectionIconVariant;
11
+ /** Tile size — controls box and icon size. Defaults to "md". */
12
+ size?: SectionIconSize;
13
+ /** Override the background colour (wins over variant). */
14
+ backgroundColor?: string;
15
+ /** Override the icon colour (wins over variant). */
16
+ color?: string;
17
+ /** Optional radius override (defaults to design-system md radius). */
18
+ borderRadius?: number | string;
19
+ /** Accessibility label; when omitted, the tile is decorative (aria-hidden). */
20
+ ariaLabel?: string;
21
+ className?: string;
22
+ style?: CSSProperties;
23
+ }
@@ -0,0 +1,165 @@
1
+ import React, { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { SegmentedInputField } from "./SegmentedInputField";
4
+ import type { SegmentedInputFieldProps } from "./SegmentedInputField.types";
5
+
6
+ const meta: Meta<typeof SegmentedInputField> = {
7
+ title: "Design System/SegmentedInputField",
8
+ component: SegmentedInputField,
9
+ tags: [],
10
+ decorators: [
11
+ (Story) => (
12
+ <div style={{ padding: "24px", maxWidth: "780px" }}>
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
17
+ args: {
18
+ values: Array(6).fill(""),
19
+ type: "text",
20
+ inputMode: "numeric",
21
+ autoComplete: "one-time-code",
22
+ size: "lg",
23
+ disabled: false,
24
+ error: false,
25
+ maxLength: 1,
26
+ getAriaLabel: (index) => `Segment ${index + 1}`,
27
+ sanitizeSegment: (raw) => raw.replace(/[^0-9]/g, "").slice(0, 1),
28
+ validateSegment: (nextValue) => nextValue === "" || /^[0-9]$/.test(nextValue),
29
+ onPasteValue: (pasted, length) => {
30
+ const numbers = pasted.replace(/[^0-9]/g, "");
31
+ return Array(length)
32
+ .fill("")
33
+ .map((_, idx) => numbers[idx] ?? "");
34
+ },
35
+ } as SegmentedInputFieldProps,
36
+ argTypes: {
37
+ size: {
38
+ control: { type: "radio" },
39
+ options: ["sm", "md", "lg"],
40
+ },
41
+ disabled: { control: { type: "boolean" } },
42
+ error: { control: { type: "boolean" } },
43
+ type: { control: { type: "text" } },
44
+ inputMode: { control: { type: "text" } },
45
+ autoComplete: { control: { type: "text" } },
46
+ maxLength: { control: { type: "number" } },
47
+ showSeparator: { control: { type: "boolean" } },
48
+ values: { control: { type: "object" } },
49
+ onValuesChange: { table: { disable: true } },
50
+ separator: { table: { disable: true } },
51
+ getAriaLabel: { table: { disable: true } },
52
+ sanitizeSegment: { table: { disable: true } },
53
+ validateSegment: { table: { disable: true } },
54
+ onPasteValue: { table: { disable: true } },
55
+ isSegmentComplete: { table: { disable: true } },
56
+ onSegmentKeyDown: { table: { disable: true } },
57
+ },
58
+ };
59
+
60
+ export default meta;
61
+ type Story = StoryObj<typeof SegmentedInputField>;
62
+
63
+ const Controlled = (props: SegmentedInputFieldProps) => {
64
+ const [values, setValues] = useState<string[]>(props.values);
65
+
66
+ React.useEffect(() => {
67
+ setValues(props.values);
68
+ }, [props.values]);
69
+
70
+ return <SegmentedInputField {...props} values={values} onValuesChange={setValues} />;
71
+ };
72
+
73
+ export const Playground: Story = {
74
+ render: (args) => <Controlled {...args} />,
75
+ };
76
+
77
+ const ShowcaseComponent = () => {
78
+ const [otp, setOtp] = useState(Array(6).fill(""));
79
+ const [ip, setIp] = useState(["", "", "", ""]);
80
+
81
+ return (
82
+ <div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
83
+ <div>
84
+ <div style={{ marginBottom: "8px", fontWeight: 600 }}>
85
+ OTP (6 digits)
86
+ </div>
87
+ <SegmentedInputField
88
+ values={otp}
89
+ onValuesChange={setOtp}
90
+ type="password"
91
+ inputMode="numeric"
92
+ maxLength={1}
93
+ autoComplete="one-time-code"
94
+ getAriaLabel={(i) => `OTP digit ${i + 1}`}
95
+ sanitizeSegment={(raw) => raw.replace(/[^0-9]/g, "").slice(0, 1)}
96
+ validateSegment={(v) => v === "" || /^[0-9]$/.test(v)}
97
+ onPasteValue={(pasted, length) => {
98
+ const numbers = pasted.replace(/[^0-9]/g, "");
99
+ return Array(length)
100
+ .fill("")
101
+ .map((_, idx) => numbers[idx] ?? "");
102
+ }}
103
+ />
104
+ </div>
105
+
106
+ <div>
107
+ <div style={{ marginBottom: "8px", fontWeight: 600 }}>
108
+ IPv4 pattern (supports "*" per octet)
109
+ </div>
110
+ <SegmentedInputField
111
+ values={ip}
112
+ onValuesChange={setIp}
113
+ inputMode="numeric"
114
+ maxLength={3}
115
+ autoComplete="off"
116
+ showSeparator
117
+ separator={<span className="spanMargin m-0">.</span>}
118
+ getAriaLabel={(i) => `IP part ${i + 1}`}
119
+ sanitizeSegment={(raw) => {
120
+ if (raw === "*") return raw;
121
+ return raw.replace(/[^0-9]/g, "").slice(0, 3);
122
+ }}
123
+ validateSegment={(v) => {
124
+ if (v === "" || v === "*") return true;
125
+ const n = Number(v);
126
+ return Number.isInteger(n) && n >= 0 && n <= 255;
127
+ }}
128
+ isSegmentComplete={(v) => v === "*" || v.length === 3}
129
+ />
130
+ </div>
131
+
132
+ <div>
133
+ <div style={{ marginBottom: "8px", fontWeight: 600 }}>Error</div>
134
+ <SegmentedInputField
135
+ values={otp}
136
+ onValuesChange={setOtp}
137
+ error
138
+ inputMode="numeric"
139
+ maxLength={1}
140
+ getAriaLabel={(i) => `OTP digit ${i + 1}`}
141
+ sanitizeSegment={(raw) => raw.replace(/[^0-9]/g, "").slice(0, 1)}
142
+ validateSegment={(v) => v === "" || /^[0-9]$/.test(v)}
143
+ />
144
+ </div>
145
+
146
+ <div>
147
+ <div style={{ marginBottom: "8px", fontWeight: 600 }}>Disabled</div>
148
+ <SegmentedInputField
149
+ values={otp}
150
+ onValuesChange={setOtp}
151
+ disabled
152
+ inputMode="numeric"
153
+ maxLength={1}
154
+ getAriaLabel={(i) => `OTP digit ${i + 1}`}
155
+ sanitizeSegment={(raw) => raw.replace(/[^0-9]/g, "").slice(0, 1)}
156
+ validateSegment={(v) => v === "" || /^[0-9]$/.test(v)}
157
+ />
158
+ </div>
159
+ </div>
160
+ );
161
+ };
162
+
163
+ export const Showcase: Story = {
164
+ render: () => <ShowcaseComponent />,
165
+ };
@@ -0,0 +1,174 @@
1
+ import React, { useRef } from "react";
2
+ import { useTokens } from "../../ThemeProvider/ThemeContext";
3
+ import { TextField } from "../TextField/TextField";
4
+ import type {
5
+ SegmentedInputFieldProps,
6
+ SegmentedInputChangeContext,
7
+ } from "./SegmentedInputField.types";
8
+
9
+ export const SegmentedInputField = ({
10
+ values,
11
+ onValuesChange,
12
+ disabled = false,
13
+ error = false,
14
+ type = "text",
15
+ size = "lg",
16
+ className,
17
+ containerClassName,
18
+ inputClassName = "otp-container-size",
19
+ inputMode,
20
+ autoComplete,
21
+ maxLength = 1,
22
+ separator = null,
23
+ showSeparator = false,
24
+ getAriaLabel,
25
+ sanitizeSegment = (v) => v,
26
+ validateSegment = () => true,
27
+ onSegmentKeyDown,
28
+ onPasteValue,
29
+ isSegmentComplete,
30
+ }: SegmentedInputFieldProps) => {
31
+ const inputRefs = useRef<
32
+ Array<HTMLInputElement | HTMLTextAreaElement | null>
33
+ >([]);
34
+ const tokens = useTokens();
35
+
36
+ const focusInput = (index: number) => {
37
+ const target = inputRefs.current[index];
38
+ if (target && typeof target.focus === "function") target.focus();
39
+ };
40
+
41
+ // Token-driven segment width so layout matches design system sizing
42
+ // across responsive breakpoints and avoids relying on legacy CSS.
43
+ const segmentWidth =
44
+ tokens?.semantic?.size?.[size]?.otpWidth ??
45
+ tokens?.semantic?.field?.otpWidth;
46
+
47
+ const setValueAt = (index: number, rawValue: string) => {
48
+ const current = [...values];
49
+ const sanitized = sanitizeSegment(rawValue, index, current);
50
+ const next = [...current];
51
+ next[index] = sanitized;
52
+ const context: SegmentedInputChangeContext = {
53
+ previousValues: current,
54
+ nextValues: next,
55
+ };
56
+ if (!validateSegment(sanitized, index, context)) return;
57
+ onValuesChange(next);
58
+ const complete =
59
+ isSegmentComplete?.(sanitized, index, next) ??
60
+ (Boolean(sanitized) &&
61
+ (maxLength ? sanitized.length >= maxLength : true));
62
+ if (complete && index < values.length - 1) focusInput(index + 1);
63
+ };
64
+
65
+ const handleKeyDown = (
66
+ index: number,
67
+ e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
68
+ ) => {
69
+ const current = [...values];
70
+ const context: SegmentedInputChangeContext = {
71
+ previousValues: current,
72
+ nextValues: current,
73
+ };
74
+ onSegmentKeyDown?.(index, e, context);
75
+ if (e.defaultPrevented) return;
76
+
77
+ if (e.key === "Backspace") {
78
+ e.preventDefault();
79
+ const next = [...current];
80
+ if (next[index]) {
81
+ next[index] = "";
82
+ } else if (index > 0) {
83
+ next[index - 1] = "";
84
+ focusInput(index - 1);
85
+ }
86
+ onValuesChange(next);
87
+ return;
88
+ }
89
+
90
+ if (e.key === "ArrowLeft" && index > 0) {
91
+ e.preventDefault();
92
+ focusInput(index - 1);
93
+ return;
94
+ }
95
+
96
+ if (e.key === "ArrowRight" && index < values.length - 1) {
97
+ e.preventDefault();
98
+ focusInput(index + 1);
99
+ }
100
+ };
101
+
102
+ const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
103
+ if (!onPasteValue) return;
104
+ e.preventDefault();
105
+ const rawText = e.clipboardData?.getData("text") ?? "";
106
+ const next = onPasteValue(rawText, values.length);
107
+ if (!Array.isArray(next) || next.length !== values.length) return;
108
+ onValuesChange(next);
109
+ let lastFilled = 0;
110
+ for (let i = next.length - 1; i >= 0; i -= 1) {
111
+ if (next[i]) {
112
+ lastFilled = i;
113
+ break;
114
+ }
115
+ }
116
+ focusInput(lastFilled);
117
+ };
118
+
119
+ return (
120
+ <div
121
+ className={[containerClassName, className].filter(Boolean).join(" ")}
122
+ onPaste={handlePaste}
123
+ style={{
124
+ display: "flex",
125
+ alignItems: "center",
126
+ justifyContent: "center",
127
+ gap: tokens.foundation.spacing["2"],
128
+ }}
129
+ >
130
+ {values.map((value, index) => (
131
+ <React.Fragment key={index}>
132
+ <div
133
+ className={inputClassName}
134
+ style={
135
+ segmentWidth
136
+ ? {
137
+ width: segmentWidth,
138
+ minWidth: segmentWidth,
139
+ flex: "0 0 auto",
140
+ }
141
+ : undefined
142
+ }
143
+ >
144
+ <TextField
145
+ value={value}
146
+ onChange={(e) => setValueAt(index, e.target.value)}
147
+ type={type}
148
+ fullWidth
149
+ size={size}
150
+ label=""
151
+ disabled={disabled}
152
+ error={error}
153
+ inputRef={(el) => {
154
+ inputRefs.current[index] = el;
155
+ }}
156
+ autoComplete={autoComplete}
157
+ inputProps={{
158
+ inputMode,
159
+ maxLength,
160
+ onKeyDown: (
161
+ e: React.KeyboardEvent<
162
+ HTMLInputElement | HTMLTextAreaElement
163
+ >,
164
+ ) => handleKeyDown(index, e),
165
+ "aria-label": getAriaLabel?.(index),
166
+ }}
167
+ />
168
+ </div>
169
+ {showSeparator && index < values.length - 1 ? separator : null}
170
+ </React.Fragment>
171
+ ))}
172
+ </div>
173
+ );
174
+ };
@@ -0,0 +1,51 @@
1
+ import type React from "react";
2
+ import type { TextFieldSize } from "../TextField/TextField.types";
3
+
4
+ export interface SegmentedInputChangeContext {
5
+ previousValues: string[];
6
+ nextValues: string[];
7
+ }
8
+
9
+ export interface SegmentedInputFieldProps {
10
+ values: string[];
11
+ onValuesChange: (nextValues: string[]) => void;
12
+ disabled?: boolean;
13
+ /** When true, shows error styling on each segment input. */
14
+ error?: boolean;
15
+ type?: React.InputHTMLAttributes<HTMLInputElement>["type"];
16
+ size?: TextFieldSize;
17
+ className?: string;
18
+ containerClassName?: string;
19
+ inputClassName?: string;
20
+ inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"];
21
+ autoComplete?: string;
22
+ maxLength?: number;
23
+ separator?: React.ReactNode;
24
+ showSeparator?: boolean;
25
+ getAriaLabel?: (index: number) => string;
26
+ sanitizeSegment?: (
27
+ rawValue: string,
28
+ index: number,
29
+ values: string[],
30
+ ) => string;
31
+ validateSegment?: (
32
+ nextSegmentValue: string,
33
+ index: number,
34
+ context: SegmentedInputChangeContext,
35
+ ) => boolean;
36
+ onSegmentKeyDown?: (
37
+ index: number,
38
+ event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
39
+ context: SegmentedInputChangeContext,
40
+ ) => void;
41
+ onPasteValue?: (pastedText: string, length: number) => string[];
42
+ /**
43
+ * Determines when to auto-advance focus to the next segment.
44
+ * Defaults to "segment is non-empty AND reaches maxLength (if provided)".
45
+ */
46
+ isSegmentComplete?: (
47
+ segmentValue: string,
48
+ index: number,
49
+ values: string[],
50
+ ) => boolean;
51
+ }