@krapwoo/ds-viewer 0.1.1 → 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 (159) hide show
  1. package/README.md +100 -78
  2. package/dist/cli/config.js +26 -1
  3. package/dist/cli/dev.js +50 -5
  4. package/dist/cli/glob.js +8 -0
  5. package/dist/cli/init.js +148 -18
  6. package/dist/cli/main.js +66 -4
  7. package/dist/cli/props.js +166 -18
  8. package/dist/cli/sync.js +17 -1
  9. package/dist/cli/workspace.js +49 -21
  10. package/native/catalog/CatalogShell.tsx +6 -1
  11. package/native/catalog/CatalogSidebar.tsx +98 -5
  12. package/native/catalog/TokenRow.tsx +3 -2
  13. package/native/catalog/index.ts +6 -5
  14. package/native/catalog/logoLayout.ts +12 -0
  15. package/native/catalog/pageApi.ts +10 -1
  16. package/native/catalog/tokens.ts +5 -0
  17. package/package.json +8 -6
  18. package/starter-kit/WHEN_TO_USE.md +124 -0
  19. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  20. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  21. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  22. package/starter-kit/components/Avatar/Avatar.catalog.tsx +72 -0
  23. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  24. package/starter-kit/components/Avatar/index.ts +2 -0
  25. package/starter-kit/components/Badge/Badge.catalog.tsx +96 -0
  26. package/starter-kit/components/Badge/Badge.tsx +56 -0
  27. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  28. package/starter-kit/components/Badge/index.ts +2 -0
  29. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  30. package/starter-kit/components/Banner/Banner.tsx +257 -0
  31. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  32. package/starter-kit/components/Banner/index.ts +2 -0
  33. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  34. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  35. package/starter-kit/components/BottomSheet/index.ts +2 -0
  36. package/starter-kit/components/Button/Button.catalog.tsx +113 -0
  37. package/starter-kit/components/Button/Button.tsx +173 -0
  38. package/starter-kit/components/Button/Button.types.ts +52 -0
  39. package/starter-kit/components/Button/index.ts +2 -0
  40. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  41. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  42. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  43. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  44. package/starter-kit/components/Card/Card.tsx +133 -0
  45. package/starter-kit/components/Card/index.ts +2 -0
  46. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  47. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  48. package/starter-kit/components/Checkbox/index.ts +2 -0
  49. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  50. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  51. package/starter-kit/components/Dialog/index.ts +2 -0
  52. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  53. package/starter-kit/components/Divider/Divider.tsx +20 -0
  54. package/starter-kit/components/Divider/index.ts +2 -0
  55. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  56. package/starter-kit/components/Dock/Dock.tsx +58 -0
  57. package/starter-kit/components/Dock/index.ts +2 -0
  58. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  59. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  60. package/starter-kit/components/Dropdown/index.ts +2 -0
  61. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  62. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  63. package/starter-kit/components/EmptyState/index.ts +2 -0
  64. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  65. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  66. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  67. package/starter-kit/components/FieldContainer/index.ts +2 -0
  68. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  69. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  70. package/starter-kit/components/InputClearButton/index.ts +2 -0
  71. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  72. package/starter-kit/components/InputField/InputField.tsx +418 -0
  73. package/starter-kit/components/InputField/index.ts +2 -0
  74. package/starter-kit/components/List/List.catalog.tsx +30 -0
  75. package/starter-kit/components/List/List.tsx +36 -0
  76. package/starter-kit/components/List/index.ts +2 -0
  77. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  78. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  79. package/starter-kit/components/ListItem/index.ts +2 -0
  80. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  81. package/starter-kit/components/Loading/Loading.tsx +121 -0
  82. package/starter-kit/components/Loading/index.ts +2 -0
  83. package/starter-kit/components/Pill/Pill.catalog.tsx +93 -0
  84. package/starter-kit/components/Pill/Pill.tsx +181 -0
  85. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  86. package/starter-kit/components/Pill/index.ts +2 -0
  87. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  88. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  89. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  90. package/starter-kit/components/PillRow/index.ts +2 -0
  91. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  92. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  93. package/starter-kit/components/ProgressDots/index.ts +2 -0
  94. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  95. package/starter-kit/components/Radio/Radio.tsx +114 -0
  96. package/starter-kit/components/Radio/index.ts +2 -0
  97. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  98. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  99. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  100. package/starter-kit/components/SearchField/index.ts +2 -0
  101. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  102. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  103. package/starter-kit/components/SectionHeader/index.ts +2 -0
  104. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  105. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  106. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  107. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  108. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  109. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  110. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  111. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  112. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  113. package/starter-kit/components/Shimmer/index.ts +2 -0
  114. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  115. package/starter-kit/components/Surface/Surface.tsx +45 -0
  116. package/starter-kit/components/Surface/index.ts +2 -0
  117. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  118. package/starter-kit/components/Switch/Switch.tsx +129 -0
  119. package/starter-kit/components/Switch/index.ts +2 -0
  120. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  121. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  122. package/starter-kit/components/TextArea/index.ts +2 -0
  123. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  124. package/starter-kit/components/Toast/Toast.tsx +144 -0
  125. package/starter-kit/components/Toast/index.ts +2 -0
  126. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  127. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  128. package/starter-kit/components/Tooltip/index.ts +2 -0
  129. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  130. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  131. package/starter-kit/components/TopNav/index.ts +2 -0
  132. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  133. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  134. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  135. package/starter-kit/components/index.ts +36 -0
  136. package/starter-kit/components/statusColors.ts +27 -0
  137. package/starter-kit/icons/Icon.native.tsx +90 -0
  138. package/starter-kit/icons/index.ts +10 -0
  139. package/starter-kit/icons/paths.ts +623 -0
  140. package/starter-kit/icons/types.ts +69 -0
  141. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  142. package/starter-kit/pages/Font.catalog.tsx +45 -0
  143. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  144. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  145. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  146. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  147. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  148. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  149. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  150. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  151. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  152. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  153. package/starter-kit/tokens/index.ts +50 -0
  154. package/starter-kit/tokens/motion.ts +90 -0
  155. package/starter-kit/tokens/palette.ts +111 -0
  156. package/starter-kit/tokens/scales.ts +106 -0
  157. package/starter-kit/tokens/semantic.ts +98 -0
  158. package/starter-kit/tokens/shadow.ts +36 -0
  159. package/starter-kit/tokens/typography.ts +72 -0
