@krapwoo/ds-viewer 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -78
- package/dist/cli/config.js +26 -1
- package/dist/cli/dev.js +50 -5
- package/dist/cli/glob.js +8 -0
- package/dist/cli/init.js +148 -18
- package/dist/cli/main.js +66 -4
- package/dist/cli/props.js +166 -18
- package/dist/cli/sync.js +17 -1
- package/dist/cli/workspace.js +49 -21
- package/native/catalog/CatalogShell.tsx +6 -1
- package/native/catalog/CatalogSidebar.tsx +98 -5
- package/native/catalog/TokenRow.tsx +3 -2
- package/native/catalog/index.ts +6 -5
- package/native/catalog/logoLayout.ts +12 -0
- package/native/catalog/pageApi.ts +10 -1
- package/native/catalog/tokens.ts +5 -0
- package/package.json +8 -6
- package/starter-kit/WHEN_TO_USE.md +124 -0
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.catalog.tsx +23 -0
- package/starter-kit/components/AnimatedChevron/AnimatedChevron.tsx +46 -0
- package/starter-kit/components/AnimatedChevron/index.ts +2 -0
- package/starter-kit/components/Avatar/Avatar.catalog.tsx +72 -0
- package/starter-kit/components/Avatar/Avatar.tsx +74 -0
- package/starter-kit/components/Avatar/index.ts +2 -0
- package/starter-kit/components/Badge/Badge.catalog.tsx +96 -0
- package/starter-kit/components/Badge/Badge.tsx +56 -0
- package/starter-kit/components/Badge/Badge.types.ts +18 -0
- package/starter-kit/components/Badge/index.ts +2 -0
- package/starter-kit/components/Banner/Banner.catalog.tsx +87 -0
- package/starter-kit/components/Banner/Banner.tsx +257 -0
- package/starter-kit/components/Banner/Banner.types.ts +38 -0
- package/starter-kit/components/Banner/index.ts +2 -0
- package/starter-kit/components/BottomSheet/BottomSheet.catalog.tsx +77 -0
- package/starter-kit/components/BottomSheet/BottomSheet.tsx +151 -0
- package/starter-kit/components/BottomSheet/index.ts +2 -0
- package/starter-kit/components/Button/Button.catalog.tsx +113 -0
- package/starter-kit/components/Button/Button.tsx +173 -0
- package/starter-kit/components/Button/Button.types.ts +52 -0
- package/starter-kit/components/Button/index.ts +2 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.catalog.tsx +39 -0
- package/starter-kit/components/ButtonGroup/ButtonGroup.tsx +84 -0
- package/starter-kit/components/ButtonGroup/index.ts +2 -0
- package/starter-kit/components/Card/Card.catalog.tsx +68 -0
- package/starter-kit/components/Card/Card.tsx +133 -0
- package/starter-kit/components/Card/index.ts +2 -0
- package/starter-kit/components/Checkbox/Checkbox.catalog.tsx +28 -0
- package/starter-kit/components/Checkbox/Checkbox.tsx +104 -0
- package/starter-kit/components/Checkbox/index.ts +2 -0
- package/starter-kit/components/Dialog/Dialog.catalog.tsx +41 -0
- package/starter-kit/components/Dialog/Dialog.tsx +82 -0
- package/starter-kit/components/Dialog/index.ts +2 -0
- package/starter-kit/components/Divider/Divider.catalog.tsx +35 -0
- package/starter-kit/components/Divider/Divider.tsx +20 -0
- package/starter-kit/components/Divider/index.ts +2 -0
- package/starter-kit/components/Dock/Dock.catalog.tsx +71 -0
- package/starter-kit/components/Dock/Dock.tsx +58 -0
- package/starter-kit/components/Dock/index.ts +2 -0
- package/starter-kit/components/Dropdown/Dropdown.catalog.tsx +69 -0
- package/starter-kit/components/Dropdown/Dropdown.tsx +162 -0
- package/starter-kit/components/Dropdown/index.ts +2 -0
- package/starter-kit/components/EmptyState/EmptyState.catalog.tsx +67 -0
- package/starter-kit/components/EmptyState/EmptyState.tsx +88 -0
- package/starter-kit/components/EmptyState/index.ts +2 -0
- package/starter-kit/components/FieldContainer/FieldContainer.catalog.tsx +76 -0
- package/starter-kit/components/FieldContainer/FieldContainer.tsx +99 -0
- package/starter-kit/components/FieldContainer/FieldContainer.types.ts +27 -0
- package/starter-kit/components/FieldContainer/index.ts +2 -0
- package/starter-kit/components/InputClearButton/InputClearButton.catalog.tsx +18 -0
- package/starter-kit/components/InputClearButton/InputClearButton.tsx +63 -0
- package/starter-kit/components/InputClearButton/index.ts +2 -0
- package/starter-kit/components/InputField/InputField.catalog.tsx +76 -0
- package/starter-kit/components/InputField/InputField.tsx +418 -0
- package/starter-kit/components/InputField/index.ts +2 -0
- package/starter-kit/components/List/List.catalog.tsx +30 -0
- package/starter-kit/components/List/List.tsx +36 -0
- package/starter-kit/components/List/index.ts +2 -0
- package/starter-kit/components/ListItem/ListItem.catalog.tsx +89 -0
- package/starter-kit/components/ListItem/ListItem.tsx +165 -0
- package/starter-kit/components/ListItem/index.ts +2 -0
- package/starter-kit/components/Loading/Loading.catalog.tsx +34 -0
- package/starter-kit/components/Loading/Loading.tsx +121 -0
- package/starter-kit/components/Loading/index.ts +2 -0
- package/starter-kit/components/Pill/Pill.catalog.tsx +93 -0
- package/starter-kit/components/Pill/Pill.tsx +181 -0
- package/starter-kit/components/Pill/Pill.types.ts +30 -0
- package/starter-kit/components/Pill/index.ts +2 -0
- package/starter-kit/components/PillRow/PillRow.catalog.tsx +91 -0
- package/starter-kit/components/PillRow/PillRow.tsx +140 -0
- package/starter-kit/components/PillRow/PillRow.types.ts +24 -0
- package/starter-kit/components/PillRow/index.ts +2 -0
- package/starter-kit/components/ProgressDots/ProgressDots.catalog.tsx +22 -0
- package/starter-kit/components/ProgressDots/ProgressDots.tsx +42 -0
- package/starter-kit/components/ProgressDots/index.ts +2 -0
- package/starter-kit/components/Radio/Radio.catalog.tsx +38 -0
- package/starter-kit/components/Radio/Radio.tsx +114 -0
- package/starter-kit/components/Radio/index.ts +2 -0
- package/starter-kit/components/Radio/usePressScale.ts +33 -0
- package/starter-kit/components/SearchField/SearchField.catalog.tsx +28 -0
- package/starter-kit/components/SearchField/SearchField.tsx +98 -0
- package/starter-kit/components/SearchField/index.ts +2 -0
- package/starter-kit/components/SectionHeader/SectionHeader.catalog.tsx +72 -0
- package/starter-kit/components/SectionHeader/SectionHeader.tsx +139 -0
- package/starter-kit/components/SectionHeader/index.ts +2 -0
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.catalog.tsx +31 -0
- package/starter-kit/components/SegmentedToggle/SegmentedToggle.tsx +163 -0
- package/starter-kit/components/SegmentedToggle/index.ts +2 -0
- package/starter-kit/components/SegmentedToggle/useSlideAnim.ts +29 -0
- package/starter-kit/components/Shimmer/Breathing.catalog.tsx +29 -0
- package/starter-kit/components/Shimmer/Shimmer.catalog.tsx +89 -0
- package/starter-kit/components/Shimmer/Shimmer.tsx +158 -0
- package/starter-kit/components/Shimmer/Shimmer.types.ts +27 -0
- package/starter-kit/components/Shimmer/SkeletonGroup.catalog.tsx +39 -0
- package/starter-kit/components/Shimmer/index.ts +2 -0
- package/starter-kit/components/Surface/Surface.catalog.tsx +44 -0
- package/starter-kit/components/Surface/Surface.tsx +45 -0
- package/starter-kit/components/Surface/index.ts +2 -0
- package/starter-kit/components/Switch/Switch.catalog.tsx +33 -0
- package/starter-kit/components/Switch/Switch.tsx +129 -0
- package/starter-kit/components/Switch/index.ts +2 -0
- package/starter-kit/components/TextArea/TextArea.catalog.tsx +29 -0
- package/starter-kit/components/TextArea/TextArea.tsx +99 -0
- package/starter-kit/components/TextArea/index.ts +2 -0
- package/starter-kit/components/Toast/Toast.catalog.tsx +61 -0
- package/starter-kit/components/Toast/Toast.tsx +144 -0
- package/starter-kit/components/Toast/index.ts +2 -0
- package/starter-kit/components/Tooltip/Tooltip.catalog.tsx +121 -0
- package/starter-kit/components/Tooltip/Tooltip.tsx +195 -0
- package/starter-kit/components/Tooltip/index.ts +2 -0
- package/starter-kit/components/TopNav/TopNav.catalog.tsx +71 -0
- package/starter-kit/components/TopNav/TopNav.tsx +77 -0
- package/starter-kit/components/TopNav/index.ts +2 -0
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.catalog.tsx +30 -0
- package/starter-kit/components/UnderlineTabs/UnderlineTabs.tsx +156 -0
- package/starter-kit/components/UnderlineTabs/index.ts +2 -0
- package/starter-kit/components/index.ts +36 -0
- package/starter-kit/components/statusColors.ts +27 -0
- package/starter-kit/icons/Icon.native.tsx +90 -0
- package/starter-kit/icons/index.ts +10 -0
- package/starter-kit/icons/paths.ts +623 -0
- package/starter-kit/icons/types.ts +69 -0
- package/starter-kit/pages/Colors.catalog.tsx +55 -0
- package/starter-kit/pages/Font.catalog.tsx +45 -0
- package/starter-kit/pages/Icons.catalog.tsx +51 -0
- package/starter-kit/pages/Manifest.catalog.tsx +24 -0
- package/starter-kit/pages/Motion.catalog.tsx +101 -0
- package/starter-kit/pages/Radius.catalog.tsx +35 -0
- package/starter-kit/pages/ReportIssue.catalog.tsx +148 -0
- package/starter-kit/pages/SavedTrips.catalog.tsx +247 -0
- package/starter-kit/pages/Shadow.catalog.tsx +34 -0
- package/starter-kit/pages/Spacing.catalog.tsx +12 -0
- package/starter-kit/pages/TripPlannerForm.catalog.tsx +48 -0
- package/starter-kit/pages/Typography.catalog.tsx +17 -0
- package/starter-kit/tokens/index.ts +50 -0
- package/starter-kit/tokens/motion.ts +90 -0
- package/starter-kit/tokens/palette.ts +111 -0
- package/starter-kit/tokens/scales.ts +106 -0
- package/starter-kit/tokens/semantic.ts +98 -0
- package/starter-kit/tokens/shadow.ts +36 -0
- package/starter-kit/tokens/typography.ts +72 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { SectionHeader } from './SectionHeader';
|
|
4
|
+
|
|
5
|
+
export default defineCatalogPage({
|
|
6
|
+
component: 'SectionHeader',
|
|
7
|
+
group: 'Layout',
|
|
8
|
+
description: 'An uppercase muted section label with an optional inline icon and a right-aligned ghost button.',
|
|
9
|
+
whenToUse: "A label above a group of related rows within a screen (e.g. above a List) — for the screen's own top bar, use TopNav.",
|
|
10
|
+
a11y: 'Title renders as plain Text — no heading role. A tappable labelIcon (onPress set) becomes accessibilityRole="button" with accessibilityLabel falling back to the section\'s own title; a non-interactive labelIcon has no accessibility node of its own. The trailing button is a real Button, so it carries Button\'s own accessibility for free.',
|
|
11
|
+
variants: {
|
|
12
|
+
itemsFill: true,
|
|
13
|
+
items: [
|
|
14
|
+
{ key: 'title-only', name: 'Title only', node: <SectionHeader title="Nearby stations" /> },
|
|
15
|
+
{
|
|
16
|
+
key: 'with-icon',
|
|
17
|
+
name: 'With icon',
|
|
18
|
+
node: <SectionHeader title="Trip details" labelIcon={{ name: 'info-circle', accessibilityLabel: 'About trip details' }} />,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
key: 'with-button',
|
|
22
|
+
name: 'With trailing button',
|
|
23
|
+
node: <SectionHeader title="Saved places" trailingButtonLabel="See all" onTrailingButtonPress={() => {}} />,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
states: {
|
|
28
|
+
itemsFill: true,
|
|
29
|
+
items: [
|
|
30
|
+
{
|
|
31
|
+
key: 'tappable-icon',
|
|
32
|
+
name: 'Tappable icon',
|
|
33
|
+
node: (
|
|
34
|
+
<SectionHeader
|
|
35
|
+
title="Delays"
|
|
36
|
+
labelIcon={{ name: 'info-circle', onPress: () => {}, accessibilityLabel: 'What causes delays' }}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: 'icon-and-button',
|
|
42
|
+
name: 'Icon + trailing button',
|
|
43
|
+
// This instance demonstrates the 'trailing' (default) icon position — tagged so
|
|
44
|
+
// checkCompleteness sees both enum values covered, not just the 'leading' one below.
|
|
45
|
+
props: { trailingButtonIconPosition: 'trailing' },
|
|
46
|
+
node: (
|
|
47
|
+
<SectionHeader
|
|
48
|
+
title="Alerts"
|
|
49
|
+
labelIcon={{ name: 'bell' }}
|
|
50
|
+
trailingButtonLabel="Manage"
|
|
51
|
+
trailingButtonIconName="chevron-right"
|
|
52
|
+
onTrailingButtonPress={() => {}}
|
|
53
|
+
/>
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: 'leading-icon-position',
|
|
58
|
+
name: 'Trailing button, leading icon',
|
|
59
|
+
props: { trailingButtonIconPosition: 'leading' },
|
|
60
|
+
node: (
|
|
61
|
+
<SectionHeader
|
|
62
|
+
title="Saved places"
|
|
63
|
+
trailingButtonLabel="Add"
|
|
64
|
+
trailingButtonIconName="add"
|
|
65
|
+
trailingButtonIconPosition="leading"
|
|
66
|
+
onTrailingButtonPress={() => {}}
|
|
67
|
+
/>
|
|
68
|
+
),
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY, DS_FONT_WEIGHT, DS_RADIUS } from '../../tokens';
|
|
11
|
+
import { Icon } from '../../icons/Icon.native';
|
|
12
|
+
import type { IconName } from '../../icons';
|
|
13
|
+
import { Button } from '../Button';
|
|
14
|
+
|
|
15
|
+
// Bumped up from the global 2px default — at this icon's small 16px render size, a default-weight
|
|
16
|
+
// stroke reads noticeably thinner than the bold (semibold, uppercase) label text it sits beside;
|
|
17
|
+
// this brings its visual weight in line with that text, the same reasoning Checkbox's own
|
|
18
|
+
// thicker check icon uses for a small glyph that needs to read clearly next to bold UI.
|
|
19
|
+
const LABEL_ICON_STROKE_WIDTH = 2.5;
|
|
20
|
+
|
|
21
|
+
export interface SectionHeaderLabelIcon {
|
|
22
|
+
name: IconName;
|
|
23
|
+
/** Render size in points. Defaults to 16 — matching the trailing button's own small-size icon
|
|
24
|
+
* (Button's `small` SIZE_TOKENS) and the row's own 16px line-height, so the row reads at a
|
|
25
|
+
* consistent height whether the label icon or the trailing button is the one present. */
|
|
26
|
+
size?: number;
|
|
27
|
+
/** When provided the icon is wrapped in a Pressable; omit for a non-interactive decoration. */
|
|
28
|
+
onPress?: () => void;
|
|
29
|
+
accessibilityLabel?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SectionHeaderProps {
|
|
33
|
+
/** Section label text. */
|
|
34
|
+
title: string;
|
|
35
|
+
/**
|
|
36
|
+
* Icon shown right after the title text with a 4 px gap.
|
|
37
|
+
* Pass `onPress` to make it tappable (e.g. info); omit for a static decoration (e.g. pin).
|
|
38
|
+
*/
|
|
39
|
+
labelIcon?: SectionHeaderLabelIcon;
|
|
40
|
+
/** Label for a right-aligned ghost button. */
|
|
41
|
+
trailingButtonLabel?: string;
|
|
42
|
+
/** Icon to show on the trailing button. */
|
|
43
|
+
trailingButtonIconName?: IconName;
|
|
44
|
+
/** Whether the icon appears before or after the button label (default: 'trailing'). */
|
|
45
|
+
trailingButtonIconPosition?: 'leading' | 'trailing';
|
|
46
|
+
/** Called when the trailing button is pressed. */
|
|
47
|
+
onTrailingButtonPress?: () => void;
|
|
48
|
+
style?: StyleProp<ViewStyle>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An uppercase muted section label with an optional inline icon and a right-aligned ghost button. */
|
|
52
|
+
export function SectionHeader({
|
|
53
|
+
title,
|
|
54
|
+
labelIcon,
|
|
55
|
+
trailingButtonLabel,
|
|
56
|
+
trailingButtonIconName,
|
|
57
|
+
trailingButtonIconPosition = 'trailing',
|
|
58
|
+
onTrailingButtonPress,
|
|
59
|
+
style,
|
|
60
|
+
}: SectionHeaderProps) {
|
|
61
|
+
const [iconFocused, setIconFocused] = useState(false);
|
|
62
|
+
const iconEl = labelIcon ? (
|
|
63
|
+
labelIcon.onPress ? (
|
|
64
|
+
<Pressable
|
|
65
|
+
onPress={labelIcon.onPress}
|
|
66
|
+
onFocus={() => setIconFocused(true)}
|
|
67
|
+
onBlur={() => setIconFocused(false)}
|
|
68
|
+
// Pads the ~16px icon out to the 44pt touch-target minimum.
|
|
69
|
+
hitSlop={14}
|
|
70
|
+
accessibilityRole="button"
|
|
71
|
+
accessibilityLabel={labelIcon.accessibilityLabel ?? title}
|
|
72
|
+
// Keyboard focus shows the same highlight as a press (same policy as ListItem/SegmentedToggle).
|
|
73
|
+
style={({ pressed }) => [styles.labelIconPressable, (pressed || iconFocused) && styles.labelIconPressed]}
|
|
74
|
+
>
|
|
75
|
+
<Icon name={labelIcon.name} size={labelIcon.size ?? 16} color={DS_SEMANTIC.text.muted} strokeWidth={LABEL_ICON_STROKE_WIDTH} />
|
|
76
|
+
</Pressable>
|
|
77
|
+
) : (
|
|
78
|
+
<Icon name={labelIcon.name} size={labelIcon.size ?? 16} color={DS_SEMANTIC.text.muted} strokeWidth={LABEL_ICON_STROKE_WIDTH} />
|
|
79
|
+
)
|
|
80
|
+
) : null;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<View style={[styles.container, style]}>
|
|
84
|
+
<View style={styles.left}>
|
|
85
|
+
<View style={styles.titleGroup}>
|
|
86
|
+
<Text style={styles.title} numberOfLines={1}>
|
|
87
|
+
{title}
|
|
88
|
+
</Text>
|
|
89
|
+
{iconEl}
|
|
90
|
+
</View>
|
|
91
|
+
</View>
|
|
92
|
+
{trailingButtonLabel ? (
|
|
93
|
+
<Button
|
|
94
|
+
label={trailingButtonLabel}
|
|
95
|
+
variant="ghost"
|
|
96
|
+
size="small"
|
|
97
|
+
showIcon={!!trailingButtonIconName}
|
|
98
|
+
iconName={trailingButtonIconName}
|
|
99
|
+
iconPosition={trailingButtonIconPosition}
|
|
100
|
+
onPress={onTrailingButtonPress}
|
|
101
|
+
/>
|
|
102
|
+
) : null}
|
|
103
|
+
</View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const styles = StyleSheet.create({
|
|
108
|
+
container: {
|
|
109
|
+
flexDirection: 'row',
|
|
110
|
+
alignItems: 'center',
|
|
111
|
+
width: '100%',
|
|
112
|
+
},
|
|
113
|
+
left: {
|
|
114
|
+
flex: 1,
|
|
115
|
+
flexDirection: 'row',
|
|
116
|
+
alignItems: 'center',
|
|
117
|
+
},
|
|
118
|
+
titleGroup: {
|
|
119
|
+
flexDirection: 'row',
|
|
120
|
+
alignItems: 'center',
|
|
121
|
+
gap: DS_SPACING[200],
|
|
122
|
+
},
|
|
123
|
+
title: {
|
|
124
|
+
fontSize: DS_TYPOGRAPHY.labelXs.fontSize,
|
|
125
|
+
fontWeight: DS_FONT_WEIGHT.semibold,
|
|
126
|
+
color: DS_SEMANTIC.text.muted,
|
|
127
|
+
textTransform: 'uppercase',
|
|
128
|
+
// Match the tallest trailing control (16px small button icon / labelIcon) so the row height is
|
|
129
|
+
// constant whether or not a button/icon is present — otherwise toggling them changes the header
|
|
130
|
+
// height and shifts the content beneath it.
|
|
131
|
+
lineHeight: 16,
|
|
132
|
+
},
|
|
133
|
+
labelIconPressable: {
|
|
134
|
+
borderRadius: DS_RADIUS.round,
|
|
135
|
+
},
|
|
136
|
+
labelIconPressed: {
|
|
137
|
+
backgroundColor: DS_SEMANTIC.interaction.pressed,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { SegmentedToggle } from './SegmentedToggle';
|
|
4
|
+
|
|
5
|
+
function SegmentedToggleDemo() {
|
|
6
|
+
const [value, setValue] = useState('map');
|
|
7
|
+
return (
|
|
8
|
+
<SegmentedToggle
|
|
9
|
+
value={value}
|
|
10
|
+
onChange={setValue}
|
|
11
|
+
options={[
|
|
12
|
+
{ value: 'map', label: 'Map', iconName: 'map' },
|
|
13
|
+
{ value: 'list', label: 'List', iconName: 'menu' },
|
|
14
|
+
{ value: 'saved', label: 'Saved', iconName: 'bell', badge: 3 },
|
|
15
|
+
// Label-only segment (no iconName) — the layout every other option's icon would otherwise hide.
|
|
16
|
+
{ value: 'settings', label: 'Settings' },
|
|
17
|
+
]}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default defineCatalogPage({
|
|
23
|
+
component: 'SegmentedToggle',
|
|
24
|
+
group: 'Selection',
|
|
25
|
+
// Phone width plus a small phone, where long labels truncate.
|
|
26
|
+
previewWidths: [402, 320],
|
|
27
|
+
description: 'A row of mutually-exclusive options on a recessed track, with a white thumb that slides to the selected segment. Two or more options.',
|
|
28
|
+
whenToUse: 'A filled, heavier-weight control for a primary, prominent choice on the screen. For quieter secondary navigation, use UnderlineTabs.',
|
|
29
|
+
a11y: 'The row is accessibilityRole="tablist"; each segment is a "tab" with accessibilityState.selected reflecting the current value.',
|
|
30
|
+
render: () => <SegmentedToggleDemo />,
|
|
31
|
+
});
|
|
@@ -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,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
|
+
});
|