@krapwoo/ds-viewer 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +182 -78
  2. package/dist/cli/agentsFile.js +31 -0
  3. package/dist/cli/config.js +26 -1
  4. package/dist/cli/dev.js +50 -5
  5. package/dist/cli/doctor.js +492 -0
  6. package/dist/cli/explain.js +151 -0
  7. package/dist/cli/githubAction.js +68 -0
  8. package/dist/cli/glob.js +8 -0
  9. package/dist/cli/init.js +152 -18
  10. package/dist/cli/main.js +115 -4
  11. package/dist/cli/pageIndex.js +27 -1
  12. package/dist/cli/props.js +170 -21
  13. package/dist/cli/staticPage.js +356 -0
  14. package/dist/cli/sync.js +28 -5
  15. package/dist/cli/workspace.js +49 -21
  16. package/dist/native/catalog/comparison.js +283 -0
  17. package/dist/native/catalog/tokens.js +119 -0
  18. package/dist/native/catalog/types.js +1 -0
  19. package/native/catalog/CatalogShell.tsx +6 -1
  20. package/native/catalog/CatalogSidebar.tsx +98 -5
  21. package/native/catalog/ComparisonGrid.tsx +9 -7
  22. package/native/catalog/SpacingScaleGallery.tsx +1 -1
  23. package/native/catalog/TokenRow.tsx +3 -2
  24. package/native/catalog/catalogNavigation.ts +1 -1
  25. package/native/catalog/comparison.ts +63 -5
  26. package/native/catalog/index.ts +8 -6
  27. package/native/catalog/logoLayout.ts +12 -0
  28. package/native/catalog/pageApi.ts +21 -3
  29. package/native/catalog/tokens.ts +5 -0
  30. package/native/catalog/types.ts +9 -6
  31. package/package.json +12 -6
  32. package/starter-kit/WHEN_TO_USE.md +124 -0
  33. package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
  34. package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
  35. package/starter-kit/components/AnimatedChevron/index.ts +2 -0
  36. package/starter-kit/components/Avatar/Avatar.catalog.tsx +54 -0
  37. package/starter-kit/components/Avatar/Avatar.tsx +74 -0
  38. package/starter-kit/components/Avatar/index.ts +2 -0
  39. package/starter-kit/components/Badge/Badge.catalog.tsx +88 -0
  40. package/starter-kit/components/Badge/Badge.tsx +56 -0
  41. package/starter-kit/components/Badge/Badge.types.ts +18 -0
  42. package/starter-kit/components/Badge/index.ts +2 -0
  43. package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
  44. package/starter-kit/components/Banner/Banner.tsx +257 -0
  45. package/starter-kit/components/Banner/Banner.types.ts +38 -0
  46. package/starter-kit/components/Banner/index.ts +2 -0
  47. package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
  48. package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
  49. package/starter-kit/components/BottomSheet/index.ts +2 -0
  50. package/starter-kit/components/Button/Button.catalog.tsx +99 -0
  51. package/starter-kit/components/Button/Button.tsx +173 -0
  52. package/starter-kit/components/Button/Button.types.ts +52 -0
  53. package/starter-kit/components/Button/index.ts +2 -0
  54. package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
  55. package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
  56. package/starter-kit/components/ButtonGroup/index.ts +2 -0
  57. package/starter-kit/components/Card/Card.catalog.tsx +68 -0
  58. package/starter-kit/components/Card/Card.tsx +133 -0
  59. package/starter-kit/components/Card/index.ts +2 -0
  60. package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
  61. package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
  62. package/starter-kit/components/Checkbox/index.ts +2 -0
  63. package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
  64. package/starter-kit/components/Dialog/Dialog.tsx +82 -0
  65. package/starter-kit/components/Dialog/index.ts +2 -0
  66. package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
  67. package/starter-kit/components/Divider/Divider.tsx +20 -0
  68. package/starter-kit/components/Divider/index.ts +2 -0
  69. package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
  70. package/starter-kit/components/Dock/Dock.tsx +58 -0
  71. package/starter-kit/components/Dock/index.ts +2 -0
  72. package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
  73. package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
  74. package/starter-kit/components/Dropdown/index.ts +2 -0
  75. package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
  76. package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
  77. package/starter-kit/components/EmptyState/index.ts +2 -0
  78. package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
  79. package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
  80. package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
  81. package/starter-kit/components/FieldContainer/index.ts +2 -0
  82. package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
  83. package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
  84. package/starter-kit/components/InputClearButton/index.ts +2 -0
  85. package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
  86. package/starter-kit/components/InputField/InputField.tsx +418 -0
  87. package/starter-kit/components/InputField/index.ts +2 -0
  88. package/starter-kit/components/List/List.catalog.tsx +30 -0
  89. package/starter-kit/components/List/List.tsx +36 -0
  90. package/starter-kit/components/List/index.ts +2 -0
  91. package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
  92. package/starter-kit/components/ListItem/ListItem.tsx +165 -0
  93. package/starter-kit/components/ListItem/index.ts +2 -0
  94. package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
  95. package/starter-kit/components/Loading/Loading.tsx +121 -0
  96. package/starter-kit/components/Loading/index.ts +2 -0
  97. package/starter-kit/components/Pill/Pill.catalog.tsx +79 -0
  98. package/starter-kit/components/Pill/Pill.tsx +181 -0
  99. package/starter-kit/components/Pill/Pill.types.ts +30 -0
  100. package/starter-kit/components/Pill/index.ts +2 -0
  101. package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
  102. package/starter-kit/components/PillRow/PillRow.tsx +140 -0
  103. package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
  104. package/starter-kit/components/PillRow/index.ts +2 -0
  105. package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
  106. package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
  107. package/starter-kit/components/ProgressDots/index.ts +2 -0
  108. package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
  109. package/starter-kit/components/Radio/Radio.tsx +114 -0
  110. package/starter-kit/components/Radio/index.ts +2 -0
  111. package/starter-kit/components/Radio/usePressScale.ts +33 -0
  112. package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
  113. package/starter-kit/components/SearchField/SearchField.tsx +98 -0
  114. package/starter-kit/components/SearchField/index.ts +2 -0
  115. package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
  116. package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
  117. package/starter-kit/components/SectionHeader/index.ts +2 -0
  118. package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
  119. package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
  120. package/starter-kit/components/SegmentedToggle/index.ts +2 -0
  121. package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
  122. package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
  123. package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
  124. package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
  125. package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
  126. package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
  127. package/starter-kit/components/Shimmer/index.ts +2 -0
  128. package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
  129. package/starter-kit/components/Surface/Surface.tsx +45 -0
  130. package/starter-kit/components/Surface/index.ts +2 -0
  131. package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
  132. package/starter-kit/components/Switch/Switch.tsx +129 -0
  133. package/starter-kit/components/Switch/index.ts +2 -0
  134. package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
  135. package/starter-kit/components/TextArea/TextArea.tsx +99 -0
  136. package/starter-kit/components/TextArea/index.ts +2 -0
  137. package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
  138. package/starter-kit/components/Toast/Toast.tsx +144 -0
  139. package/starter-kit/components/Toast/index.ts +2 -0
  140. package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
  141. package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
  142. package/starter-kit/components/Tooltip/index.ts +2 -0
  143. package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
  144. package/starter-kit/components/TopNav/TopNav.tsx +77 -0
  145. package/starter-kit/components/TopNav/index.ts +2 -0
  146. package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
  147. package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
  148. package/starter-kit/components/UnderlineTabs/index.ts +2 -0
  149. package/starter-kit/components/index.ts +36 -0
  150. package/starter-kit/components/statusColors.ts +27 -0
  151. package/starter-kit/icons/Icon.native.tsx +90 -0
  152. package/starter-kit/icons/index.ts +10 -0
  153. package/starter-kit/icons/paths.ts +623 -0
  154. package/starter-kit/icons/types.ts +69 -0
  155. package/starter-kit/pages/Colors.catalog.tsx +55 -0
  156. package/starter-kit/pages/Font.catalog.tsx +45 -0
  157. package/starter-kit/pages/Icons.catalog.tsx +51 -0
  158. package/starter-kit/pages/Manifest.catalog.tsx +24 -0
  159. package/starter-kit/pages/Motion.catalog.tsx +101 -0
  160. package/starter-kit/pages/Radius.catalog.tsx +35 -0
  161. package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
  162. package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
  163. package/starter-kit/pages/Shadow.catalog.tsx +34 -0
  164. package/starter-kit/pages/Spacing.catalog.tsx +12 -0
  165. package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
  166. package/starter-kit/pages/Typography.catalog.tsx +17 -0
  167. package/starter-kit/tokens/index.ts +50 -0
  168. package/starter-kit/tokens/motion.ts +90 -0
  169. package/starter-kit/tokens/palette.ts +111 -0
  170. package/starter-kit/tokens/scales.ts +106 -0
  171. package/starter-kit/tokens/semantic.ts +98 -0
  172. package/starter-kit/tokens/shadow.ts +36 -0
  173. package/starter-kit/tokens/typography.ts +72 -0
