@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,114 @@
|
|
|
1
|
+
import React, { useEffect, useRef, 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_TYPOGRAPHY, DS_A11Y_MIN_TOUCH_TARGET, DS_MOTION_DURATION } from '../../tokens';
|
|
4
|
+
import { usePressScale } from './usePressScale';
|
|
5
|
+
|
|
6
|
+
export interface RadioProps {
|
|
7
|
+
/** Whether this radio is the selected one. */
|
|
8
|
+
selected: boolean;
|
|
9
|
+
/** Tap handler — select this option in the consumer's state. */
|
|
10
|
+
onPress: () => void;
|
|
11
|
+
/** Optional inline label rendered to the right of the circle. */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Non-interactive, dimmed. */
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
style?: StyleProp<ViewStyle>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const CIRCLE_SIZE = 20;
|
|
19
|
+
const DOT_SIZE = 10;
|
|
20
|
+
// Pads the tappable area out to the accessibility minimum without growing the visual circle — same
|
|
21
|
+
// reasoning as Button's own `iconOnlyHitSlop`. Applied on all sides since a no-label Radio's row is
|
|
22
|
+
// exactly CIRCLE_SIZE tall/wide; a labelled row is already wider, where the extra hitSlop is
|
|
23
|
+
// harmless overlap into surrounding whitespace rather than another control.
|
|
24
|
+
const HIT_SLOP = Math.max(0, Math.ceil((DS_A11Y_MIN_TOUCH_TARGET - CIRCLE_SIZE) / 2));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A single circular selection control — a filled dot appears in the ring when selected. A group of
|
|
28
|
+
* mutually-exclusive Radios is just multiple instances sharing one "selected value" in the consumer
|
|
29
|
+
* (the same way a native radio group works); this component itself only knows its own selected state.
|
|
30
|
+
* The circle grows slightly while pressed, for tactile feedback before release, and the dot itself
|
|
31
|
+
* pops in/out (scale + opacity) rather than snapping — both kept JS-driven (`useNativeDriver: false`)
|
|
32
|
+
* for the same Fabric-safety reason.
|
|
33
|
+
*/
|
|
34
|
+
export function Radio({ selected, onPress, label, disabled = false, style }: RadioProps) {
|
|
35
|
+
const { pressScale, onPressIn, onPressOut } = usePressScale(disabled);
|
|
36
|
+
const dotAnim = useRef(new Animated.Value(selected ? 1 : 0)).current;
|
|
37
|
+
const [focused, setFocused] = useState(false);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
Animated.timing(dotAnim, {
|
|
41
|
+
toValue: selected ? 1 : 0,
|
|
42
|
+
duration: DS_MOTION_DURATION.fast,
|
|
43
|
+
useNativeDriver: false,
|
|
44
|
+
}).start();
|
|
45
|
+
}, [selected, dotAnim]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Pressable
|
|
49
|
+
onPress={() => !disabled && onPress()}
|
|
50
|
+
onPressIn={onPressIn}
|
|
51
|
+
onPressOut={onPressOut}
|
|
52
|
+
onFocus={() => setFocused(true)}
|
|
53
|
+
onBlur={() => setFocused(false)}
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
accessibilityRole="radio"
|
|
56
|
+
accessibilityState={{ selected, disabled }}
|
|
57
|
+
accessibilityLabel={label}
|
|
58
|
+
hitSlop={HIT_SLOP}
|
|
59
|
+
style={[styles.row, disabled && styles.disabled, style]}
|
|
60
|
+
>
|
|
61
|
+
<Animated.View style={[styles.circle, selected && styles.circleSelected, { transform: [{ scale: pressScale }] }]}>
|
|
62
|
+
<Animated.View style={[styles.dot, { opacity: dotAnim, transform: [{ scale: dotAnim }] }]} />
|
|
63
|
+
{/* Keyboard-focus ring — same interaction.focused treatment Button/Pill use; a zero-layout
|
|
64
|
+
absolute overlay so focusing never shifts layout. */}
|
|
65
|
+
{focused && !disabled && <View pointerEvents="none" style={styles.focusRing} />}
|
|
66
|
+
</Animated.View>
|
|
67
|
+
{!!label && <Text style={styles.label}>{label}</Text>}
|
|
68
|
+
</Pressable>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const styles = StyleSheet.create({
|
|
73
|
+
row: {
|
|
74
|
+
flexDirection: 'row',
|
|
75
|
+
alignItems: 'center',
|
|
76
|
+
gap: DS_SPACING[400],
|
|
77
|
+
},
|
|
78
|
+
circle: {
|
|
79
|
+
width: CIRCLE_SIZE,
|
|
80
|
+
height: CIRCLE_SIZE,
|
|
81
|
+
borderRadius: DS_RADIUS.round,
|
|
82
|
+
borderWidth: 2,
|
|
83
|
+
borderColor: DS_SEMANTIC.border.dark,
|
|
84
|
+
alignItems: 'center',
|
|
85
|
+
justifyContent: 'center',
|
|
86
|
+
},
|
|
87
|
+
circleSelected: {
|
|
88
|
+
borderColor: DS_SEMANTIC.emphasis.info,
|
|
89
|
+
},
|
|
90
|
+
dot: {
|
|
91
|
+
width: DOT_SIZE,
|
|
92
|
+
height: DOT_SIZE,
|
|
93
|
+
borderRadius: DS_RADIUS.round,
|
|
94
|
+
backgroundColor: DS_SEMANTIC.emphasis.info,
|
|
95
|
+
},
|
|
96
|
+
label: {
|
|
97
|
+
...DS_TYPOGRAPHY.bodyMd,
|
|
98
|
+
color: DS_SEMANTIC.text.regular,
|
|
99
|
+
},
|
|
100
|
+
disabled: {
|
|
101
|
+
opacity: DS_SEMANTIC.interaction.disabledOpacity,
|
|
102
|
+
},
|
|
103
|
+
// 2px air + 2px ring outside the circle; fully round to stay concentric.
|
|
104
|
+
focusRing: {
|
|
105
|
+
position: 'absolute',
|
|
106
|
+
top: -4,
|
|
107
|
+
bottom: -4,
|
|
108
|
+
left: -4,
|
|
109
|
+
right: -4,
|
|
110
|
+
borderWidth: 2,
|
|
111
|
+
borderColor: DS_SEMANTIC.interaction.focused,
|
|
112
|
+
borderRadius: DS_RADIUS.round,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Animated } from 'react-native';
|
|
3
|
+
import { DS_MOTION_DURATION } from '../../tokens';
|
|
4
|
+
|
|
5
|
+
/** How much a control's own touchable grows while pressed. */
|
|
6
|
+
export const PRESS_SCALE = 1.15;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared by Radio, Checkbox, and Switch: "grow slightly while pressed, before release" tactile
|
|
10
|
+
* feedback — the exact same state/animation mechanics were previously copy-pasted verbatim across
|
|
11
|
+
* all three. `disabled` controls are inert; `onPressIn` won't start the grow for them (a disabled
|
|
12
|
+
* control has no interaction to give feedback about).
|
|
13
|
+
*/
|
|
14
|
+
export function usePressScale(disabled: boolean) {
|
|
15
|
+
const [pressed, setPressed] = useState(false);
|
|
16
|
+
const pressScale = useRef(new Animated.Value(1)).current;
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
Animated.timing(pressScale, {
|
|
20
|
+
toValue: pressed ? PRESS_SCALE : 1,
|
|
21
|
+
// Grow-in at 100ms — deliberately snappier than any duration token (press feedback must feel
|
|
22
|
+
// instant under the finger); the release settles at the standard fast duration.
|
|
23
|
+
duration: pressed ? 100 : DS_MOTION_DURATION.fast,
|
|
24
|
+
useNativeDriver: false,
|
|
25
|
+
}).start();
|
|
26
|
+
}, [pressed, pressScale]);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
pressScale,
|
|
30
|
+
onPressIn: () => !disabled && setPressed(true),
|
|
31
|
+
onPressOut: () => setPressed(false),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { SearchField } from './SearchField';
|
|
4
|
+
|
|
5
|
+
function SearchFieldDemo() {
|
|
6
|
+
const [value, setValue] = useState('');
|
|
7
|
+
return <SearchField value={value} onChangeText={setValue} placeholder="Search stations" />;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default defineCatalogPage({
|
|
11
|
+
component: 'SearchField',
|
|
12
|
+
group: 'Inputs',
|
|
13
|
+
description: 'Single-line search input on the shared field chrome — leading search icon, a border that darkens on focus, and a clear button while active with text.',
|
|
14
|
+
whenToUse: 'Free-text filtering/searching only — never a named field with a fixed value. For that, use InputField; for a small known set of choices, use Dropdown.',
|
|
15
|
+
a11y: 'Forwards a ref to the underlying TextInput; all TextInputProps pass through, so pass accessibilityLabel/placeholder as needed.',
|
|
16
|
+
variants: {
|
|
17
|
+
itemsFill: true,
|
|
18
|
+
items: [{ key: 'default', name: 'Default (type to see clear button)', node: <SearchFieldDemo /> }],
|
|
19
|
+
},
|
|
20
|
+
states: {
|
|
21
|
+
itemsFill: true,
|
|
22
|
+
items: [
|
|
23
|
+
{ key: 'empty', name: 'Empty', node: <SearchField value="" onChangeText={() => {}} placeholder="Search stations" /> },
|
|
24
|
+
{ key: 'filled', name: 'With value', node: <SearchField value="Union Sq" onChangeText={() => {}} /> },
|
|
25
|
+
{ key: 'disabled', name: 'Disabled', node: <SearchField value="" onChangeText={() => {}} placeholder="Search stations" disabled /> },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React, { forwardRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
TextInput,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Platform,
|
|
6
|
+
type TextInputProps,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { FieldContainer } from '../FieldContainer';
|
|
11
|
+
import { InputClearButton } from '../InputClearButton';
|
|
12
|
+
import { Icon } from '../../icons/Icon.native';
|
|
13
|
+
import type { IconName } from '../../icons';
|
|
14
|
+
import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY, DS_ICON_SIZE } from '../../tokens';
|
|
15
|
+
|
|
16
|
+
export interface SearchFieldProps extends TextInputProps {
|
|
17
|
+
/** Leading icon (defaults to the search glyph). */
|
|
18
|
+
iconName?: IconName;
|
|
19
|
+
/** Extra layout applied to the field container (e.g. margins). */
|
|
20
|
+
containerStyle?: StyleProp<ViewStyle>;
|
|
21
|
+
/** Disabled state — dimmed icon/text (a step past the hint/placeholder tone, so it reads as
|
|
22
|
+
* inactive rather than merely empty), non-interactive. Forces the field non-editable regardless
|
|
23
|
+
* of `editable`, matching InputField/TextArea. */
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Single-line search input on the shared field chrome ({@link FieldContainer}): white fill, medium
|
|
29
|
+
* radius, a 1px subtle border that **darkens to border.dark on focus**, 56px tall, with a leading search
|
|
30
|
+
* icon. While the field is active (focused with text) a **clear (×) button** appears on the right and
|
|
31
|
+
* wipes the input on tap. Forwards a ref to the underlying TextInput; all `TextInputProps` pass through.
|
|
32
|
+
* Works controlled (`value` + `onChangeText`) or uncontrolled (`defaultValue`). Pick one mode per
|
|
33
|
+
* mounted instance and stick with it.
|
|
34
|
+
*/
|
|
35
|
+
export const SearchField = forwardRef<TextInput, SearchFieldProps>(function SearchField(
|
|
36
|
+
{ iconName = 'search', containerStyle, style, value, defaultValue, onChangeText, onFocus, onBlur, disabled = false, editable, ...rest },
|
|
37
|
+
ref,
|
|
38
|
+
) {
|
|
39
|
+
const isControlled = value !== undefined;
|
|
40
|
+
const [internal, setInternal] = useState(defaultValue ?? '');
|
|
41
|
+
const [focused, setFocused] = useState(false);
|
|
42
|
+
const text = isControlled ? value : internal;
|
|
43
|
+
|
|
44
|
+
const change = (t: string) => {
|
|
45
|
+
if (!isControlled) setInternal(t);
|
|
46
|
+
onChangeText?.(t);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const showClear = !disabled && focused && !!text && text.length > 0;
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<FieldContainer focused={focused} disabled={disabled} style={[styles.container, containerStyle]}>
|
|
53
|
+
<Icon
|
|
54
|
+
name={iconName}
|
|
55
|
+
size={DS_ICON_SIZE.sm}
|
|
56
|
+
color={disabled ? DS_SEMANTIC.text.disabled : focused ? DS_SEMANTIC.text.regular : DS_SEMANTIC.text.muted}
|
|
57
|
+
/>
|
|
58
|
+
<TextInput
|
|
59
|
+
ref={ref}
|
|
60
|
+
style={[styles.input, disabled && styles.inputDisabled, style]}
|
|
61
|
+
placeholderTextColor={disabled ? DS_SEMANTIC.text.disabled : DS_SEMANTIC.text.muted}
|
|
62
|
+
value={text}
|
|
63
|
+
onChangeText={change}
|
|
64
|
+
onFocus={(e) => { setFocused(true); onFocus?.(e); }}
|
|
65
|
+
onBlur={(e) => { setFocused(false); onBlur?.(e); }}
|
|
66
|
+
editable={disabled ? false : editable}
|
|
67
|
+
{...rest}
|
|
68
|
+
/>
|
|
69
|
+
{showClear ? <InputClearButton onPress={() => change('')} accessibilityLabel="Clear search" /> : null}
|
|
70
|
+
</FieldContainer>
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const styles = StyleSheet.create({
|
|
75
|
+
container: {
|
|
76
|
+
flexDirection: 'row',
|
|
77
|
+
alignItems: 'center',
|
|
78
|
+
gap: DS_SPACING[800],
|
|
79
|
+
height: DS_SPACING[2800],
|
|
80
|
+
paddingHorizontal: DS_SPACING[800],
|
|
81
|
+
},
|
|
82
|
+
input: {
|
|
83
|
+
flex: 1,
|
|
84
|
+
minWidth: 0,
|
|
85
|
+
// Just the token's size, not the full bodyMd (a TextInput mishandles lineHeight's vertical centering).
|
|
86
|
+
fontSize: DS_TYPOGRAPHY.bodyMd.fontSize,
|
|
87
|
+
color: DS_SEMANTIC.text.regular,
|
|
88
|
+
padding: 0,
|
|
89
|
+
margin: 0,
|
|
90
|
+
includeFontPadding: false,
|
|
91
|
+
...Platform.select({ android: { textAlignVertical: 'center' as const }, default: {} }),
|
|
92
|
+
},
|
|
93
|
+
// A step lighter than the placeholder's muted tone — see DS_SEMANTIC.text.disabled's own doc
|
|
94
|
+
// comment on why disabled text shouldn't just reuse the hint color.
|
|
95
|
+
inputDisabled: {
|
|
96
|
+
color: DS_SEMANTIC.text.disabled,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -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
|
+
});
|