@@ -0,0 +1,76 @@
1
+ import React, { useState } from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { InputField } from './InputField';
4
+
5
+ function InputFieldDemo() {
6
+ const [value, setValue] = useState('');
7
+ return (
8
+ <InputField
9
+ label="From"
10
+ value={value}
11
+ onChangeText={setValue}
12
+ editable
13
+ placeholder="Search a station"
14
+ />
15
+ );
16
+ }
17
+
18
+ export default defineCatalogPage({
19
+ component: 'InputField',
20
+ group: 'Inputs',
21
+ description: 'A floating-label field. Resting: a centred, body-sized label with no border. Active (focused, or picker with active set) or filled: the label floats to a small top caption, row 2 shows the value/input, and a border fades in while active.',
22
+ whenToUse: 'A named field with a fixed identity ("To", "Arrive by") across the interaction. For free-text search with no floating label, use SearchField; for one of a small known set of choices, use Dropdown.',
23
+ a11y: 'The editable mode is a live TextInput; the picker mode is a Pressable row. Either mode shows a clear button while active (focused, or active for a picker) with a value set. Provide a meaningful label.',
24
+ variants: {
25
+ itemsFill: true,
26
+ items: [
27
+ {
28
+ key: 'default',
29
+ name: 'Default',
30
+ props: { label: 'To' },
31
+ node: <InputField label="To" placeholder="Search a station" onPress={() => {}} />,
32
+ },
33
+ {
34
+ key: 'icon-label',
35
+ name: 'Icon label',
36
+ props: { label: 'icon' },
37
+ node: <InputField label="icon" labelIcon="flag" value="Custom stop" onPress={() => {}} />,
38
+ },
39
+ ],
40
+ },
41
+ states: {
42
+ itemsFill: true,
43
+ items: [
44
+ { key: 'editable', name: 'Editable', props: { label: 'From' }, node: <InputFieldDemo /> },
45
+ {
46
+ key: 'picker',
47
+ name: 'Picker (filled)',
48
+ node: <InputField label="To" value="Grand Central" placeholder="Search a station" onPress={() => {}} />,
49
+ },
50
+ {
51
+ key: 'active',
52
+ name: 'Active (external picker open)',
53
+ node: <InputField label="To" value="Grand Central" placeholder="Search a station" active onPress={() => {}} />,
54
+ },
55
+ {
56
+ key: 'optional',
57
+ name: 'Optional label',
58
+ props: { label: 'Name' },
59
+ node: <InputField label="Name" placeholder="Nickname" optional onPress={() => {}} />,
60
+ },
61
+ {
62
+ key: 'value-icon',
63
+ name: 'Value with icon',
64
+ props: { label: 'Walk time' },
65
+ node: <InputField label="Walk time" value="8 min" valueIcon="footprints" />,
66
+ },
67
+ {
68
+ key: 'value-accent',
69
+ name: 'Accent value',
70
+ props: { label: 'Arrive by' },
71
+ node: <InputField label="Arrive by" value="9:12 AM" valueAccent />,
72
+ },
73
+ { key: 'disabled', name: 'Disabled', node: <InputField label="Arrive by" value="9:12 AM" disabled /> },
74
+ ],
75
+ },
76
+ });
@@ -0,0 +1,418 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ Animated,
7
+ Pressable,
8
+ StyleSheet,
9
+ Platform,
10
+ type StyleProp,
11
+ type ViewStyle,
12
+ type TextInputProps,
13
+ } from 'react-native';
14
+ import { InputClearButton } from '../InputClearButton';
15
+ import { useSurfaceTone } from '../Surface';
16
+ import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_TYPOGRAPHY, DS_FONT_WEIGHT } from '../../tokens';
17
+ import { Icon } from '../../icons/Icon.native';
18
+ import type { IconName } from '../../icons';
19
+
20
+ /**
21
+ * The label shown at the head of the field. Text variants render as a word; `'icon'` renders a
22
+ * leading icon instead (supply `labelIcon`).
23
+ */
24
+ export type InputFieldLabelVariant = 'To' | 'From' | 'Walk time' | 'Arrive by' | 'Name' | 'icon';
25
+
26
+ const LABEL_TEXT: Record<Exclude<InputFieldLabelVariant, 'icon'>, string> = {
27
+ To: 'To',
28
+ From: 'From',
29
+ 'Walk time': 'Walk time',
30
+ 'Arrive by': 'Arrive by',
31
+ Name: 'Name',
32
+ };
33
+
34
+ // Float / border cross-fade duration. JS-driven (useNativeDriver:false) so the label size cross-fade
35
+ // and the border-colour fade both render in the web preview too — native-driver opacity doesn't.
36
+ const ANIM_MS = 170;
37
+
38
+ export interface InputFieldProps {
39
+ /** Leading label slot. */
40
+ label?: InputFieldLabelVariant;
41
+ /** Icon for the `label="icon"` variant. */
42
+ labelIcon?: IconName;
43
+ /** Current value. */
44
+ value?: string;
45
+ /** Trailing icon rendered after the value text. Also tints the value text with the accent colour,
46
+ * so the two always match. */
47
+ valueIcon?: IconName;
48
+ /** Tint the value text with the accent (info) colour. Overridden by valueIcon's tint when both are set. */
49
+ valueAccent?: boolean;
50
+ /** Shown when empty. */
51
+ placeholder?: string;
52
+ onPress?: () => void;
53
+ /** Editable-mode change handler. */
54
+ onChangeText?: (text: string) => void;
55
+ /** Live TextInput vs. tappable picker. */
56
+ editable?: boolean;
57
+ /** Force the active (floated label + border) look — for a picker field whose external picker (e.g.
58
+ * a Dropdown's sheet) is open. Editable fields derive this from focus and ignore it. */
59
+ active?: boolean;
60
+ /** Appends " (Optional)" to the RESTING label only — it drops off once the label floats, so the
61
+ * marker reads at a glance before the field is touched without cluttering the active hint. */
62
+ optional?: boolean;
63
+ /** Disabled state — dimmed label/value text (a step lighter than the hint/placeholder tone),
64
+ * non-interactive, no border. */
65
+ disabled?: boolean;
66
+ style?: StyleProp<ViewStyle>;
67
+ inputProps?: Omit<TextInputProps, 'value' | 'onChangeText' | 'placeholder' | 'editable'>;
68
+ }
69
+
70
+ /**
71
+ * Floating-label field. Three states:
72
+ * - Resting: only the muted label, body-sized and vertically centred (no border).
73
+ * - Active: the label shrinks/rises to a small top label; row 2 shows the value/hint (+ a clear
74
+ * button for editable fields); a border wraps the whole field (editable → while focused; picker
75
+ * → while `active`).
76
+ * - Filled: same two-row layout with the entered value on row 2, and the border removed.
77
+ */
78
+ export function InputField({
79
+ label = 'To',
80
+ labelIcon,
81
+ value,
82
+ valueIcon,
83
+ valueAccent = false,
84
+ placeholder,
85
+ onPress,
86
+ onChangeText,
87
+ editable = false,
88
+ active = false,
89
+ optional = false,
90
+ disabled = false,
91
+ style,
92
+ inputProps,
93
+ }: InputFieldProps) {
94
+ const [focused, setFocused] = useState(false);
95
+ const [pressed, setPressed] = useState(false);
96
+ const inputRef = useRef<TextInput>(null);
97
+ const onWhite = useSurfaceTone() === 'white';
98
+
99
+ const hasValue = Boolean(value?.trim());
100
+ const isEditable = editable && !disabled;
101
+ // Border-showing "active": an editable field while focused; a picker field while its external
102
+ // picker is open (`active`) or being pressed. Never for a disabled field.
103
+ const isActive = !disabled && (isEditable ? focused : (active || pressed));
104
+ // Label floats to the top whenever the field is active or already holds a value.
105
+ const isFloated = isActive || hasValue;
106
+
107
+ const floatAnim = useRef(new Animated.Value(isFloated ? 1 : 0)).current;
108
+ const activeAnim = useRef(new Animated.Value(isActive ? 1 : 0)).current;
109
+ useEffect(() => {
110
+ Animated.timing(floatAnim, { toValue: isFloated ? 1 : 0, duration: ANIM_MS, useNativeDriver: false }).start();
111
+ }, [isFloated, floatAnim]);
112
+ useEffect(() => {
113
+ Animated.timing(activeAnim, { toValue: isActive ? 1 : 0, duration: ANIM_MS, useNativeDriver: false }).start();
114
+ }, [isActive, activeAnim]);
115
+
116
+ const borderColor = activeAnim.interpolate({
117
+ inputRange: [0, 1],
118
+ outputRange: ['rgba(0,0,0,0)', DS_SEMANTIC.border.dark],
119
+ });
120
+ const restOpacity = floatAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] });
121
+ const floatTranslate = floatAnim.interpolate({ inputRange: [0, 1], outputRange: [6, 0] });
122
+ // The label morphs as ONE element (position + size) between its resting and floated look, rather
123
+ // than cross-fading two separately-positioned copies — animating `top`/`fontSize` directly (not a
124
+ // `scale` transform) sidesteps needing a left-anchored transform-origin, which RN doesn't support
125
+ // consistently across native/web. Safe because this whole animation is already JS-driven
126
+ // (`useNativeDriver:false` — see ANIM_MS above), which allows animating arbitrary style numbers,
127
+ // not just transform/opacity.
128
+ const REST_LABEL_TOP = (FIELD_MIN_HEIGHT - 20) / 2; // vertically centers the rest-sized (20 lineHeight) label
129
+ const labelTop = floatAnim.interpolate({ inputRange: [0, 1], outputRange: [REST_LABEL_TOP, PAD] });
130
+ const labelFontSize = floatAnim.interpolate({ inputRange: [0, 1], outputRange: [DS_TYPOGRAPHY.bodyMd.fontSize, DS_TYPOGRAPHY.labelXs.fontSize] });
131
+ const labelLineHeight = floatAnim.interpolate({ inputRange: [0, 1], outputRange: [20, 16] });
132
+
133
+ const handleFocus = useCallback(
134
+ (event: Parameters<NonNullable<TextInputProps['onFocus']>>[0]) => {
135
+ setFocused(true);
136
+ inputProps?.onFocus?.(event);
137
+ },
138
+ [inputProps],
139
+ );
140
+ const handleBlur = useCallback(
141
+ (event: Parameters<NonNullable<TextInputProps['onBlur']>>[0]) => {
142
+ setFocused(false);
143
+ inputProps?.onBlur?.(event);
144
+ },
145
+ [inputProps],
146
+ );
147
+ const handlePress = useCallback(() => {
148
+ if (disabled) return;
149
+ if (isEditable) inputRef.current?.focus();
150
+ else onPress?.();
151
+ }, [disabled, isEditable, onPress]);
152
+ // Clear the entry and keep the caret in the field so the user can retype straight away.
153
+ const handleClear = useCallback(() => {
154
+ onChangeText?.('');
155
+ inputRef.current?.focus();
156
+ }, [onChangeText]);
157
+
158
+ // A clear button belongs on any field that's both active AND already holds a value — an editable
159
+ // field while focused, or a picker field while its external picker is open (`active`) — but stays
160
+ // out of the resting/filled state either way (clearing a field the user isn't currently looking at
161
+ // would be a surprising action to expose).
162
+ const showClear = !disabled && hasValue && (isEditable ? focused : active);
163
+
164
+ // The label is muted in both states, so — unlike the icon variant below — a text label needs no
165
+ // per-state node at all; it's one continuously-morphing element (see `fieldBody`). The "(Optional)"
166
+ // marker fades out on its own as the field floats, since it only makes sense at rest.
167
+ const labelBase = label === 'icon' ? '' : LABEL_TEXT[label];
168
+ // Disabled dims the label along with the value (see `styles.disabledText`) so the whole field
169
+ // reads as one washed-out unit, not just its value text.
170
+ const labelIconColor = disabled ? DS_SEMANTIC.text.disabled : DS_SEMANTIC.text.muted;
171
+ const restIconNode = label === 'icon' ? <Icon name={labelIcon ?? 'flag'} color={labelIconColor} /> : null;
172
+ const floatIconNode = label === 'icon' ? <Icon name={labelIcon ?? 'flag'} size={12} color={labelIconColor} /> : null;
173
+
174
+ // Row 2 content: the text input (editable) or the value / placeholder-hint (picker & display).
175
+ let valueRow: React.ReactNode;
176
+ if (isEditable) {
177
+ valueRow = (
178
+ <TextInput
179
+ ref={inputRef}
180
+ {...inputProps}
181
+ style={[styles.value, styles.valueInput, inputProps?.style]}
182
+ value={value}
183
+ placeholder={placeholder}
184
+ // text.muted (grey600), not the lighter grey500 hint tone this used when the field's own
185
+ // background was surface.white — grey500 falls short of AA (~4.34:1) on surface.main, the
186
+ // field's current background (see DS_SEMANTIC.text.muted's own doc comment).
187
+ placeholderTextColor={DS_SEMANTIC.text.muted}
188
+ onChangeText={onChangeText}
189
+ onFocus={handleFocus}
190
+ onBlur={handleBlur}
191
+ editable
192
+ />
193
+ );
194
+ } else if (hasValue && valueIcon) {
195
+ // Icon-paired value: tight icon+text pairing, tinted with the accent colour.
196
+ valueRow = (
197
+ <View style={styles.valueIconGroup}>
198
+ <Text style={[styles.value, styles.valueName, styles.valueAccent]} numberOfLines={1}>
199
+ {value}
200
+ </Text>
201
+ <Icon name={valueIcon} size={16} color={DS_SEMANTIC.emphasis.info} />
202
+ </View>
203
+ );
204
+ } else {
205
+ const showPlaceholder = !hasValue;
206
+ valueRow = (
207
+ <Text
208
+ style={[
209
+ styles.value,
210
+ styles.valueName,
211
+ disabled && styles.disabledText,
212
+ showPlaceholder && !disabled && styles.hintText,
213
+ hasValue && !disabled && valueAccent && styles.valueAccent,
214
+ ]}
215
+ numberOfLines={1}
216
+ >
217
+ {hasValue ? value : placeholder}
218
+ </Text>
219
+ );
220
+ }
221
+
222
+ const fieldBody = (
223
+ <Animated.View style={[styles.field, { backgroundColor: onWhite ? DS_SEMANTIC.surface.recessed : DS_SEMANTIC.surface.white }, { borderColor }]}>
224
+ {label === 'icon' ? (
225
+ <>
226
+ {/* Icon label has no in-between size to morph through — it still cross-fades between its
227
+ two fixed sizes, same as before. */}
228
+ <Animated.View style={[styles.restLabelWrap, { opacity: restOpacity }]} pointerEvents="none">
229
+ {restIconNode}
230
+ </Animated.View>
231
+ <Animated.View
232
+ style={[styles.floatLabelWrap, { opacity: floatAnim, transform: [{ translateY: floatTranslate }] }]}
233
+ pointerEvents="none"
234
+ >
235
+ {floatIconNode}
236
+ </Animated.View>
237
+ </>
238
+ ) : (
239
+ // One continuously-morphing label: `top`/`fontSize`/`lineHeight` animate directly between the
240
+ // resting and floated look, rather than cross-fading two separately-positioned copies.
241
+ <Animated.View style={[styles.morphLabelWrap, { top: labelTop }]} pointerEvents="none">
242
+ <Animated.Text
243
+ style={[styles.morphLabel, { fontSize: labelFontSize, lineHeight: labelLineHeight }, disabled && styles.disabledText]}
244
+ numberOfLines={1}
245
+ >
246
+ {labelBase}
247
+ </Animated.Text>
248
+ {optional && (
249
+ <Animated.Text style={[styles.optionalMark, { opacity: restOpacity }, disabled && styles.disabledText]}> (Optional)</Animated.Text>
250
+ )}
251
+ </Animated.View>
252
+ )}
253
+ {/* Value row — hint/value/input; fades in with the float. Trailing space reserved when the
254
+ clear button shows, so long text never runs under it. */}
255
+ <Animated.View style={[styles.valueRow, showClear && styles.valueRowClear, { opacity: floatAnim }]}>
256
+ {valueRow}
257
+ </Animated.View>
258
+ {/* Clear button — vertically centred over the whole field (not just the value row). */}
259
+ {showClear && (
260
+ <View style={styles.clearButton}>
261
+ <InputClearButton onPress={handleClear} accessibilityLabel={`Clear ${labelBase || 'field'}`} />
262
+ </View>
263
+ )}
264
+ </Animated.View>
265
+ );
266
+
267
+ // In picker mode (not editable, driven by `onPress`) the field is really a button — the inner
268
+ // TextInput branch gets accessibility for free from the real `<TextInput>`, but this Pressable
269
+ // otherwise has no name/role of its own for a screen reader to announce. Suppressed while
270
+ // `showClear` is up: the clear button rendered inside `fieldBody` is itself an
271
+ // accessibilityRole="button" Pressable, and nesting one button-role element inside another is
272
+ // invalid HTML (and a confusing screen-reader target either way) — so this outer role steps aside
273
+ // for that one state rather than wrapping the inner button.
274
+ const isPicker = !isEditable;
275
+ const announceAsButton = isPicker && !showClear;
276
+
277
+ return (
278
+ <Pressable
279
+ onPress={handlePress}
280
+ onPressIn={() => {
281
+ if (!isEditable && !disabled) setPressed(true);
282
+ }}
283
+ onPressOut={() => setPressed(false)}
284
+ disabled={disabled || (!isEditable && !onPress)}
285
+ accessibilityRole={announceAsButton ? 'button' : undefined}
286
+ accessibilityLabel={announceAsButton ? [labelBase, hasValue ? value : undefined].filter(Boolean).join(', ') : undefined}
287
+ accessibilityState={announceAsButton ? { disabled } : undefined}
288
+ style={[styles.wrap, style]}
289
+ >
290
+ {fieldBody}
291
+ </Pressable>
292
+ );
293
+ }
294
+
295
+ const FIELD_MIN_HEIGHT = 63;
296
+ const PAD = DS_SPACING[600]; // 12
297
+ const CLEAR_SIZE = 24;
298
+ // Trailing inset for the value row when the clear button is shown: field padding + the button + a gap.
299
+ const CLEAR_RIGHT_INSET = PAD + CLEAR_SIZE + DS_SPACING[400];
300
+
301
+ const styles = StyleSheet.create({
302
+ wrap: {
303
+ width: '100%',
304
+ },
305
+ field: {
306
+ // backgroundColor is applied inline at the call site — it depends on the ambient useSurfaceTone()
307
+ // (recessed on white, opaque white on a muted surface — same mechanism FieldContainer uses;
308
+ // InputField predates FieldContainer and still owns its own styles for its label-morph animation).
309
+ borderRadius: DS_RADIUS.medium,
310
+ // 1px reserved always (transparent when inactive) so the field never resizes when the border fades in.
311
+ borderWidth: 1,
312
+ borderColor: 'rgba(0,0,0,0)',
313
+ minHeight: FIELD_MIN_HEIGHT,
314
+ width: '100%',
315
+ position: 'relative',
316
+ justifyContent: 'center',
317
+ },
318
+ restLabelWrap: {
319
+ position: 'absolute',
320
+ left: PAD,
321
+ right: PAD,
322
+ top: 0,
323
+ bottom: 0,
324
+ justifyContent: 'center',
325
+ alignItems: 'flex-start',
326
+ },
327
+ floatLabelWrap: {
328
+ position: 'absolute',
329
+ left: PAD,
330
+ // `right` gives the floated label a real width — without it, an absolute Text with only `left`
331
+ // collapses to its first child's width on iOS/Fabric and drops the trailing "(Optional)" node.
332
+ right: PAD,
333
+ top: PAD,
334
+ },
335
+ // Text-label case: one row, `top` animates between the resting/floated position (see `labelTop`
336
+ // in the component body). Same `right`-needs-a-real-width reasoning as `floatLabelWrap` above.
337
+ morphLabelWrap: {
338
+ position: 'absolute',
339
+ left: PAD,
340
+ right: PAD,
341
+ flexDirection: 'row',
342
+ alignItems: 'flex-end',
343
+ },
344
+ morphLabel: {
345
+ fontWeight: DS_FONT_WEIGHT.semibold,
346
+ color: DS_SEMANTIC.text.muted,
347
+ includeFontPadding: false,
348
+ ...Platform.select({ android: { textAlignVertical: 'center' as const }, default: {} }),
349
+ },
350
+ // Logical start/end (not left/right) so the clear-button inset override below (`valueRowClear`)
351
+ // composes on the same trailing edge in both LTR and RTL.
352
+ valueRow: {
353
+ position: 'absolute',
354
+ start: PAD,
355
+ end: PAD,
356
+ bottom: PAD,
357
+ minHeight: 21,
358
+ flexDirection: 'row',
359
+ alignItems: 'center',
360
+ gap: DS_SPACING[400],
361
+ },
362
+ // Only ever shown at rest (fades out with `restOpacity`), so it keeps the resting label's own
363
+ // fontSize rather than tracking the morph — by the time that'd matter, it's already invisible.
364
+ optionalMark: { ...DS_TYPOGRAPHY.bodyMd, color: DS_SEMANTIC.text.muted },
365
+ value: {
366
+ ...DS_TYPOGRAPHY.bodyMd,
367
+ color: DS_SEMANTIC.text.regular,
368
+ lineHeight: 20,
369
+ padding: 0,
370
+ margin: 0,
371
+ flexShrink: 1,
372
+ minWidth: 0,
373
+ includeFontPadding: false,
374
+ ...Platform.select({ android: { textAlignVertical: 'center' as const }, default: {} }),
375
+ },
376
+ // Editable input fills the row so the clear button is pushed to the far right edge.
377
+ valueInput: {
378
+ flex: 1,
379
+ },
380
+ valueName: {
381
+ flexShrink: 1,
382
+ minWidth: 0,
383
+ },
384
+ // Disabled label/value text — a step lighter than `hintText`'s muted tone, so a disabled field
385
+ // reads as inactive rather than merely empty (see DS_SEMANTIC.text.disabled's own doc comment).
386
+ disabledText: {
387
+ color: DS_SEMANTIC.text.disabled,
388
+ },
389
+ // Picker-mode placeholder — same text.muted rationale as the TextInput's placeholderTextColor above.
390
+ hintText: {
391
+ color: DS_SEMANTIC.text.muted,
392
+ },
393
+ valueAccent: {
394
+ color: DS_SEMANTIC.emphasis.info,
395
+ },
396
+ valueIconGroup: {
397
+ flexDirection: 'row',
398
+ alignItems: 'center',
399
+ gap: DS_SPACING[200],
400
+ flexShrink: 1,
401
+ minWidth: 0,
402
+ },
403
+ // Pinned to the trailing edge (logical `end`, so it mirrors in RTL) and centred over the FULL
404
+ // field height (top:0/bottom:0), so it sits at the field's vertical middle rather than aligning
405
+ // with the bottom-anchored value row.
406
+ clearButton: {
407
+ position: 'absolute',
408
+ end: PAD,
409
+ top: 0,
410
+ bottom: 0,
411
+ width: CLEAR_SIZE,
412
+ alignItems: 'center',
413
+ justifyContent: 'center',
414
+ },
415
+ valueRowClear: {
416
+ end: CLEAR_RIGHT_INSET,
417
+ },
418
+ });
@@ -0,0 +1,2 @@
1
+ export { InputField } from './InputField';
2
+ export type { InputFieldProps, InputFieldLabelVariant } from './InputField';
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { Avatar } from '../Avatar';
4
+ import { ListItem } from '../ListItem';
5
+ import { List } from './List';
6
+ import { Icon } from '../../icons/Icon.native';
7
+ import { DS_SEMANTIC } from '../../tokens';
8
+
9
+ export default defineCatalogPage({
10
+ component: 'List',
11
+ group: 'Layout',
12
+ description: 'Stacks ListItem rows on a rounded white surface, with a Divider automatically inserted between each consecutive pair — never after the last.',
13
+ a11y: 'A plain View; each ListItem child carries its own accessibility.',
14
+ variants: {
15
+ itemsFill: true,
16
+ items: [
17
+ {
18
+ key: 'default',
19
+ name: 'Default',
20
+ node: (
21
+ <List>
22
+ <ListItem title="Notifications" trailing={<Icon name="chevron-right" color={DS_SEMANTIC.text.muted} />} onPress={() => {}} />
23
+ <ListItem title="Jordan Lee" subtitle="Last trip: Uptown & The Bronx" leading={<Avatar initials="JL" size={32} />} onPress={() => {}} />
24
+ <ListItem title="Delete account" onPress={() => {}} />
25
+ </List>
26
+ ),
27
+ },
28
+ ],
29
+ },
30
+ });
@@ -0,0 +1,36 @@
1
+ import React, { type ReactNode } from 'react';
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_RADIUS, DS_SEMANTIC } from '../../tokens';
4
+ import { Divider } from '../Divider';
5
+
6
+ export interface ListProps {
7
+ /** ListItem elements, stacked with a Divider automatically inserted between each consecutive
8
+ * pair — never after the last. */
9
+ children: ReactNode;
10
+ style?: StyleProp<ViewStyle>;
11
+ }
12
+
13
+ /** Stacks ListItem rows on a rounded white surface, with a Divider between each pair — the same
14
+ * "insert a divider between, never after the last" mechanism the catalog's own DividedStack uses. */
15
+ export function List({ children, style }: ListProps) {
16
+ const items = React.Children.toArray(children).filter(Boolean);
17
+ return (
18
+ <View style={[styles.list, style]}>
19
+ {items.map((child, i) => (
20
+ <React.Fragment key={i}>
21
+ {child}
22
+ {i < items.length - 1 && <Divider />}
23
+ </React.Fragment>
24
+ ))}
25
+ </View>
26
+ );
27
+ }
28
+
29
+ const styles = StyleSheet.create({
30
+ list: {
31
+ width: '100%',
32
+ borderRadius: DS_RADIUS.medium,
33
+ overflow: 'hidden',
34
+ backgroundColor: DS_SEMANTIC.surface.white,
35
+ },
36
+ });
@@ -0,0 +1,2 @@
1
+ export { List } from './List';
2
+ export type { ListProps } from './List';
@@ -0,0 +1,89 @@
1
+ import React from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { Avatar } from '../Avatar';
4
+ import { Badge } from '../Badge';
5
+ import { Button } from '../Button';
6
+ import { ListItem } from './ListItem';
7
+ import { Icon } from '../../icons/Icon.native';
8
+ import { DS_SEMANTIC } from '../../tokens';
9
+
10
+ export default defineCatalogPage({
11
+ component: 'ListItem',
12
+ group: 'Layout',
13
+ description: 'A single row: optional leading/trailing slots flanking a title (+ optional subtitle/footer). Stack several inside a List for a settings screen, menu, or search-results list.',
14
+ whenToUse: "A row in a set of visually-light peers sharing a List's own surface and dividers. For a standalone unit with its own shadow, use Card instead.",
15
+ a11y: 'A tappable row (onPress set) renders as a Pressable with accessibilityRole="button" and an accessibilityLabel built from title + subtitle; a plain row is a non-interactive View.',
16
+ // No `variant` prop exists on ListItem — its "Variants" column shows the title-only default and
17
+ // the title+subtitle look; slot/interaction configurations live under "States".
18
+ variants: {
19
+ itemsFill: true,
20
+ items: [
21
+ { key: 'default', name: 'Default', node: <ListItem title="Notifications" /> },
22
+ { key: 'subtitle', name: 'With subtitle', node: <ListItem title="Notifications" subtitle="Delay and service alerts" /> },
23
+ ],
24
+ },
25
+ states: {
26
+ itemsFill: true,
27
+ items: [
28
+ {
29
+ key: 'leading',
30
+ name: 'With leading',
31
+ node: <ListItem title="Jordan Lee" subtitle="Last trip: Uptown & The Bronx" leading={<Avatar initials="JL" size={32} />} />,
32
+ },
33
+ {
34
+ key: 'trailing',
35
+ name: 'With trailing',
36
+ node: <ListItem title="Notifications" trailing={<Icon name="chevron-right" color={DS_SEMANTIC.text.muted} />} />,
37
+ },
38
+ {
39
+ key: 'footer-text',
40
+ name: 'With footer (text)',
41
+ node: <ListItem title="Signal delay" subtitle="Reported near 14 St" footer="2 min ago" />,
42
+ },
43
+ {
44
+ key: 'footer-badge',
45
+ name: 'With footer (badge)',
46
+ node: <ListItem title="Line suspended" subtitle="96 St and 137 St" footer={<Badge variant="negative" label="Service alert" />} />,
47
+ },
48
+ {
49
+ key: 'footer-button',
50
+ name: 'With footer (button)',
51
+ node: <ListItem title="Trip request" subtitle="Jordan Lee wants to share a ride" footer={<Button label="Accept" size="small" onPress={() => {}} />} />,
52
+ },
53
+ {
54
+ key: 'trailing-text',
55
+ name: 'With trailing data',
56
+ node: (
57
+ <ListItem
58
+ title="Language"
59
+ trailingText="English"
60
+ trailing={<Icon name="chevron-right" color={DS_SEMANTIC.text.muted} />}
61
+ onPress={() => {}}
62
+ />
63
+ ),
64
+ },
65
+ {
66
+ key: 'trailing-subtext',
67
+ name: 'With trailing data (secondary)',
68
+ node: <ListItem title="Storage used" trailingText="2.4 GB" trailingSubtext="of 5 GB" />,
69
+ },
70
+ {
71
+ key: 'trailing-button',
72
+ name: 'With trailing button',
73
+ node: <ListItem title="Pending invite" subtitle="Sam Rivera" trailing={<Button label="Accept" size="small" onPress={() => {}} />} />,
74
+ },
75
+ {
76
+ key: 'pressable',
77
+ name: 'Pressable',
78
+ node: (
79
+ <ListItem
80
+ title="Notifications"
81
+ trailing={<Icon name="chevron-right" color={DS_SEMANTIC.text.muted} />}
82
+ onPress={() => {}}
83
+ />
84
+ ),
85
+ },
86
+ { key: 'disabled', name: 'Disabled', node: <ListItem title="Notifications" onPress={() => {}} disabled /> },
87
+ ],
88
+ },
89
+ });