@himakarinv/nativex 1.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/images.d.ts +14 -0
  4. package/index.js +8 -0
  5. package/nativewind-preset.js +18 -0
  6. package/package.json +120 -0
  7. package/src/assets/branding/logo.jpg +0 -0
  8. package/src/assets/branding/softech-logo.png +0 -0
  9. package/src/assets/icons/2-layers.svg +3 -0
  10. package/src/assets/icons/Forbidden.svg +211 -0
  11. package/src/assets/icons/Internal-server-error.svg +119 -0
  12. package/src/assets/icons/Log-out-nav.svg +4 -0
  13. package/src/assets/icons/Not-Found.svg +282 -0
  14. package/src/assets/icons/Unauthorized.svg +314 -0
  15. package/src/assets/icons/User-Frame.svg +5 -0
  16. package/src/assets/icons/alert.svg +4 -0
  17. package/src/assets/icons/arrow-right.svg +3 -0
  18. package/src/assets/icons/avatar-female.svg +10 -0
  19. package/src/assets/icons/avatar-male.svg +10 -0
  20. package/src/assets/icons/bell.svg +3 -0
  21. package/src/assets/icons/calendar-event.svg +11 -0
  22. package/src/assets/icons/calendar.svg +6 -0
  23. package/src/assets/icons/card.svg +4 -0
  24. package/src/assets/icons/caret-right.svg +3 -0
  25. package/src/assets/icons/chat.svg +11 -0
  26. package/src/assets/icons/check.svg +3 -0
  27. package/src/assets/icons/checkbox-minus.svg +3 -0
  28. package/src/assets/icons/checkmark.svg +3 -0
  29. package/src/assets/icons/chevron-down.svg +3 -0
  30. package/src/assets/icons/chevron-left.svg +9 -0
  31. package/src/assets/icons/chevron-right.svg +9 -0
  32. package/src/assets/icons/circle-radio-active.svg +10 -0
  33. package/src/assets/icons/circle-radio.svg +10 -0
  34. package/src/assets/icons/clock.svg +3 -0
  35. package/src/assets/icons/close.svg +3 -0
  36. package/src/assets/icons/code.svg +3 -0
  37. package/src/assets/icons/country.svg +20 -0
  38. package/src/assets/icons/dot.svg +3 -0
  39. package/src/assets/icons/download.svg +1 -0
  40. package/src/assets/icons/dropdown-arrow.svg +3 -0
  41. package/src/assets/icons/dropdown-avatar.svg +3 -0
  42. package/src/assets/icons/email.svg +3 -0
  43. package/src/assets/icons/error.svg +3 -0
  44. package/src/assets/icons/eye-off.svg +1 -0
  45. package/src/assets/icons/eye.svg +1 -0
  46. package/src/assets/icons/hashtag-icon.svg +3 -0
  47. package/src/assets/icons/help-circle.svg +10 -0
  48. package/src/assets/icons/help.svg +4 -0
  49. package/src/assets/icons/hide-checkbox.svg +3 -0
  50. package/src/assets/icons/home-smile.svg +3 -0
  51. package/src/assets/icons/home.svg +3 -0
  52. package/src/assets/icons/info.svg +5 -0
  53. package/src/assets/icons/left-arrow.svg +3 -0
  54. package/src/assets/icons/log-out.svg +3 -0
  55. package/src/assets/icons/notification-box.svg +4 -0
  56. package/src/assets/icons/pencil.svg +1 -0
  57. package/src/assets/icons/plus.svg +3 -0
  58. package/src/assets/icons/radio-dot.svg +3 -0
  59. package/src/assets/icons/right-arrow.svg +3 -0
  60. package/src/assets/icons/search.svg +3 -0
  61. package/src/assets/icons/settings.svg +11 -0
  62. package/src/assets/icons/slack.svg +17 -0
  63. package/src/assets/icons/slash.svg +3 -0
  64. package/src/assets/icons/success.svg +3 -0
  65. package/src/assets/icons/successicon.svg +5 -0
  66. package/src/assets/icons/three-dot-vertical.svg +5 -0
  67. package/src/assets/icons/trash.svg +1 -0
  68. package/src/assets/icons/trend-down-01.svg +3 -0
  69. package/src/assets/icons/trend-up-01.svg +3 -0
  70. package/src/assets/icons/up-arrow.svg +3 -0
  71. package/src/assets/icons/user-plus.svg +10 -0
  72. package/src/assets/icons/user.svg +3 -0
  73. package/src/assets/icons/users.svg +3 -0
  74. package/src/assets/icons/vertical-dots-true-icon.svg +5 -0
  75. package/src/assets/icons/vertical-line.svg +3 -0
  76. package/src/assets/icons/vertical-three-dots-icon.svg +5 -0
  77. package/src/assets/icons/warning.svg +3 -0
  78. package/src/assets/icons/zap.svg +3 -0
  79. package/src/components/ActionsMenu/ActionsMenu.tsx +314 -0
  80. package/src/components/ActionsMenu/index.ts +2 -0
  81. package/src/components/Alert/Alert.tsx +291 -0
  82. package/src/components/Alert/index.ts +2 -0
  83. package/src/components/Badge/Badge.tsx +230 -0
  84. package/src/components/Badge/index.ts +2 -0
  85. package/src/components/Breadcrumb/Breadcrumb.tsx +364 -0
  86. package/src/components/Breadcrumb/index.ts +7 -0
  87. package/src/components/Button/Button.tsx +584 -0
  88. package/src/components/Button/index.ts +9 -0
  89. package/src/components/Checkbox/Checkbox.tsx +336 -0
  90. package/src/components/Checkbox/index.ts +2 -0
  91. package/src/components/CreatePassword/CreatePassword.tsx +161 -0
  92. package/src/components/CreatePassword/index.ts +2 -0
  93. package/src/components/DataTable/DataTable.tsx +822 -0
  94. package/src/components/DataTable/index.ts +10 -0
  95. package/src/components/Datepicker/Datepicker.tsx +797 -0
  96. package/src/components/Datepicker/index.ts +2 -0
  97. package/src/components/Dropdown/Dropdown.tsx +277 -0
  98. package/src/components/Dropdown/index.ts +7 -0
  99. package/src/components/DropdownMenu/DropdownMenu.tsx +471 -0
  100. package/src/components/DropdownMenu/index.ts +11 -0
  101. package/src/components/DynamicForm/DynamicForm.tsx +741 -0
  102. package/src/components/DynamicForm/index.ts +13 -0
  103. package/src/components/ErrorPages/ErrorPages.tsx +135 -0
  104. package/src/components/ErrorPages/index.ts +2 -0
  105. package/src/components/ForgotPassword/ForgotPassword.tsx +121 -0
  106. package/src/components/ForgotPassword/index.ts +2 -0
  107. package/src/components/HintError/HintError.tsx +37 -0
  108. package/src/components/HintError/index.ts +2 -0
  109. package/src/components/Icon/Icon.tsx +43 -0
  110. package/src/components/Icon/icon-registry.ts +227 -0
  111. package/src/components/Icon/index.ts +3 -0
  112. package/src/components/InputDropdown/InputDropdown.tsx +662 -0
  113. package/src/components/InputDropdown/index.ts +9 -0
  114. package/src/components/InputField/InputField.tsx +527 -0
  115. package/src/components/InputField/index.ts +2 -0
  116. package/src/components/Label/Label.tsx +88 -0
  117. package/src/components/Label/index.ts +2 -0
  118. package/src/components/LoginForm/LoginForm.tsx +158 -0
  119. package/src/components/LoginForm/index.ts +2 -0
  120. package/src/components/LoginShell/LoginShell.tsx +97 -0
  121. package/src/components/LoginShell/RoleCard.tsx +24 -0
  122. package/src/components/LoginShell/SoftechLogo.tsx +24 -0
  123. package/src/components/LoginShell/branding-assets.ts +2 -0
  124. package/src/components/LoginShell/index.ts +20 -0
  125. package/src/components/LoginShell/login-copy.ts +45 -0
  126. package/src/components/LoginShell/loginPageStyles.ts +210 -0
  127. package/src/components/Modals/Modals.tsx +313 -0
  128. package/src/components/Modals/index.ts +2 -0
  129. package/src/components/Offcanvas/Offcanvas.tsx +404 -0
  130. package/src/components/Offcanvas/index.ts +13 -0
  131. package/src/components/PageHeader/PageHeader.tsx +128 -0
  132. package/src/components/PageHeader/index.ts +2 -0
  133. package/src/components/Pagination/Pagination.tsx +540 -0
  134. package/src/components/Pagination/index.ts +8 -0
  135. package/src/components/ResetPassword/ResetPassword.tsx +172 -0
  136. package/src/components/ResetPassword/index.ts +2 -0
  137. package/src/components/RoleSelection/RoleSelection.tsx +111 -0
  138. package/src/components/RoleSelection/index.ts +2 -0
  139. package/src/components/SendOtp/SendOtp.tsx +149 -0
  140. package/src/components/SendOtp/index.ts +2 -0
  141. package/src/components/Sidenavbar/Sidenavbar.tsx +358 -0
  142. package/src/components/Sidenavbar/index.ts +2 -0
  143. package/src/components/StatCard/StatCard.tsx +393 -0
  144. package/src/components/StatCard/index.ts +6 -0
  145. package/src/components/Tabs/Tabs.tsx +239 -0
  146. package/src/components/Tabs/index.ts +2 -0
  147. package/src/components/Textarea/Textarea.tsx +217 -0
  148. package/src/components/Textarea/index.ts +2 -0
  149. package/src/components/Timepicker/Timepicker.tsx +384 -0
  150. package/src/components/Timepicker/index.ts +2 -0
  151. package/src/components/Toast/Toast.tsx +278 -0
  152. package/src/components/Toast/index.ts +2 -0
  153. package/src/components/Toggle/Toggle.tsx +296 -0
  154. package/src/components/Toggle/index.ts +2 -0
  155. package/src/components/Typeahead/Typeahead.tsx +521 -0
  156. package/src/components/Typeahead/index.ts +2 -0
  157. package/src/components/Verification/Verification.tsx +374 -0
  158. package/src/components/Verification/index.ts +2 -0
  159. package/src/index.ts +39 -0
  160. package/src/theme/borders.ts +19 -0
  161. package/src/theme/button.ts +41 -0
  162. package/src/theme/colors.ts +273 -0
  163. package/src/theme/index.ts +14 -0
  164. package/src/theme/nativewind-theme.js +182 -0
  165. package/src/theme/radius.ts +12 -0
  166. package/src/theme/shadows.ts +40 -0
  167. package/src/theme/spacing.ts +18 -0
  168. package/src/theme/typography.ts +35 -0
  169. package/src/utils/a11y.ts +21 -0
  170. package/svg.d.ts +7 -0
