@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.
- 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 +102 -3
- 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 +11 -7
- 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,181 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Pressable, Text, StyleSheet, View } from 'react-native';
|
|
3
|
+
import { DS_SEMANTIC, DS_RADIUS, DS_SPACING, DS_TYPOGRAPHY, DS_SHADOW } from '../../tokens';
|
|
4
|
+
import { Icon } from '../../icons/Icon.native';
|
|
5
|
+
import { Loading } from '../Loading';
|
|
6
|
+
import type { PillProps } from './Pill.types';
|
|
7
|
+
|
|
8
|
+
function PillIcon({
|
|
9
|
+
name,
|
|
10
|
+
selected,
|
|
11
|
+
size = 16,
|
|
12
|
+
}: {
|
|
13
|
+
name: NonNullable<PillProps['iconName']>;
|
|
14
|
+
selected: boolean;
|
|
15
|
+
size?: number;
|
|
16
|
+
}) {
|
|
17
|
+
return (
|
|
18
|
+
<Icon name={name} size={size} color={selected ? DS_SEMANTIC.text.inverse : DS_SEMANTIC.text.regular} />
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A single-line selection chip. `selected` fills solid dark with inverse content; `not_selected`
|
|
24
|
+
* sits on white with a resting shadow. Leading slot holds an `iconName`, a custom `icon` node
|
|
25
|
+
* (sizes naturally), or the default menu icon; the label can be hidden for an icon-only pill.
|
|
26
|
+
*/
|
|
27
|
+
export function Pill({
|
|
28
|
+
label = 'Text',
|
|
29
|
+
variant = 'selected',
|
|
30
|
+
accessibilityLabel,
|
|
31
|
+
showText = true,
|
|
32
|
+
iconName,
|
|
33
|
+
iconSize = 16,
|
|
34
|
+
icon,
|
|
35
|
+
onPress,
|
|
36
|
+
disabled = false,
|
|
37
|
+
loading = false,
|
|
38
|
+
style,
|
|
39
|
+
}: PillProps) {
|
|
40
|
+
const isSelected = variant === 'selected';
|
|
41
|
+
const isDisabled = disabled || loading;
|
|
42
|
+
const [focused, setFocused] = useState(false);
|
|
43
|
+
|
|
44
|
+
// A custom `icon` node (e.g. a badge) is taller than a plain icon. The pill keeps a fixed
|
|
45
|
+
// height regardless, so the badge centers in the same height as an icon+label pill; its horizontal
|
|
46
|
+
// padding is then reduced to equal the (smaller) vertical breathing room around the taller badge.
|
|
47
|
+
const isBadge = !iconName && icon != null;
|
|
48
|
+
|
|
49
|
+
// The leading slot sizes to its content, so it holds an `iconName` (at `iconSize`), a custom `icon`
|
|
50
|
+
// node of any size, or the default menu icon.
|
|
51
|
+
const iconNode = iconName ? (
|
|
52
|
+
<PillIcon name={iconName} selected={isSelected} size={iconSize} />
|
|
53
|
+
) : (
|
|
54
|
+
icon ?? <PillIcon name="menu" selected={isSelected} size={iconSize} />
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const content = loading ? (
|
|
58
|
+
<Loading
|
|
59
|
+
size={16}
|
|
60
|
+
color={isSelected ? DS_SEMANTIC.text.inverse : DS_SEMANTIC.text.regular}
|
|
61
|
+
/>
|
|
62
|
+
) : (
|
|
63
|
+
<>
|
|
64
|
+
<View style={styles.iconSlot}>{iconNode}</View>
|
|
65
|
+
{showText && label ? (
|
|
66
|
+
<Text
|
|
67
|
+
// Pills are single-line chips — never wrap (a sub-pixel-tight equal width would otherwise
|
|
68
|
+
// fold the label onto two lines and grow the pill).
|
|
69
|
+
numberOfLines={1}
|
|
70
|
+
style={[
|
|
71
|
+
styles.label,
|
|
72
|
+
{ color: isSelected ? DS_SEMANTIC.text.inverse : DS_SEMANTIC.text.regular },
|
|
73
|
+
]}
|
|
74
|
+
>
|
|
75
|
+
{label}
|
|
76
|
+
</Text>
|
|
77
|
+
) : null}
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Icon-only pills are forced to a fixed width equal to the pill's own fixed height (not left to
|
|
82
|
+
// emerge from padding + the icon's own width, which only happens to land on a square at the
|
|
83
|
+
// default iconSize=16 — passing a different `iconSize` would otherwise stretch it into a
|
|
84
|
+
// rounded rect instead of a true circle) — a fixed square, with content centred inside it via the
|
|
85
|
+
// container's own `alignItems`/`justifyContent: center`, keeps it circular at any icon size.
|
|
86
|
+
const containerStyle = [
|
|
87
|
+
styles.container,
|
|
88
|
+
showText ? { paddingHorizontal: isBadge ? DS_SPACING[400] : DS_SPACING[600] } : styles.iconOnly,
|
|
89
|
+
isSelected ? styles.selected : styles.notSelected,
|
|
90
|
+
isDisabled && styles.disabled,
|
|
91
|
+
style,
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// A selected pill is the current state — tapping it is a no-op, so it renders as a plain View
|
|
95
|
+
// (no pressed feedback) even when an onPress is wired up.
|
|
96
|
+
if (onPress && !isSelected) {
|
|
97
|
+
return (
|
|
98
|
+
<Pressable
|
|
99
|
+
onPress={onPress}
|
|
100
|
+
disabled={isDisabled}
|
|
101
|
+
// Extends the 40pt-tall pill to the 44pt minimum touch target. An icon-only pill is also only
|
|
102
|
+
// ~40pt wide (no label to widen it), so it needs the same padding on left/right too.
|
|
103
|
+
hitSlop={showText ? { top: 2, bottom: 2 } : { top: 2, bottom: 2, left: 2, right: 2 }}
|
|
104
|
+
accessibilityRole="button"
|
|
105
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
106
|
+
accessibilityState={{ disabled: isDisabled, busy: loading, selected: isSelected }}
|
|
107
|
+
onFocus={() => setFocused(true)}
|
|
108
|
+
onBlur={() => setFocused(false)}
|
|
109
|
+
style={({ pressed }) => [
|
|
110
|
+
containerStyle,
|
|
111
|
+
pressed && !isDisabled && styles.pressed,
|
|
112
|
+
focused && !isDisabled && styles.focused,
|
|
113
|
+
]}
|
|
114
|
+
>
|
|
115
|
+
{content}
|
|
116
|
+
</Pressable>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return <View style={containerStyle}>{content}</View>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const styles = StyleSheet.create({
|
|
124
|
+
container: {
|
|
125
|
+
flexDirection: 'row',
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
justifyContent: 'center',
|
|
128
|
+
gap: DS_SPACING[200],
|
|
129
|
+
// Fixed height so a tall leading node doesn't grow the pill past an icon+label one.
|
|
130
|
+
// Horizontal padding is applied per-instance (equal to the vertical breathing room): normal pills
|
|
131
|
+
// use 12, badge pills 8; icon-only pills get a fixed width instead (see `iconOnly` below).
|
|
132
|
+
height: DS_SPACING[2000],
|
|
133
|
+
borderRadius: DS_RADIUS.round,
|
|
134
|
+
},
|
|
135
|
+
// Fixed width equal to `container`'s own fixed height — a true circle regardless of the leading
|
|
136
|
+
// icon's size, rather than letting width emerge from padding + icon width (see the containerStyle
|
|
137
|
+
// comment above).
|
|
138
|
+
iconOnly: {
|
|
139
|
+
width: DS_SPACING[2000],
|
|
140
|
+
},
|
|
141
|
+
selected: {
|
|
142
|
+
// surface.inverse, not text.regular — this is a dark panel FILL, not ink; the two tokens share a
|
|
143
|
+
// value today but must be free to diverge in a rebrand.
|
|
144
|
+
backgroundColor: DS_SEMANTIC.surface.inverse,
|
|
145
|
+
// Transparent border matching not_selected's 2px, so both variants have identical layout boxes
|
|
146
|
+
// (otherwise unselected pills render taller and the row looks uneven). Reserved at the focus
|
|
147
|
+
// ring's own width (not 1px) so focusing only swaps the border colour, never its width — a
|
|
148
|
+
// width change on focus would otherwise shift the pill's layout, same fix as Button's border.
|
|
149
|
+
borderWidth: 2,
|
|
150
|
+
borderColor: 'transparent',
|
|
151
|
+
},
|
|
152
|
+
notSelected: {
|
|
153
|
+
backgroundColor: DS_SEMANTIC.surface.white,
|
|
154
|
+
// No border — the resting shadow alone separates the pill from the surface. Keep a transparent
|
|
155
|
+
// border (matching selected's reserved focus-ring width) so the box matches its height.
|
|
156
|
+
borderWidth: 2,
|
|
157
|
+
borderColor: 'transparent',
|
|
158
|
+
...DS_SHADOW.resting,
|
|
159
|
+
},
|
|
160
|
+
pressed: {
|
|
161
|
+
backgroundColor: DS_SEMANTIC.interaction.pressed,
|
|
162
|
+
},
|
|
163
|
+
focused: {
|
|
164
|
+
borderColor: DS_SEMANTIC.interaction.focused,
|
|
165
|
+
},
|
|
166
|
+
// Dims the WHOLE pill — container opacity multiplies down through every child in RN, so the
|
|
167
|
+
// label/icon dim with it; there is no way for a child to opt back out of an ancestor's opacity.
|
|
168
|
+
disabled: {
|
|
169
|
+
opacity: DS_SEMANTIC.interaction.disabledOpacity,
|
|
170
|
+
},
|
|
171
|
+
// Sizes to its content so the leading slot can hold an icon of any size or a custom node.
|
|
172
|
+
iconSlot: {
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
justifyContent: 'center',
|
|
175
|
+
},
|
|
176
|
+
// No pinned lineHeight — the pill's height follows the label typography (padding + natural line
|
|
177
|
+
// height), so a type-scale change resizes the pill instead of rattling inside a fixed box.
|
|
178
|
+
label: {
|
|
179
|
+
...DS_TYPOGRAPHY.labelSm,
|
|
180
|
+
},
|
|
181
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import type { IconName } from '../../icons';
|
|
4
|
+
|
|
5
|
+
/** Selection state of the pill. */
|
|
6
|
+
export type PillVariant = 'selected' | 'not_selected';
|
|
7
|
+
|
|
8
|
+
export interface PillProps {
|
|
9
|
+
/** Chip text. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Selection state. */
|
|
12
|
+
variant?: PillVariant;
|
|
13
|
+
/** Screen-reader name. Strongly recommended for an icon-only pill (`showText={false}`) — it is not
|
|
14
|
+
* enforced, so omitting it means the placeholder `label` default gets announced instead. Defaults
|
|
15
|
+
* to `label` for text pills. */
|
|
16
|
+
accessibilityLabel?: string;
|
|
17
|
+
/** Hide the label for an icon-only pill. */
|
|
18
|
+
showText?: boolean;
|
|
19
|
+
/** Renders in the inverse/regular color based on selection (preferred over `icon`). */
|
|
20
|
+
iconName?: IconName;
|
|
21
|
+
/** Render size for `iconName` (default 16). */
|
|
22
|
+
iconSize?: number;
|
|
23
|
+
/** Custom leading node — sizes naturally; takes precedence over the default menu icon. */
|
|
24
|
+
icon?: ReactNode;
|
|
25
|
+
/** Tap handler. */
|
|
26
|
+
onPress?: () => void;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
style?: StyleProp<ViewStyle>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { PillRow } from './PillRow';
|
|
4
|
+
|
|
5
|
+
export default defineCatalogPage({
|
|
6
|
+
component: 'PillRow',
|
|
7
|
+
group: 'Actions',
|
|
8
|
+
description: 'A horizontally-scrolling row of Pill chips, with an optional trailing icon-only add pill — keeps the selected pill scrolled into view automatically.',
|
|
9
|
+
whenToUse: 'A scrollable row of selection chips (e.g. saved-place shortcuts) — for a fixed row of action buttons instead, use ButtonGroup.',
|
|
10
|
+
a11y: 'The row itself carries no accessibility role; each Pill (including the add pill) keeps its own accessibilityLabel/role from the Pill component.',
|
|
11
|
+
variants: {
|
|
12
|
+
itemsFill: true,
|
|
13
|
+
items: [
|
|
14
|
+
{
|
|
15
|
+
key: 'few',
|
|
16
|
+
name: 'Few pills',
|
|
17
|
+
node: (
|
|
18
|
+
<PillRow
|
|
19
|
+
pills={[
|
|
20
|
+
{ id: 'home', label: 'Home', variant: 'selected', iconName: 'home' },
|
|
21
|
+
{ id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
|
|
22
|
+
]}
|
|
23
|
+
/>
|
|
24
|
+
),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: 'many',
|
|
28
|
+
name: 'Many pills (scrollable)',
|
|
29
|
+
node: (
|
|
30
|
+
<PillRow
|
|
31
|
+
pills={[
|
|
32
|
+
{ id: 'home', label: 'Home', variant: 'not_selected', iconName: 'home' },
|
|
33
|
+
{ id: 'work', label: 'Work', variant: 'selected', iconName: 'briefcase' },
|
|
34
|
+
{ id: 'gym', label: 'Gym', variant: 'not_selected', iconName: 'map' },
|
|
35
|
+
{ id: 'saved', label: 'Saved', variant: 'not_selected', iconName: 'bell' },
|
|
36
|
+
{ id: 'nearby', label: 'Nearby', variant: 'not_selected', iconName: 'pin' },
|
|
37
|
+
{ id: 'recent', label: 'Recent', variant: 'not_selected', iconName: 'clock' },
|
|
38
|
+
]}
|
|
39
|
+
/>
|
|
40
|
+
),
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
states: {
|
|
45
|
+
itemsFill: true,
|
|
46
|
+
items: [
|
|
47
|
+
{
|
|
48
|
+
key: 'no-add',
|
|
49
|
+
name: 'No add pill',
|
|
50
|
+
props: { showAddPill: false },
|
|
51
|
+
node: (
|
|
52
|
+
<PillRow
|
|
53
|
+
showAddPill={false}
|
|
54
|
+
pills={[
|
|
55
|
+
{ id: 'home', label: 'Home', variant: 'selected', iconName: 'home' },
|
|
56
|
+
{ id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
|
|
57
|
+
]}
|
|
58
|
+
/>
|
|
59
|
+
),
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
key: 'add-selected',
|
|
63
|
+
name: 'Add pill selected',
|
|
64
|
+
props: { addSelected: true },
|
|
65
|
+
node: (
|
|
66
|
+
<PillRow
|
|
67
|
+
addSelected
|
|
68
|
+
pills={[
|
|
69
|
+
{ id: 'home', label: 'Home', variant: 'not_selected', iconName: 'home' },
|
|
70
|
+
{ id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
|
|
71
|
+
]}
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: 'min-width',
|
|
77
|
+
name: 'Minimum pill width',
|
|
78
|
+
props: { minPillWidth: 96 },
|
|
79
|
+
node: (
|
|
80
|
+
<PillRow
|
|
81
|
+
minPillWidth={96}
|
|
82
|
+
pills={[
|
|
83
|
+
{ id: 'home', label: 'Home', variant: 'selected', iconName: 'home' },
|
|
84
|
+
{ id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
|
|
85
|
+
]}
|
|
86
|
+
/>
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { ScrollView, View, StyleSheet, type LayoutChangeEvent } from 'react-native';
|
|
3
|
+
import { DS_SPACING } from '../../tokens';
|
|
4
|
+
import { Pill } from '../Pill';
|
|
5
|
+
import type { PillRowItem, PillRowProps } from './PillRow.types';
|
|
6
|
+
|
|
7
|
+
const GAP = DS_SPACING[600];
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PILLS: NonNullable<PillRowProps['pills']> = [
|
|
10
|
+
{ id: 'home', label: 'Home', variant: 'selected', iconName: 'home' },
|
|
11
|
+
{ id: 'work', label: 'Work', variant: 'not_selected', iconName: 'briefcase' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A horizontally-scrolling row of Pills with an optional trailing icon-only add pill. Keeps the
|
|
16
|
+
* selected pill scrolled into view, and resets to the start when the pill set genuinely changes.
|
|
17
|
+
*/
|
|
18
|
+
export function PillRow({
|
|
19
|
+
pills: pillsProp,
|
|
20
|
+
showAddPill = true,
|
|
21
|
+
minPillWidth = 0,
|
|
22
|
+
onAddPress,
|
|
23
|
+
addSelected = false,
|
|
24
|
+
style,
|
|
25
|
+
trailing,
|
|
26
|
+
}: PillRowProps) {
|
|
27
|
+
const pills = pillsProp ?? DEFAULT_PILLS;
|
|
28
|
+
const idsKey = pills.map(p => p.id).join('|');
|
|
29
|
+
|
|
30
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
31
|
+
const scrollXRef = useRef(0);
|
|
32
|
+
// Laid-out position of each pill (x within the scroll content + width), kept for scroll-into-view.
|
|
33
|
+
const posRef = useRef<Map<string, { x: number; width: number }>>(new Map());
|
|
34
|
+
const [viewportW, setViewportW] = useState(0);
|
|
35
|
+
|
|
36
|
+
const onPillLayout = (id: string, e: LayoutChangeEvent) => {
|
|
37
|
+
const { x, width } = e.nativeEvent.layout;
|
|
38
|
+
posRef.current.set(id, { x, width });
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// A genuinely different set of pills (not just a selection change within the same set) forgets
|
|
42
|
+
// wherever the row was scrolled to and starts back at the beginning — otherwise a stale scroll
|
|
43
|
+
// offset or a stale cached position from the PREVIOUS pill set could carry over onto this one.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
posRef.current.clear();
|
|
46
|
+
scrollRef.current?.scrollTo({ x: 0, animated: false });
|
|
47
|
+
}, [idsKey]);
|
|
48
|
+
|
|
49
|
+
// Keep the selected pill fully in view. The selected pill is the one with variant 'selected', or the
|
|
50
|
+
// add pill (tracked under '__add__') when it's selected. Re-runs whenever the selection or the layout
|
|
51
|
+
// changes; deferred a frame so positions are fresh after a layout change.
|
|
52
|
+
const selectedId = useMemo(
|
|
53
|
+
() => (addSelected ? '__add__' : pills.find(p => p.variant === 'selected')?.id),
|
|
54
|
+
[addSelected, pills],
|
|
55
|
+
);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!selectedId || viewportW <= 0) return;
|
|
58
|
+
const raf = requestAnimationFrame(() => {
|
|
59
|
+
const pos = posRef.current.get(selectedId);
|
|
60
|
+
if (!pos) return;
|
|
61
|
+
const left = scrollXRef.current;
|
|
62
|
+
const right = left + viewportW;
|
|
63
|
+
if (pos.x < left) {
|
|
64
|
+
scrollRef.current?.scrollTo({ x: Math.max(0, pos.x - GAP), animated: true });
|
|
65
|
+
} else if (pos.x + pos.width > right) {
|
|
66
|
+
scrollRef.current?.scrollTo({ x: pos.x + pos.width - viewportW + GAP, animated: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return () => cancelAnimationFrame(raf);
|
|
70
|
+
}, [selectedId, viewportW, idsKey]);
|
|
71
|
+
|
|
72
|
+
// `minPillWidth` is applied as a floor (`minWidth`) so each pill grows to fit its own label (never
|
|
73
|
+
// truncates) while short labels still hold the minimum.
|
|
74
|
+
const pillStyle = minPillWidth > 0 ? { minWidth: minPillWidth } : undefined;
|
|
75
|
+
|
|
76
|
+
const renderPill = (pill: PillRowItem) => (
|
|
77
|
+
<View key={pill.id} onLayout={e => onPillLayout(pill.id, e)}>
|
|
78
|
+
<Pill
|
|
79
|
+
label={pill.label}
|
|
80
|
+
variant={pill.variant}
|
|
81
|
+
showText={pill.showText}
|
|
82
|
+
iconName={pill.iconName}
|
|
83
|
+
iconSize={pill.iconSize}
|
|
84
|
+
icon={pill.icon}
|
|
85
|
+
onPress={pill.onPress}
|
|
86
|
+
disabled={pill.disabled}
|
|
87
|
+
loading={pill.loading}
|
|
88
|
+
accessibilityLabel={pill.accessibilityLabel}
|
|
89
|
+
style={pillStyle}
|
|
90
|
+
/>
|
|
91
|
+
</View>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<ScrollView
|
|
96
|
+
ref={scrollRef}
|
|
97
|
+
horizontal
|
|
98
|
+
showsHorizontalScrollIndicator={false}
|
|
99
|
+
// `style` (the ScrollView's OWN box, not its scrollable content) needs an explicit bound —
|
|
100
|
+
// without it, react-native-web sizes the scroll viewport to hug its content instead of the
|
|
101
|
+
// width its parent actually gives it, so it never overflows and never becomes swipable at
|
|
102
|
+
// all (a wide pill set just spills past the layout silently). `contentContainerStyle` is a
|
|
103
|
+
// separate thing: the inner row that's *allowed* to be wider than the viewport.
|
|
104
|
+
style={styles.scrollView}
|
|
105
|
+
contentContainerStyle={[styles.row, style]}
|
|
106
|
+
onLayout={e => setViewportW(e.nativeEvent.layout.width)}
|
|
107
|
+
onScroll={e => {
|
|
108
|
+
scrollXRef.current = e.nativeEvent.contentOffset.x;
|
|
109
|
+
}}
|
|
110
|
+
scrollEventThrottle={16}
|
|
111
|
+
>
|
|
112
|
+
{pills.map(renderPill)}
|
|
113
|
+
{showAddPill && (
|
|
114
|
+
// Record the add pill's position (under '__add__') so it can be scrolled into view when
|
|
115
|
+
// selected — tracked separately from `pills` since it's rendered outside that list.
|
|
116
|
+
<View onLayout={e => onPillLayout('__add__', e)}>
|
|
117
|
+
<Pill
|
|
118
|
+
variant={addSelected ? 'selected' : 'not_selected'}
|
|
119
|
+
showText={false}
|
|
120
|
+
iconName="pencil"
|
|
121
|
+
accessibilityLabel="Edit"
|
|
122
|
+
onPress={onAddPress}
|
|
123
|
+
/>
|
|
124
|
+
</View>
|
|
125
|
+
)}
|
|
126
|
+
{trailing}
|
|
127
|
+
</ScrollView>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const styles = StyleSheet.create({
|
|
132
|
+
scrollView: { width: '100%' },
|
|
133
|
+
row: {
|
|
134
|
+
flexDirection: 'row',
|
|
135
|
+
alignItems: 'center',
|
|
136
|
+
gap: DS_SPACING[600],
|
|
137
|
+
minHeight: DS_SPACING[2400],
|
|
138
|
+
paddingVertical: DS_SPACING[200],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import type { PillProps } from '../Pill';
|
|
4
|
+
|
|
5
|
+
export interface PillRowItem extends Omit<PillProps, 'style'> {
|
|
6
|
+
id: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PillRowProps {
|
|
10
|
+
/** Pills to render — each is a Pill's own props plus an id. Defaults to a two-item Home/Work
|
|
11
|
+
* sample. */
|
|
12
|
+
pills?: PillRowItem[];
|
|
13
|
+
/** Icon-only add/edit pill at the end of the row. */
|
|
14
|
+
showAddPill?: boolean;
|
|
15
|
+
/** Floor for the content pills' width (px). Excludes the icon-only add pill so it stays circular. */
|
|
16
|
+
minPillWidth?: number;
|
|
17
|
+
/** Tap handler for the add pill. */
|
|
18
|
+
onAddPress?: () => void;
|
|
19
|
+
/** Render the add pill in the selected state. */
|
|
20
|
+
addSelected?: boolean;
|
|
21
|
+
style?: StyleProp<ViewStyle>;
|
|
22
|
+
/** Custom trailing content instead of the add pill. */
|
|
23
|
+
trailing?: ReactNode;
|
|
24
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { ProgressDots } from './ProgressDots';
|
|
4
|
+
|
|
5
|
+
export default defineCatalogPage({
|
|
6
|
+
component: 'ProgressDots',
|
|
7
|
+
group: 'Feedback',
|
|
8
|
+
description: 'A row of dots for a stepped flow; the active dot widens into a pill.',
|
|
9
|
+
a11y: 'Decorative progress indicator; convey the "step X of Y" position in text for screen readers.',
|
|
10
|
+
// No `variant` prop exists on ProgressDots — its "Variants" column just shows the one default
|
|
11
|
+
// look; `active`'s different positions are progress states, covered under "States".
|
|
12
|
+
variants: {
|
|
13
|
+
items: [{ key: 'default', name: 'Default', node: <ProgressDots active={1} total={4} /> }],
|
|
14
|
+
},
|
|
15
|
+
states: {
|
|
16
|
+
items: [
|
|
17
|
+
{ key: 'first', name: 'First step', node: <ProgressDots active={0} total={4} /> },
|
|
18
|
+
{ key: 'middle', name: 'Middle step', node: <ProgressDots active={2} total={4} /> },
|
|
19
|
+
{ key: 'last', name: 'Last step', node: <ProgressDots active={3} total={4} /> },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { DS_SEMANTIC, DS_SPACING } from '../../tokens';
|
|
4
|
+
|
|
5
|
+
export interface ProgressDotsProps {
|
|
6
|
+
/** 0-based index of the current step. The active dot widens into a pill. */
|
|
7
|
+
active: number;
|
|
8
|
+
/** Total number of steps (dots). */
|
|
9
|
+
total?: number;
|
|
10
|
+
style?: StyleProp<ViewStyle>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Step progress indicator — a row of dots where the current step is a widened pill.
|
|
15
|
+
*/
|
|
16
|
+
export function ProgressDots({ active, total = 6, style }: ProgressDotsProps) {
|
|
17
|
+
return (
|
|
18
|
+
<View
|
|
19
|
+
style={[styles.row, style]}
|
|
20
|
+
accessible
|
|
21
|
+
accessibilityRole="progressbar"
|
|
22
|
+
accessibilityLabel={`Step ${active + 1} of ${total}`}
|
|
23
|
+
accessibilityValue={{ min: 1, max: total, now: active + 1 }}
|
|
24
|
+
>
|
|
25
|
+
{Array.from({ length: total }).map((_, i) => (
|
|
26
|
+
<View key={i} style={[styles.dot, i === active && styles.dotActive]} />
|
|
27
|
+
))}
|
|
28
|
+
</View>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 7px is intentionally smaller than DS_SPACING[400]=8px — optical correction for small dots.
|
|
33
|
+
const DOT_H = 7;
|
|
34
|
+
const DOT_PILL_W = 20;
|
|
35
|
+
const DOT_RADIUS = 4;
|
|
36
|
+
|
|
37
|
+
const styles = StyleSheet.create({
|
|
38
|
+
row: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: DS_SPACING[200] },
|
|
39
|
+
dot: { width: DOT_H, height: DOT_H, borderRadius: DOT_RADIUS, backgroundColor: DS_SEMANTIC.border.subtle },
|
|
40
|
+
// surface.inverse, not text.regular — a dark FILL, not ink (same reasoning as Pill/Toast).
|
|
41
|
+
dotActive: { width: DOT_PILL_W, borderRadius: DOT_RADIUS, backgroundColor: DS_SEMANTIC.surface.inverse },
|
|
42
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View, StyleSheet } from 'react-native';
|
|
3
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
4
|
+
import { Radio } from './Radio';
|
|
5
|
+
import { DS_SPACING } from '../../tokens';
|
|
6
|
+
|
|
7
|
+
const styles = StyleSheet.create({
|
|
8
|
+
radioGroup: { gap: DS_SPACING[600], alignItems: 'flex-start' },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function RadioGroupDemo() {
|
|
12
|
+
const [value, setValue] = useState('uptown');
|
|
13
|
+
return (
|
|
14
|
+
<View style={styles.radioGroup}>
|
|
15
|
+
<Radio selected={value === 'uptown'} onPress={() => setValue('uptown')} label="Uptown & The Bronx" />
|
|
16
|
+
<Radio selected={value === 'downtown'} onPress={() => setValue('downtown')} label="Downtown & Brooklyn" />
|
|
17
|
+
</View>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default defineCatalogPage({
|
|
22
|
+
component: 'Radio',
|
|
23
|
+
group: 'Controls',
|
|
24
|
+
specimenSize: 'compact',
|
|
25
|
+
description: 'A single circular selection control — a filled dot appears in the ring when selected. A group of mutually-exclusive Radios is just multiple instances sharing one selected value in the consumer.',
|
|
26
|
+
whenToUse: 'One selection from a mutually-exclusive set — checking one should un-check another. For an independent on/off fact, use Checkbox; for a setting that takes effect immediately, use Switch.',
|
|
27
|
+
a11y: 'Renders a Pressable with accessibilityRole="radio" and accessibilityState.selected; the optional label doubles as its accessibilityLabel.',
|
|
28
|
+
variants: {
|
|
29
|
+
items: [{ key: 'default', name: 'Default (tap to select)', node: <RadioGroupDemo /> }],
|
|
30
|
+
},
|
|
31
|
+
states: {
|
|
32
|
+
items: [
|
|
33
|
+
{ key: 'unselected', name: 'Unselected', node: <Radio selected={false} onPress={() => {}} label="Uptown & The Bronx" /> },
|
|
34
|
+
{ key: 'selected', name: 'Selected', node: <Radio selected={true} onPress={() => {}} label="Uptown & The Bronx" /> },
|
|
35
|
+
{ key: 'disabled', name: 'Disabled', node: <Radio selected={true} onPress={() => {}} disabled label="Uptown & The Bronx" /> },
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
});
|