@krapwoo/ds-viewer 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +100 -78
  2. package/dist/cli/config.js +26 -1
  3. package/dist/cli/dev.js +50 -5
  4. package/dist/cli/glob.js +8 -0
  5. package/dist/cli/init.js +148 -18
  6. package/dist/cli/main.js +66 -4
  7. package/dist/cli/props.js +166 -18
  8. package/dist/cli/sync.js +17 -1
  9. package/dist/cli/workspace.js +49 -21
  10. package/native/catalog/CatalogShell.tsx +6 -1
  11. package/native/catalog/CatalogSidebar.tsx +98 -5
  12. package/native/catalog/TokenRow.tsx +3 -2
  13. package/native/catalog/index.ts +6 -5
  14. package/native/catalog/logoLayout.ts +12 -0
  15. package/native/catalog/pageApi.ts +10 -1
  16. package/native/catalog/tokens.ts +5 -0
  17. package/package.json +8 -6
  18. package/starter-kit/WHEN_TO_USE.md +124 -0
  19. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  20. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  21. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  22. package/starter-kit/components/Avatar/Avatar.catalog.tsx +72 -0
  23. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  24. package/starter-kit/components/Avatar/index.ts +2 -0
  25. package/starter-kit/components/Badge/Badge.catalog.tsx +96 -0
  26. package/starter-kit/components/Badge/Badge.tsx +56 -0
  27. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  28. package/starter-kit/components/Badge/index.ts +2 -0
  29. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  30. package/starter-kit/components/Banner/Banner.tsx +257 -0
  31. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  32. package/starter-kit/components/Banner/index.ts +2 -0
  33. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  34. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  35. package/starter-kit/components/BottomSheet/index.ts +2 -0
  36. package/starter-kit/components/Button/Button.catalog.tsx +113 -0
  37. package/starter-kit/components/Button/Button.tsx +173 -0
  38. package/starter-kit/components/Button/Button.types.ts +52 -0
  39. package/starter-kit/components/Button/index.ts +2 -0
  40. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  41. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  42. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  43. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  44. package/starter-kit/components/Card/Card.tsx +133 -0
  45. package/starter-kit/components/Card/index.ts +2 -0
  46. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  47. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  48. package/starter-kit/components/Checkbox/index.ts +2 -0
  49. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  50. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  51. package/starter-kit/components/Dialog/index.ts +2 -0
  52. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  53. package/starter-kit/components/Divider/Divider.tsx +20 -0
  54. package/starter-kit/components/Divider/index.ts +2 -0
  55. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  56. package/starter-kit/components/Dock/Dock.tsx +58 -0
  57. package/starter-kit/components/Dock/index.ts +2 -0
  58. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  59. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  60. package/starter-kit/components/Dropdown/index.ts +2 -0
  61. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  62. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  63. package/starter-kit/components/EmptyState/index.ts +2 -0
  64. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  65. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  66. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  67. package/starter-kit/components/FieldContainer/index.ts +2 -0
  68. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  69. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  70. package/starter-kit/components/InputClearButton/index.ts +2 -0
  71. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  72. package/starter-kit/components/InputField/InputField.tsx +418 -0
  73. package/starter-kit/components/InputField/index.ts +2 -0
  74. package/starter-kit/components/List/List.catalog.tsx +30 -0
  75. package/starter-kit/components/List/List.tsx +36 -0
  76. package/starter-kit/components/List/index.ts +2 -0
  77. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  78. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  79. package/starter-kit/components/ListItem/index.ts +2 -0
  80. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  81. package/starter-kit/components/Loading/Loading.tsx +121 -0
  82. package/starter-kit/components/Loading/index.ts +2 -0
  83. package/starter-kit/components/Pill/Pill.catalog.tsx +93 -0
  84. package/starter-kit/components/Pill/Pill.tsx +181 -0
  85. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  86. package/starter-kit/components/Pill/index.ts +2 -0
  87. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  88. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  89. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  90. package/starter-kit/components/PillRow/index.ts +2 -0
  91. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  92. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  93. package/starter-kit/components/ProgressDots/index.ts +2 -0
  94. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  95. package/starter-kit/components/Radio/Radio.tsx +114 -0
  96. package/starter-kit/components/Radio/index.ts +2 -0
  97. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  98. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  99. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  100. package/starter-kit/components/SearchField/index.ts +2 -0
  101. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  102. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  103. package/starter-kit/components/SectionHeader/index.ts +2 -0
  104. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  105. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  106. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  107. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  108. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  109. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  110. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  111. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  112. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  113. package/starter-kit/components/Shimmer/index.ts +2 -0
  114. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  115. package/starter-kit/components/Surface/Surface.tsx +45 -0
  116. package/starter-kit/components/Surface/index.ts +2 -0
  117. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  118. package/starter-kit/components/Switch/Switch.tsx +129 -0
  119. package/starter-kit/components/Switch/index.ts +2 -0
  120. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  121. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  122. package/starter-kit/components/TextArea/index.ts +2 -0
  123. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  124. package/starter-kit/components/Toast/Toast.tsx +144 -0
  125. package/starter-kit/components/Toast/index.ts +2 -0
  126. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  127. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  128. package/starter-kit/components/Tooltip/index.ts +2 -0
  129. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  130. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  131. package/starter-kit/components/TopNav/index.ts +2 -0
  132. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  133. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  134. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  135. package/starter-kit/components/index.ts +36 -0
  136. package/starter-kit/components/statusColors.ts +27 -0
  137. package/starter-kit/icons/Icon.native.tsx +90 -0
  138. package/starter-kit/icons/index.ts +10 -0
  139. package/starter-kit/icons/paths.ts +623 -0
  140. package/starter-kit/icons/types.ts +69 -0
  141. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  142. package/starter-kit/pages/Font.catalog.tsx +45 -0
  143. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  144. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  145. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  146. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  147. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  148. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  149. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  150. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  151. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  152. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  153. package/starter-kit/tokens/index.ts +50 -0
  154. package/starter-kit/tokens/motion.ts +90 -0
  155. package/starter-kit/tokens/palette.ts +111 -0
  156. package/starter-kit/tokens/scales.ts +106 -0
  157. package/starter-kit/tokens/semantic.ts +98 -0
  158. package/starter-kit/tokens/shadow.ts +36 -0
  159. package/starter-kit/tokens/typography.ts +72 -0
