@krapwoo/ds-viewer 0.1.1 → 0.3.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 (173) hide show
  1. package/README.md +182 -78
  2. package/dist/cli/agentsFile.js +31 -0
  3. package/dist/cli/config.js +26 -1
  4. package/dist/cli/dev.js +50 -5
  5. package/dist/cli/doctor.js +492 -0
  6. package/dist/cli/explain.js +151 -0
  7. package/dist/cli/githubAction.js +68 -0
  8. package/dist/cli/glob.js +8 -0
  9. package/dist/cli/init.js +152 -18
  10. package/dist/cli/main.js +115 -4
  11. package/dist/cli/pageIndex.js +27 -1
  12. package/dist/cli/props.js +170 -21
  13. package/dist/cli/staticPage.js +356 -0
  14. package/dist/cli/sync.js +28 -5
  15. package/dist/cli/workspace.js +49 -21
  16. package/dist/native/catalog/comparison.js +283 -0
  17. package/dist/native/catalog/tokens.js +119 -0
  18. package/dist/native/catalog/types.js +1 -0
  19. package/native/catalog/CatalogShell.tsx +6 -1
  20. package/native/catalog/CatalogSidebar.tsx +98 -5
  21. package/native/catalog/ComparisonGrid.tsx +9 -7
  22. package/native/catalog/SpacingScaleGallery.tsx +1 -1
  23. package/native/catalog/TokenRow.tsx +3 -2
  24. package/native/catalog/catalogNavigation.ts +1 -1
  25. package/native/catalog/comparison.ts +63 -5
  26. package/native/catalog/index.ts +8 -6
  27. package/native/catalog/logoLayout.ts +12 -0
  28. package/native/catalog/pageApi.ts +21 -3
  29. package/native/catalog/tokens.ts +5 -0
  30. package/native/catalog/types.ts +9 -6
  31. package/package.json +12 -6
  32. package/starter-kit/WHEN_TO_USE.md +124 -0
  33. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  34. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  35. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  36. package/starter-kit/components/Avatar/Avatar.catalog.tsx +54 -0
  37. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  38. package/starter-kit/components/Avatar/index.ts +2 -0
  39. package/starter-kit/components/Badge/Badge.catalog.tsx +88 -0
  40. package/starter-kit/components/Badge/Badge.tsx +56 -0
  41. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  42. package/starter-kit/components/Badge/index.ts +2 -0
  43. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  44. package/starter-kit/components/Banner/Banner.tsx +257 -0
  45. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  46. package/starter-kit/components/Banner/index.ts +2 -0
  47. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  48. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  49. package/starter-kit/components/BottomSheet/index.ts +2 -0
  50. package/starter-kit/components/Button/Button.catalog.tsx +99 -0
  51. package/starter-kit/components/Button/Button.tsx +173 -0
  52. package/starter-kit/components/Button/Button.types.ts +52 -0
  53. package/starter-kit/components/Button/index.ts +2 -0
  54. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  55. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  56. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  57. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  58. package/starter-kit/components/Card/Card.tsx +133 -0
  59. package/starter-kit/components/Card/index.ts +2 -0
  60. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  61. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  62. package/starter-kit/components/Checkbox/index.ts +2 -0
  63. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  64. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  65. package/starter-kit/components/Dialog/index.ts +2 -0
  66. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  67. package/starter-kit/components/Divider/Divider.tsx +20 -0
  68. package/starter-kit/components/Divider/index.ts +2 -0
  69. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  70. package/starter-kit/components/Dock/Dock.tsx +58 -0
  71. package/starter-kit/components/Dock/index.ts +2 -0
  72. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  73. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  74. package/starter-kit/components/Dropdown/index.ts +2 -0
  75. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  76. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  77. package/starter-kit/components/EmptyState/index.ts +2 -0
  78. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  79. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  80. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  81. package/starter-kit/components/FieldContainer/index.ts +2 -0
  82. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  83. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  84. package/starter-kit/components/InputClearButton/index.ts +2 -0
  85. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  86. package/starter-kit/components/InputField/InputField.tsx +418 -0
  87. package/starter-kit/components/InputField/index.ts +2 -0
  88. package/starter-kit/components/List/List.catalog.tsx +30 -0
  89. package/starter-kit/components/List/List.tsx +36 -0
  90. package/starter-kit/components/List/index.ts +2 -0
  91. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  92. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  93. package/starter-kit/components/ListItem/index.ts +2 -0
  94. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  95. package/starter-kit/components/Loading/Loading.tsx +121 -0
  96. package/starter-kit/components/Loading/index.ts +2 -0
  97. package/starter-kit/components/Pill/Pill.catalog.tsx +79 -0
  98. package/starter-kit/components/Pill/Pill.tsx +181 -0
  99. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  100. package/starter-kit/components/Pill/index.ts +2 -0
  101. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  102. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  103. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  104. package/starter-kit/components/PillRow/index.ts +2 -0
  105. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  106. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  107. package/starter-kit/components/ProgressDots/index.ts +2 -0
  108. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  109. package/starter-kit/components/Radio/Radio.tsx +114 -0
  110. package/starter-kit/components/Radio/index.ts +2 -0
  111. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  112. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  113. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  114. package/starter-kit/components/SearchField/index.ts +2 -0
  115. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  116. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  117. package/starter-kit/components/SectionHeader/index.ts +2 -0
  118. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  119. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  120. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  121. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  122. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  123. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  124. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  125. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  126. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  127. package/starter-kit/components/Shimmer/index.ts +2 -0
  128. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  129. package/starter-kit/components/Surface/Surface.tsx +45 -0
  130. package/starter-kit/components/Surface/index.ts +2 -0
  131. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  132. package/starter-kit/components/Switch/Switch.tsx +129 -0
  133. package/starter-kit/components/Switch/index.ts +2 -0
  134. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  135. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  136. package/starter-kit/components/TextArea/index.ts +2 -0
  137. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  138. package/starter-kit/components/Toast/Toast.tsx +144 -0
  139. package/starter-kit/components/Toast/index.ts +2 -0
  140. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  141. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  142. package/starter-kit/components/Tooltip/index.ts +2 -0
  143. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  144. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  145. package/starter-kit/components/TopNav/index.ts +2 -0
  146. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  147. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  148. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  149. package/starter-kit/components/index.ts +36 -0
  150. package/starter-kit/components/statusColors.ts +27 -0
  151. package/starter-kit/icons/Icon.native.tsx +90 -0
  152. package/starter-kit/icons/index.ts +10 -0
  153. package/starter-kit/icons/paths.ts +623 -0
  154. package/starter-kit/icons/types.ts +69 -0
  155. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  156. package/starter-kit/pages/Font.catalog.tsx +45 -0
  157. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  158. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  159. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  160. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  161. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  162. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  163. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  164. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  165. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  166. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  167. package/starter-kit/tokens/index.ts +50 -0
  168. package/starter-kit/tokens/motion.ts +90 -0
  169. package/starter-kit/tokens/palette.ts +111 -0
  170. package/starter-kit/tokens/scales.ts +106 -0
  171. package/starter-kit/tokens/semantic.ts +98 -0
  172. package/starter-kit/tokens/shadow.ts +36 -0
  173. package/starter-kit/tokens/typography.ts +72 -0
