@krapwoo/ds-viewer 0.1.0 → 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 +102 -3
  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 +11 -7
  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,195 @@
1
+ import React, { useEffect, useRef, useState, type ReactNode } from 'react';
2
+ import { View, Text, StyleSheet, useWindowDimensions, type LayoutChangeEvent, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_SHADOW, DS_TYPOGRAPHY } from '../../tokens';
4
+
5
+ export interface TooltipProps {
6
+ /** Whether the bubble is shown. Fully controlled — drive this from the wrapped trigger's own
7
+ * onLongPress/onPressIn, since mobile has no hover. */
8
+ visible: boolean;
9
+ /** The tooltip text. */
10
+ label: string;
11
+ /** Which side of the trigger the bubble appears on. @default 'top' */
12
+ placement?: 'top' | 'bottom';
13
+ /** Which edge of the trigger the bubble prefers to anchor to horizontally: `'center'` (default)
14
+ * centers the bubble on the trigger; `'left'`/`'right'` aligns the bubble's respective edge with
15
+ * the trigger's — useful when the trigger sits near a screen edge and a centered bubble would
16
+ * extend past it. This is only a *preference*: the bubble is always clamped to stay fully within
17
+ * the screen regardless, and the arrow is always computed to point at the trigger's true center,
18
+ * never a decorative fixed corner offset. */
19
+ align?: 'left' | 'center' | 'right';
20
+ /** The trigger content the bubble is anchored to. */
21
+ children: ReactNode;
22
+ style?: StyleProp<ViewStyle>;
23
+ }
24
+
25
+ const GAP = DS_SPACING[200];
26
+ const MIN_WIDTH = 60;
27
+ const MAX_WIDTH_RATIO = 0.8;
28
+ const ARROW_SIZE = 6;
29
+ // How close the bubble (and the arrow within it) may come to the screen's own edge.
30
+ const EDGE_MARGIN = DS_SPACING[400];
31
+ // For align='left'/'right': how far the bubble extends *past* the trigger's own edge, instead of
32
+ // aligning flush with it. This gives the arrow (still always computed from the trigger's true
33
+ // center — see below) breathing room from the bubble's rounded corner by shifting the bubble
34
+ // itself, never by clamping the arrow away from accuracy the way a min-inset-on-the-arrow approach
35
+ // would (that approach was tried and reverted: it silently mis-pointed the arrow for any trigger
36
+ // narrower than the inset).
37
+ const EDGE_OVERHANG = DS_SPACING[400];
38
+ // Same colour as the bubble's own background, referenced directly (not via a resolved variable)
39
+ // since — unlike Toast — Tooltip has only the one dark surface, no variant/light-background mode.
40
+ // surface.inverse, not text.regular — it's a dark PANEL fill, not ink (same reasoning as Toast).
41
+ const BUBBLE_COLOR = DS_SEMANTIC.surface.inverse;
42
+
43
+ /**
44
+ * A small floating label anchored above or below its wrapped trigger, with a small triangular arrow
45
+ * pointing back at it. Measures the trigger's real position (`measureInWindow`, since a plain
46
+ * onLayout only gives a position relative to the trigger's own parent, not the screen) and the
47
+ * bubble's own rendered width (onLayout), then computes the bubble's horizontal offset itself
48
+ * instead of just flexbox-centering it — this is what makes it possible to (a) clamp the bubble so
49
+ * it never runs past the screen's left/right edge and (b) always place the arrow at the trigger's
50
+ * true horizontal center, even when the bubble itself had to shift away from `align`'s preferred
51
+ * position to stay on screen. No `numberOfLines` cap on the label — it wraps to however many lines
52
+ * it needs rather than truncating; `maxWidth` (80% of the screen width, via `useWindowDimensions` so
53
+ * it tracks rotation/resize) is what keeps a long label from ever running edge-to-edge. The arrow is
54
+ * the classic CSS border-triangle trick (a zero-size box with one transparent-adjacent border side
55
+ * coloured in) — RN's View supports per-side border colours, so this needs no SVG.
56
+ */
57
+ export function Tooltip({ visible, label, placement = 'top', align = 'center', children, style }: TooltipProps) {
58
+ const { width: windowWidth } = useWindowDimensions();
59
+ const maxWidth = Math.round(windowWidth * MAX_WIDTH_RATIO);
60
+
61
+ const wrapRef = useRef<View>(null);
62
+ const [trigger, setTrigger] = useState<{ x: number; width: number } | null>(null);
63
+ const [bubbleWidth, setBubbleWidth] = useState(0);
64
+
65
+ // Re-measures whenever the bubble becomes visible or the window is resized — the trigger's own
66
+ // on-screen position can only be read once it (and any layout change causing it) has settled.
67
+ useEffect(() => {
68
+ if (!visible) return undefined;
69
+ const raf = requestAnimationFrame(() => {
70
+ wrapRef.current?.measureInWindow((x, _y, width) => setTrigger({ x, width }));
71
+ });
72
+ return () => cancelAnimationFrame(raf);
73
+ }, [visible, windowWidth]);
74
+
75
+ // Falls back to plain flex-centering (the old behavior) for the one frame before the trigger/
76
+ // bubble have been measured, so there's no flash of an unpositioned bubble at 0,0.
77
+ let bubbleOffsetStyle: ViewStyle = styles.bubbleWrapCentered;
78
+ let arrowLeft: number | null = null;
79
+
80
+ if (trigger && bubbleWidth > 0) {
81
+ const triggerCenterAbs = trigger.x + trigger.width / 2;
82
+ const idealLeftAbs =
83
+ align === 'left'
84
+ ? trigger.x - EDGE_OVERHANG
85
+ : align === 'right'
86
+ ? trigger.x + trigger.width + EDGE_OVERHANG - bubbleWidth
87
+ : triggerCenterAbs - bubbleWidth / 2;
88
+ const minLeftAbs = EDGE_MARGIN;
89
+ const maxLeftAbs = windowWidth - bubbleWidth - EDGE_MARGIN;
90
+ // maxLeftAbs can be < minLeftAbs for a bubble wider than the screen minus margins — clamp the
91
+ // clamp range itself so a too-wide bubble still lands at minLeftAbs instead of NaN/garbage.
92
+ const clampedLeftAbs = Math.min(Math.max(idealLeftAbs, minLeftAbs), Math.max(maxLeftAbs, minLeftAbs));
93
+ bubbleOffsetStyle = { alignSelf: 'flex-start', marginLeft: clampedLeftAbs - trigger.x };
94
+ // Always derived from the trigger's real position — never clamped/overridden — so the arrow is
95
+ // guaranteed accurate regardless of how far the bubble above just shifted for `align` or screen
96
+ // clamping. EDGE_OVERHANG (baked into idealLeftAbs) is what gives it breathing room from the
97
+ // bubble's corner; this line doesn't need its own inset logic on top of that.
98
+ arrowLeft = Math.min(Math.max(triggerCenterAbs - clampedLeftAbs, 0), bubbleWidth);
99
+ }
100
+
101
+ return (
102
+ <View style={styles.wrap} ref={wrapRef}>
103
+ {children}
104
+ {visible && (
105
+ <View pointerEvents="none" style={[styles.overlay, placement === 'top' ? styles.overlayTop : styles.overlayBottom]}>
106
+ <View style={[styles.bubbleWrap, bubbleOffsetStyle]}>
107
+ <View
108
+ style={[styles.bubble, { maxWidth }, style]}
109
+ onLayout={(e: LayoutChangeEvent) => setBubbleWidth(e.nativeEvent.layout.width)}
110
+ // Announce the hint when it appears — without this, a screen-reader user gets no
111
+ // signal the tooltip exists unless they stumble onto it by swiping.
112
+ accessibilityLiveRegion="polite"
113
+ >
114
+ <Text style={styles.label}>{label}</Text>
115
+ </View>
116
+ <View
117
+ style={[
118
+ styles.arrow,
119
+ placement === 'top' ? styles.arrowTop : styles.arrowBottom,
120
+ arrowLeft != null ? { left: arrowLeft, marginLeft: -ARROW_SIZE } : styles.arrowCenter,
121
+ ]}
122
+ />
123
+ </View>
124
+ </View>
125
+ )}
126
+ </View>
127
+ );
128
+ }
129
+
130
+ const styles = StyleSheet.create({
131
+ // Shrinks to the trigger's own content width, so the overlay below (left:0/right:0) matches it —
132
+ // without this, a Tooltip inside a wider flex row would stretch and mis-centre the bubble.
133
+ wrap: { alignSelf: 'flex-start' },
134
+ overlay: { position: 'absolute', left: 0, right: 0, zIndex: 10 },
135
+ overlayTop: { bottom: '100%', marginBottom: GAP },
136
+ overlayBottom: { top: '100%', marginTop: GAP },
137
+ // Shrink-wraps to `bubble`'s own real width (the only in-flow child; `arrow` is absolutely
138
+ // positioned so it doesn't affect this) — the shared positioning context the computed
139
+ // `marginLeft`/arrow offsets above are both relative to.
140
+ bubbleWrap: { alignItems: 'center' },
141
+ // Pre-measurement fallback only — matches the old always-centered look.
142
+ bubbleWrapCentered: { alignSelf: 'center' },
143
+ bubble: {
144
+ // Flexbox's implicit `min-width: auto` would otherwise let this shrink down to its longest
145
+ // single word (collapsing "Tap to add a stop" into a tall, narrow, multi-line sliver) since it's
146
+ // a flex child of `overlay`, a column container whose own width matches the (often much
147
+ // narrower) trigger — `flexShrink: 0` alone doesn't stop that on web; `width: 'max-content'`
148
+ // (a web-only CSS value, harmlessly ignored by RN's own layout engine on native, where this
149
+ // shrink-to-longest-word quirk doesn't occur) forces the natural single-line width instead.
150
+ flexShrink: 0,
151
+ width: 'max-content' as unknown as number,
152
+ minWidth: MIN_WIDTH,
153
+ backgroundColor: BUBBLE_COLOR,
154
+ borderRadius: DS_RADIUS.small,
155
+ paddingHorizontal: DS_SPACING[400],
156
+ paddingVertical: DS_SPACING[200],
157
+ ...DS_SHADOW.card,
158
+ },
159
+ label: {
160
+ ...DS_TYPOGRAPHY.bodyXs,
161
+ color: DS_SEMANTIC.text.inverse,
162
+ textAlign: 'center',
163
+ // The actual node that was collapsing to its min-content (longest word) width — `flexShrink: 0`
164
+ // on `bubble` alone wasn't enough, since this Text is itself a shrinkable flex child one level in.
165
+ flexShrink: 0,
166
+ },
167
+ // A zero-size box with only one border side coloured in draws as a triangle — the classic CSS
168
+ // trick, which RN's per-side border-color/width props support natively too. Horizontal position
169
+ // is applied separately above (a computed `left`, or `arrowCenter` before the first measurement).
170
+ arrow: {
171
+ position: 'absolute',
172
+ width: 0,
173
+ height: 0,
174
+ borderLeftWidth: ARROW_SIZE,
175
+ borderRightWidth: ARROW_SIZE,
176
+ borderLeftColor: 'transparent',
177
+ borderRightColor: 'transparent',
178
+ },
179
+ arrowCenter: { left: '50%', marginLeft: -ARROW_SIZE },
180
+ // Bubble sits above the trigger (placement="top") — the arrow hangs off the bubble's bottom edge,
181
+ // pointing down at the trigger below it. `bottom: 0` on the overlay-relative arrow lines its own
182
+ // top edge up with the bubble's bottom edge exactly, since the bubble itself sits flush at the
183
+ // overlay's bottom (overlayTop's `marginBottom: GAP` already reserves the gap between the two).
184
+ arrowTop: {
185
+ bottom: -ARROW_SIZE,
186
+ borderTopWidth: ARROW_SIZE,
187
+ borderTopColor: BUBBLE_COLOR,
188
+ },
189
+ // Mirror of the above — bubble sits below the trigger, arrow hangs off its top edge pointing up.
190
+ arrowBottom: {
191
+ top: -ARROW_SIZE,
192
+ borderBottomWidth: ARROW_SIZE,
193
+ borderBottomColor: BUBBLE_COLOR,
194
+ },
195
+ });
@@ -0,0 +1,2 @@
1
+ export { Tooltip } from './Tooltip';
2
+ export type { TooltipProps } from './Tooltip';
@@ -0,0 +1,71 @@
1
+ import React from 'react';
2
+ import { Text, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { Button } from '../Button';
5
+ import { TopNav } from './TopNav';
6
+ import { DS_SEMANTIC, DS_TYPOGRAPHY } from '../../tokens';
7
+
8
+ const styles = StyleSheet.create({
9
+ cardTitle: { ...DS_TYPOGRAPHY.labelMd, color: DS_SEMANTIC.text.regular },
10
+ });
11
+
12
+ export default defineCatalogPage({
13
+ component: 'TopNav',
14
+ group: 'Navigation',
15
+ description: 'A screen\'s top bar — fixed-width leading/trailing slots flanking a centered title (or custom center content). Slots reserve their layout space even when empty, so the title stays centered no matter which sides are populated.',
16
+ a11y: 'The title renders with accessibilityRole="header". Slot content (typically icon-only Buttons) carries its own accessibilityLabel.',
17
+ // No `variant` prop exists on TopNav — its "Variants" column just shows the one default look,
18
+ // with both slots populated; the individual slot combinations live under "States".
19
+ variants: {
20
+ itemsFill: true,
21
+ items: [
22
+ {
23
+ key: 'default',
24
+ name: 'Default',
25
+ node: (
26
+ <TopNav
27
+ title="Trip planner"
28
+ leading={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="chevron-left" accessibilityLabel="Back" onPress={() => {}} />}
29
+ trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="search" accessibilityLabel="Search" onPress={() => {}} />}
30
+ />
31
+ ),
32
+ },
33
+ ],
34
+ },
35
+ states: {
36
+ itemsFill: true,
37
+ items: [
38
+ { key: 'title-only', name: 'Title only', node: <TopNav title="Settings" /> },
39
+ {
40
+ key: 'leading-only',
41
+ name: 'Leading only',
42
+ node: (
43
+ <TopNav
44
+ title="Trip details"
45
+ leading={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="chevron-left" accessibilityLabel="Back" onPress={() => {}} />}
46
+ />
47
+ ),
48
+ },
49
+ {
50
+ key: 'trailing-only',
51
+ name: 'Trailing only',
52
+ node: (
53
+ <TopNav
54
+ title="Saved trips"
55
+ trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="add" accessibilityLabel="Add" onPress={() => {}} />}
56
+ />
57
+ ),
58
+ },
59
+ {
60
+ key: 'custom-center',
61
+ name: 'Custom center',
62
+ node: (
63
+ <TopNav
64
+ leading={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="clear" accessibilityLabel="Close" onPress={() => {}} />}
65
+ center={<Text style={styles.cardTitle}>Custom center content</Text>}
66
+ />
67
+ ),
68
+ },
69
+ ],
70
+ },
71
+ });
@@ -0,0 +1,77 @@
1
+ import React, { isValidElement, type ReactNode } from 'react';
2
+ import { View, Text, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY, DS_A11Y_MIN_TOUCH_TARGET } from '../../tokens';
4
+ import { Button, type ButtonProps } from '../Button';
5
+
6
+ // Both slots reserve this width whether populated or empty, so the title stays centered
7
+ // regardless of which side(s) actually have content.
8
+ const SLOT_SIZE = DS_A11Y_MIN_TOUCH_TARGET;
9
+
10
+ /** Dev-time consistency check (same policy as ButtonGroup's `checkConsistency`) — a nav bar's own
11
+ * leading/trailing actions read as one quiet, uniform family (back arrow, close, overflow menu),
12
+ * never as a prominent CTA. Warns (never throws) if a slot's Button doesn't follow that: `small`
13
+ * size, and not `ghost` (which reads as an inline text action, not a nav icon — see Button's own
14
+ * variant-choice doc). */
15
+ function checkSlotButton(node: ReactNode, slotName: 'leading' | 'trailing'): void {
16
+ if (!isValidElement(node) || node.type !== Button) return;
17
+ const props = node.props as ButtonProps;
18
+ if (props.size && props.size !== 'small') {
19
+ console.warn(`[TopNav] ${slotName} button uses size="${props.size}" — TopNav slot buttons should use size="small".`);
20
+ }
21
+ if (props.variant === 'ghost') {
22
+ console.warn(
23
+ `[TopNav] ${slotName} button uses variant="ghost" — that reads as an inline text action, not a nav icon. Use variant="secondary" (TopNav's default) instead.`,
24
+ );
25
+ }
26
+ }
27
+
28
+ export interface TopNavProps {
29
+ /** Centered title text. Ignored when `center` is set. */
30
+ title?: string;
31
+ /** Custom content overriding the centered title — e.g. a search field or segmented toggle. */
32
+ center?: ReactNode;
33
+ /** Leading slot — typically a back/close icon Button. Keep it `size="small"` and
34
+ * `variant="secondary"` (TopNav's default) unless there's a specific reason to deviate — never
35
+ * `ghost`, which reads as an inline text action rather than a nav icon. */
36
+ leading?: ReactNode;
37
+ /** Trailing slot — typically an action icon Button. Same size/variant guidance as `leading`. */
38
+ trailing?: ReactNode;
39
+ style?: StyleProp<ViewStyle>;
40
+ }
41
+
42
+ /** A screen's top bar: fixed-width leading/trailing slots flanking a centered title (or custom
43
+ * `center` content). Slots reserve their layout space even when empty, so the title stays
44
+ * centered whether one, both, or neither side has content. */
45
+ export function TopNav({ title, center, leading, trailing, style }: TopNavProps) {
46
+ checkSlotButton(leading, 'leading');
47
+ checkSlotButton(trailing, 'trailing');
48
+ return (
49
+ <View style={[styles.row, style]}>
50
+ <View style={styles.slot}>{leading}</View>
51
+ <View style={styles.center}>
52
+ {center ?? (title != null && (
53
+ <Text style={styles.title} numberOfLines={1} accessibilityRole="header">
54
+ {title}
55
+ </Text>
56
+ ))}
57
+ </View>
58
+ <View style={[styles.slot, styles.slotTrailing]}>{trailing}</View>
59
+ </View>
60
+ );
61
+ }
62
+
63
+ const styles = StyleSheet.create({
64
+ row: {
65
+ width: '100%',
66
+ flexDirection: 'row',
67
+ alignItems: 'center',
68
+ paddingHorizontal: DS_SPACING[800],
69
+ paddingTop: DS_SPACING[800],
70
+ paddingBottom: DS_SPACING[400],
71
+ backgroundColor: DS_SEMANTIC.surface.white,
72
+ },
73
+ slot: { width: SLOT_SIZE, height: SLOT_SIZE, alignItems: 'flex-start', justifyContent: 'center' },
74
+ slotTrailing: { alignItems: 'flex-end' },
75
+ center: { flex: 1, alignItems: 'center' },
76
+ title: { ...DS_TYPOGRAPHY.labelMd, color: DS_SEMANTIC.text.regular },
77
+ });
@@ -0,0 +1,2 @@
1
+ export { TopNav } from './TopNav';
2
+ export type { TopNavProps } from './TopNav';
@@ -0,0 +1,30 @@
1
+ import React, { useState } from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { UnderlineTabs } from './UnderlineTabs';
4
+
5
+ function UnderlineTabsDemo() {
6
+ const [value, setValue] = useState('all');
7
+ return (
8
+ <UnderlineTabs
9
+ value={value}
10
+ onChange={setValue}
11
+ options={[
12
+ { value: 'all', label: 'All' },
13
+ // Leading-icon tab — the layout every other option (icon-less) would otherwise hide.
14
+ { value: 'nearby', label: 'Nearby', iconName: 'pin' },
15
+ { value: 'favorites', label: 'Favorites', badge: 2 },
16
+ ]}
17
+ />
18
+ );
19
+ }
20
+
21
+ export default defineCatalogPage({
22
+ component: 'UnderlineTabs',
23
+ group: 'Selection',
24
+ // Phone width plus a small phone, where long labels truncate.
25
+ previewWidths: [402, 320],
26
+ description: 'A quieter tab switcher — left-aligned labels over a hairline rule, with a sliding underline indicator. Same options/value/onChange API as SegmentedToggle.',
27
+ whenToUse: "Quiet, secondary navigation within a screen that already has a clear primary focus. For a prominent, primary choice, use SegmentedToggle.",
28
+ a11y: 'Each tab is a Pressable label; the underline is a visual indicator only, so selection is also conveyed by the active label weight.',
29
+ render: () => <UnderlineTabsDemo />,
30
+ });
@@ -0,0 +1,156 @@
1
+ import React, { useState } from 'react';
2
+ import { View, Text, Pressable, Animated, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_TYPOGRAPHY, DS_ICON_SIZE, DS_A11Y_MIN_TOUCH_TARGET } from '../../tokens';
4
+ import { Icon } from '../../icons/Icon.native';
5
+ import type { IconName } from '../../icons';
6
+ import { useSlideAnim } from '../SegmentedToggle/useSlideAnim';
7
+
8
+ export interface UnderlineTabOption {
9
+ value: string;
10
+ label: string;
11
+ /** Optional leading icon (16px) shown before the label. */
12
+ iconName?: IconName;
13
+ /** Optional count shown in a circular badge after the label (hidden when 0/undefined). */
14
+ badge?: number;
15
+ }
16
+
17
+ export interface UnderlineTabsProps {
18
+ /** The tabs. */
19
+ options: UnderlineTabOption[];
20
+ /** The currently-selected option `value`. */
21
+ value: string;
22
+ /** Selection handler. */
23
+ onChange: (value: string) => void;
24
+ style?: StyleProp<ViewStyle>;
25
+ }
26
+
27
+ const INDICATOR_HEIGHT = 2;
28
+
29
+ /**
30
+ * Low-prominence tab switcher — left-aligned text labels over a hairline rule, with a thin underline
31
+ * indicator that slides to the selected tab. A quieter stand-in for SegmentedToggle: same
32
+ * options/value/onChange API, but no filled track or lifted thumb. Two or more options.
33
+ */
34
+ export function UnderlineTabs({ options, value, onChange, style }: UnderlineTabsProps) {
35
+ // -1 when `value` matches no option (e.g. stale state after the options list changed). The
36
+ // indicator is hidden entirely in that case — clamping to tab 0 would underline a tab whose own
37
+ // label doesn't render as selected, a visibly contradictory state.
38
+ const matchedIndex = options.findIndex(o => o.value === value);
39
+ const hasSelection = matchedIndex !== -1;
40
+ const selectedIndex = Math.max(0, matchedIndex);
41
+ // Per-tab measured geometry (x offset + width within the row), so the indicator can size and slide to
42
+ // each variable-width label.
43
+ const [layouts, setLayouts] = useState<Record<number, { x: number; width: number }>>({});
44
+ // Which tab currently holds keyboard focus (null = none).
45
+ const [focusedValue, setFocusedValue] = useState<string | null>(null);
46
+ // translateX + width animate together; width can't use the native driver, so neither does.
47
+ const anim = useSlideAnim(selectedIndex, false);
48
+ const measured = options.every((_, i) => layouts[i] != null);
49
+
50
+ // Unlike SegmentedToggle's analytically-computed thumb position, this indicator slides to each
51
+ // tab's real measured `x` (from onLayout) — already RTL-correct as-is, since onLayout reports actual
52
+ // post-mirroring screen position, not an index-based LTR assumption.
53
+ const indices = options.map((_, i) => i);
54
+ const indicatorX = measured
55
+ ? anim.interpolate({ inputRange: indices, outputRange: indices.map(i => layouts[i].x) })
56
+ : 0;
57
+ const indicatorW = measured
58
+ ? anim.interpolate({ inputRange: indices, outputRange: indices.map(i => layouts[i].width) })
59
+ : 0;
60
+
61
+ return (
62
+ <View style={[styles.row, style]} accessibilityRole="tablist">
63
+ {options.map((opt, i) => {
64
+ const selected = opt.value === value;
65
+ const color = selected ? DS_SEMANTIC.text.regular : DS_SEMANTIC.text.muted;
66
+ return (
67
+ <Pressable
68
+ key={opt.value}
69
+ onPress={() => onChange(opt.value)}
70
+ onLayout={e => {
71
+ const { x, width } = e.nativeEvent.layout;
72
+ setLayouts(prev =>
73
+ prev[i] && prev[i].x === x && prev[i].width === width ? prev : { ...prev, [i]: { x, width } },
74
+ );
75
+ }}
76
+ onFocus={() => setFocusedValue(opt.value)}
77
+ onBlur={() => setFocusedValue(prev => (prev === opt.value ? null : prev))}
78
+ accessibilityRole="tab"
79
+ accessibilityLabel={opt.badge != null && opt.badge > 0 ? `${opt.label}, ${opt.badge}` : opt.label}
80
+ accessibilityState={{ selected }}
81
+ // Keyboard focus shows the same highlight as a press (same policy as SegmentedToggle/ListItem).
82
+ style={({ pressed }) => [styles.tab, (pressed || focusedValue === opt.value) && styles.tabPressed]}
83
+ >
84
+ {opt.iconName && <Icon name={opt.iconName} size={DS_ICON_SIZE.sm} color={color} />}
85
+ <Text style={[styles.label, { color }]}>{opt.label}</Text>
86
+ {opt.badge != null && opt.badge > 0 && (
87
+ <View style={[styles.badge, { backgroundColor: color }]}>
88
+ <Text style={[styles.badgeText, { color: DS_SEMANTIC.surface.white }]}>{opt.badge}</Text>
89
+ </View>
90
+ )}
91
+ </Pressable>
92
+ );
93
+ })}
94
+ {measured && hasSelection && (
95
+ <Animated.View
96
+ pointerEvents="none"
97
+ style={[styles.indicator, { width: indicatorW, transform: [{ translateX: indicatorX }] }]}
98
+ />
99
+ )}
100
+ </View>
101
+ );
102
+ }
103
+
104
+ const styles = StyleSheet.create({
105
+ row: {
106
+ flexDirection: 'row',
107
+ alignItems: 'flex-end',
108
+ gap: DS_SPACING[800],
109
+ borderBottomWidth: StyleSheet.hairlineWidth,
110
+ borderBottomColor: DS_SEMANTIC.element.divider,
111
+ },
112
+ tab: {
113
+ flexDirection: 'row',
114
+ alignItems: 'center',
115
+ gap: DS_SPACING[200],
116
+ paddingTop: DS_SPACING[400],
117
+ paddingBottom: DS_SPACING[600],
118
+ // A fixed floor (not a spacing token — this is an accessibility guideline number, not a design
119
+ // choice) so the tab's compact visual padding still guarantees a touch target.
120
+ minHeight: DS_A11Y_MIN_TOUCH_TARGET,
121
+ },
122
+ tabPressed: {
123
+ backgroundColor: DS_SEMANTIC.interaction.pressed,
124
+ borderRadius: DS_RADIUS.small,
125
+ },
126
+ label: {
127
+ // Matches the section-header label size (labelXs) it stands in for.
128
+ ...DS_TYPOGRAPHY.labelXs,
129
+ // Pin the line box to the badge height (18) so a tab is the same height with or without a count
130
+ // badge — otherwise the taller badged tab grows the row and shifts the divider/content below it.
131
+ lineHeight: 18,
132
+ },
133
+ // The sliding underline, sat on the hairline rule beneath the selected tab.
134
+ indicator: {
135
+ position: 'absolute',
136
+ left: 0,
137
+ bottom: -StyleSheet.hairlineWidth,
138
+ height: INDICATOR_HEIGHT,
139
+ borderRadius: INDICATOR_HEIGHT / 2,
140
+ backgroundColor: DS_SEMANTIC.text.regular,
141
+ },
142
+ // Fixed-diameter circle so the count badge stays round. 18 doesn't match a DS_SPACING/DS_ICON_SIZE
143
+ // step (16/20 are the neighbors) — sized to comfortably fit a 1-2 digit count at labelXs.
144
+ badge: {
145
+ width: 18,
146
+ height: 18,
147
+ borderRadius: DS_RADIUS.round,
148
+ alignItems: 'center',
149
+ justifyContent: 'center',
150
+ },
151
+ badgeText: {
152
+ ...DS_TYPOGRAPHY.labelXs,
153
+ // Pinned to the badge's own 18px diameter (above) so the count sits centered in the circle.
154
+ lineHeight: 18,
155
+ },
156
+ });
@@ -0,0 +1,2 @@
1
+ export { UnderlineTabs } from './UnderlineTabs';
2
+ export type { UnderlineTabsProps, UnderlineTabOption } from './UnderlineTabs';
@@ -0,0 +1,36 @@
1
+ /** Barrel for the React Native component tree. */
2
+ export * from './AnimatedChevron';
3
+ export * from './Avatar';
4
+ export * from './Badge';
5
+ export * from './Banner';
6
+ export * from './BottomSheet';
7
+ export * from './Button';
8
+ export * from './ButtonGroup';
9
+ export * from './Card';
10
+ export * from './Checkbox';
11
+ export * from './Dialog';
12
+ export * from './Divider';
13
+ export * from './Dock';
14
+ export * from './Dropdown';
15
+ export * from './EmptyState';
16
+ export * from './FieldContainer';
17
+ export * from './InputClearButton';
18
+ export * from './InputField';
19
+ export * from './List';
20
+ export * from './ListItem';
21
+ export * from './Loading';
22
+ export * from './Pill';
23
+ export * from './PillRow';
24
+ export * from './ProgressDots';
25
+ export * from './Radio';
26
+ export * from './SearchField';
27
+ export * from './SectionHeader';
28
+ export * from './SegmentedToggle';
29
+ export * from './Shimmer';
30
+ export * from './Surface';
31
+ export * from './Switch';
32
+ export * from './TextArea';
33
+ export * from './Toast';
34
+ export * from './Tooltip';
35
+ export * from './TopNav';
36
+ export * from './UnderlineTabs';
@@ -0,0 +1,27 @@
1
+ import { DS_SEMANTIC } from '../tokens';
2
+
3
+ /**
4
+ * The one shared status→color lookup behind every status-tinted component (Badge, Banner): a light
5
+ * tinted fill (`shade`) paired with a saturated foreground (`emphasis`), so a badge and a banner of
6
+ * the same status always read as the same color identity. Previously each component maintained its
7
+ * own identical copy of this map — one place to change now keeps them matched by construction.
8
+ * (Toast's variant map lives separately: its variant names differ (`success`/`informational`) and it
9
+ * bundles per-variant icons, so it's a different shape, not a third copy of this one.)
10
+ */
11
+ export type StatusVariant = 'neutral' | 'info' | 'positive' | 'warning' | 'negative';
12
+
13
+ export const STATUS_BG: Record<StatusVariant, string> = {
14
+ neutral: DS_SEMANTIC.shade.neutral,
15
+ info: DS_SEMANTIC.shade.info,
16
+ positive: DS_SEMANTIC.shade.positive,
17
+ warning: DS_SEMANTIC.shade.warning,
18
+ negative: DS_SEMANTIC.shade.negative,
19
+ };
20
+
21
+ export const STATUS_FG: Record<StatusVariant, string> = {
22
+ neutral: DS_SEMANTIC.emphasis.neutral,
23
+ info: DS_SEMANTIC.emphasis.info,
24
+ positive: DS_SEMANTIC.emphasis.positive,
25
+ warning: DS_SEMANTIC.emphasis.warning,
26
+ negative: DS_SEMANTIC.emphasis.negative,
27
+ };