@@ -0,0 +1,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';
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { Text, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { Card } from './Card';
5
+ import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
6
+
7
+ const styles = StyleSheet.create({
8
+ cardTitle: { ...DS_TYPOGRAPHY.labelMd, color: DS_SEMANTIC.text.regular },
9
+ cardBody: { ...DS_TYPOGRAPHY.bodySm, color: DS_SEMANTIC.text.muted, marginTop: DS_SPACING[200] },
10
+ });
11
+
12
+ export default defineCatalogPage({
13
+ component: 'Card',
14
+ group: 'Surfaces',
15
+ description: 'The primary content surface — a rounded white card with a soft resting shadow. Pass children, or onPress to make the whole card a button.',
16
+ whenToUse: 'A standalone, self-contained unit with its own shadow. For a row in a homogeneous stack of peers (with dividers between them), use ListItem inside a List instead.',
17
+ a11y: 'When onPress is set, renders a Pressable with accessibilityRole="button" and a visible focus ring; a plain card is a non-interactive View.',
18
+ variants: {
19
+ itemsFill: true,
20
+ items: [
21
+ {
22
+ key: 'default',
23
+ name: 'Default',
24
+ node: (
25
+ <Card>
26
+ <Text style={styles.cardTitle}>Uptown & The Bronx</Text>
27
+ <Text style={styles.cardBody}>Next train in 4 min · every 6–8 min</Text>
28
+ </Card>
29
+ ),
30
+ },
31
+ ],
32
+ },
33
+ states: {
34
+ itemsFill: true,
35
+ items: [
36
+ {
37
+ key: 'plain',
38
+ name: 'Plain',
39
+ node: (
40
+ <Card>
41
+ <Text style={styles.cardTitle}>Uptown & The Bronx</Text>
42
+ <Text style={styles.cardBody}>Next train in 4 min · every 6–8 min</Text>
43
+ </Card>
44
+ ),
45
+ },
46
+ {
47
+ key: 'pressable',
48
+ name: 'Pressable',
49
+ node: (
50
+ <Card onPress={() => {}}>
51
+ <Text style={styles.cardTitle}>Crosstown Bus M14</Text>
52
+ <Text style={styles.cardBody}>Tap to view live arrivals</Text>
53
+ </Card>
54
+ ),
55
+ },
56
+ {
57
+ key: 'disabled',
58
+ name: 'Disabled',
59
+ node: (
60
+ <Card onPress={() => {}} disabled>
61
+ <Text style={styles.cardTitle}>Franklin Ave Shuttle</Text>
62
+ <Text style={styles.cardBody}>Temporarily unavailable</Text>
63
+ </Card>
64
+ ),
65
+ },
66
+ ],
67
+ },
68
+ });
@@ -0,0 +1,133 @@
1
+ import React, { type ReactNode } from 'react';
2
+ import {
3
+ View,
4
+ Pressable,
5
+ StyleSheet,
6
+ type StyleProp,
7
+ type ViewStyle,
8
+ type AccessibilityState,
9
+ } from 'react-native';
10
+ import { DS_SEMANTIC, DS_RADIUS, DS_SPACING, DS_SHADOW } from '../../tokens';
11
+
12
+ // Dev-only fill for the Figma slot placeholders (shown when no `children` are passed). Deliberately
13
+ // vivid so it always reads as scaffolding, never as a real surface — not a themed token.
14
+ const DEV_PLACEHOLDER_BG = '#f4c3ff';
15
+
16
+ interface CardBaseProps {
17
+ /** Card content. */
18
+ children?: ReactNode;
19
+ /** Figma slot visibility toggles — prefer `children` for real content. */
20
+ showSlot1?: boolean;
21
+ showSlot2?: boolean;
22
+ showSlot3?: boolean;
23
+ showSlot4?: boolean;
24
+ showSlot5?: boolean;
25
+ accessibilityLabel?: string;
26
+ accessibilityState?: AccessibilityState;
27
+ style?: StyleProp<ViewStyle>;
28
+ }
29
+
30
+ // `disabled` only means something on a pressable card — a plain (non-`onPress`) card can't be
31
+ // disabled, so that combination is a type error here instead of a silently-ignored prop.
32
+ export type CardProps =
33
+ | (CardBaseProps & {
34
+ /** Makes the whole card a tappable button. */
35
+ onPress: () => void;
36
+ /** Only valid on a pressable card. */
37
+ disabled?: boolean;
38
+ })
39
+ | (CardBaseProps & { onPress?: undefined; disabled?: undefined });
40
+
41
+ /**
42
+ * The primary content surface: a rounded white card with a soft resting shadow. Pass `children`
43
+ * for real content, or `onPress` to make the whole card a tappable button (pressed + focus states).
44
+ */
45
+ export function Card({
46
+ children,
47
+ showSlot1 = true,
48
+ showSlot2 = true,
49
+ showSlot3 = true,
50
+ showSlot4 = true,
51
+ showSlot5 = true,
52
+ onPress,
53
+ disabled = false,
54
+ accessibilityLabel,
55
+ accessibilityState,
56
+ style,
57
+ }: CardProps) {
58
+ const [focused, setFocused] = React.useState(false);
59
+ const cardStyle = ({ pressed }: { pressed: boolean }) => [
60
+ styles.card,
61
+ pressed && !disabled && styles.cardOnTap,
62
+ focused && !disabled && styles.cardFocused,
63
+ disabled && styles.cardDisabled,
64
+ style,
65
+ ];
66
+
67
+ const inner = children ?? renderSlots(showSlot1, showSlot2, showSlot3, showSlot4, showSlot5);
68
+
69
+ if (onPress) {
70
+ return (
71
+ <Pressable
72
+ onPress={onPress}
73
+ disabled={disabled}
74
+ accessibilityRole="button"
75
+ accessibilityLabel={accessibilityLabel}
76
+ accessibilityState={{ ...accessibilityState, disabled }}
77
+ onFocus={() => setFocused(true)}
78
+ onBlur={() => setFocused(false)}
79
+ style={cardStyle}
80
+ >
81
+ {inner}
82
+ </Pressable>
83
+ );
84
+ }
85
+
86
+ return (
87
+ <View
88
+ style={[styles.card, style]}
89
+ accessible={!!accessibilityLabel}
90
+ accessibilityLabel={accessibilityLabel}
91
+ accessibilityState={accessibilityState}
92
+ >
93
+ {inner}
94
+ </View>
95
+ );
96
+ }
97
+
98
+ function renderSlots(
99
+ showSlot1: boolean,
100
+ showSlot2: boolean,
101
+ showSlot3: boolean,
102
+ showSlot4: boolean,
103
+ showSlot5: boolean,
104
+ ) {
105
+ const slots = [showSlot1, showSlot2, showSlot3, showSlot4, showSlot5].filter(Boolean);
106
+ return slots.map((_, index) => <View key={index} style={styles.placeholder} />);
107
+ }
108
+
109
+ const styles = StyleSheet.create({
110
+ card: {
111
+ backgroundColor: DS_SEMANTIC.surface.white,
112
+ borderRadius: DS_RADIUS.medium,
113
+ padding: DS_SPACING[800],
114
+ width: '100%',
115
+ ...DS_SHADOW.resting,
116
+ },
117
+ cardOnTap: {
118
+ backgroundColor: DS_SEMANTIC.interaction.pressed,
119
+ },
120
+ cardFocused: {
121
+ borderWidth: 2,
122
+ borderColor: DS_SEMANTIC.interaction.focused,
123
+ },
124
+ cardDisabled: {
125
+ opacity: DS_SEMANTIC.interaction.disabledOpacity,
126
+ },
127
+ placeholder: {
128
+ height: 59,
129
+ backgroundColor: DEV_PLACEHOLDER_BG,
130
+ borderRadius: DS_RADIUS.xs,
131
+ width: '100%',
132
+ },
133
+ });
@@ -0,0 +1,2 @@
1
+ export { Card } from './Card';
2
+ export type { CardProps } from './Card';