@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,91 @@
1
+ import React from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { PillRow } from './PillRow';
4
+
5
+ export default defineCatalogPage({
6
+ component: 'PillRow',
7
+ group: 'Actions',
8
+ description: 'A horizontally-scrolling row of Pill chips, with an optional trailing icon-only add pill — keeps the selected pill scrolled into view automatically.',
9
+ whenToUse: 'A scrollable row of selection chips (e.g. saved-place shortcuts) — for a fixed row of action buttons instead, use ButtonGroup.',
10
+ a11y: 'The row itself carries no accessibility role; each Pill (including the add pill) keeps its own accessibilityLabel/role from the Pill component.',
11
+ variants: {
12
+ itemsFill: true,
13
+ items: [
14
+ {
15
+ key: 'few',
16
+ name: 'Few pills',
17
+ node: (
18
+ <PillRow
19
+ pills={[
20
+ { id: 'home', label: 'Home', variant: 'selected', iconName: 'home' },
21
+ { id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
22
+ ]}
23
+ />
24
+ ),
25
+ },
26
+ {
27
+ key: 'many',
28
+ name: 'Many pills (scrollable)',
29
+ node: (
30
+ <PillRow
31
+ pills={[
32
+ { id: 'home', label: 'Home', variant: 'not_selected', iconName: 'home' },
33
+ { id: 'work', label: 'Work', variant: 'selected', iconName: 'briefcase' },
34
+ { id: 'gym', label: 'Gym', variant: 'not_selected', iconName: 'map' },
35
+ { id: 'saved', label: 'Saved', variant: 'not_selected', iconName: 'bell' },
36
+ { id: 'nearby', label: 'Nearby', variant: 'not_selected', iconName: 'pin' },
37
+ { id: 'recent', label: 'Recent', variant: 'not_selected', iconName: 'clock' },
38
+ ]}
39
+ />
40
+ ),
41
+ },
42
+ ],
43
+ },
44
+ states: {
45
+ itemsFill: true,
46
+ items: [
47
+ {
48
+ key: 'no-add',
49
+ name: 'No add pill',
50
+ props: { showAddPill: false },
51
+ node: (
52
+ <PillRow
53
+ showAddPill={false}
54
+ pills={[
55
+ { id: 'home', label: 'Home', variant: 'selected', iconName: 'home' },
56
+ { id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
57
+ ]}
58
+ />
59
+ ),
60
+ },
61
+ {
62
+ key: 'add-selected',
63
+ name: 'Add pill selected',
64
+ props: { addSelected: true },
65
+ node: (
66
+ <PillRow
67
+ addSelected
68
+ pills={[
69
+ { id: 'home', label: 'Home', variant: 'not_selected', iconName: 'home' },
70
+ { id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
71
+ ]}
72
+ />
73
+ ),
74
+ },
75
+ {
76
+ key: 'min-width',
77
+ name: 'Minimum pill width',
78
+ props: { minPillWidth: 96 },
79
+ node: (
80
+ <PillRow
81
+ minPillWidth={96}
82
+ pills={[
83
+ { id: 'home', label: 'Home', variant: 'selected', iconName: 'home' },
84
+ { id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
85
+ ]}
86
+ />
87
+ ),
88
+ },
89
+ ],
90
+ },
91
+ });
@@ -0,0 +1,140 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ScrollView, View, StyleSheet, type LayoutChangeEvent } from 'react-native';
3
+ import { DS_SPACING } from '../../tokens';
4
+ import { Pill } from '../Pill';
5
+ import type { PillRowItem, PillRowProps } from './PillRow.types';
6
+
7
+ const GAP = DS_SPACING[600];
8
+
9
+ const DEFAULT_PILLS: NonNullable<PillRowProps['pills']> = [
10
+ { id: 'home', label: 'Home', variant: 'selected', iconName: 'home' },
11
+ { id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
12
+ ];
13
+
14
+ /**
15
+ * A horizontally-scrolling row of Pills with an optional trailing icon-only add pill. Keeps the
16
+ * selected pill scrolled into view, and resets to the start when the pill set genuinely changes.
17
+ */
18
+ export function PillRow({
19
+ pills: pillsProp,
20
+ showAddPill = true,
21
+ minPillWidth = 0,
22
+ onAddPress,
23
+ addSelected = false,
24
+ style,
25
+ trailing,
26
+ }: PillRowProps) {
27
+ const pills = pillsProp ?? DEFAULT_PILLS;
28
+ const idsKey = pills.map(p => p.id).join('|');
29
+
30
+ const scrollRef = useRef<ScrollView>(null);
31
+ const scrollXRef = useRef(0);
32
+ // Laid-out position of each pill (x within the scroll content + width), kept for scroll-into-view.
33
+ const posRef = useRef<Map<string, { x: number; width: number }>>(new Map());
34
+ const [viewportW, setViewportW] = useState(0);
35
+
36
+ const onPillLayout = (id: string, e: LayoutChangeEvent) => {
37
+ const { x, width } = e.nativeEvent.layout;
38
+ posRef.current.set(id, { x, width });
39
+ };
40
+
41
+ // A genuinely different set of pills (not just a selection change within the same set) forgets
42
+ // wherever the row was scrolled to and starts back at the beginning — otherwise a stale scroll
43
+ // offset or a stale cached position from the PREVIOUS pill set could carry over onto this one.
44
+ useEffect(() => {
45
+ posRef.current.clear();
46
+ scrollRef.current?.scrollTo({ x: 0, animated: false });
47
+ }, [idsKey]);
48
+
49
+ // Keep the selected pill fully in view. The selected pill is the one with variant 'selected', or the
50
+ // add pill (tracked under '__add__') when it's selected. Re-runs whenever the selection or the layout
51
+ // changes; deferred a frame so positions are fresh after a layout change.
52
+ const selectedId = useMemo(
53
+ () => (addSelected ? '__add__' : pills.find(p => p.variant === 'selected')?.id),
54
+ [addSelected, pills],
55
+ );
56
+ useEffect(() => {
57
+ if (!selectedId || viewportW <= 0) return;
58
+ const raf = requestAnimationFrame(() => {
59
+ const pos = posRef.current.get(selectedId);
60
+ if (!pos) return;
61
+ const left = scrollXRef.current;
62
+ const right = left + viewportW;
63
+ if (pos.x < left) {
64
+ scrollRef.current?.scrollTo({ x: Math.max(0, pos.x - GAP), animated: true });
65
+ } else if (pos.x + pos.width > right) {
66
+ scrollRef.current?.scrollTo({ x: pos.x + pos.width - viewportW + GAP, animated: true });
67
+ }
68
+ });
69
+ return () => cancelAnimationFrame(raf);
70
+ }, [selectedId, viewportW, idsKey]);
71
+
72
+ // `minPillWidth` is applied as a floor (`minWidth`) so each pill grows to fit its own label (never
73
+ // truncates) while short labels still hold the minimum.
74
+ const pillStyle = minPillWidth > 0 ? { minWidth: minPillWidth } : undefined;
75
+
76
+ const renderPill = (pill: PillRowItem) => (
77
+ <View key={pill.id} onLayout={e => onPillLayout(pill.id, e)}>
78
+ <Pill
79
+ label={pill.label}
80
+ variant={pill.variant}
81
+ showText={pill.showText}
82
+ iconName={pill.iconName}
83
+ iconSize={pill.iconSize}
84
+ icon={pill.icon}
85
+ onPress={pill.onPress}
86
+ disabled={pill.disabled}
87
+ loading={pill.loading}
88
+ accessibilityLabel={pill.accessibilityLabel}
89
+ style={pillStyle}
90
+ />
91
+ </View>
92
+ );
93
+
94
+ return (
95
+ <ScrollView
96
+ ref={scrollRef}
97
+ horizontal
98
+ showsHorizontalScrollIndicator={false}
99
+ // `style` (the ScrollView's OWN box, not its scrollable content) needs an explicit bound —
100
+ // without it, react-native-web sizes the scroll viewport to hug its content instead of the
101
+ // width its parent actually gives it, so it never overflows and never becomes swipable at
102
+ // all (a wide pill set just spills past the layout silently). `contentContainerStyle` is a
103
+ // separate thing: the inner row that's *allowed* to be wider than the viewport.
104
+ style={styles.scrollView}
105
+ contentContainerStyle={[styles.row, style]}
106
+ onLayout={e => setViewportW(e.nativeEvent.layout.width)}
107
+ onScroll={e => {
108
+ scrollXRef.current = e.nativeEvent.contentOffset.x;
109
+ }}
110
+ scrollEventThrottle={16}
111
+ >
112
+ {pills.map(renderPill)}
113
+ {showAddPill && (
114
+ // Record the add pill's position (under '__add__') so it can be scrolled into view when
115
+ // selected — tracked separately from `pills` since it's rendered outside that list.
116
+ <View onLayout={e => onPillLayout('__add__', e)}>
117
+ <Pill
118
+ variant={addSelected ? 'selected' : 'not_selected'}
119
+ showText={false}
120
+ iconName="pencil"
121
+ accessibilityLabel="Edit"
122
+ onPress={onAddPress}
123
+ />
124
+ </View>
125
+ )}
126
+ {trailing}
127
+ </ScrollView>
128
+ );
129
+ }
130
+
131
+ const styles = StyleSheet.create({
132
+ scrollView: { width: '100%' },
133
+ row: {
134
+ flexDirection: 'row',
135
+ alignItems: 'center',
136
+ gap: DS_SPACING[600],
137
+ minHeight: DS_SPACING[2400],
138
+ paddingVertical: DS_SPACING[200],
139
+ },
140
+ });
@@ -0,0 +1,24 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StyleProp, ViewStyle } from 'react-native';
3
+ import type { PillProps } from '../Pill';
4
+
5
+ export interface PillRowItem extends Omit<PillProps, 'style'> {
6
+ id: string;
7
+ }
8
+
9
+ export interface PillRowProps {
10
+ /** Pills to render — each is a Pill's own props plus an id. Defaults to a two-item Home/Work
11
+ * sample. */
12
+ pills?: PillRowItem[];
13
+ /** Icon-only add/edit pill at the end of the row. */
14
+ showAddPill?: boolean;
15
+ /** Floor for the content pills' width (px). Excludes the icon-only add pill so it stays circular. */
16
+ minPillWidth?: number;
17
+ /** Tap handler for the add pill. */
18
+ onAddPress?: () => void;
19
+ /** Render the add pill in the selected state. */
20
+ addSelected?: boolean;
21
+ style?: StyleProp<ViewStyle>;
22
+ /** Custom trailing content instead of the add pill. */
23
+ trailing?: ReactNode;
24
+ }
@@ -0,0 +1,2 @@
1
+ export { PillRow } from './PillRow';
2
+ export type { PillRowProps, PillRowItem } from './PillRow.types';
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { ProgressDots } from './ProgressDots';
4
+
5
+ export default defineCatalogPage({
6
+ component: 'ProgressDots',
7
+ group: 'Feedback',
8
+ description: 'A row of dots for a stepped flow; the active dot widens into a pill.',
9
+ a11y: 'Decorative progress indicator; convey the "step X of Y" position in text for screen readers.',
10
+ // No `variant` prop exists on ProgressDots — its "Variants" column just shows the one default
11
+ // look; `active`'s different positions are progress states, covered under "States".
12
+ variants: {
13
+ items: [{ key: 'default', name: 'Default', node: <ProgressDots active={1} total={4} /> }],
14
+ },
15
+ states: {
16
+ items: [
17
+ { key: 'first', name: 'First step', node: <ProgressDots active={0} total={4} /> },
18
+ { key: 'middle', name: 'Middle step', node: <ProgressDots active={2} total={4} /> },
19
+ { key: 'last', name: 'Last step', node: <ProgressDots active={3} total={4} /> },
20
+ ],
21
+ },
22
+ });
@@ -0,0 +1,42 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_SPACING } from '../../tokens';
4
+
5
+ export interface ProgressDotsProps {
6
+ /** 0-based index of the current step. The active dot widens into a pill. */
7
+ active: number;
8
+ /** Total number of steps (dots). */
9
+ total?: number;
10
+ style?: StyleProp<ViewStyle>;
11
+ }
12
+
13
+ /**
14
+ * Step progress indicator — a row of dots where the current step is a widened pill.
15
+ */
16
+ export function ProgressDots({ active, total = 6, style }: ProgressDotsProps) {
17
+ return (
18
+ <View
19
+ style={[styles.row, style]}
20
+ accessible
21
+ accessibilityRole="progressbar"
22
+ accessibilityLabel={`Step ${active + 1} of ${total}`}
23
+ accessibilityValue={{ min: 1, max: total, now: active + 1 }}
24
+ >
25
+ {Array.from({ length: total }).map((_, i) => (
26
+ <View key={i} style={[styles.dot, i === active && styles.dotActive]} />
27
+ ))}
28
+ </View>
29
+ );
30
+ }
31
+
32
+ // 7px is intentionally smaller than DS_SPACING[400]=8px — optical correction for small dots.
33
+ const DOT_H = 7;
34
+ const DOT_PILL_W = 20;
35
+ const DOT_RADIUS = 4;
36
+
37
+ const styles = StyleSheet.create({
38
+ row: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: DS_SPACING[200] },
39
+ dot: { width: DOT_H, height: DOT_H, borderRadius: DOT_RADIUS, backgroundColor: DS_SEMANTIC.border.subtle },
40
+ // surface.inverse, not text.regular — a dark FILL, not ink (same reasoning as Pill/Toast).
41
+ dotActive: { width: DOT_PILL_W, borderRadius: DOT_RADIUS, backgroundColor: DS_SEMANTIC.surface.inverse },
42
+ });
@@ -0,0 +1,2 @@
1
+ export { ProgressDots } from './ProgressDots';
2
+ export type { ProgressDotsProps } from './ProgressDots';
@@ -0,0 +1,38 @@
1
+ import React, { useState } from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { Radio } from './Radio';
5
+ import { DS_SPACING } from '../../tokens';
6
+
7
+ const styles = StyleSheet.create({
8
+ radioGroup: { gap: DS_SPACING[600], alignItems: 'flex-start' },
9
+ });
10
+
11
+ function RadioGroupDemo() {
12
+ const [value, setValue] = useState('uptown');
13
+ return (
14
+ <View style={styles.radioGroup}>
15
+ <Radio selected={value === 'uptown'} onPress={() => setValue('uptown')} label="Uptown & The Bronx" />
16
+ <Radio selected={value === 'downtown'} onPress={() => setValue('downtown')} label="Downtown & Brooklyn" />
17
+ </View>
18
+ );
19
+ }
20
+
21
+ export default defineCatalogPage({
22
+ component: 'Radio',
23
+ group: 'Controls',
24
+ specimenSize: 'compact',
25
+ description: 'A single circular selection control — a filled dot appears in the ring when selected. A group of mutually-exclusive Radios is just multiple instances sharing one selected value in the consumer.',
26
+ whenToUse: 'One selection from a mutually-exclusive set — checking one should un-check another. For an independent on/off fact, use Checkbox; for a setting that takes effect immediately, use Switch.',
27
+ a11y: 'Renders a Pressable with accessibilityRole="radio" and accessibilityState.selected; the optional label doubles as its accessibilityLabel.',
28
+ variants: {
29
+ items: [{ key: 'default', name: 'Default (tap to select)', node: <RadioGroupDemo /> }],
30
+ },
31
+ states: {
32
+ items: [
33
+ { key: 'unselected', name: 'Unselected', node: <Radio selected={false} onPress={() => {}} label="Uptown & The Bronx" /> },
34
+ { key: 'selected', name: 'Selected', node: <Radio selected={true} onPress={() => {}} label="Uptown & The Bronx" /> },
35
+ { key: 'disabled', name: 'Disabled', node: <Radio selected={true} onPress={() => {}} disabled label="Uptown & The Bronx" /> },
36
+ ],
37
+ },
38
+ });
@@ -0,0 +1,114 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { Pressable, Text, View, Animated, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_TYPOGRAPHY, DS_A11Y_MIN_TOUCH_TARGET, DS_MOTION_DURATION } from '../../tokens';
4
+ import { usePressScale } from './usePressScale';
5
+
6
+ export interface RadioProps {
7
+ /** Whether this radio is the selected one. */
8
+ selected: boolean;
9
+ /** Tap handler — select this option in the consumer's state. */
10
+ onPress: () => void;
11
+ /** Optional inline label rendered to the right of the circle. */
12
+ label?: string;
13
+ /** Non-interactive, dimmed. */
14
+ disabled?: boolean;
15
+ style?: StyleProp<ViewStyle>;
16
+ }
17
+
18
+ const CIRCLE_SIZE = 20;
19
+ const DOT_SIZE = 10;
20
+ // Pads the tappable area out to the accessibility minimum without growing the visual circle — same
21
+ // reasoning as Button's own `iconOnlyHitSlop`. Applied on all sides since a no-label Radio's row is
22
+ // exactly CIRCLE_SIZE tall/wide; a labelled row is already wider, where the extra hitSlop is
23
+ // harmless overlap into surrounding whitespace rather than another control.
24
+ const HIT_SLOP = Math.max(0, Math.ceil((DS_A11Y_MIN_TOUCH_TARGET - CIRCLE_SIZE) / 2));
25
+
26
+ /**
27
+ * A single circular selection control — a filled dot appears in the ring when selected. A group of
28
+ * mutually-exclusive Radios is just multiple instances sharing one "selected value" in the consumer
29
+ * (the same way a native radio group works); this component itself only knows its own selected state.
30
+ * The circle grows slightly while pressed, for tactile feedback before release, and the dot itself
31
+ * pops in/out (scale + opacity) rather than snapping — both kept JS-driven (`useNativeDriver: false`)
32
+ * for the same Fabric-safety reason.
33
+ */
34
+ export function Radio({ selected, onPress, label, disabled = false, style }: RadioProps) {
35
+ const { pressScale, onPressIn, onPressOut } = usePressScale(disabled);
36
+ const dotAnim = useRef(new Animated.Value(selected ? 1 : 0)).current;
37
+ const [focused, setFocused] = useState(false);
38
+
39
+ useEffect(() => {
40
+ Animated.timing(dotAnim, {
41
+ toValue: selected ? 1 : 0,
42
+ duration: DS_MOTION_DURATION.fast,
43
+ useNativeDriver: false,
44
+ }).start();
45
+ }, [selected, dotAnim]);
46
+
47
+ return (
48
+ <Pressable
49
+ onPress={() => !disabled && onPress()}
50
+ onPressIn={onPressIn}
51
+ onPressOut={onPressOut}
52
+ onFocus={() => setFocused(true)}
53
+ onBlur={() => setFocused(false)}
54
+ disabled={disabled}
55
+ accessibilityRole="radio"
56
+ accessibilityState={{ selected, disabled }}
57
+ accessibilityLabel={label}
58
+ hitSlop={HIT_SLOP}
59
+ style={[styles.row, disabled && styles.disabled, style]}
60
+ >
61
+ <Animated.View style={[styles.circle, selected && styles.circleSelected, { transform: [{ scale: pressScale }] }]}>
62
+ <Animated.View style={[styles.dot, { opacity: dotAnim, transform: [{ scale: dotAnim }] }]} />
63
+ {/* Keyboard-focus ring — same interaction.focused treatment Button/Pill use; a zero-layout
64
+ absolute overlay so focusing never shifts layout. */}
65
+ {focused && !disabled && <View pointerEvents="none" style={styles.focusRing} />}
66
+ </Animated.View>
67
+ {!!label && <Text style={styles.label}>{label}</Text>}
68
+ </Pressable>
69
+ );
70
+ }
71
+
72
+ const styles = StyleSheet.create({
73
+ row: {
74
+ flexDirection: 'row',
75
+ alignItems: 'center',
76
+ gap: DS_SPACING[400],
77
+ },
78
+ circle: {
79
+ width: CIRCLE_SIZE,
80
+ height: CIRCLE_SIZE,
81
+ borderRadius: DS_RADIUS.round,
82
+ borderWidth: 2,
83
+ borderColor: DS_SEMANTIC.border.dark,
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
86
+ },
87
+ circleSelected: {
88
+ borderColor: DS_SEMANTIC.emphasis.info,
89
+ },
90
+ dot: {
91
+ width: DOT_SIZE,
92
+ height: DOT_SIZE,
93
+ borderRadius: DS_RADIUS.round,
94
+ backgroundColor: DS_SEMANTIC.emphasis.info,
95
+ },
96
+ label: {
97
+ ...DS_TYPOGRAPHY.bodyMd,
98
+ color: DS_SEMANTIC.text.regular,
99
+ },
100
+ disabled: {
101
+ opacity: DS_SEMANTIC.interaction.disabledOpacity,
102
+ },
103
+ // 2px air + 2px ring outside the circle; fully round to stay concentric.
104
+ focusRing: {
105
+ position: 'absolute',
106
+ top: -4,
107
+ bottom: -4,
108
+ left: -4,
109
+ right: -4,
110
+ borderWidth: 2,
111
+ borderColor: DS_SEMANTIC.interaction.focused,
112
+ borderRadius: DS_RADIUS.round,
113
+ },
114
+ });
@@ -0,0 +1,2 @@
1
+ export { Radio } from './Radio';
2
+ export type { RadioProps } from './Radio';
@@ -0,0 +1,33 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Animated } from 'react-native';
3
+ import { DS_MOTION_DURATION } from '../../tokens';
4
+
5
+ /** How much a control's own touchable grows while pressed. */
6
+ export const PRESS_SCALE = 1.15;
7
+
8
+ /**
9
+ * Shared by Radio, Checkbox, and Switch: "grow slightly while pressed, before release" tactile
10
+ * feedback — the exact same state/animation mechanics were previously copy-pasted verbatim across
11
+ * all three. `disabled` controls are inert; `onPressIn` won't start the grow for them (a disabled
12
+ * control has no interaction to give feedback about).
13
+ */
14
+ export function usePressScale(disabled: boolean) {
15
+ const [pressed, setPressed] = useState(false);
16
+ const pressScale = useRef(new Animated.Value(1)).current;
17
+
18
+ useEffect(() => {
19
+ Animated.timing(pressScale, {
20
+ toValue: pressed ? PRESS_SCALE : 1,
21
+ // Grow-in at 100ms — deliberately snappier than any duration token (press feedback must feel
22
+ // instant under the finger); the release settles at the standard fast duration.
23
+ duration: pressed ? 100 : DS_MOTION_DURATION.fast,
24
+ useNativeDriver: false,
25
+ }).start();
26
+ }, [pressed, pressScale]);
27
+
28
+ return {
29
+ pressScale,
30
+ onPressIn: () => !disabled && setPressed(true),
31
+ onPressOut: () => setPressed(false),
32
+ };
33
+ }
@@ -0,0 +1,28 @@
1
+ import React, { useState } from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { SearchField } from './SearchField';
4
+
5
+ function SearchFieldDemo() {
6
+ const [value, setValue] = useState('');
7
+ return <SearchField value={value} onChangeText={setValue} placeholder="Search stations" />;
8
+ }
9
+
10
+ export default defineCatalogPage({
11
+ component: 'SearchField',
12
+ group: 'Inputs',
13
+ description: 'Single-line search input on the shared field chrome — leading search icon, a border that darkens on focus, and a clear button while active with text.',
14
+ whenToUse: 'Free-text filtering/searching only — never a named field with a fixed value. For that, use InputField; for a small known set of choices, use Dropdown.',
15
+ a11y: 'Forwards a ref to the underlying TextInput; all TextInputProps pass through, so pass accessibilityLabel/placeholder as needed.',
16
+ variants: {
17
+ itemsFill: true,
18
+ items: [{ key: 'default', name: 'Default (type to see clear button)', node: <SearchFieldDemo /> }],
19
+ },
20
+ states: {
21
+ itemsFill: true,
22
+ items: [
23
+ { key: 'empty', name: 'Empty', node: <SearchField value="" onChangeText={() => {}} placeholder="Search stations" /> },
24
+ { key: 'filled', name: 'With value', node: <SearchField value="Union Sq" onChangeText={() => {}} /> },
25
+ { key: 'disabled', name: 'Disabled', node: <SearchField value="" onChangeText={() => {}} placeholder="Search stations" disabled /> },
26
+ ],
27
+ },
28
+ });
@@ -0,0 +1,98 @@
1
+ import React, { forwardRef, useState } from 'react';
2
+ import {
3
+ TextInput,
4
+ StyleSheet,
5
+ Platform,
6
+ type TextInputProps,
7
+ type StyleProp,
8
+ type ViewStyle,
9
+ } from 'react-native';
10
+ import { FieldContainer } from '../FieldContainer';
11
+ import { InputClearButton } from '../InputClearButton';
12
+ import { Icon } from '../../icons/Icon.native';
13
+ import type { IconName } from '../../icons';
14
+ import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY, DS_ICON_SIZE } from '../../tokens';
15
+
16
+ export interface SearchFieldProps extends TextInputProps {
17
+ /** Leading icon (defaults to the search glyph). */
18
+ iconName?: IconName;
19
+ /** Extra layout applied to the field container (e.g. margins). */
20
+ containerStyle?: StyleProp<ViewStyle>;
21
+ /** Disabled state — dimmed icon/text (a step past the hint/placeholder tone, so it reads as
22
+ * inactive rather than merely empty), non-interactive. Forces the field non-editable regardless
23
+ * of `editable`, matching InputField/TextArea. */
24
+ disabled?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Single-line search input on the shared field chrome ({@link FieldContainer}): white fill, medium
29
+ * radius, a 1px subtle border that **darkens to border.dark on focus**, 56px tall, with a leading search
30
+ * icon. While the field is active (focused with text) a **clear (×) button** appears on the right and
31
+ * wipes the input on tap. Forwards a ref to the underlying TextInput; all `TextInputProps` pass through.
32
+ * Works controlled (`value` + `onChangeText`) or uncontrolled (`defaultValue`). Pick one mode per
33
+ * mounted instance and stick with it.
34
+ */
35
+ export const SearchField = forwardRef<TextInput, SearchFieldProps>(function SearchField(
36
+ { iconName = 'search', containerStyle, style, value, defaultValue, onChangeText, onFocus, onBlur, disabled = false, editable, ...rest },
37
+ ref,
38
+ ) {
39
+ const isControlled = value !== undefined;
40
+ const [internal, setInternal] = useState(defaultValue ?? '');
41
+ const [focused, setFocused] = useState(false);
42
+ const text = isControlled ? value : internal;
43
+
44
+ const change = (t: string) => {
45
+ if (!isControlled) setInternal(t);
46
+ onChangeText?.(t);
47
+ };
48
+
49
+ const showClear = !disabled && focused && !!text && text.length > 0;
50
+
51
+ return (
52
+ <FieldContainer focused={focused} disabled={disabled} style={[styles.container, containerStyle]}>
53
+ <Icon
54
+ name={iconName}
55
+ size={DS_ICON_SIZE.sm}
56
+ color={disabled ? DS_SEMANTIC.text.disabled : focused ? DS_SEMANTIC.text.regular : DS_SEMANTIC.text.muted}
57
+ />
58
+ <TextInput
59
+ ref={ref}
60
+ style={[styles.input, disabled && styles.inputDisabled, style]}
61
+ placeholderTextColor={disabled ? DS_SEMANTIC.text.disabled : DS_SEMANTIC.text.muted}
62
+ value={text}
63
+ onChangeText={change}
64
+ onFocus={(e) => { setFocused(true); onFocus?.(e); }}
65
+ onBlur={(e) => { setFocused(false); onBlur?.(e); }}
66
+ editable={disabled ? false : editable}
67
+ {...rest}
68
+ />
69
+ {showClear ? <InputClearButton onPress={() => change('')} accessibilityLabel="Clear search" /> : null}
70
+ </FieldContainer>
71
+ );
72
+ });
73
+
74
+ const styles = StyleSheet.create({
75
+ container: {
76
+ flexDirection: 'row',
77
+ alignItems: 'center',
78
+ gap: DS_SPACING[800],
79
+ height: DS_SPACING[2800],
80
+ paddingHorizontal: DS_SPACING[800],
81
+ },
82
+ input: {
83
+ flex: 1,
84
+ minWidth: 0,
85
+ // Just the token's size, not the full bodyMd (a TextInput mishandles lineHeight's vertical centering).
86
+ fontSize: DS_TYPOGRAPHY.bodyMd.fontSize,
87
+ color: DS_SEMANTIC.text.regular,
88
+ padding: 0,
89
+ margin: 0,
90
+ includeFontPadding: false,
91
+ ...Platform.select({ android: { textAlignVertical: 'center' as const }, default: {} }),
92
+ },
93
+ // A step lighter than the placeholder's muted tone — see DS_SEMANTIC.text.disabled's own doc
94
+ // comment on why disabled text shouldn't just reuse the hint color.
95
+ inputDisabled: {
96
+ color: DS_SEMANTIC.text.disabled,
97
+ },
98
+ });
@@ -0,0 +1,2 @@
1
+ export { SearchField } from './SearchField';
2
+ export type { SearchFieldProps } from './SearchField';