@@ -0,0 +1,217 @@
1
+ import { useEffect, useState } from 'react';
2
+ import {
3
+ Platform,
4
+ StyleSheet,
5
+ Text,
6
+ TextInput,
7
+ View,
8
+ type StyleProp,
9
+ type TextStyle,
10
+ type ViewStyle,
11
+ } from 'react-native';
12
+
13
+ import { borders, button as buttonTokens, colors, radius, shadows, spacing, typography } from '../../theme';
14
+ import { HintError } from '../HintError';
15
+ import { Label } from '../Label';
16
+
17
+ export const TextareaVariant = {
18
+ Default: 'default',
19
+ Danger: 'danger',
20
+ } as const;
21
+
22
+ export type TextareaVariant = (typeof TextareaVariant)[keyof typeof TextareaVariant];
23
+
24
+ export type TextareaProps = {
25
+ label?: string;
26
+ required?: boolean;
27
+ placeholder?: string;
28
+ hint?: string;
29
+ variant?: TextareaVariant;
30
+ disabled?: boolean;
31
+ value?: string;
32
+ id?: string;
33
+ rows?: number;
34
+ maxLength?: number;
35
+ accessibilityLabel?: string;
36
+ onValueChange?: (value: string) => void;
37
+ style?: StyleProp<ViewStyle>;
38
+ };
39
+
40
+ const isNative = Platform.OS !== 'web';
41
+ const FIELD_WIDTH = isNative ? 230 : 320;
42
+ const FIELD_HEIGHT = isNative
43
+ ? buttonTokens.height.xl + buttonTokens.height.lg
44
+ : spacing['7xl'] + spacing['7xl'] + spacing['6xl'];
45
+
46
+ function webFocusChrome(isDanger: boolean): ViewStyle {
47
+ const ring = isDanger ? colors.danger.light : colors.textarea.focusShadow;
48
+ return {
49
+ boxShadow: `0 0 0 ${borders.width.thick}px ${ring}, 0 ${shadows.boxLight.offsetY}px ${shadows.boxLight.radius}px ${colors.textarea.shadow}`,
50
+ } as ViewStyle;
51
+ }
52
+
53
+ function nativeRingStyle(color: string): ViewStyle {
54
+ return {
55
+ position: 'absolute',
56
+ top: -borders.width.thick,
57
+ right: -borders.width.thick,
58
+ bottom: -borders.width.thick,
59
+ left: -borders.width.thick,
60
+ borderRadius: radius.md + borders.width.thick,
61
+ borderWidth: borders.width.thick,
62
+ borderColor: color,
63
+ zIndex: 0,
64
+ };
65
+ }
66
+
67
+ export function Textarea({
68
+ label = '',
69
+ required = false,
70
+ placeholder = '',
71
+ hint = '',
72
+ variant = TextareaVariant.Default,
73
+ disabled = false,
74
+ value,
75
+ id,
76
+ rows = 4,
77
+ maxLength,
78
+ accessibilityLabel,
79
+ onValueChange,
80
+ style,
81
+ }: TextareaProps) {
82
+ const [internalValue, setInternalValue] = useState(value ?? '');
83
+ const [focused, setFocused] = useState(false);
84
+
85
+ useEffect(() => {
86
+ if (value !== undefined) {
87
+ setInternalValue(value);
88
+ }
89
+ }, [value]);
90
+
91
+ const isDanger = variant === TextareaVariant.Danger;
92
+ const fieldBorder = focused
93
+ ? isDanger
94
+ ? colors.danger.medium
95
+ : colors.focus
96
+ : isDanger
97
+ ? colors.textarea.dangerBorder
98
+ : colors.input.border;
99
+ const fieldBackground = disabled
100
+ ? isDanger
101
+ ? colors.danger.lightest
102
+ : colors.gray.lightest
103
+ : colors.textarea.background;
104
+
105
+ const setText = (next: string) => {
106
+ setInternalValue(next);
107
+ onValueChange?.(next);
108
+ };
109
+
110
+ return (
111
+ <View style={[styles.wrapper, style]}>
112
+ <Label text={label} required={required} />
113
+
114
+ <View style={styles.fieldHost}>
115
+ {focused && isNative ? (
116
+ <View
117
+ pointerEvents="none"
118
+ style={nativeRingStyle(isDanger ? colors.danger.light : colors.textarea.focusShadow)}
119
+ />
120
+ ) : null}
121
+
122
+ <View
123
+ style={[
124
+ styles.fieldShell,
125
+ { borderColor: fieldBorder, backgroundColor: fieldBackground },
126
+ focused && !isNative && webFocusChrome(isDanger),
127
+ ]}>
128
+ <TextInput
129
+ nativeID={id}
130
+ value={internalValue}
131
+ onChangeText={setText}
132
+ onFocus={() => setFocused(true)}
133
+ onBlur={() => setFocused(false)}
134
+ placeholder={placeholder}
135
+ placeholderTextColor={colors.textarea.placeholder}
136
+ editable={!disabled}
137
+ multiline
138
+ numberOfLines={rows}
139
+ maxLength={maxLength}
140
+ textAlignVertical="top"
141
+ accessibilityLabel={accessibilityLabel || label}
142
+ cursorColor={colors.focus}
143
+ selectionColor={colors.focus}
144
+ underlineColorAndroid="transparent"
145
+ style={[styles.input, Platform.OS === 'web' ? webInputReset : null]}
146
+ />
147
+ </View>
148
+ </View>
149
+
150
+ <View style={styles.footer}>
151
+ <View style={styles.hintSlot}>
152
+ <HintError hint={isDanger ? undefined : hint} error={isDanger ? hint : undefined} />
153
+ </View>
154
+ {maxLength ? (
155
+ <Text style={styles.charCount}>
156
+ {internalValue.length}/{maxLength}
157
+ </Text>
158
+ ) : null}
159
+ </View>
160
+ </View>
161
+ );
162
+ }
163
+
164
+ const webInputReset = {
165
+ outlineStyle: 'solid',
166
+ outlineWidth: borders.width.none,
167
+ outlineColor: colors.transparent,
168
+ } as TextStyle;
169
+
170
+ const styles = StyleSheet.create({
171
+ wrapper: {
172
+ width: FIELD_WIDTH,
173
+ flexDirection: 'column',
174
+ },
175
+ fieldHost: {
176
+ position: 'relative',
177
+ width: FIELD_WIDTH,
178
+ overflow: 'visible',
179
+ },
180
+ fieldShell: {
181
+ position: 'relative',
182
+ width: FIELD_WIDTH,
183
+ height: FIELD_HEIGHT,
184
+ borderWidth: borders.width.thin,
185
+ borderRadius: radius.md,
186
+ backgroundColor: colors.textarea.background,
187
+ zIndex: 1,
188
+ overflow: 'hidden',
189
+ },
190
+ input: {
191
+ width: '100%',
192
+ height: '100%',
193
+ paddingVertical: buttonTokens.paddingY.xl,
194
+ paddingHorizontal: spacing.base + spacing.xs,
195
+ fontSize: typography.fontSize.sm,
196
+ lineHeight: typography.fontSize.sm * typography.lineHeight.normal,
197
+ color: colors.textarea.text,
198
+ fontFamily: typography.fontFamily.base,
199
+ },
200
+ footer: {
201
+ flexDirection: 'row',
202
+ justifyContent: 'space-between',
203
+ alignItems: 'center',
204
+ },
205
+ hintSlot: {
206
+ flex: 1,
207
+ minWidth: spacing.none,
208
+ },
209
+ charCount: {
210
+ marginTop: spacing.sm + spacing.xs,
211
+ marginLeft: spacing.md,
212
+ fontSize: typography.fontSize.sm,
213
+ lineHeight: typography.fontSize.sm * typography.lineHeight.tight,
214
+ color: colors.textarea.label,
215
+ fontFamily: typography.fontFamily.base,
216
+ },
217
+ });
@@ -0,0 +1,2 @@
1
+ export { Textarea, TextareaVariant } from './Textarea';
2
+ export type { TextareaProps } from './Textarea';
@@ -0,0 +1,384 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Pressable, StyleSheet, Text, TextInput, View, type StyleProp, type ViewStyle } from 'react-native';
3
+
4
+ import { borders, colors, radius, spacing, typography } from '../../theme';
5
+ import { Label } from '../Label';
6
+
7
+ export const TimepickerSize = {
8
+ Small: 'small',
9
+ Medium: 'medium',
10
+ Large: 'large',
11
+ } as const;
12
+
13
+ export type TimepickerSize = (typeof TimepickerSize)[keyof typeof TimepickerSize];
14
+
15
+ export type TimepickerTime = {
16
+ hour: number;
17
+ minute: number;
18
+ second: number;
19
+ };
20
+
21
+ export type TimepickerProps = {
22
+ time?: TimepickerTime | null;
23
+ label?: string;
24
+ required?: boolean;
25
+ disabled?: boolean;
26
+ meridian?: boolean;
27
+ seconds?: boolean;
28
+ readonlyInputs?: boolean;
29
+ hourStep?: number;
30
+ minuteStep?: number;
31
+ secondStep?: number;
32
+ size?: TimepickerSize;
33
+ onTimeChange?: (time: TimepickerTime) => void;
34
+ style?: StyleProp<ViewStyle>;
35
+ };
36
+
37
+ type FieldKey = 'hour' | 'minute' | 'second';
38
+
39
+ const UNIT = {
40
+ [TimepickerSize.Small]: {
41
+ width: spacing['7xl'],
42
+ height: spacing['4xl'],
43
+ fontSize: typography.fontSize.sm,
44
+ spacer: spacing.base,
45
+ meridianPad: spacing.sm,
46
+ },
47
+ [TimepickerSize.Medium]: {
48
+ width: spacing['7xl'] + spacing.sm,
49
+ height: spacing['5xl'],
50
+ fontSize: typography.fontSize.sm,
51
+ spacer: spacing.base,
52
+ meridianPad: spacing.md,
53
+ },
54
+ [TimepickerSize.Large]: {
55
+ width: spacing['7xl'] + spacing.base,
56
+ height: spacing['6xl'],
57
+ fontSize: typography.fontSize.base,
58
+ spacer: spacing.lg,
59
+ meridianPad: spacing.base,
60
+ },
61
+ } as const;
62
+
63
+ function pad2(value: number) {
64
+ return String(value).padStart(2, '0');
65
+ }
66
+
67
+ function onlyDigits(text: string) {
68
+ return text.replace(/\D/g, '').slice(0, 2);
69
+ }
70
+
71
+ function clamp(value: number, min: number, max: number) {
72
+ return Math.min(max, Math.max(min, value));
73
+ }
74
+
75
+ function applyStep(value: number, step: number, max: number) {
76
+ const safeStep = step > 0 ? step : 1;
77
+ return clamp(Math.round(value / safeStep) * safeStep, 0, max);
78
+ }
79
+
80
+ function normalizeTime(time: TimepickerTime | null | undefined): TimepickerTime {
81
+ if (!time) {
82
+ return { hour: 0, minute: 0, second: 0 };
83
+ }
84
+ return {
85
+ hour: clamp(time.hour, 0, 23),
86
+ minute: clamp(time.minute, 0, 59),
87
+ second: clamp(time.second ?? 0, 0, 59),
88
+ };
89
+ }
90
+
91
+ function displayHour(hour: number, meridian: boolean) {
92
+ if (!meridian) {
93
+ return hour;
94
+ }
95
+ const hour12 = hour % 12;
96
+ return hour12 === 0 ? 12 : hour12;
97
+ }
98
+
99
+ function fromDisplayHour(value: number, meridian: boolean, isPm: boolean) {
100
+ if (!meridian) {
101
+ return clamp(value, 0, 23);
102
+ }
103
+ const hour12 = clamp(value, 1, 12);
104
+ if (hour12 === 12) {
105
+ return isPm ? 12 : 0;
106
+ }
107
+ return isPm ? hour12 + 12 : hour12;
108
+ }
109
+
110
+ function unitFocusRing(focused: boolean): ViewStyle | undefined {
111
+ if (!focused) {
112
+ return undefined;
113
+ }
114
+ return {
115
+ boxShadow: `0 0 0 ${borders.focusRing.width}px ${colors.timepicker.focus}`,
116
+ } as ViewStyle;
117
+ }
118
+
119
+ export function Timepicker({
120
+ time,
121
+ label = '',
122
+ required = false,
123
+ disabled = false,
124
+ meridian = false,
125
+ seconds = false,
126
+ readonlyInputs = false,
127
+ hourStep = 1,
128
+ minuteStep = 1,
129
+ secondStep = 1,
130
+ size = TimepickerSize.Medium,
131
+ onTimeChange,
132
+ style,
133
+ }: TimepickerProps) {
134
+ const [value, setValue] = useState<TimepickerTime>(() => normalizeTime(time));
135
+ const [focusedField, setFocusedField] = useState<FieldKey | null>(null);
136
+ const [hourText, setHourText] = useState(() => pad2(displayHour(normalizeTime(time).hour, meridian)));
137
+ const [minuteText, setMinuteText] = useState(() => pad2(normalizeTime(time).minute));
138
+ const [secondText, setSecondText] = useState(() => pad2(normalizeTime(time).second));
139
+ const [meridianHovered, setMeridianHovered] = useState(false);
140
+ const inputsLocked = disabled || readonlyInputs;
141
+ const isPm = value.hour >= 12;
142
+ const tokens = UNIT[size];
143
+
144
+ const syncTexts = (next: TimepickerTime, nextMeridian = meridian) => {
145
+ setHourText(pad2(displayHour(next.hour, nextMeridian)));
146
+ setMinuteText(pad2(next.minute));
147
+ setSecondText(pad2(next.second));
148
+ };
149
+
150
+ useEffect(() => {
151
+ const next = normalizeTime(time);
152
+ setValue(next);
153
+ if (!focusedField) {
154
+ syncTexts(next);
155
+ }
156
+ }, [time?.hour, time?.minute, time?.second, meridian]);
157
+
158
+ const commit = (next: TimepickerTime) => {
159
+ setValue(next);
160
+ onTimeChange?.({ ...next });
161
+ };
162
+
163
+ const parseField = (field: FieldKey, text: string) => {
164
+ const digits = onlyDigits(text);
165
+ if (field === 'hour') {
166
+ setHourText(digits);
167
+ } else if (field === 'minute') {
168
+ setMinuteText(digits);
169
+ } else {
170
+ setSecondText(digits);
171
+ }
172
+ if (!digits) {
173
+ return;
174
+ }
175
+ const parsed = Number(digits);
176
+ if (!Number.isFinite(parsed)) {
177
+ return;
178
+ }
179
+ if (field === 'hour') {
180
+ commit({ ...value, hour: fromDisplayHour(parsed, meridian, isPm) });
181
+ return;
182
+ }
183
+ if (field === 'minute') {
184
+ commit({ ...value, minute: clamp(parsed, 0, 59) });
185
+ return;
186
+ }
187
+ commit({ ...value, second: clamp(parsed, 0, 59) });
188
+ };
189
+
190
+ const blurField = () => {
191
+ setFocusedField(null);
192
+ const next = {
193
+ hour: applyStep(value.hour, hourStep, 23),
194
+ minute: applyStep(value.minute, minuteStep, 59),
195
+ second: applyStep(value.second, secondStep, 59),
196
+ };
197
+ commit(next);
198
+ syncTexts(next);
199
+ };
200
+
201
+ const toggleMeridian = () => {
202
+ if (disabled) {
203
+ return;
204
+ }
205
+ const next = { ...value, hour: (value.hour + 12) % 24 };
206
+ commit(next);
207
+ syncTexts(next);
208
+ };
209
+
210
+ const renderUnit = (field: FieldKey, fieldValue: string, fieldLabel: string, placeholder: string) => {
211
+ const focused = focusedField === field;
212
+ return (
213
+ <View
214
+ style={[
215
+ styles.unitBox,
216
+ {
217
+ width: tokens.width,
218
+ height: tokens.height,
219
+ },
220
+ focused && styles.unitBoxFocused,
221
+ focused && unitFocusRing(focused),
222
+ disabled && styles.unitBoxDisabled,
223
+ ]}>
224
+ <TextInput
225
+ value={fieldValue}
226
+ placeholder={placeholder}
227
+ placeholderTextColor={colors.timepicker.placeholder}
228
+ editable={!inputsLocked}
229
+ keyboardType="number-pad"
230
+ inputMode="numeric"
231
+ maxLength={2}
232
+ selectTextOnFocus
233
+ autoCorrect={false}
234
+ autoCapitalize="none"
235
+ accessibilityLabel={fieldLabel}
236
+ underlineColorAndroid="transparent"
237
+ cursorColor={colors.primary.base}
238
+ selectionColor={colors.timepicker.focus}
239
+ onFocus={() => setFocusedField(field)}
240
+ onBlur={blurField}
241
+ onChangeText={(next) => parseField(field, next)}
242
+ style={[
243
+ styles.unitInput,
244
+ { fontSize: tokens.fontSize },
245
+ disabled && styles.unitInputDisabled,
246
+ ]}
247
+ />
248
+ </View>
249
+ );
250
+ };
251
+
252
+ const renderSpacer = (text: string) => (
253
+ <Text style={[styles.spacer, { width: tokens.spacer, fontSize: tokens.fontSize, lineHeight: tokens.height }]}>
254
+ {text}
255
+ </Text>
256
+ );
257
+
258
+ return (
259
+ <View style={[styles.host, style]}>
260
+ {label ? <Label text={label} required={required} /> : null}
261
+ <View style={styles.control}>
262
+ {renderUnit('hour', hourText, 'Hours', 'HH')}
263
+ {renderSpacer(':')}
264
+ {renderUnit('minute', minuteText, 'Minutes', 'MM')}
265
+ {seconds ? (
266
+ <>
267
+ {renderSpacer(':')}
268
+ {renderUnit('second', secondText, 'Seconds', 'SS')}
269
+ </>
270
+ ) : null}
271
+ {meridian ? (
272
+ <>
273
+ {renderSpacer('')}
274
+ <Pressable
275
+ accessibilityRole="button"
276
+ accessibilityLabel="Toggle AM PM"
277
+ accessibilityState={{ disabled }}
278
+ disabled={disabled}
279
+ onHoverIn={() => setMeridianHovered(true)}
280
+ onHoverOut={() => setMeridianHovered(false)}
281
+ onPress={toggleMeridian}
282
+ style={({ pressed }) => [
283
+ styles.meridian,
284
+ {
285
+ height: tokens.height,
286
+ minWidth: tokens.width + tokens.meridianPad,
287
+ paddingHorizontal: tokens.meridianPad,
288
+ },
289
+ !disabled && (pressed || meridianHovered) && styles.meridianActive,
290
+ disabled && styles.meridianDisabled,
291
+ ]}>
292
+ {({ pressed }) => (
293
+ <Text
294
+ style={[
295
+ styles.meridianText,
296
+ { fontSize: tokens.fontSize },
297
+ !disabled && (pressed || meridianHovered) && styles.meridianTextActive,
298
+ disabled && styles.meridianTextDisabled,
299
+ ]}>
300
+ {isPm ? 'PM' : 'AM'}
301
+ </Text>
302
+ )}
303
+ </Pressable>
304
+ </>
305
+ ) : null}
306
+ </View>
307
+ </View>
308
+ );
309
+ }
310
+
311
+ const styles = StyleSheet.create({
312
+ host: {
313
+ alignSelf: 'center',
314
+ alignItems: 'flex-start',
315
+ gap: spacing.xs,
316
+ maxWidth: '100%',
317
+ },
318
+ control: {
319
+ flexDirection: 'row',
320
+ alignItems: 'center',
321
+ flexWrap: 'nowrap',
322
+ },
323
+ unitBox: {
324
+ justifyContent: 'center',
325
+ backgroundColor: colors.timepicker.background,
326
+ borderWidth: borders.width.thin,
327
+ borderColor: colors.timepicker.border,
328
+ borderRadius: radius.md,
329
+ },
330
+ unitBoxFocused: {
331
+ borderColor: colors.primary.base,
332
+ },
333
+ unitBoxDisabled: {
334
+ backgroundColor: colors.timepicker.disabledBackground,
335
+ borderColor: colors.timepicker.disabled,
336
+ },
337
+ unitInput: {
338
+ ...StyleSheet.absoluteFillObject,
339
+ paddingHorizontal: spacing.none,
340
+ paddingVertical: spacing.none,
341
+ margin: spacing.none,
342
+ textAlign: 'center',
343
+ textAlignVertical: 'center',
344
+ color: colors.timepicker.text,
345
+ fontFamily: typography.fontFamily.base,
346
+ fontWeight: typography.fontWeight.regular,
347
+ includeFontPadding: false,
348
+ },
349
+ unitInputDisabled: {
350
+ color: colors.timepicker.disabledText,
351
+ },
352
+ spacer: {
353
+ color: colors.text.muted,
354
+ textAlign: 'center',
355
+ fontFamily: typography.fontFamily.base,
356
+ fontWeight: typography.fontWeight.regular,
357
+ },
358
+ meridian: {
359
+ alignItems: 'center',
360
+ justifyContent: 'center',
361
+ backgroundColor: colors.timepicker.background,
362
+ borderWidth: borders.width.thin,
363
+ borderColor: colors.primary.base,
364
+ borderRadius: radius.md,
365
+ },
366
+ meridianActive: {
367
+ backgroundColor: colors.primary.base,
368
+ borderColor: colors.primary.base,
369
+ },
370
+ meridianText: {
371
+ color: colors.primary.base,
372
+ fontFamily: typography.fontFamily.base,
373
+ fontWeight: typography.fontWeight.regular,
374
+ },
375
+ meridianTextActive: {
376
+ color: colors.timepicker.background,
377
+ },
378
+ meridianDisabled: {
379
+ opacity: 0.55,
380
+ },
381
+ meridianTextDisabled: {
382
+ color: colors.primary.base,
383
+ },
384
+ });
@@ -0,0 +1,2 @@
1
+ export { Timepicker, TimepickerSize } from './Timepicker';
2
+ export type { TimepickerProps, TimepickerTime } from './Timepicker';