@@ -0,0 +1,61 @@
1
+ import React, { useState } from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage, PhoneFrame } from '@krapwoo/ds-viewer';
4
+ import { Button } from '../Button';
5
+ import { Toast } from './Toast';
6
+ import { DS_SPACING } from '../../tokens';
7
+
8
+ const styles = StyleSheet.create({
9
+ // Toast demo — floats the toast near the top of the PhoneFrame, the same way a real screen would
10
+ // position it (Toast itself renders no absolute overlay; that's always the call site's job).
11
+ toastDemoOverlay: { position: 'absolute', top: DS_SPACING[800], left: DS_SPACING[600], right: DS_SPACING[600] },
12
+ });
13
+
14
+ // Drives Toast's `visible` prop directly (rather than the usual mount/unmount-the-whole-component
15
+ // pattern) so this demo actually exercises the slide-in-from-top/fade animation, not just the two
16
+ // static end states.
17
+ function ToastDemo() {
18
+ const [visible, setVisible] = useState(true);
19
+ return (
20
+ <PhoneFrame>
21
+ <Button label={visible ? 'Hide toast' : 'Show toast'} onPress={() => setVisible((v) => !v)} />
22
+ <View style={styles.toastDemoOverlay} pointerEvents="box-none">
23
+ <Toast message="Trip saved" variant="success" visible={visible} />
24
+ </View>
25
+ </PhoneFrame>
26
+ );
27
+ }
28
+
29
+ export default defineCatalogPage({
30
+ component: 'Toast',
31
+ group: 'Feedback',
32
+ description: 'A transient confirmation bar. Without a variant it renders the dark surface with inverse text; a variant shifts to a pastel status color.',
33
+ whenToUse: "Transient and self-contained, floating over the screen, expected to go away on its own or via its own action. For a persistent, in-flow message about a standing condition, use Banner.",
34
+ a11y: 'Rendered with accessibilityRole="alert" so screen readers announce it when it appears.',
35
+ variants: {
36
+ itemsFill: true,
37
+ items: [
38
+ { key: 'base', name: 'Base (no variant)', node: <Toast message="Changes saved" /> },
39
+ { key: 'success', name: 'Success', props: { variant: 'success' }, node: <Toast message="Trip saved" variant="success" /> },
40
+ { key: 'informational', name: 'Informational', props: { variant: 'informational' }, node: <Toast message="New app version available" variant="informational" /> },
41
+ { key: 'warning', name: 'Warning', props: { variant: 'warning' }, node: <Toast message="Signal delays reported" variant="warning" /> },
42
+ { key: 'negative', name: 'Negative', props: { variant: 'negative' }, node: <Toast message="Failed to save trip" variant="negative" /> },
43
+ { key: 'neutral', name: 'Neutral', props: { variant: 'neutral' }, node: <Toast message="3 new updates" variant="neutral" /> },
44
+ ],
45
+ },
46
+ states: {
47
+ itemsFill: true,
48
+ items: [
49
+ {
50
+ key: 'action',
51
+ name: 'With action',
52
+ node: <Toast message="Trip removed" action={{ label: 'Undo', onPress: () => {} }} />,
53
+ },
54
+ {
55
+ key: 'visible-toggle',
56
+ name: 'Slide-in animation (toggle)',
57
+ node: <ToastDemo />,
58
+ },
59
+ ],
60
+ },
61
+ });
@@ -0,0 +1,144 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { View, Text, Animated, Easing, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_TYPOGRAPHY, DS_FONT_WEIGHT, DS_SHADOW, DS_ICON_SIZE, DS_MOTION_DURATION, DS_MOTION_EASING } from '../../tokens';
4
+ import { Icon } from '../../icons/Icon.native';
5
+ import { Button } from '../Button';
6
+ import type { IconName } from '../../icons';
7
+
8
+ // Generously bigger than any real toast's rendered height, so the slide-in always starts fully
9
+ // above the visible area regardless of message length — no onLayout measurement needed (unlike
10
+ // Banner's collapse, this only needs "off-screen" vs "resting", not the real height).
11
+ const ENTER_FROM_Y = -100;
12
+
13
+ export type ToastVariant = 'success' | 'informational' | 'warning' | 'negative' | 'neutral';
14
+
15
+ // Same light tinted fill + saturated foreground pairing as Banner/Badge, so a variant toast reads as
16
+ // the same colour identity as the badge/banner of that status.
17
+ const VARIANT_CONFIG: Record<ToastVariant, { bg: string; fg: string; icon: IconName }> = {
18
+ success: { bg: DS_SEMANTIC.shade.positive, fg: DS_SEMANTIC.emphasis.positive, icon: 'circle-check' },
19
+ informational: { bg: DS_SEMANTIC.shade.info, fg: DS_SEMANTIC.emphasis.info, icon: 'info' },
20
+ warning: { bg: DS_SEMANTIC.shade.warning, fg: DS_SEMANTIC.emphasis.warning, icon: 'triangle-alert'},
21
+ negative: { bg: DS_SEMANTIC.shade.negative, fg: DS_SEMANTIC.emphasis.negative, icon: 'circle-slash' },
22
+ neutral: { bg: DS_SEMANTIC.shade.neutral, fg: DS_SEMANTIC.emphasis.neutral, icon: 'info-circle' },
23
+ };
24
+
25
+ export interface ToastProps {
26
+ /** Toast text. */
27
+ message: string;
28
+ /** Whether the toast is mounted and shown — animates in by sliding down from above (+ fading in)
29
+ * when it becomes true, and slides back out the same way when it becomes false. @default true
30
+ * (renders already in its resting position, no animation — for call sites that mount/unmount the
31
+ * whole Toast themselves rather than toggling this prop). */
32
+ visible?: boolean;
33
+ /** When set, applies one of the semantic colour schemes (bg + default icon). */
34
+ variant?: ToastVariant;
35
+ /** Override the icon. Defaults to the variant's icon, or `circle-check` for the base style. */
36
+ iconName?: IconName;
37
+ /** Override the icon colour. Defaults to the variant's emphasis colour for variant toasts, white (matching the text) for base. */
38
+ iconColor?: string;
39
+ /** Optional trailing action — e.g. "Undo". Colour-matched to the toast's own text/icon colour
40
+ * (whichever that resolves to for the active variant), not a separate fixed accent. */
41
+ action?: { label: string; onPress: () => void };
42
+ style?: StyleProp<ViewStyle>;
43
+ }
44
+
45
+ /**
46
+ * A transient confirmation bar. Without `variant`, renders the default dark surface with inverse text.
47
+ * With `variant`, the background shifts to the same light tinted fill Banner/Badge use for that
48
+ * status, with the matching saturated emphasis colour for text/icon — not white. Slides in from
49
+ * above (+ fades in) when `visible` turns true, and reverses on the way out — the same
50
+ * measure-free, JS-driven (`useNativeDriver: false`, so it's visible in the web preview too, not
51
+ * just on native) enter/exit pattern Dialog/BottomSheet use for their own show/hide.
52
+ */
53
+ export function Toast({ message, visible = true, variant, iconName, iconColor, action, style }: ToastProps) {
54
+ const [mounted, setMounted] = useState(visible);
55
+ const progress = useRef(new Animated.Value(visible ? 1 : 0)).current;
56
+
57
+ useEffect(() => {
58
+ if (visible) setMounted(true);
59
+ const duration = visible ? DS_MOTION_DURATION.base : DS_MOTION_DURATION.fast;
60
+ Animated.timing(progress, {
61
+ toValue: visible ? 1 : 0,
62
+ duration,
63
+ // `decelerate` — a toast entering the screen, per DS_MOTION_EASING_USE.
64
+ easing: Easing.bezier(...DS_MOTION_EASING.decelerate),
65
+ useNativeDriver: false,
66
+ }).start();
67
+ // A plain timer (not the animation's own `.start()` callback) drives the unmount-after-exit —
68
+ // `finished` can come back false (or the callback can simply not fire) when this Animated.Value
69
+ // is fighting for frames alongside other continuously-looping JS-driven animations elsewhere on
70
+ // the same screen (Loading, Shimmer), which left Toast permanently stuck invisible-but-mounted
71
+ // in exactly that scenario. A timer matching the animation's own duration is deterministic
72
+ // regardless of what the Animated internals do.
73
+ let hideTimer: ReturnType<typeof setTimeout> | undefined;
74
+ if (!visible) hideTimer = setTimeout(() => setMounted(false), duration);
75
+ return () => {
76
+ if (hideTimer != null) clearTimeout(hideTimer);
77
+ };
78
+ }, [visible, progress]);
79
+
80
+ if (!mounted) return null;
81
+
82
+ const cfg = variant ? VARIANT_CONFIG[variant] : null;
83
+ const resolvedIcon = iconName ?? cfg?.icon ?? 'circle-check';
84
+ // Message, icon (by default), and the action label all share this colour, so the whole bar reads
85
+ // as one consistent colour identity regardless of variant.
86
+ const resolvedTextColor = cfg ? cfg.fg : DS_SEMANTIC.text.inverse;
87
+ const resolvedIconColor = iconColor ?? resolvedTextColor;
88
+
89
+ const translateY = progress.interpolate({ inputRange: [0, 1], outputRange: [ENTER_FROM_Y, 0] });
90
+
91
+ return (
92
+ <Animated.View
93
+ style={[styles.toast, cfg && { backgroundColor: cfg.bg }, { opacity: progress, transform: [{ translateY }] }, style]}
94
+ accessibilityRole="alert"
95
+ >
96
+ <Icon name={resolvedIcon} size={DS_ICON_SIZE.sm} color={resolvedIconColor} />
97
+ <Text style={[styles.text, { color: resolvedTextColor }]} numberOfLines={2}>{message}</Text>
98
+ {action && (
99
+ <>
100
+ {/* A plain vertical rule tinted to resolvedTextColor at reduced opacity — not the shared
101
+ Divider component, which is a fixed subtle-black horizontal line meant for light
102
+ surfaces; Toast's own background can be dark (base) or a light tint (variant), so the
103
+ divider has to adapt to whichever text colour is active instead. */}
104
+ <View style={[styles.divider, { backgroundColor: resolvedTextColor }]} />
105
+ {/* Always ghost + label-only — Toast's action is a quiet inline affordance, never a second
106
+ visual weight competing with the message. Ghost's own padding is already 0, so it sits
107
+ flush like the rest of the row; textStyle overrides ghost's fixed label colour with
108
+ resolvedTextColor so the action still colour-matches this toast's own variant. */}
109
+ <Button variant="ghost" size="small" label={action.label} onPress={action.onPress} textStyle={{ color: resolvedTextColor }} />
110
+ </>
111
+ )}
112
+ </Animated.View>
113
+ );
114
+ }
115
+
116
+ const styles = StyleSheet.create({
117
+ toast: {
118
+ flexDirection: 'row',
119
+ alignItems: 'center',
120
+ gap: DS_SPACING[300],
121
+ // surface.inverse, not text.regular — the base toast is a dark inverse PANEL; using the ink
122
+ // token here would silently recolor it if a rebrand ever splits ink from dark surfaces.
123
+ backgroundColor: DS_SEMANTIC.surface.inverse,
124
+ borderRadius: DS_RADIUS.medium,
125
+ paddingVertical: DS_SPACING[800],
126
+ paddingHorizontal: DS_SPACING[600],
127
+ borderWidth: 2,
128
+ borderColor: 'transparent',
129
+ ...DS_SHADOW.card,
130
+ },
131
+ text: {
132
+ flex: 1,
133
+ ...DS_TYPOGRAPHY.bodySm,
134
+ fontWeight: DS_FONT_WEIGHT.medium,
135
+ },
136
+ divider: {
137
+ width: 1,
138
+ height: 20,
139
+ opacity: 0.3,
140
+ // Extra space on top of the row's own `gap` — the divider reads as a rule between two distinct
141
+ // regions (message vs. action), so it gets more breathing room than the icon-to-label gap does.
142
+ marginHorizontal: DS_SPACING[200],
143
+ },
144
+ });
@@ -0,0 +1,2 @@
1
+ export { Toast } from './Toast';
2
+ export type { ToastProps, ToastVariant } from './Toast';
@@ -0,0 +1,121 @@
1
+ import React, { useState } from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { Button } from '../Button';
5
+ import { Tooltip } from './Tooltip';
6
+
7
+ const styles = StyleSheet.create({
8
+ // Tooltip demos — the bubble is an absolutely-positioned overlay above/below the trigger, sized
9
+ // relative to the trigger's own tiny box, not this box. A fixed-height box with the trigger
10
+ // centred inside gives the bubble equal clearance whichever direction it points, so stacked items
11
+ // (States/Configurations renders 5 of these one after another) don't crowd or overlap each other.
12
+ tooltipDemoBox: { height: 80, alignItems: 'center', justifyContent: 'center' },
13
+ });
14
+
15
+ function TooltipDemo() {
16
+ const [visible, setVisible] = useState(true);
17
+ return (
18
+ <Tooltip visible={visible} label="Tap to add a stop">
19
+ <Button
20
+ variant="ghost"
21
+ size="medium"
22
+ showIcon
23
+ showLabel={false}
24
+ iconName="add"
25
+ accessibilityLabel="Add stop"
26
+ onPress={() => setVisible((v) => !v)}
27
+ />
28
+ </Tooltip>
29
+ );
30
+ }
31
+
32
+ export default defineCatalogPage({
33
+ component: 'Tooltip',
34
+ group: 'Overlays',
35
+ description: 'A small floating label anchored above or below its wrapped trigger. Fully controlled — drive `visible` from the trigger\'s own onLongPress/onPressIn, since mobile has no hover.',
36
+ a11y: 'The bubble is a plain, non-interactive View; the trigger you wrap it around carries its own accessibility.',
37
+ variants: {
38
+ items: [
39
+ {
40
+ key: 'default',
41
+ name: 'Default (tap to toggle)',
42
+ props: { placement: 'top', align: 'center' },
43
+ node: (
44
+ <View style={styles.tooltipDemoBox}>
45
+ <TooltipDemo />
46
+ </View>
47
+ ),
48
+ },
49
+ ],
50
+ },
51
+ states: {
52
+ itemsFill: true,
53
+ items: [
54
+ {
55
+ key: 'bottom',
56
+ name: 'Placement: bottom',
57
+ props: { placement: 'bottom' },
58
+ node: (
59
+ <View style={styles.tooltipDemoBox}>
60
+ <Tooltip visible label="Tap to add a stop" placement="bottom">
61
+ <Button variant="ghost" size="medium" showIcon showLabel={false} iconName="add" accessibilityLabel="Add stop" onPress={() => {}} />
62
+ </Tooltip>
63
+ </View>
64
+ ),
65
+ },
66
+ {
67
+ // The arrow always attaches to whichever bubble edge faces the trigger — so a
68
+ // placement="bottom" bubble (sitting below the trigger) has its arrow on its own TOP
69
+ // edge, not its bottom. Naming/labels below describe the arrow's actual visual corner on
70
+ // the bubble, not the `placement` value, since that's what a reader looking at the
71
+ // rendered example actually sees.
72
+ key: 'top-left',
73
+ name: 'Top-left arrow',
74
+ props: { placement: 'bottom', align: 'left' },
75
+ node: (
76
+ <View style={styles.tooltipDemoBox}>
77
+ <Tooltip visible label="Top left" placement="bottom" align="left">
78
+ <Button variant="ghost" size="medium" showIcon showLabel={false} iconName="add" accessibilityLabel="Add stop" onPress={() => {}} />
79
+ </Tooltip>
80
+ </View>
81
+ ),
82
+ },
83
+ {
84
+ key: 'top-right',
85
+ name: 'Top-right arrow',
86
+ props: { placement: 'bottom', align: 'right' },
87
+ node: (
88
+ <View style={styles.tooltipDemoBox}>
89
+ <Tooltip visible label="Top right" placement="bottom" align="right">
90
+ <Button variant="ghost" size="medium" showIcon showLabel={false} iconName="add" accessibilityLabel="Add stop" onPress={() => {}} />
91
+ </Tooltip>
92
+ </View>
93
+ ),
94
+ },
95
+ {
96
+ key: 'bottom-left',
97
+ name: 'Bottom-left arrow',
98
+ props: { placement: 'top', align: 'left' },
99
+ node: (
100
+ <View style={styles.tooltipDemoBox}>
101
+ <Tooltip visible label="Bottom left" placement="top" align="left">
102
+ <Button variant="ghost" size="medium" showIcon showLabel={false} iconName="add" accessibilityLabel="Add stop" onPress={() => {}} />
103
+ </Tooltip>
104
+ </View>
105
+ ),
106
+ },
107
+ {
108
+ key: 'bottom-right',
109
+ name: 'Bottom-right arrow',
110
+ props: { placement: 'top', align: 'right' },
111
+ node: (
112
+ <View style={styles.tooltipDemoBox}>
113
+ <Tooltip visible label="Bottom right" placement="top" align="right">
114
+ <Button variant="ghost" size="medium" showIcon showLabel={false} iconName="add" accessibilityLabel="Add stop" onPress={() => {}} />
115
+ </Tooltip>
116
+ </View>
117
+ ),
118
+ },
119
+ ],
120
+ },
121
+ });
@@ -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
+ });