@@ -0,0 +1,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';
@@ -0,0 +1,33 @@
1
+ import React, { useState } from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { Switch } from './Switch';
4
+
5
+ function SwitchDemo() {
6
+ const [value, setValue] = useState(true);
7
+ return <Switch value={value} onValueChange={setValue} accessibilityLabel="Notifications" />;
8
+ }
9
+
10
+ function SwitchLabelDemo() {
11
+ const [value, setValue] = useState(true);
12
+ return <Switch value={value} onValueChange={setValue} label="Notifications" />;
13
+ }
14
+
15
+ export default defineCatalogPage({
16
+ component: 'Switch',
17
+ group: 'Controls',
18
+ specimenSize: 'compact',
19
+ description: 'A boolean on/off toggle. The thumb slides and the track crossfades colour, sharing SegmentedToggle/UnderlineTabs\' own slide-animation hook for a consistent motion feel.',
20
+ whenToUse: 'A setting that takes effect immediately, no separate save step. For recording a fact a future action (like a form submit) will act on, use Checkbox; for one-of-many exclusive selection, use Radio.',
21
+ a11y: 'Renders a Pressable with accessibilityRole="switch" and accessibilityState.checked — pass accessibilityLabel to say what it controls.',
22
+ variants: {
23
+ items: [{ key: 'default', name: 'Default (tap to toggle)', node: <SwitchDemo /> }],
24
+ },
25
+ states: {
26
+ items: [
27
+ { key: 'off', name: 'Off', node: <Switch value={false} onValueChange={() => {}} accessibilityLabel="Notifications" /> },
28
+ { key: 'on', name: 'On', node: <Switch value={true} onValueChange={() => {}} accessibilityLabel="Notifications" /> },
29
+ { key: 'label', name: 'With label', props: { label: true }, node: <SwitchLabelDemo /> },
30
+ { key: 'disabled', name: 'Disabled', node: <Switch value={true} onValueChange={() => {}} disabled accessibilityLabel="Notifications" /> },
31
+ ],
32
+ },
33
+ });
@@ -0,0 +1,129 @@
1
+ import React, { useState } from 'react';
2
+ import { Pressable, Text, View, Animated, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_SHADOW, DS_TYPOGRAPHY, DS_A11Y_MIN_TOUCH_TARGET } from '../../tokens';
4
+ import { useSlideAnim } from '../SegmentedToggle/useSlideAnim';
5
+ import { usePressScale } from '../Radio/usePressScale';
6
+
7
+ export interface SwitchProps {
8
+ /** Whether the switch is on. */
9
+ value: boolean;
10
+ /** Called with the new value on tap. */
11
+ onValueChange: (value: boolean) => void;
12
+ /** Optional inline label rendered to the left of the track, at the same bodyMd size/weight
13
+ * Checkbox/Radio use for their own labels — tapping it toggles the switch too. */
14
+ label?: string;
15
+ /** Non-interactive, dimmed. */
16
+ disabled?: boolean;
17
+ /** Accessible name. Defaults to `label` when set — pass this separately only when the switch
18
+ * needs a different (or the only) spoken name, e.g. no visible label at all. */
19
+ accessibilityLabel?: string;
20
+ style?: StyleProp<ViewStyle>;
21
+ }
22
+
23
+ const TRACK_WIDTH = 51;
24
+ const TRACK_HEIGHT = 31;
25
+ // Sized so (TRACK_HEIGHT - THUMB_SIZE) / 2 lands on 4 — the same total inset SegmentedToggle's own
26
+ // thumb gets (its TRACK_PADDING + THUMB_INSET, both DS_SPACING[100]) — so the two sliding controls'
27
+ // thumbs read with the same amount of breathing room around them.
28
+ const THUMB_SIZE = 23;
29
+ // Horizontal inset for the thumb's off/on positions — matched to the vertical gap the track's own
30
+ // `justifyContent: 'center'` already gives it ((TRACK_HEIGHT - THUMB_SIZE) / 2 = 4), so the padding
31
+ // around the thumb reads as an even ring on every side, not tighter left/right than top/bottom.
32
+ const THUMB_INSET = (TRACK_HEIGHT - THUMB_SIZE) / 2;
33
+ // Pads the tappable area vertically out to the accessibility minimum — same reasoning as Button's
34
+ // own `iconOnlyHitSlop`. TRACK_WIDTH (51) already clears the minimum horizontally, so only the
35
+ // vertical sides need it.
36
+ const HIT_SLOP = {
37
+ top: Math.ceil((DS_A11Y_MIN_TOUCH_TARGET - TRACK_HEIGHT) / 2),
38
+ bottom: Math.ceil((DS_A11Y_MIN_TOUCH_TARGET - TRACK_HEIGHT) / 2),
39
+ };
40
+
41
+ /**
42
+ * A boolean on/off toggle. The thumb slides between its two resting positions and the track fades
43
+ * between its off/on colours together, driven by the same shared slide animation SegmentedToggle and
44
+ * UnderlineTabs use (JS-driven here, since the track colour crossfade needs it) for a consistent
45
+ * motion feel across the DS's sliding controls. The thumb also grows slightly while pressed, for
46
+ * tactile feedback before release. Kept on the same JS-driven pipeline as the slide/colour (not
47
+ * `useNativeDriver: true`) — mixing native- and JS-driven animations on the same Animated.View's
48
+ * transform is unreliable in RN, and this component's slide already has to be JS-driven anyway.
49
+ */
50
+ export function Switch({ value, onValueChange, label, disabled = false, accessibilityLabel, style }: SwitchProps) {
51
+ const anim = useSlideAnim(value ? 1 : 0, false);
52
+ const { pressScale, onPressIn, onPressOut } = usePressScale(disabled);
53
+ const [focused, setFocused] = useState(false);
54
+
55
+ const trackColor = anim.interpolate({
56
+ inputRange: [0, 1],
57
+ outputRange: [DS_SEMANTIC.surface.recessed, DS_SEMANTIC.emphasis.positive],
58
+ });
59
+ const thumbX = anim.interpolate({
60
+ inputRange: [0, 1],
61
+ outputRange: [THUMB_INSET, TRACK_WIDTH - THUMB_SIZE - THUMB_INSET],
62
+ });
63
+
64
+ return (
65
+ <Pressable
66
+ onPress={() => !disabled && onValueChange(!value)}
67
+ onPressIn={onPressIn}
68
+ onPressOut={onPressOut}
69
+ onFocus={() => setFocused(true)}
70
+ onBlur={() => setFocused(false)}
71
+ disabled={disabled}
72
+ accessibilityRole="switch"
73
+ accessibilityState={{ checked: value, disabled }}
74
+ accessibilityLabel={accessibilityLabel ?? label}
75
+ hitSlop={HIT_SLOP}
76
+ style={[styles.row, disabled && styles.disabled, style]}
77
+ >
78
+ {/* Label sits before the track (unlike Checkbox/Radio's control-then-label) — matches the
79
+ near-universal settings-row convention (description left, toggle right), while still
80
+ sharing their bodyMd label typography. */}
81
+ {!!label && <Text style={styles.label}>{label}</Text>}
82
+ <Animated.View style={[styles.track, { backgroundColor: trackColor }]}>
83
+ <Animated.View style={[styles.thumb, { transform: [{ translateX: thumbX }, { scale: pressScale }] }]} />
84
+ {/* Keyboard-focus ring — same interaction.focused treatment Button/Pill use; a zero-layout
85
+ absolute overlay so focusing never shifts layout. */}
86
+ {focused && !disabled && <View pointerEvents="none" style={styles.focusRing} />}
87
+ </Animated.View>
88
+ </Pressable>
89
+ );
90
+ }
91
+
92
+ const styles = StyleSheet.create({
93
+ row: {
94
+ flexDirection: 'row',
95
+ alignItems: 'center',
96
+ gap: DS_SPACING[400],
97
+ },
98
+ label: {
99
+ ...DS_TYPOGRAPHY.bodyMd,
100
+ color: DS_SEMANTIC.text.regular,
101
+ },
102
+ track: {
103
+ width: TRACK_WIDTH,
104
+ height: TRACK_HEIGHT,
105
+ borderRadius: DS_RADIUS.round,
106
+ justifyContent: 'center',
107
+ },
108
+ thumb: {
109
+ width: THUMB_SIZE,
110
+ height: THUMB_SIZE,
111
+ borderRadius: DS_RADIUS.round,
112
+ backgroundColor: DS_SEMANTIC.surface.white,
113
+ ...DS_SHADOW.resting,
114
+ },
115
+ disabled: {
116
+ opacity: DS_SEMANTIC.interaction.disabledOpacity,
117
+ },
118
+ // 2px air + 2px ring outside the track; fully round to stay concentric.
119
+ focusRing: {
120
+ position: 'absolute',
121
+ top: -4,
122
+ bottom: -4,
123
+ left: -4,
124
+ right: -4,
125
+ borderWidth: 2,
126
+ borderColor: DS_SEMANTIC.interaction.focused,
127
+ borderRadius: DS_RADIUS.round,
128
+ },
129
+ });
@@ -0,0 +1,2 @@
1
+ export { Switch } from './Switch';
2
+ export type { SwitchProps } from './Switch';
@@ -0,0 +1,29 @@
1
+ import React, { useState } from 'react';
2
+ import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
+ import { TextArea } from './TextArea';
4
+
5
+ function TextAreaDemo() {
6
+ const [value, setValue] = useState('');
7
+ return <TextArea value={value} onChangeText={setValue} placeholder="Describe the issue…" />;
8
+ }
9
+
10
+ export default defineCatalogPage({
11
+ component: 'TextArea',
12
+ group: 'Inputs',
13
+ description: 'A multi-line input that grows with its content from a minimum height.',
14
+ a11y: 'A multiline TextInput; pass accessibilityLabel via inputProps when there is no visible label beside it.',
15
+ variants: {
16
+ itemsFill: true,
17
+ items: [{ key: 'default', name: 'Default', node: <TextAreaDemo /> }],
18
+ },
19
+ states: {
20
+ itemsFill: true,
21
+ items: [
22
+ {
23
+ key: 'disabled',
24
+ name: 'Disabled',
25
+ node: <TextArea value="This report has already been submitted." onChangeText={() => {}} editable={false} />,
26
+ },
27
+ ],
28
+ },
29
+ });
@@ -0,0 +1,99 @@
1
+ import React, { useCallback, useState } from 'react';
2
+ import {
3
+ TextInput,
4
+ StyleSheet,
5
+ type StyleProp,
6
+ type ViewStyle,
7
+ type TextInputProps,
8
+ } from 'react-native';
9
+ import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
10
+ import { FieldContainer } from '../FieldContainer';
11
+
12
+ export interface TextAreaProps {
13
+ /** Current text. */
14
+ value: string;
15
+ /** Change handler. */
16
+ onChangeText: (text: string) => void;
17
+ /** Shown when empty. */
18
+ placeholder?: string;
19
+ /** Min height of the field before it grows with content (default 96). */
20
+ minHeight?: number;
21
+ editable?: boolean;
22
+ autoFocus?: boolean;
23
+ inputRef?: React.Ref<TextInput>;
24
+ style?: StyleProp<ViewStyle>;
25
+ /** Extra TextInput props (e.g. maxLength, accessibilityLabel, onFocus). */
26
+ inputProps?: Omit<
27
+ TextInputProps,
28
+ 'value' | 'onChangeText' | 'placeholder' | 'editable' | 'multiline' | 'autoFocus'
29
+ >;
30
+ }
31
+
32
+ /**
33
+ * A multiline free-text field — built on {@link FieldContainer} (white surface, medium radius, border
34
+ * that darkens on focus) with top-aligned text that grows from `minHeight`. For single-line labelled
35
+ * rows use {@link InputField} — both share the same FieldContainer chrome.
36
+ */
37
+ export function TextArea({
38
+ value,
39
+ onChangeText,
40
+ placeholder,
41
+ minHeight = 96,
42
+ editable = true,
43
+ autoFocus = false,
44
+ inputRef,
45
+ style,
46
+ inputProps,
47
+ }: TextAreaProps) {
48
+ const [focused, setFocused] = useState(false);
49
+
50
+ const handleFocus = useCallback(
51
+ (e: Parameters<NonNullable<TextInputProps['onFocus']>>[0]) => {
52
+ setFocused(true);
53
+ inputProps?.onFocus?.(e);
54
+ },
55
+ [inputProps],
56
+ );
57
+ const handleBlur = useCallback(
58
+ (e: Parameters<NonNullable<TextInputProps['onBlur']>>[0]) => {
59
+ setFocused(false);
60
+ inputProps?.onBlur?.(e);
61
+ },
62
+ [inputProps],
63
+ );
64
+
65
+ return (
66
+ <FieldContainer focused={focused} disabled={!editable} style={style}>
67
+ <TextInput
68
+ {...inputProps}
69
+ ref={inputRef}
70
+ style={[styles.input, { minHeight }, !editable && styles.inputDisabled]}
71
+ value={value}
72
+ onChangeText={onChangeText}
73
+ placeholder={placeholder}
74
+ placeholderTextColor={editable ? DS_SEMANTIC.text.muted : DS_SEMANTIC.text.disabled}
75
+ editable={editable}
76
+ autoFocus={autoFocus}
77
+ onFocus={handleFocus}
78
+ onBlur={handleBlur}
79
+ multiline
80
+ textAlignVertical="top"
81
+ />
82
+ </FieldContainer>
83
+ );
84
+ }
85
+
86
+ const styles = StyleSheet.create({
87
+ // The chrome (surface/border/radius) lives on FieldContainer; the input owns the text style + padding
88
+ // so a tap anywhere over the padded area still focuses it.
89
+ input: {
90
+ ...DS_TYPOGRAPHY.bodySm,
91
+ color: DS_SEMANTIC.text.regular,
92
+ padding: DS_SPACING[400],
93
+ },
94
+ // A step lighter than the hint tone so a non-editable area reads as inactive, not just filled —
95
+ // same convention as InputField/SearchField (see DS_SEMANTIC.text.disabled's own doc comment).
96
+ inputDisabled: {
97
+ color: DS_SEMANTIC.text.disabled,
98
+ },
99
+ });
@@ -0,0 +1,2 @@
1
+ export { TextArea } from './TextArea';
2
+ export type { TextAreaProps } from './TextArea';