@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,151 @@
1
+ import React, { createContext, type ReactNode, isValidElement, cloneElement, useContext, useEffect, useRef, useState } from 'react';
2
+ import { View, Pressable, ScrollView, Animated, Easing, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_RADIUS, DS_SHADOW, DS_SPACING, DS_MOTION_DURATION, DS_MOTION_EASING } from '../../tokens';
4
+ import { Dock, type DockProps } from '../Dock';
5
+
6
+ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
7
+ const MAX_HEIGHT_PERCENT = '90%';
8
+
9
+ /** True while rendering inside an already-open BottomSheet's tree (header/children/footer alike).
10
+ * Any component that opens its OWN BottomSheet (e.g. Dropdown) reads this via
11
+ * `useInsideBottomSheetWarning` to dev-warn against stacking two sheets — a sheet already covers
12
+ * the whole screen, so a second one has nowhere sensible to appear and just traps the user between
13
+ * two backdrops. Defaults to `false` for anything rendered outside a BottomSheet entirely. */
14
+ const BottomSheetContext = createContext(false);
15
+
16
+ /** Call this from a component that opens its own BottomSheet (Dropdown, another picker, …) — warns
17
+ * once per render if it's currently nested inside a different, already-open BottomSheet. Console
18
+ * warning only (never throws), matching this codebase's other dev-time consistency checks (e.g.
19
+ * ButtonGroup's `checkConsistency`). */
20
+ export function useInsideBottomSheetWarning(componentName: string): void {
21
+ const insideBottomSheet = useContext(BottomSheetContext);
22
+ if (insideBottomSheet) {
23
+ console.warn(
24
+ `[${componentName}] is opening a BottomSheet from inside another, already-open BottomSheet. Never stack two sheets — restructure so ${componentName} lives outside the outer sheet (e.g. in the screen that opens it), or replace it with an inline control (e.g. SegmentedToggle/Radio) for use inside a sheet.`,
25
+ );
26
+ }
27
+ }
28
+
29
+ export interface BottomSheetProps {
30
+ /** Whether the sheet (and its backdrop) are mounted and shown. */
31
+ visible: boolean;
32
+ /** Called when the backdrop is tapped — the sheet doesn't close itself; the caller decides. */
33
+ onDismiss: () => void;
34
+ /** Top area — typically a TopNav with a close/back action. */
35
+ header?: ReactNode;
36
+ /** Middle content area. Grows with its content up to 90% of the available height, then scrolls. */
37
+ children: ReactNode;
38
+ /** Bottom area — typically a Dock with the sheet's primary actions. When `footer` is a Dock
39
+ * element, its `elevated` prop is driven automatically from whether the content area above it
40
+ * actually overflows (needs to scroll) — no need to pass `elevated` yourself. */
41
+ footer?: ReactNode;
42
+ style?: StyleProp<ViewStyle>;
43
+ }
44
+
45
+ /**
46
+ * A sheet that slides up from the bottom over a dismissible backdrop. Height is driven by its
47
+ * content (not a fixed set of snap points) up to a cap, past which the content area scrolls. Not
48
+ * portaled through RN's `Modal` — it's a plain absolutely-positioned overlay, so it fills whichever
49
+ * parent View hosts it (RN Views are positioning contexts by default), which also lets the catalog
50
+ * contain a live demo inside a bounded preview frame instead of covering the whole page.
51
+ */
52
+ export function BottomSheet({ visible, onDismiss, header, children, footer, style }: BottomSheetProps) {
53
+ const progress = useRef(new Animated.Value(0)).current;
54
+ const [mounted, setMounted] = useState(visible);
55
+ // The sheet's own rendered height, measured via onLayout — its closed position is "translated
56
+ // down by exactly its own height" (fully below the visible area), not a small fixed nudge, so the
57
+ // motion genuinely reads as sliding in from off-screen regardless of how tall the content is.
58
+ // Falls back to a generous guess before the first measurement lands, to avoid a jump on first open.
59
+ const [sheetHeight, setSheetHeight] = useState(0);
60
+ // Drives a Dock footer's `elevated` shadow: on only once the content area's actual measured
61
+ // height exceeds what it's laid out to show, i.e. it's genuinely scrollable — not just "there is
62
+ // a Dock", so a short sheet stays flat and only a scrolling one gets the separating shadow.
63
+ const [scrollLayoutHeight, setScrollLayoutHeight] = useState(0);
64
+ const [scrollContentHeight, setScrollContentHeight] = useState(0);
65
+ const contentOverflows = scrollContentHeight > scrollLayoutHeight + 1;
66
+
67
+ useEffect(() => {
68
+ if (visible) setMounted(true);
69
+ // Backdrop opacity still fades (a scrim has no position to slide from); the sheet itself only
70
+ // ever moves via translateY below — no opacity animation on the sheet, just the slide.
71
+ const anim = Animated.timing(progress, {
72
+ toValue: visible ? 1 : 0,
73
+ duration: visible ? DS_MOTION_DURATION.base : DS_MOTION_DURATION.fast,
74
+ // `decelerate` — a sheet entering the screen, per DS_MOTION_EASING_USE (previously left on
75
+ // RN's default in-out ease, the one enter/exit overlay without an explicit curve).
76
+ easing: Easing.bezier(...DS_MOTION_EASING.decelerate),
77
+ useNativeDriver: false,
78
+ });
79
+ anim.start(({ finished }) => {
80
+ if (finished && !visible) setMounted(false);
81
+ });
82
+ return () => anim.stop();
83
+ }, [visible, progress]);
84
+
85
+ if (!mounted) return null;
86
+
87
+ const translateY = progress.interpolate({ inputRange: [0, 1], outputRange: [sheetHeight || 400, 0] });
88
+
89
+ const resolvedFooter =
90
+ isValidElement(footer) && footer.type === Dock
91
+ ? cloneElement(footer as React.ReactElement<DockProps>, { elevated: contentOverflows })
92
+ : footer;
93
+
94
+ return (
95
+ <View style={styles.overlay} pointerEvents={visible ? 'auto' : 'none'}>
96
+ <AnimatedPressable
97
+ style={[styles.backdrop, { opacity: progress }]}
98
+ onPress={onDismiss}
99
+ accessibilityRole="button"
100
+ accessibilityLabel="Dismiss"
101
+ />
102
+ <Animated.View
103
+ onLayout={(e) => setSheetHeight(e.nativeEvent.layout.height)}
104
+ style={[styles.sheet, style, { transform: [{ translateY }] }]}
105
+ // Contain assistive-tech focus while open (iOS) — without this, content behind the scrim
106
+ // stays reachable by screen readers even though it's visually blocked.
107
+ accessibilityViewIsModal
108
+ >
109
+ <View style={styles.handle} />
110
+ <BottomSheetContext.Provider value>
111
+ {header}
112
+ <ScrollView
113
+ style={styles.content}
114
+ contentContainerStyle={styles.contentContainer}
115
+ bounces={false}
116
+ onLayout={(e) => setScrollLayoutHeight(e.nativeEvent.layout.height)}
117
+ onContentSizeChange={(_width, height) => setScrollContentHeight(height)}
118
+ >
119
+ {children}
120
+ </ScrollView>
121
+ {resolvedFooter}
122
+ </BottomSheetContext.Provider>
123
+ </Animated.View>
124
+ </View>
125
+ );
126
+ }
127
+
128
+ const styles = StyleSheet.create({
129
+ overlay: { ...StyleSheet.absoluteFillObject, justifyContent: 'flex-end' },
130
+ backdrop: { ...StyleSheet.absoluteFillObject, backgroundColor: DS_SEMANTIC.element.overlayBackdrop },
131
+ sheet: {
132
+ maxHeight: MAX_HEIGHT_PERCENT,
133
+ backgroundColor: DS_SEMANTIC.surface.white,
134
+ borderTopLeftRadius: DS_RADIUS.large,
135
+ borderTopRightRadius: DS_RADIUS.large,
136
+ ...DS_SHADOW.bottomSheet,
137
+ },
138
+ // Purely a visual affordance (this sheet has no drag-to-dismiss gesture wired up) — signals "you
139
+ // can swipe this" the same way native sheets do, sized to the common ~36×4 grabber convention.
140
+ handle: {
141
+ alignSelf: 'center',
142
+ width: 36,
143
+ height: 4,
144
+ borderRadius: DS_RADIUS.round,
145
+ backgroundColor: DS_SEMANTIC.border.light,
146
+ marginTop: DS_SPACING[300],
147
+ marginBottom: DS_SPACING[100],
148
+ },
149
+ content: { flexGrow: 0 },
150
+ contentContainer: { padding: DS_SPACING[800] },
151
+ });
@@ -0,0 +1,2 @@
1
+ export { BottomSheet, useInsideBottomSheetWarning } from './BottomSheet';
2
+ export type { BottomSheetProps } from './BottomSheet';
@@ -0,0 +1,113 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage, type ComparisonDef } from '@krapwoo/ds-viewer';
4
+ import { Button } from './Button';
5
+ import type { ButtonVariant } from './Button.types';
6
+ import { DS_SEMANTIC } from '../../tokens';
7
+
8
+ const styles = StyleSheet.create({
9
+ // `white` is documented as "solid light (for dark/photo backgrounds)" — invisible on this
10
+ // catalog's own white card, so it needs a dark backdrop to read as a real button.
11
+ darkBackdrop: {
12
+ backgroundColor: DS_SEMANTIC.text.regular,
13
+ borderRadius: 12,
14
+ padding: 16,
15
+ alignItems: 'center',
16
+ },
17
+ });
18
+
19
+ // Grid comparisons: each cell is a real instance with exactly the props its row and column name.
20
+ // Never build these by multiplying `variants` with `states` — those are pre-rendered nodes and
21
+ // cannot combine.
22
+ function grid(
23
+ rowLabel: string,
24
+ columnLabel: string,
25
+ rows: { key: string; label: string }[],
26
+ columns: { key: string; label: string }[],
27
+ cell: (row: string, column: string) => React.ReactNode,
28
+ size?: ComparisonDef['size'],
29
+ ): ComparisonDef {
30
+ return {
31
+ rowLabel,
32
+ columnLabel,
33
+ rows,
34
+ columns,
35
+ cells: rows.flatMap((row) => columns.map((column) => ({ rowKey: row.key, columnKey: column.key, node: cell(row.key, column.key) }))),
36
+ size,
37
+ };
38
+ }
39
+
40
+ // Button: Variant × State. `white` keeps the dark backdrop its variant requires.
41
+ const BUTTON_COMPARISON = grid(
42
+ 'Variant',
43
+ 'State',
44
+ [
45
+ { key: 'primary', label: 'Primary' },
46
+ { key: 'secondary', label: 'Secondary' },
47
+ { key: 'tertiary', label: 'Tertiary' },
48
+ { key: 'white', label: 'White' },
49
+ { key: 'ghost', label: 'Ghost' },
50
+ ],
51
+ [
52
+ { key: 'default', label: 'Default' },
53
+ { key: 'disabled', label: 'Disabled' },
54
+ { key: 'loading', label: 'Loading' },
55
+ ],
56
+ (row, column) => {
57
+ const button = (
58
+ <Button
59
+ label="Continue"
60
+ variant={row as ButtonVariant}
61
+ disabled={column === 'disabled'}
62
+ loading={column === 'loading'}
63
+ onPress={() => {}}
64
+ />
65
+ );
66
+ return row === 'white' ? <View style={styles.darkBackdrop}>{button}</View> : button;
67
+ },
68
+ );
69
+
70
+ export default defineCatalogPage({
71
+ component: 'Button',
72
+ group: 'Actions',
73
+ comparison: BUTTON_COMPARISON,
74
+ description:
75
+ 'The primary tap target. Five visual weights, three sizes, optional leading/trailing icon, plus loading and icon-only modes.',
76
+ whenToUse: 'An action — something happens on tap. For a tappable chip that just flips a persistent selected state, use Pill instead.',
77
+ a11y: 'Renders a Pressable with accessibilityRole="button"; pass accessibilityLabel for icon-only buttons where the label is hidden.',
78
+ variants: {
79
+ items: [
80
+ { key: 'primary', name: 'Primary', props: { variant: 'primary', size: 'large' }, node: <Button label="Primary" variant="primary" onPress={() => {}} /> },
81
+ { key: 'secondary', name: 'Secondary', props: { variant: 'secondary', size: 'large' }, node: <Button label="Secondary" variant="secondary" onPress={() => {}} /> },
82
+ { key: 'tertiary', name: 'Tertiary', props: { variant: 'tertiary', size: 'large' }, node: <Button label="Tertiary" variant="tertiary" onPress={() => {}} /> },
83
+ {
84
+ key: 'white',
85
+ name: 'White',
86
+ props: { variant: 'white', size: 'large' },
87
+ node: (
88
+ <View style={styles.darkBackdrop}>
89
+ <Button label="White" variant="white" onPress={() => {}} />
90
+ </View>
91
+ ),
92
+ },
93
+ { key: 'ghost', name: 'Ghost', props: { variant: 'ghost', size: 'large' }, node: <Button label="Ghost" variant="ghost" onPress={() => {}} /> },
94
+ ],
95
+ },
96
+ states: {
97
+ items: [
98
+ { key: 'with-icon', name: 'With icon', props: { iconPosition: 'leading' }, node: <Button label="Add stop" showIcon iconName="add" onPress={() => {}} /> },
99
+ {
100
+ key: 'trailing-icon',
101
+ name: 'Trailing icon',
102
+ props: { iconPosition: 'trailing' },
103
+ node: <Button label="Continue" showIcon iconName="chevron-right" iconPosition="trailing" onPress={() => {}} />,
104
+ },
105
+ { key: 'icon-only', name: 'Icon-only', node: <Button label="Add stop" showIcon showLabel={false} iconName="add" onPress={() => {}} /> },
106
+ { key: 'loading', name: 'Loading', node: <Button label="Loading" loading onPress={() => {}} /> },
107
+ { key: 'disabled', name: 'Disabled', node: <Button label="Disabled" disabled onPress={() => {}} /> },
108
+ { key: 'full-width', name: 'Full width', fill: true, node: <Button label="Confirm trip" fullWidth onPress={() => {}} /> },
109
+ { key: 'medium', name: 'Medium size', props: { size: 'medium' }, node: <Button label="Add stop" size="medium" onPress={() => {}} /> },
110
+ { key: 'small', name: 'Small size', props: { size: 'small' }, node: <Button label="Add stop" size="small" onPress={() => {}} /> },
111
+ ],
112
+ },
113
+ });
@@ -0,0 +1,173 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Pressable, Text, View, StyleSheet, type ViewStyle, type TextStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_RADIUS, DS_SPACING, DS_TYPOGRAPHY, DS_FONT_WEIGHT, DS_A11Y_MIN_TOUCH_TARGET } from '../../tokens';
4
+ import { Icon } from '../../icons/Icon.native';
5
+ import { Loading } from '../Loading';
6
+ import type { ButtonProps, ButtonVariant, ButtonSize } from './Button.types';
7
+
8
+ type SizeTokens = { paddingVertical: number; paddingHorizontal: number; fontSize: number; lineHeight: number; iconSize: number };
9
+
10
+ // The icon size tracks the label's line box so the icon stays proportionate to the text at every size.
11
+ const SIZE_TOKENS: Record<ButtonSize, SizeTokens> = {
12
+ large: { paddingVertical: DS_SPACING[800], paddingHorizontal: DS_SPACING[1200], fontSize: DS_TYPOGRAPHY.labelMd.fontSize, lineHeight: 20, iconSize: 20 },
13
+ medium: { paddingVertical: DS_SPACING[600], paddingHorizontal: DS_SPACING[800], fontSize: DS_TYPOGRAPHY.labelSm.fontSize, lineHeight: 18, iconSize: 18 },
14
+ small: { paddingVertical: DS_SPACING[400], paddingHorizontal: DS_SPACING[800], fontSize: DS_TYPOGRAPHY.labelXs.fontSize, lineHeight: 16, iconSize: 16 },
15
+ };
16
+
17
+ type VariantStyles = { container: ViewStyle; containerPressed: ViewStyle; containerDisabled: ViewStyle; label: TextStyle };
18
+
19
+ const VARIANT_STYLES: Record<ButtonVariant, VariantStyles> = {
20
+ primary: {
21
+ container: { backgroundColor: DS_SEMANTIC.text.regular },
22
+ containerPressed: { backgroundColor: DS_SEMANTIC.emphasis.neutral },
23
+ containerDisabled: { backgroundColor: DS_SEMANTIC.border.light },
24
+ label: { color: DS_SEMANTIC.text.inverse },
25
+ },
26
+ secondary: {
27
+ container: { backgroundColor: DS_SEMANTIC.surface.recessed },
28
+ // Not DS_SEMANTIC.interaction.pressed/disabledOpacity — this variant's resting background is
29
+ // already the translucent surface.recessed overlay, so its pressed/disabled states are their own
30
+ // tuned darker/lighter versions of that same overlay (recessedPressed/recessedDisabled) rather
31
+ // than a second, independently-chosen overlay stacked on top.
32
+ containerPressed: { backgroundColor: DS_SEMANTIC.surface.recessedPressed },
33
+ containerDisabled: { backgroundColor: DS_SEMANTIC.surface.recessedDisabled },
34
+ label: { color: DS_SEMANTIC.text.regular },
35
+ },
36
+ tertiary: {
37
+ container: { backgroundColor: 'transparent' },
38
+ containerPressed: { backgroundColor: DS_SEMANTIC.interaction.pressed },
39
+ containerDisabled: { backgroundColor: 'transparent' },
40
+ label: { color: DS_SEMANTIC.text.regular },
41
+ },
42
+ white: {
43
+ container: { backgroundColor: DS_SEMANTIC.surface.white },
44
+ containerPressed: { backgroundColor: DS_SEMANTIC.surface.main },
45
+ containerDisabled: { backgroundColor: DS_SEMANTIC.surface.muted },
46
+ label: { color: DS_SEMANTIC.text.regular },
47
+ },
48
+ ghost: {
49
+ container: { backgroundColor: 'transparent' },
50
+ containerPressed: { backgroundColor: DS_SEMANTIC.interaction.pressed },
51
+ containerDisabled: { backgroundColor: 'transparent' },
52
+ label: { color: DS_SEMANTIC.text.regular },
53
+ },
54
+ };
55
+
56
+ function ButtonImpl({
57
+ label = 'Button',
58
+ variant = 'primary',
59
+ size = 'large',
60
+ showIcon = false,
61
+ iconName = 'add',
62
+ iconPosition = 'leading',
63
+ showLabel = true,
64
+ onPress,
65
+ disabled = false,
66
+ loading = false,
67
+ fullWidth = false,
68
+ style,
69
+ textStyle,
70
+ testID,
71
+ accessibilityLabel,
72
+ }: ButtonProps) {
73
+ const variantStyle = VARIANT_STYLES[variant];
74
+ const isGhost = variant === 'ghost';
75
+ const isIconOnly = showIcon && !showLabel;
76
+ const sizeTokens = SIZE_TOKENS[size];
77
+ const isDisabled = disabled || loading || !onPress;
78
+ // Ghost reads text-first, so its icon is sized to the label's line box rather than the larger standard.
79
+ const renderedIconSize = isGhost ? Math.min(sizeTokens.iconSize, sizeTokens.lineHeight) : sizeTokens.iconSize;
80
+ const iconColor = variant === 'primary' ? DS_SEMANTIC.text.inverse : DS_SEMANTIC.text.regular;
81
+ const spinnerColor = iconColor;
82
+ // Match the loader to the resting content height so swapping in the spinner never changes the height.
83
+ const loaderSize = Math.max(showIcon ? sizeTokens.iconSize : 0, showLabel ? sizeTokens.lineHeight : 0) || sizeTokens.iconSize;
84
+ // small/medium icon-only buttons render under the touch-target minimum — pad the tap area out
85
+ // with hitSlop rather than growing the visual button itself.
86
+ const iconOnlyVisualSize = sizeTokens.paddingVertical * 2 + sizeTokens.iconSize;
87
+ const iconOnlyHitSlop = Math.max(0, Math.ceil((DS_A11Y_MIN_TOUCH_TARGET - iconOnlyVisualSize) / 2));
88
+ // Ghost has zero padding (`baseGhost`) — its visual height is just the content's own line box, so
89
+ // every ghost button (label-only or icon-only alike — the icon is itself capped to this same
90
+ // line-box height, see `renderedIconSize` above) needs hitSlop to reach the touch-target minimum,
91
+ // not just the icon-only case the other variants need it for.
92
+ const ghostHitSlop = Math.max(0, Math.ceil((DS_A11Y_MIN_TOUCH_TARGET - sizeTokens.lineHeight) / 2));
93
+
94
+ const [focused, setFocused] = useState(false);
95
+ const accessibilityState = useMemo(() => ({ disabled: isDisabled, busy: loading }), [isDisabled, loading]);
96
+
97
+ const renderInner = () => (
98
+ <View style={[styles.content, isGhost && styles.contentGhost, disabled && styles.contentDisabled]}>
99
+ {showIcon && iconPosition === 'leading' && (
100
+ <Icon name={iconName} size={renderedIconSize} color={iconColor} />
101
+ )}
102
+ {showLabel && (
103
+ <Text
104
+ style={[styles.label, { fontSize: sizeTokens.fontSize, lineHeight: sizeTokens.lineHeight }, variantStyle.label, textStyle]}
105
+ >
106
+ {label}
107
+ </Text>
108
+ )}
109
+ {showIcon && iconPosition === 'trailing' && (
110
+ <Icon name={iconName} size={renderedIconSize} color={iconColor} />
111
+ )}
112
+ </View>
113
+ );
114
+
115
+ return (
116
+ <Pressable
117
+ testID={testID}
118
+ onPress={onPress}
119
+ disabled={isDisabled}
120
+ accessibilityRole="button"
121
+ accessibilityLabel={accessibilityLabel ?? label}
122
+ accessibilityState={accessibilityState}
123
+ hitSlop={isGhost ? ghostHitSlop : isIconOnly ? iconOnlyHitSlop : undefined}
124
+ onFocus={() => setFocused(true)}
125
+ onBlur={() => setFocused(false)}
126
+ style={({ pressed }) => [
127
+ styles.base,
128
+ isGhost ? styles.baseGhost : {
129
+ paddingVertical: sizeTokens.paddingVertical,
130
+ paddingHorizontal: isIconOnly ? sizeTokens.paddingVertical : sizeTokens.paddingHorizontal,
131
+ },
132
+ variantStyle.container,
133
+ fullWidth && styles.fullWidth,
134
+ pressed && !isDisabled && variantStyle.containerPressed,
135
+ isDisabled && !loading && variantStyle.containerDisabled,
136
+ focused && !isDisabled && styles.focused,
137
+ style,
138
+ ]}
139
+ >
140
+ {loading ? <Loading color={spinnerColor} size={loaderSize} /> : renderInner()}
141
+ </Pressable>
142
+ );
143
+ }
144
+
145
+ /** Skips re-rendering when this button's own props are unchanged. */
146
+ export const Button = React.memo(ButtonImpl);
147
+
148
+ const styles = StyleSheet.create({
149
+ base: {
150
+ flexDirection: 'row',
151
+ alignItems: 'center',
152
+ justifyContent: 'center',
153
+ borderRadius: DS_RADIUS.round,
154
+ borderWidth: 2,
155
+ borderColor: 'transparent',
156
+ },
157
+ baseGhost: {
158
+ flexDirection: 'row',
159
+ alignItems: 'center',
160
+ justifyContent: 'center',
161
+ borderRadius: DS_RADIUS.small,
162
+ borderWidth: 0,
163
+ padding: 0,
164
+ },
165
+ focused: { borderColor: DS_SEMANTIC.interaction.focused },
166
+ fullWidth: { alignSelf: 'stretch' },
167
+ content: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[200] },
168
+ contentGhost: { gap: DS_SPACING[100] },
169
+ // Dims the whole content (icon + label together) so an icon-only disabled button is still visibly
170
+ // disabled, not just a disabled button with no label to dim.
171
+ contentDisabled: { opacity: DS_SEMANTIC.interaction.disabledOpacity },
172
+ label: { fontWeight: DS_FONT_WEIGHT.semibold, textAlign: 'center' },
173
+ });
@@ -0,0 +1,52 @@
1
+ import type { StyleProp, ViewStyle, TextStyle } from 'react-native';
2
+ import type { IconName } from '../../icons';
3
+
4
+ /** Which one this is in its screen/modal/sheet — a decision about the action's importance, not
5
+ * just a visual pick:
6
+ * - `primary` — *the* primary action to take on this surface (the one thing it wants you to do).
7
+ * Only one per screen/modal/sheet.
8
+ * - `secondary` — worth considering in context, but not the primary action.
9
+ * - `tertiary` — fine if the user never notices it; a low-stakes, easy-to-skip action.
10
+ * - `white` — the same weight/role as `primary`, for a dark or photo background instead of a
11
+ * light one (never mix the two — pick whichever reads on the surface behind it).
12
+ * - `ghost` — for an action inline within a large, word-heavy area (body text, a toast's "Undo") —
13
+ * not a nav-bar icon or a standalone CTA; see TopNav's own leading/trailing slot guidance
14
+ * (`size="small"`, `variant="secondary"` by default) for that case instead. */
15
+ export type ButtonVariant = 'primary' | 'secondary' | 'tertiary' | 'white' | 'ghost';
16
+ export type ButtonSize = 'large' | 'medium' | 'small';
17
+ export type ButtonIconPosition = 'leading' | 'trailing';
18
+
19
+ export interface ButtonProps {
20
+ /** Button text. */
21
+ label?: string;
22
+ /** Which action this is, not just a look: primary = the one main action here; secondary = worth
23
+ * considering, not primary; tertiary = fine if the user skips it; white = primary on a dark/photo
24
+ * bg; ghost = inline within word-heavy text, not a nav icon or standalone CTA. */
25
+ variant?: ButtonVariant;
26
+ /** Control size. */
27
+ size?: ButtonSize;
28
+ /** Show the icon named by `iconName`. */
29
+ showIcon?: boolean;
30
+ /** Which icon to render. */
31
+ iconName?: IconName;
32
+ /** Which side of the label the icon sits on. */
33
+ iconPosition?: ButtonIconPosition;
34
+ /** Hide the label (with showIcon) for an icon-only, square button. */
35
+ showLabel?: boolean;
36
+ /** Tap handler. */
37
+ onPress?: () => void;
38
+ /** Non-interactive, dimmed. */
39
+ disabled?: boolean;
40
+ /** Swaps the label for a spinner and disables presses. Turn on right after the tap that
41
+ * triggered it, for as long as the background task it kicked off is still running — not a
42
+ * general "busy" flag unrelated to a real in-flight action following this exact tap. */
43
+ loading?: boolean;
44
+ /** Stretch to fill the available width. Use for the main CTA(s) pinned to the bottom of a
45
+ * screen/modal/sheet (e.g. inside a Dock); leave off (the default, dynamic/content-hugging
46
+ * width) for a button placed within surrounding context — a card, a row, inline actions. */
47
+ fullWidth?: boolean;
48
+ style?: StyleProp<ViewStyle>;
49
+ textStyle?: StyleProp<TextStyle>;
50
+ testID?: string;
51
+ accessibilityLabel?: string;
52
+ }
@@ -0,0 +1,2 @@
1
+ export { Button } from './Button';
2
+ export type { ButtonProps, ButtonVariant, ButtonSize, ButtonIconPosition } from './Button.types';
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { ButtonGroup } from './ButtonGroup';
4
+ import { Button } from '../Button';
5
+
6
+ export default defineCatalogPage({
7
+ component: 'ButtonGroup',
8
+ group: 'Actions',
9
+ description: 'Groups Button elements in one of two layouts: horizontal (up to two, auto-width, trailing-aligned) or vertical (up to three, stretched full width — the same layout Dock\'s own button area uses). Every button in a group should read as one family — the same variant tier (primary/secondary/tertiary/white, never ghost), the same size, and either all icon+label or all label-only, never a mix. Dev-console warns if two or more children disagree.',
10
+ a11y: 'A plain View; each Button child carries its own accessibility role and label.',
11
+ variants: {
12
+ itemsFill: true,
13
+ items: [
14
+ {
15
+ key: 'horizontal',
16
+ name: 'Horizontal',
17
+ props: { variant: 'horizontal' },
18
+ node: (
19
+ <ButtonGroup variant="horizontal">
20
+ <Button label="Cancel" variant="tertiary" onPress={() => {}} />
21
+ <Button label="Confirm" onPress={() => {}} />
22
+ </ButtonGroup>
23
+ ),
24
+ },
25
+ {
26
+ key: 'vertical',
27
+ name: 'Vertical',
28
+ props: { variant: 'vertical' },
29
+ node: (
30
+ <ButtonGroup variant="vertical">
31
+ <Button label="Start trip" onPress={() => {}} />
32
+ <Button label="Save for later" variant="secondary" onPress={() => {}} />
33
+ <Button label="Share" variant="tertiary" onPress={() => {}} />
34
+ </ButtonGroup>
35
+ ),
36
+ },
37
+ ],
38
+ },
39
+ });
@@ -0,0 +1,84 @@
1
+ import React, { type ReactNode } from 'react';
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SPACING } from '../../tokens';
4
+ import type { ButtonProps, ButtonVariant } from '../Button';
5
+
6
+ export type ButtonGroupVariant = 'horizontal' | 'vertical';
7
+
8
+ export interface ButtonGroupProps {
9
+ /** Which layout to use.
10
+ * @default 'horizontal' */
11
+ variant?: ButtonGroupVariant;
12
+ /** Button elements. Horizontal holds up to two; vertical holds up to three. Extra children are dropped. */
13
+ children: ReactNode;
14
+ style?: StyleProp<ViewStyle>;
15
+ }
16
+
17
+ const MAX_BUTTONS: Record<ButtonGroupVariant, number> = { horizontal: 2, vertical: 3 };
18
+
19
+ // `ghost` reads as a quiet, text-only, link-like action — placed beside a "real" (filled/outlined)
20
+ // button it reads as mismatched weight, as if one of the two isn't really a button. Every other
21
+ // variant reads as a standalone action of comparable weight, so those are safe to group.
22
+ const ALLOWED_VARIANTS: ButtonVariant[] = ['primary', 'secondary', 'tertiary', 'white'];
23
+
24
+ /** Opt-in-style consistency check (same policy as SectionBlock's own `checkCompleteness`): every
25
+ * button in a group should read as one family — same variant tier, same size, and either all
26
+ * icon+label or all label-only, never a mix (a lone icon+label button beside label-only siblings
27
+ * draws the eye as "the different one" instead of as an equally-weighted option). Warns in the
28
+ * console rather than throwing, since a mismatch is a design inconsistency to fix, not a crash. */
29
+ function checkConsistency(buttons: ReactNode[]): void {
30
+ const props = buttons
31
+ .filter((b): b is React.ReactElement<ButtonProps> => React.isValidElement(b))
32
+ .map((b) => b.props);
33
+ if (props.length < 2) return;
34
+
35
+ for (const p of props) {
36
+ if (p.variant && !ALLOWED_VARIANTS.includes(p.variant)) {
37
+ console.warn(
38
+ `[ButtonGroup] "${p.label ?? '(no label)'}" uses variant="${p.variant}" — grouped buttons should use one of ${ALLOWED_VARIANTS.join('/')}, not ${p.variant}.`,
39
+ );
40
+ }
41
+ }
42
+
43
+ const sizes = new Set(props.map((p) => p.size ?? 'large'));
44
+ if (sizes.size > 1) {
45
+ console.warn(`[ButtonGroup] Buttons have mismatched sizes (${[...sizes].join(', ')}) — every button in a group should use the same size.`);
46
+ }
47
+
48
+ // Keyed on the (showIcon, showLabel) pair, not showIcon alone — an icon-only button
49
+ // (showIcon + showLabel={false}) next to an icon+label one is exactly the visually-mismatched
50
+ // pairing this check exists to catch, and both have showIcon true.
51
+ const configs = new Set(
52
+ props.map((p) => {
53
+ const icon = p.showIcon ?? false;
54
+ const label = p.showLabel ?? true;
55
+ return icon && label ? 'icon+label' : icon ? 'icon-only' : 'label-only';
56
+ }),
57
+ );
58
+ if (configs.size > 1) {
59
+ console.warn(
60
+ `[ButtonGroup] Buttons mix configurations (${[...configs].join(', ')}) — every button in a group should use the same configuration.`,
61
+ );
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Groups Button elements in one of two layouts:
67
+ * - `horizontal` (default, up to two): auto-width, trailing-aligned — a dialog's Cancel/Confirm pair.
68
+ * - `vertical` (up to three): stretched full width, stacked — the same layout Dock's own button
69
+ * area uses.
70
+ * Every button in a group should read as one family: the same variant tier (`primary`/`secondary`/
71
+ * `tertiary`/`white` — never `ghost`, which reads as a different, lower weight), the same size, and
72
+ * either all icon+label or all label-only, never a mix. Dev-console warns if two or more children
73
+ * disagree.
74
+ */
75
+ export function ButtonGroup({ variant = 'horizontal', children, style }: ButtonGroupProps) {
76
+ const buttons = React.Children.toArray(children).slice(0, MAX_BUTTONS[variant]);
77
+ checkConsistency(buttons);
78
+ return <View style={[variant === 'vertical' ? styles.vertical : styles.horizontal, style]}>{buttons}</View>;
79
+ }
80
+
81
+ const styles = StyleSheet.create({
82
+ horizontal: { flexDirection: 'row', justifyContent: 'flex-end', gap: DS_SPACING[400] },
83
+ vertical: { flexDirection: 'column', gap: DS_SPACING[400], width: '100%' },
84
+ });
@@ -0,0 +1,2 @@
1
+ export { ButtonGroup } from './ButtonGroup';
2
+ export type { ButtonGroupProps, ButtonGroupVariant } from './ButtonGroup';