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