@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,163 @@
1
+ import React, { useState } from 'react';
2
+ import { View, Text, Pressable, Animated, I18nManager, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_TYPOGRAPHY, DS_ICON_SIZE, DS_SHADOW, DS_A11Y_MIN_TOUCH_TARGET } from '../../tokens';
4
+ import { Icon } from '../../icons/Icon.native';
5
+ import type { IconName } from '../../icons';
6
+ import { useSlideAnim } from './useSlideAnim';
7
+
8
+ export interface SegmentedToggleOption {
9
+ value: string;
10
+ label: string;
11
+ /** Optional leading icon (16px) shown before the label. */
12
+ iconName?: IconName;
13
+ /** Optional count shown in a circular badge after the label (hidden when 0/undefined). */
14
+ badge?: number;
15
+ }
16
+
17
+ export interface SegmentedToggleProps {
18
+ /** The segments. */
19
+ options: SegmentedToggleOption[];
20
+ /** The currently-selected option `value`. */
21
+ value: string;
22
+ /** Selection handler. */
23
+ onChange: (value: string) => void;
24
+ style?: StyleProp<ViewStyle>;
25
+ }
26
+
27
+ const TRACK_PADDING = DS_SPACING[100]; // recessed track inset
28
+ const GAP = DS_SPACING[100]; // space between segment slots
29
+ const THUMB_INSET = DS_SPACING[100]; // shrinks the thumb inside its slot for breathing room
30
+ const TRACK_COLOR = DS_SEMANTIC.surface.recessed; // track background — matches the secondary Button fill
31
+ const THUMB_COLOR = DS_SEMANTIC.surface.white; // selected thumb (white pill)
32
+
33
+ /**
34
+ * Segmented control — a row of mutually-exclusive options on a recessed track. A white "thumb" lifts
35
+ * the selected option and slides horizontally between segments on change. The thumb sits inset from its
36
+ * slot so there's a little gap around it. Two or more options.
37
+ */
38
+ export function SegmentedToggle({ options, value, onChange, style }: SegmentedToggleProps) {
39
+ const n = options.length;
40
+ const selectedIndex = Math.max(0, options.findIndex(o => o.value === value));
41
+ const [trackW, setTrackW] = useState(0);
42
+ // Which segment currently holds keyboard focus (null = none) — one state for the whole row, since
43
+ // only one segment can be focused at a time.
44
+ const [focusedValue, setFocusedValue] = useState<string | null>(null);
45
+ // Fixed equal-width segments computed analytically, so translateX-only can stay native-driven.
46
+ const anim = useSlideAnim(selectedIndex, true);
47
+
48
+ const innerW = Math.max(0, trackW - TRACK_PADDING * 2);
49
+ const segW = n > 0 ? (innerW - GAP * (n - 1)) / n : 0;
50
+ const thumbW = Math.max(0, segW - THUMB_INSET * 2);
51
+ // Left edge of the thumb at each index (segment left + inset). `track`'s `flexDirection: 'row'`
52
+ // auto-mirrors the segments themselves in RTL, but this absolute-positioned thumb's `left`/
53
+ // `translateX` doesn't — so in RTL, index `i` visually renders at the *mirrored* slot (n-1-i).
54
+ const thumbX =
55
+ n > 1
56
+ ? anim.interpolate({
57
+ inputRange: options.map((_, i) => i),
58
+ outputRange: options.map((_, i) => {
59
+ const slot = I18nManager.isRTL ? n - 1 - i : i;
60
+ return TRACK_PADDING + slot * (segW + GAP) + THUMB_INSET;
61
+ }),
62
+ })
63
+ : TRACK_PADDING + THUMB_INSET;
64
+
65
+ return (
66
+ <View
67
+ style={[styles.track, style]}
68
+ onLayout={e => setTrackW(e.nativeEvent.layout.width)}
69
+ accessibilityRole="tablist"
70
+ >
71
+ {trackW > 0 && (
72
+ <Animated.View
73
+ pointerEvents="none"
74
+ style={[styles.thumb, { width: thumbW, transform: [{ translateX: thumbX }] }]}
75
+ />
76
+ )}
77
+ {options.map(opt => {
78
+ const selected = opt.value === value;
79
+ // Black label on the white thumb when selected; muted on the recessed track otherwise.
80
+ const color = selected ? DS_SEMANTIC.text.regular : DS_SEMANTIC.text.muted;
81
+ // The badge is a filled circle of the label colour; its number is white so it stays legible on
82
+ // both the black (selected) and muted-grey (unselected) circle.
83
+ const onColor = DS_SEMANTIC.surface.white;
84
+ return (
85
+ <Pressable
86
+ key={opt.value}
87
+ onPress={() => onChange(opt.value)}
88
+ onFocus={() => setFocusedValue(opt.value)}
89
+ onBlur={() => setFocusedValue(prev => (prev === opt.value ? null : prev))}
90
+ accessibilityRole="tab"
91
+ accessibilityLabel={opt.badge != null && opt.badge > 0 ? `${opt.label}, ${opt.badge}` : opt.label}
92
+ accessibilityState={{ selected }}
93
+ // Keyboard focus shows the same highlight as a press — a flat in-track target has no
94
+ // border to recolor, so focus mirrors the pressed treatment (same policy as ListItem).
95
+ style={({ pressed }) => [styles.segment, (pressed || focusedValue === opt.value) && styles.segmentPressed]}
96
+ >
97
+ {opt.iconName && <Icon name={opt.iconName} size={DS_ICON_SIZE.sm} color={color} />}
98
+ <Text style={[styles.label, { color }]}>{opt.label}</Text>
99
+ {opt.badge != null && opt.badge > 0 && (
100
+ <View style={[styles.badge, { backgroundColor: color }]}>
101
+ <Text style={[styles.badgeText, { color: onColor }]}>{opt.badge}</Text>
102
+ </View>
103
+ )}
104
+ </Pressable>
105
+ );
106
+ })}
107
+ </View>
108
+ );
109
+ }
110
+
111
+ const styles = StyleSheet.create({
112
+ track: {
113
+ flexDirection: 'row',
114
+ backgroundColor: TRACK_COLOR,
115
+ borderRadius: DS_RADIUS.round,
116
+ padding: TRACK_PADDING,
117
+ gap: GAP,
118
+ },
119
+ // The lifted pill behind the selected segment — slides horizontally on change.
120
+ thumb: {
121
+ position: 'absolute',
122
+ left: 0,
123
+ top: TRACK_PADDING + THUMB_INSET,
124
+ bottom: TRACK_PADDING + THUMB_INSET,
125
+ backgroundColor: THUMB_COLOR,
126
+ borderRadius: DS_RADIUS.round,
127
+ ...DS_SHADOW.resting,
128
+ },
129
+ segment: {
130
+ flex: 1,
131
+ flexDirection: 'row',
132
+ alignItems: 'center',
133
+ justifyContent: 'center',
134
+ gap: DS_SPACING[200],
135
+ paddingVertical: DS_SPACING[600],
136
+ // The track's own padding + this segment's content otherwise falls short of the touch-target
137
+ // minimum; a fixed floor (not a spacing token — this is an accessibility guideline number, not a
138
+ // design choice) keeps the segment's compact visual padding while still guaranteeing a tappable size.
139
+ minHeight: DS_A11Y_MIN_TOUCH_TARGET,
140
+ },
141
+ segmentPressed: {
142
+ backgroundColor: DS_SEMANTIC.interaction.pressed,
143
+ borderRadius: DS_RADIUS.round,
144
+ },
145
+ label: {
146
+ // Matches the section-header label size (labelXs) it stands in for.
147
+ ...DS_TYPOGRAPHY.labelXs,
148
+ },
149
+ // Fixed-diameter circle so the count badge stays round. 18 doesn't match a DS_SPACING/DS_ICON_SIZE
150
+ // step (16/20 are the neighbors) — sized to comfortably fit a 1-2 digit count at labelXs.
151
+ badge: {
152
+ width: 18,
153
+ height: 18,
154
+ borderRadius: DS_RADIUS.round,
155
+ alignItems: 'center',
156
+ justifyContent: 'center',
157
+ },
158
+ badgeText: {
159
+ ...DS_TYPOGRAPHY.labelXs,
160
+ // Pinned to the badge's own 18px diameter (above) so the count sits centered in the circle.
161
+ lineHeight: 18,
162
+ },
163
+ });
@@ -0,0 +1,2 @@
1
+ export { SegmentedToggle } from './SegmentedToggle';
2
+ export type { SegmentedToggleProps, SegmentedToggleOption } from './SegmentedToggle';
@@ -0,0 +1,29 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { Animated, Easing } from 'react-native';
3
+ import { DS_MOTION_DURATION, DS_MOTION_EASING } from '../../tokens';
4
+
5
+ /** How long the selection indicator takes to slide to its new position. */
6
+ export const SLIDE_ANIM_MS = DS_MOTION_DURATION.base;
7
+
8
+ /**
9
+ * Shared by SegmentedToggle and UnderlineTabs: an Animated.Value that eases to `selectedIndex`
10
+ * whenever it changes. Each component still does its own position math (SegmentedToggle computes
11
+ * fixed equal-width segments analytically; UnderlineTabs measures each tab's real width via onLayout)
12
+ * — those differ enough between the two that unifying them isn't safe to do blindly, but the "animate
13
+ * to the new index" mechanics underneath were byte-identical, so that part is shared here.
14
+ */
15
+ export function useSlideAnim(selectedIndex: number, useNativeDriver: boolean) {
16
+ const anim = useRef(new Animated.Value(selectedIndex)).current;
17
+
18
+ useEffect(() => {
19
+ Animated.timing(anim, {
20
+ toValue: selectedIndex,
21
+ duration: SLIDE_ANIM_MS,
22
+ // `standard` — a move that starts and ends on screen (a toggle/swap), per DS_MOTION_EASING_USE.
23
+ easing: Easing.bezier(...DS_MOTION_EASING.standard),
24
+ useNativeDriver,
25
+ }).start();
26
+ }, [selectedIndex, anim, useNativeDriver]);
27
+
28
+ return anim;
29
+ }
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { Breathing } from './Shimmer';
5
+ import { DS_RADIUS, DS_SEMANTIC } from '../../tokens';
6
+
7
+ const styles = StyleSheet.create({
8
+ block: { width: 160, height: 16, borderRadius: DS_RADIUS.small },
9
+ });
10
+
11
+ export default defineCatalogPage({
12
+ component: 'Breathing',
13
+ group: 'Feedback',
14
+ description:
15
+ "The shared animated primitive behind Shimmer — a solid block that breathes between a low and high colour on one shared, module-level clock, so every Breathing/Shimmer instance on screen stays phase-locked instead of drifting out of sync. Exported so a custom composite skeleton can reuse the same pulse directly instead of going through Shimmer's fixed text/circle/container shapes.",
16
+ a11y: 'Renders a plain Animated.View with no role by default — pass accessibilityRole="progressbar" and accessibilityLabel yourself, the same way Shimmer does internally for its own standalone (non-grouped) blocks.',
17
+ variants: {
18
+ itemsFill: true,
19
+ items: [
20
+ { key: 'default', name: 'Default', node: <Breathing style={styles.block} /> },
21
+ {
22
+ key: 'custom-colors',
23
+ name: 'Custom colours',
24
+ props: { low: DS_SEMANTIC.shade.info, high: DS_SEMANTIC.emphasis.info },
25
+ node: <Breathing style={styles.block} low={DS_SEMANTIC.shade.info} high={DS_SEMANTIC.emphasis.info} />,
26
+ },
27
+ ],
28
+ },
29
+ });
@@ -0,0 +1,89 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { Shimmer, SkeletonGroup } from './Shimmer';
5
+ import { DS_SPACING } from '../../tokens';
6
+
7
+ const styles = StyleSheet.create({
8
+ shimmerLines: { flex: 1, gap: DS_SPACING[400] },
9
+ previewRow: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[600] },
10
+ });
11
+
12
+ export default defineCatalogPage({
13
+ component: 'Shimmer',
14
+ group: 'Feedback',
15
+ description: 'Loading placeholders that breathe while content loads. `text` stands in for one line of text (an element with 3 lines gets 3 stacked `text` Shimmers, not one tall one), `circle` is always for a circular element, and `container` covers everything else (Card, Banner, image, …) — sized to match the real element it replaces, not an arbitrary block. When several stand in for one element, wrap them in `SkeletonGroup`.',
16
+ whenToUse: 'A composite skeleton (several Shimmers standing in for one element) → wrap them in SkeletonGroup so it announces "Loading" once, not once per block. A single lone Shimmer needs no wrapper — it announces on its own.',
17
+ a11y: 'A standalone Shimmer is exposed as accessibilityRole="progressbar" + accessibilityLabel="Loading". Inside a SkeletonGroup, each block goes silent and the group carries one busy "Loading" announcement for the whole skeleton — so a screen reader says "Loading" once, not once per block.',
18
+ // One standalone example per `variant` enum value (`text` is the default, so it comes first),
19
+ // plus the composed "Circle + text" usage — shown here too, not just under States, so Variants
20
+ // reads as complete on its own.
21
+ variants: {
22
+ itemsFill: true,
23
+ items: [
24
+ { key: 'text', name: 'Text (one line)', props: { variant: 'text' }, node: <Shimmer variant="text" width={160} /> },
25
+ { key: 'circle', name: 'Circle', props: { variant: 'circle' }, node: <Shimmer variant="circle" size={40} /> },
26
+ {
27
+ key: 'container',
28
+ name: 'Container (matches Card)',
29
+ props: { variant: 'container' },
30
+ // Same radius Card itself renders at (DS_RADIUS.medium, via dimensionsForVariant) and a
31
+ // plausible real Card content height — a container skeleton should reserve the same
32
+ // layout space the real element will occupy, not an arbitrary block.
33
+ node: <Shimmer variant="container" height={96} />,
34
+ },
35
+ {
36
+ key: 'circle-and-text',
37
+ name: 'Circle + text (2 lines)',
38
+ // SkeletonGroup so the whole avatar+2-lines skeleton announces "Loading" once, not three
39
+ // times (one per block) — it carries the caller's row layout itself.
40
+ node: (
41
+ <SkeletonGroup style={styles.previewRow}>
42
+ <Shimmer variant="circle" size={40} />
43
+ {/* Last line ~70% of the others' width — see ShimmerProps.variant's `'text'` doc. */}
44
+ <View style={styles.shimmerLines}>
45
+ <Shimmer variant="text" width={160} />
46
+ <Shimmer variant="text" width={112} />
47
+ </View>
48
+ </SkeletonGroup>
49
+ ),
50
+ },
51
+ ],
52
+ },
53
+ // Repeated here (not just in Variants) so States / Configurations is independently complete too.
54
+ states: {
55
+ itemsFill: true,
56
+ items: [
57
+ {
58
+ key: 'circle-and-text',
59
+ name: 'Circle + text (2 lines)',
60
+ // SkeletonGroup so the whole avatar+2-lines skeleton announces "Loading" once, not three
61
+ // times (one per block) — it carries the caller's row layout itself.
62
+ node: (
63
+ <SkeletonGroup style={styles.previewRow}>
64
+ <Shimmer variant="circle" size={40} />
65
+ {/* Last line ~70% of the others' width — see ShimmerProps.variant's `'text'` doc. */}
66
+ <View style={styles.shimmerLines}>
67
+ <Shimmer variant="text" width={160} />
68
+ <Shimmer variant="text" width={112} />
69
+ </View>
70
+ </SkeletonGroup>
71
+ ),
72
+ },
73
+ {
74
+ key: 'three-lines',
75
+ name: 'Three lines of text',
76
+ node: (
77
+ // Explicit px widths (not "100%") specifically so the last line's 70% ratio is a real,
78
+ // checkable relationship to the other two, not two independent percentages. Wrapped in a
79
+ // SkeletonGroup so all three lines announce as one "Loading" region.
80
+ <SkeletonGroup style={styles.shimmerLines}>
81
+ <Shimmer variant="text" width={200} />
82
+ <Shimmer variant="text" width={200} />
83
+ <Shimmer variant="text" width={140} />
84
+ </SkeletonGroup>
85
+ ),
86
+ },
87
+ ],
88
+ },
89
+ });
@@ -0,0 +1,158 @@
1
+ import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react';
2
+ import { View, Animated, Easing, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_RADIUS, DS_SPACING, DS_MOTION_LOOP_DURATION } from '../../tokens';
4
+ import type { ShimmerProps, ShimmerVariant } from './Shimmer.types';
5
+
6
+ // True for any Shimmer rendered inside a <SkeletonGroup>. When set, individual blocks drop their own
7
+ // "Loading" announcement — the group announces once for the whole skeleton instead (see below).
8
+ const SkeletonGroupContext = createContext(false);
9
+
10
+ export const SHIMMER_TEXT_HEIGHT = DS_SPACING[800];
11
+
12
+ // Plain Shimmer blocks pulse between these two values. Translucent black (not an opaque grey) so the
13
+ // shimmer reads correctly as a wash over whatever surface it's placed on — light, dark, or coloured —
14
+ // the same convention DS uses for its other "works on any surface" overlays. Interpolating the colour
15
+ // itself (not a separate `opacity` style) keeps each independent block visually equivalent to a plain
16
+ // alpha wash.
17
+ const PULSE_LOW = 'rgba(0, 0, 0, 0.02)';
18
+ const PULSE_HIGH = 'rgba(0, 0, 0, 0.05)';
19
+
20
+ // One pulse cycle, shared by every Shimmer instance.
21
+ const PULSE_CYCLE_MS = DS_MOTION_LOOP_DURATION.pulse;
22
+
23
+ // A single module-level clock every Shimmer/Breathing instance reads from, instead of each starting
24
+ // its own timer at mount. Two independent per-instance timers would drift out of phase depending on
25
+ // the exact moment each mounted — sharing one clock means any shimmers visible together always pulse
26
+ // in lockstep. `useNativeDriver:false` is required because we interpolate `backgroundColor` (colour
27
+ // interpolation is JS-driven).
28
+ const sharedProgress = new Animated.Value(0);
29
+ let sharedProgressStarted = false;
30
+ function ensureSharedProgressStarted(): void {
31
+ if (sharedProgressStarted) return;
32
+ sharedProgressStarted = true;
33
+ Animated.loop(
34
+ Animated.timing(sharedProgress, {
35
+ toValue: 1,
36
+ duration: PULSE_CYCLE_MS,
37
+ easing: Easing.linear,
38
+ useNativeDriver: false,
39
+ }),
40
+ ).start();
41
+ }
42
+
43
+ // ─── Shared animated primitive ────────────────────────────────────────────────
44
+ // A solid block that breathes between `low` and `high` on the shared progress value. Exported so any
45
+ // composite skeleton can reuse the same pulse (and stay in lockstep with plain Shimmers).
46
+ export function Breathing({
47
+ phase = 0,
48
+ low = PULSE_LOW,
49
+ high = PULSE_HIGH,
50
+ style,
51
+ accessibilityRole,
52
+ accessibilityLabel,
53
+ }: {
54
+ phase?: number;
55
+ /** Override the pulse's low/high colours. Defaults to the shared translucent pulse. */
56
+ low?: string;
57
+ high?: string;
58
+ style?: StyleProp<ViewStyle>;
59
+ accessibilityRole?: 'progressbar';
60
+ accessibilityLabel?: string;
61
+ }) {
62
+ useEffect(() => {
63
+ ensureSharedProgressStarted();
64
+ }, []);
65
+
66
+ // (progress + phase) % 1, then a triangle wave: high at 0, low at 0.5, high at 1 — equivalent to the
67
+ // cosine breathe, driven off the one shared linear clock so all instances stay phase-locked.
68
+ const backgroundColor = useMemo(
69
+ () =>
70
+ Animated.modulo(Animated.add(sharedProgress, phase), 1).interpolate({
71
+ inputRange: [0, 0.5, 1],
72
+ outputRange: [high, low, high],
73
+ }),
74
+ [phase, low, high],
75
+ );
76
+
77
+ return (
78
+ <Animated.View
79
+ style={[style, { backgroundColor }]}
80
+ accessibilityRole={accessibilityRole}
81
+ accessibilityLabel={accessibilityLabel}
82
+ />
83
+ );
84
+ }
85
+
86
+ function dimensionsForVariant(
87
+ variant: ShimmerVariant,
88
+ width: ShimmerProps['width'],
89
+ height: ShimmerProps['height'],
90
+ size: number,
91
+ ): ViewStyle {
92
+ switch (variant) {
93
+ case 'circle':
94
+ return { width: size, height: size, borderRadius: DS_RADIUS.round };
95
+ case 'container':
96
+ return { width: width ?? '100%', height: height ?? 80, borderRadius: DS_RADIUS.medium };
97
+ case 'text':
98
+ default:
99
+ return { width: width ?? '100%', height: height ?? SHIMMER_TEXT_HEIGHT, borderRadius: DS_RADIUS.small };
100
+ }
101
+ }
102
+
103
+ // ─── Shimmer ──────────────────────────────────────────────────────────────────
104
+ /**
105
+ * A single loading placeholder — see `ShimmerProps.variant` for what each shape stands in for.
106
+ * A real composite skeleton (e.g. a list row: avatar + a two-line label) is built by stacking
107
+ * multiple Shimmers of the right variant/count/dimensions, not by inventing one shape to cover
108
+ * everything — see the "Circle + text" example in the catalog for exactly this composition.
109
+ */
110
+ export function Shimmer({ variant = 'text', width, height, size = 40, style }: ShimmerProps) {
111
+ // Inside a <SkeletonGroup>, the group owns the single "Loading" announcement — each block stays
112
+ // silent so a composite skeleton (avatar + two text lines = 3 blocks) doesn't make a screen
113
+ // reader read "Loading, Loading, Loading". A standalone Shimmer still announces on its own.
114
+ const insideGroup = useContext(SkeletonGroupContext);
115
+ return (
116
+ <Breathing
117
+ style={[dimensionsForVariant(variant, width, height, size), style]}
118
+ accessibilityRole={insideGroup ? undefined : 'progressbar'}
119
+ accessibilityLabel={insideGroup ? undefined : 'Loading'}
120
+ />
121
+ );
122
+ }
123
+
124
+ // ─── SkeletonGroup ──────────────────────────────────────────────────────────────
125
+ /**
126
+ * Wraps a composite skeleton (several stacked Shimmers standing in for one real element — e.g. a
127
+ * list row's avatar + two text lines) so assistive tech announces it as ONE "Loading" region
128
+ * instead of once per block. Provides {@link SkeletonGroupContext} so every descendant Shimmer drops
129
+ * its own announcement; the group itself carries the single `progressbar`/busy role. Pass your own
130
+ * layout via `style` (the group is a plain View, so `flexDirection`/`gap` land where you'd expect) —
131
+ * it adds no layout of its own.
132
+ */
133
+ export function SkeletonGroup({
134
+ label = 'Loading',
135
+ style,
136
+ children,
137
+ }: {
138
+ /** Accessible name for the whole loading region. @default 'Loading' */
139
+ label?: string;
140
+ /** Layout for the group (flexDirection, gap, …) — it renders a plain View, so styles land where
141
+ * you'd expect. */
142
+ style?: StyleProp<ViewStyle>;
143
+ /** The Shimmers making up the skeleton. */
144
+ children: ReactNode;
145
+ }) {
146
+ return (
147
+ <SkeletonGroupContext.Provider value>
148
+ <View
149
+ style={style}
150
+ accessibilityRole="progressbar"
151
+ accessibilityLabel={label}
152
+ accessibilityState={{ busy: true }}
153
+ >
154
+ {children}
155
+ </View>
156
+ </SkeletonGroupContext.Provider>
157
+ );
158
+ }
@@ -0,0 +1,27 @@
1
+ import type { StyleProp, ViewStyle } from 'react-native';
2
+
3
+ export type ShimmerVariant = 'text' | 'container' | 'circle';
4
+
5
+ export interface ShimmerProps {
6
+ /** Which shape this placeholder stands in for:
7
+ * - `'text'` (default) — **one line of text**. A real element with 3 lines of text gets 3
8
+ * stacked `text` Shimmers, not one tall one — a skeleton should mirror the real line count.
9
+ * When stacking 2+ lines, give the last one ~70% of the others' width — real text rarely fills
10
+ * every line edge-to-edge, and a shorter final line reads as "this is where the paragraph
11
+ * naturally ends," not a loading glitch.
12
+ * - `'circle'` — always a circular element (an Avatar, an icon-only Pill/Button, a status dot) —
13
+ * never used for anything that isn't actually round in its loaded state.
14
+ * - `'container'` — everything else: a Card, a Banner, an image, any non-text/non-circular UI
15
+ * element. Its `width`/`height` should match the real element it's replacing (a Card skeleton
16
+ * should be Card-shaped, not an arbitrary block), so the skeleton reserves the same layout
17
+ * space the loaded content will actually occupy — no jump when it swaps in. */
18
+ variant?: ShimmerVariant;
19
+ /** Width (text and container). Defaults to 100%. */
20
+ width?: number | `${number}%`;
21
+ /** Height (text and container). Defaults: text 16, container 80 — override to match the real
22
+ * element this stands in for. */
23
+ height?: number;
24
+ /** Diameter for `circle` variant. Default 40. */
25
+ size?: number;
26
+ style?: StyleProp<ViewStyle>;
27
+ }
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { View, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { Shimmer, SkeletonGroup } from './Shimmer';
5
+ import { DS_SPACING } from '../../tokens';
6
+
7
+ const styles = StyleSheet.create({
8
+ shimmerLines: { flex: 1, gap: DS_SPACING[400] },
9
+ previewRow: { flexDirection: 'row', alignItems: 'center', gap: DS_SPACING[600] },
10
+ });
11
+
12
+ export default defineCatalogPage({
13
+ component: 'SkeletonGroup',
14
+ group: 'Sub-Parts',
15
+ description: 'An accessibility wrapper for a composite skeleton — several Shimmers standing in for one real element (e.g. a list row: an avatar + a two-line label). It announces the whole thing as one "Loading" region and silences the individual blocks, so a screen reader says "Loading" once instead of once per Shimmer. Adds no layout of its own — pass your own flexDirection/gap via style.',
16
+ whenToUse: 'Wrap 2+ Shimmers that together stand in for one element. A single lone Shimmer already announces on its own and needs no wrapper.',
17
+ a11y: 'Carries accessibilityRole="progressbar" + accessibilityLabel (default "Loading") + accessibilityState={{ busy: true }} for the whole region; provides a context that makes every descendant Shimmer drop its own announcement. Net effect: one "Loading" announcement per skeleton, not one per block.',
18
+ // No visual variants/states — it's a transparent a11y wrapper. The one example shows the
19
+ // composite it wraps; Props + Accessibility document its contract.
20
+ hide: { states: true },
21
+ variants: {
22
+ itemsFill: true,
23
+ items: [
24
+ {
25
+ key: 'composite',
26
+ name: 'Wrapping a composite skeleton',
27
+ node: (
28
+ <SkeletonGroup style={styles.previewRow}>
29
+ <Shimmer variant="circle" size={40} />
30
+ <View style={styles.shimmerLines}>
31
+ <Shimmer variant="text" width={160} />
32
+ <Shimmer variant="text" width={112} />
33
+ </View>
34
+ </SkeletonGroup>
35
+ ),
36
+ },
37
+ ],
38
+ },
39
+ });
@@ -0,0 +1,2 @@
1
+ export { Shimmer, SkeletonGroup, Breathing, SHIMMER_TEXT_HEIGHT } from './Shimmer';
2
+ export type { ShimmerProps, ShimmerVariant } from './Shimmer.types';
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { Text, StyleSheet } from 'react-native';
3
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
4
+ import { Surface } from './Surface';
5
+ import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
6
+
7
+ const styles = StyleSheet.create({
8
+ surfaceDemo: { height: 96, justifyContent: 'center', alignItems: 'center', padding: DS_SPACING[800] },
9
+ surfaceText: { ...DS_TYPOGRAPHY.bodyMd, color: DS_SEMANTIC.text.regular },
10
+ });
11
+
12
+ export default defineCatalogPage({
13
+ component: 'Surface',
14
+ group: 'Surfaces',
15
+ description:
16
+ "Declares \"this subtree's background is tone\" — renders a plain View filled with the matching token (surface.white or surface.main) and provides that tone to descendants via context, so a tone-aware component inside (e.g. FieldContainer/InputField) can automatically pick a fill that contrasts with it instead of assuming white.",
17
+ whenToUse:
18
+ "Wrap a screen (or any region) wherever its background isn't the default white — a page with a muted/grey background, for instance — so anything inside that needs to contrast with its background picks the right fill automatically.",
19
+ variants: {
20
+ itemsFill: true,
21
+ items: [
22
+ {
23
+ key: 'white',
24
+ name: 'White',
25
+ props: { tone: 'white' },
26
+ node: (
27
+ <Surface tone="white" style={styles.surfaceDemo}>
28
+ <Text style={styles.surfaceText}>surface.white</Text>
29
+ </Surface>
30
+ ),
31
+ },
32
+ {
33
+ key: 'muted',
34
+ name: 'Muted',
35
+ props: { tone: 'muted' },
36
+ node: (
37
+ <Surface tone="muted" style={styles.surfaceDemo}>
38
+ <Text style={styles.surfaceText}>surface.main</Text>
39
+ </Surface>
40
+ ),
41
+ },
42
+ ],
43
+ },
44
+ });
@@ -0,0 +1,45 @@
1
+ import React, { createContext, useContext, type ReactNode } from 'react';
2
+ import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC } from '../../tokens';
4
+
5
+ export type SurfaceTone = 'white' | 'muted';
6
+
7
+ // Defaults to 'white' — a screen with no explicit <Surface> ancestor is assumed to sit on a white
8
+ // background, the common case (matches e.g. Card's own default fill).
9
+ const SurfaceToneContext = createContext<SurfaceTone>('white');
10
+
11
+ /** What background tone the nearest ancestor `<Surface>` declared (or 'white' if there isn't one).
12
+ * Components that need to contrast with whatever's actually behind them — FieldContainer/
13
+ * InputField today — read this instead of assuming one fixed background. */
14
+ export function useSurfaceTone(): SurfaceTone {
15
+ return useContext(SurfaceToneContext);
16
+ }
17
+
18
+ export interface SurfaceProps {
19
+ /** Which background this surface renders. Descendants read this via `useSurfaceTone()` to
20
+ * automatically pick a fill that contrasts with it (e.g. a field goes white on a muted page,
21
+ * recessed on a white one) instead of needing to be told individually. */
22
+ tone: SurfaceTone;
23
+ style?: StyleProp<ViewStyle>;
24
+ children?: ReactNode;
25
+ }
26
+
27
+ /**
28
+ * Declares "this subtree's background is `tone`" — renders a plain View filled with the matching
29
+ * token (`surface.white` or `surface.main`) and provides that tone to descendants via context.
30
+ * Wrap a screen (or any region) in this wherever its background isn't the default white — a page
31
+ * with a muted/grey background, for instance — so anything inside that needs to contrast with its
32
+ * background picks the right fill automatically rather than assuming white.
33
+ */
34
+ export function Surface({ tone, style, children }: SurfaceProps) {
35
+ return (
36
+ <SurfaceToneContext.Provider value={tone}>
37
+ <View style={[tone === 'white' ? styles.white : styles.muted, style]}>{children}</View>
38
+ </SurfaceToneContext.Provider>
39
+ );
40
+ }
41
+
42
+ const styles = StyleSheet.create({
43
+ white: { backgroundColor: DS_SEMANTIC.surface.white },
44
+ muted: { backgroundColor: DS_SEMANTIC.surface.main },
45
+ });
@@ -0,0 +1,2 @@
1
+ export { Surface, useSurfaceTone } from './Surface';
2
+ export type { SurfaceProps, SurfaceTone } from './Surface';