@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,195 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { View, Text, StyleSheet, useWindowDimensions, type LayoutChangeEvent, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_SHADOW, DS_TYPOGRAPHY } from '../../tokens';
|
|
4
|
+
|
|
5
|
+
export interface TooltipProps {
|
|
6
|
+
/** Whether the bubble is shown. Fully controlled — drive this from the wrapped trigger's own
|
|
7
|
+
* onLongPress/onPressIn, since mobile has no hover. */
|
|
8
|
+
visible: boolean;
|
|
9
|
+
/** The tooltip text. */
|
|
10
|
+
label: string;
|
|
11
|
+
/** Which side of the trigger the bubble appears on. @default 'top' */
|
|
12
|
+
placement?: 'top' | 'bottom';
|
|
13
|
+
/** Which edge of the trigger the bubble prefers to anchor to horizontally: `'center'` (default)
|
|
14
|
+
* centers the bubble on the trigger; `'left'`/`'right'` aligns the bubble's respective edge with
|
|
15
|
+
* the trigger's — useful when the trigger sits near a screen edge and a centered bubble would
|
|
16
|
+
* extend past it. This is only a *preference*: the bubble is always clamped to stay fully within
|
|
17
|
+
* the screen regardless, and the arrow is always computed to point at the trigger's true center,
|
|
18
|
+
* never a decorative fixed corner offset. */
|
|
19
|
+
align?: 'left' | 'center' | 'right';
|
|
20
|
+
/** The trigger content the bubble is anchored to. */
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
style?: StyleProp<ViewStyle>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const GAP = DS_SPACING[200];
|
|
26
|
+
const MIN_WIDTH = 60;
|
|
27
|
+
const MAX_WIDTH_RATIO = 0.8;
|
|
28
|
+
const ARROW_SIZE = 6;
|
|
29
|
+
// How close the bubble (and the arrow within it) may come to the screen's own edge.
|
|
30
|
+
const EDGE_MARGIN = DS_SPACING[400];
|
|
31
|
+
// For align='left'/'right': how far the bubble extends *past* the trigger's own edge, instead of
|
|
32
|
+
// aligning flush with it. This gives the arrow (still always computed from the trigger's true
|
|
33
|
+
// center — see below) breathing room from the bubble's rounded corner by shifting the bubble
|
|
34
|
+
// itself, never by clamping the arrow away from accuracy the way a min-inset-on-the-arrow approach
|
|
35
|
+
// would (that approach was tried and reverted: it silently mis-pointed the arrow for any trigger
|
|
36
|
+
// narrower than the inset).
|
|
37
|
+
const EDGE_OVERHANG = DS_SPACING[400];
|
|
38
|
+
// Same colour as the bubble's own background, referenced directly (not via a resolved variable)
|
|
39
|
+
// since — unlike Toast — Tooltip has only the one dark surface, no variant/light-background mode.
|
|
40
|
+
// surface.inverse, not text.regular — it's a dark PANEL fill, not ink (same reasoning as Toast).
|
|
41
|
+
const BUBBLE_COLOR = DS_SEMANTIC.surface.inverse;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A small floating label anchored above or below its wrapped trigger, with a small triangular arrow
|
|
45
|
+
* pointing back at it. Measures the trigger's real position (`measureInWindow`, since a plain
|
|
46
|
+
* onLayout only gives a position relative to the trigger's own parent, not the screen) and the
|
|
47
|
+
* bubble's own rendered width (onLayout), then computes the bubble's horizontal offset itself
|
|
48
|
+
* instead of just flexbox-centering it — this is what makes it possible to (a) clamp the bubble so
|
|
49
|
+
* it never runs past the screen's left/right edge and (b) always place the arrow at the trigger's
|
|
50
|
+
* true horizontal center, even when the bubble itself had to shift away from `align`'s preferred
|
|
51
|
+
* position to stay on screen. No `numberOfLines` cap on the label — it wraps to however many lines
|
|
52
|
+
* it needs rather than truncating; `maxWidth` (80% of the screen width, via `useWindowDimensions` so
|
|
53
|
+
* it tracks rotation/resize) is what keeps a long label from ever running edge-to-edge. The arrow is
|
|
54
|
+
* the classic CSS border-triangle trick (a zero-size box with one transparent-adjacent border side
|
|
55
|
+
* coloured in) — RN's View supports per-side border colours, so this needs no SVG.
|
|
56
|
+
*/
|
|
57
|
+
export function Tooltip({ visible, label, placement = 'top', align = 'center', children, style }: TooltipProps) {
|
|
58
|
+
const { width: windowWidth } = useWindowDimensions();
|
|
59
|
+
const maxWidth = Math.round(windowWidth * MAX_WIDTH_RATIO);
|
|
60
|
+
|
|
61
|
+
const wrapRef = useRef<View>(null);
|
|
62
|
+
const [trigger, setTrigger] = useState<{ x: number; width: number } | null>(null);
|
|
63
|
+
const [bubbleWidth, setBubbleWidth] = useState(0);
|
|
64
|
+
|
|
65
|
+
// Re-measures whenever the bubble becomes visible or the window is resized — the trigger's own
|
|
66
|
+
// on-screen position can only be read once it (and any layout change causing it) has settled.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!visible) return undefined;
|
|
69
|
+
const raf = requestAnimationFrame(() => {
|
|
70
|
+
wrapRef.current?.measureInWindow((x, _y, width) => setTrigger({ x, width }));
|
|
71
|
+
});
|
|
72
|
+
return () => cancelAnimationFrame(raf);
|
|
73
|
+
}, [visible, windowWidth]);
|
|
74
|
+
|
|
75
|
+
// Falls back to plain flex-centering (the old behavior) for the one frame before the trigger/
|
|
76
|
+
// bubble have been measured, so there's no flash of an unpositioned bubble at 0,0.
|
|
77
|
+
let bubbleOffsetStyle: ViewStyle = styles.bubbleWrapCentered;
|
|
78
|
+
let arrowLeft: number | null = null;
|
|
79
|
+
|
|
80
|
+
if (trigger && bubbleWidth > 0) {
|
|
81
|
+
const triggerCenterAbs = trigger.x + trigger.width / 2;
|
|
82
|
+
const idealLeftAbs =
|
|
83
|
+
align === 'left'
|
|
84
|
+
? trigger.x - EDGE_OVERHANG
|
|
85
|
+
: align === 'right'
|
|
86
|
+
? trigger.x + trigger.width + EDGE_OVERHANG - bubbleWidth
|
|
87
|
+
: triggerCenterAbs - bubbleWidth / 2;
|
|
88
|
+
const minLeftAbs = EDGE_MARGIN;
|
|
89
|
+
const maxLeftAbs = windowWidth - bubbleWidth - EDGE_MARGIN;
|
|
90
|
+
// maxLeftAbs can be < minLeftAbs for a bubble wider than the screen minus margins — clamp the
|
|
91
|
+
// clamp range itself so a too-wide bubble still lands at minLeftAbs instead of NaN/garbage.
|
|
92
|
+
const clampedLeftAbs = Math.min(Math.max(idealLeftAbs, minLeftAbs), Math.max(maxLeftAbs, minLeftAbs));
|
|
93
|
+
bubbleOffsetStyle = { alignSelf: 'flex-start', marginLeft: clampedLeftAbs - trigger.x };
|
|
94
|
+
// Always derived from the trigger's real position — never clamped/overridden — so the arrow is
|
|
95
|
+
// guaranteed accurate regardless of how far the bubble above just shifted for `align` or screen
|
|
96
|
+
// clamping. EDGE_OVERHANG (baked into idealLeftAbs) is what gives it breathing room from the
|
|
97
|
+
// bubble's corner; this line doesn't need its own inset logic on top of that.
|
|
98
|
+
arrowLeft = Math.min(Math.max(triggerCenterAbs - clampedLeftAbs, 0), bubbleWidth);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<View style={styles.wrap} ref={wrapRef}>
|
|
103
|
+
{children}
|
|
104
|
+
{visible && (
|
|
105
|
+
<View pointerEvents="none" style={[styles.overlay, placement === 'top' ? styles.overlayTop : styles.overlayBottom]}>
|
|
106
|
+
<View style={[styles.bubbleWrap, bubbleOffsetStyle]}>
|
|
107
|
+
<View
|
|
108
|
+
style={[styles.bubble, { maxWidth }, style]}
|
|
109
|
+
onLayout={(e: LayoutChangeEvent) => setBubbleWidth(e.nativeEvent.layout.width)}
|
|
110
|
+
// Announce the hint when it appears — without this, a screen-reader user gets no
|
|
111
|
+
// signal the tooltip exists unless they stumble onto it by swiping.
|
|
112
|
+
accessibilityLiveRegion="polite"
|
|
113
|
+
>
|
|
114
|
+
<Text style={styles.label}>{label}</Text>
|
|
115
|
+
</View>
|
|
116
|
+
<View
|
|
117
|
+
style={[
|
|
118
|
+
styles.arrow,
|
|
119
|
+
placement === 'top' ? styles.arrowTop : styles.arrowBottom,
|
|
120
|
+
arrowLeft != null ? { left: arrowLeft, marginLeft: -ARROW_SIZE } : styles.arrowCenter,
|
|
121
|
+
]}
|
|
122
|
+
/>
|
|
123
|
+
</View>
|
|
124
|
+
</View>
|
|
125
|
+
)}
|
|
126
|
+
</View>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const styles = StyleSheet.create({
|
|
131
|
+
// Shrinks to the trigger's own content width, so the overlay below (left:0/right:0) matches it —
|
|
132
|
+
// without this, a Tooltip inside a wider flex row would stretch and mis-centre the bubble.
|
|
133
|
+
wrap: { alignSelf: 'flex-start' },
|
|
134
|
+
overlay: { position: 'absolute', left: 0, right: 0, zIndex: 10 },
|
|
135
|
+
overlayTop: { bottom: '100%', marginBottom: GAP },
|
|
136
|
+
overlayBottom: { top: '100%', marginTop: GAP },
|
|
137
|
+
// Shrink-wraps to `bubble`'s own real width (the only in-flow child; `arrow` is absolutely
|
|
138
|
+
// positioned so it doesn't affect this) — the shared positioning context the computed
|
|
139
|
+
// `marginLeft`/arrow offsets above are both relative to.
|
|
140
|
+
bubbleWrap: { alignItems: 'center' },
|
|
141
|
+
// Pre-measurement fallback only — matches the old always-centered look.
|
|
142
|
+
bubbleWrapCentered: { alignSelf: 'center' },
|
|
143
|
+
bubble: {
|
|
144
|
+
// Flexbox's implicit `min-width: auto` would otherwise let this shrink down to its longest
|
|
145
|
+
// single word (collapsing "Tap to add a stop" into a tall, narrow, multi-line sliver) since it's
|
|
146
|
+
// a flex child of `overlay`, a column container whose own width matches the (often much
|
|
147
|
+
// narrower) trigger — `flexShrink: 0` alone doesn't stop that on web; `width: 'max-content'`
|
|
148
|
+
// (a web-only CSS value, harmlessly ignored by RN's own layout engine on native, where this
|
|
149
|
+
// shrink-to-longest-word quirk doesn't occur) forces the natural single-line width instead.
|
|
150
|
+
flexShrink: 0,
|
|
151
|
+
width: 'max-content' as unknown as number,
|
|
152
|
+
minWidth: MIN_WIDTH,
|
|
153
|
+
backgroundColor: BUBBLE_COLOR,
|
|
154
|
+
borderRadius: DS_RADIUS.small,
|
|
155
|
+
paddingHorizontal: DS_SPACING[400],
|
|
156
|
+
paddingVertical: DS_SPACING[200],
|
|
157
|
+
...DS_SHADOW.card,
|
|
158
|
+
},
|
|
159
|
+
label: {
|
|
160
|
+
...DS_TYPOGRAPHY.bodyXs,
|
|
161
|
+
color: DS_SEMANTIC.text.inverse,
|
|
162
|
+
textAlign: 'center',
|
|
163
|
+
// The actual node that was collapsing to its min-content (longest word) width — `flexShrink: 0`
|
|
164
|
+
// on `bubble` alone wasn't enough, since this Text is itself a shrinkable flex child one level in.
|
|
165
|
+
flexShrink: 0,
|
|
166
|
+
},
|
|
167
|
+
// A zero-size box with only one border side coloured in draws as a triangle — the classic CSS
|
|
168
|
+
// trick, which RN's per-side border-color/width props support natively too. Horizontal position
|
|
169
|
+
// is applied separately above (a computed `left`, or `arrowCenter` before the first measurement).
|
|
170
|
+
arrow: {
|
|
171
|
+
position: 'absolute',
|
|
172
|
+
width: 0,
|
|
173
|
+
height: 0,
|
|
174
|
+
borderLeftWidth: ARROW_SIZE,
|
|
175
|
+
borderRightWidth: ARROW_SIZE,
|
|
176
|
+
borderLeftColor: 'transparent',
|
|
177
|
+
borderRightColor: 'transparent',
|
|
178
|
+
},
|
|
179
|
+
arrowCenter: { left: '50%', marginLeft: -ARROW_SIZE },
|
|
180
|
+
// Bubble sits above the trigger (placement="top") — the arrow hangs off the bubble's bottom edge,
|
|
181
|
+
// pointing down at the trigger below it. `bottom: 0` on the overlay-relative arrow lines its own
|
|
182
|
+
// top edge up with the bubble's bottom edge exactly, since the bubble itself sits flush at the
|
|
183
|
+
// overlay's bottom (overlayTop's `marginBottom: GAP` already reserves the gap between the two).
|
|
184
|
+
arrowTop: {
|
|
185
|
+
bottom: -ARROW_SIZE,
|
|
186
|
+
borderTopWidth: ARROW_SIZE,
|
|
187
|
+
borderTopColor: BUBBLE_COLOR,
|
|
188
|
+
},
|
|
189
|
+
// Mirror of the above — bubble sits below the trigger, arrow hangs off its top edge pointing up.
|
|
190
|
+
arrowBottom: {
|
|
191
|
+
top: -ARROW_SIZE,
|
|
192
|
+
borderBottomWidth: ARROW_SIZE,
|
|
193
|
+
borderBottomColor: BUBBLE_COLOR,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text, StyleSheet } from 'react-native';
|
|
3
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
4
|
+
import { Button } from '../Button';
|
|
5
|
+
import { TopNav } from './TopNav';
|
|
6
|
+
import { DS_SEMANTIC, DS_TYPOGRAPHY } from '../../tokens';
|
|
7
|
+
|
|
8
|
+
const styles = StyleSheet.create({
|
|
9
|
+
cardTitle: { ...DS_TYPOGRAPHY.labelMd, color: DS_SEMANTIC.text.regular },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export default defineCatalogPage({
|
|
13
|
+
component: 'TopNav',
|
|
14
|
+
group: 'Navigation',
|
|
15
|
+
description: 'A screen\'s top bar — fixed-width leading/trailing slots flanking a centered title (or custom center content). Slots reserve their layout space even when empty, so the title stays centered no matter which sides are populated.',
|
|
16
|
+
a11y: 'The title renders with accessibilityRole="header". Slot content (typically icon-only Buttons) carries its own accessibilityLabel.',
|
|
17
|
+
// No `variant` prop exists on TopNav — its "Variants" column just shows the one default look,
|
|
18
|
+
// with both slots populated; the individual slot combinations live under "States".
|
|
19
|
+
variants: {
|
|
20
|
+
itemsFill: true,
|
|
21
|
+
items: [
|
|
22
|
+
{
|
|
23
|
+
key: 'default',
|
|
24
|
+
name: 'Default',
|
|
25
|
+
node: (
|
|
26
|
+
<TopNav
|
|
27
|
+
title="Trip planner"
|
|
28
|
+
leading={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="chevron-left" accessibilityLabel="Back" onPress={() => {}} />}
|
|
29
|
+
trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="search" accessibilityLabel="Search" onPress={() => {}} />}
|
|
30
|
+
/>
|
|
31
|
+
),
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
states: {
|
|
36
|
+
itemsFill: true,
|
|
37
|
+
items: [
|
|
38
|
+
{ key: 'title-only', name: 'Title only', node: <TopNav title="Settings" /> },
|
|
39
|
+
{
|
|
40
|
+
key: 'leading-only',
|
|
41
|
+
name: 'Leading only',
|
|
42
|
+
node: (
|
|
43
|
+
<TopNav
|
|
44
|
+
title="Trip details"
|
|
45
|
+
leading={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="chevron-left" accessibilityLabel="Back" onPress={() => {}} />}
|
|
46
|
+
/>
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
key: 'trailing-only',
|
|
51
|
+
name: 'Trailing only',
|
|
52
|
+
node: (
|
|
53
|
+
<TopNav
|
|
54
|
+
title="Saved trips"
|
|
55
|
+
trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="add" accessibilityLabel="Add" onPress={() => {}} />}
|
|
56
|
+
/>
|
|
57
|
+
),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: 'custom-center',
|
|
61
|
+
name: 'Custom center',
|
|
62
|
+
node: (
|
|
63
|
+
<TopNav
|
|
64
|
+
leading={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="clear" accessibilityLabel="Close" onPress={() => {}} />}
|
|
65
|
+
center={<Text style={styles.cardTitle}>Custom center content</Text>}
|
|
66
|
+
/>
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React, { isValidElement, type ReactNode } from 'react';
|
|
2
|
+
import { View, Text, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY, DS_A11Y_MIN_TOUCH_TARGET } from '../../tokens';
|
|
4
|
+
import { Button, type ButtonProps } from '../Button';
|
|
5
|
+
|
|
6
|
+
// Both slots reserve this width whether populated or empty, so the title stays centered
|
|
7
|
+
// regardless of which side(s) actually have content.
|
|
8
|
+
const SLOT_SIZE = DS_A11Y_MIN_TOUCH_TARGET;
|
|
9
|
+
|
|
10
|
+
/** Dev-time consistency check (same policy as ButtonGroup's `checkConsistency`) — a nav bar's own
|
|
11
|
+
* leading/trailing actions read as one quiet, uniform family (back arrow, close, overflow menu),
|
|
12
|
+
* never as a prominent CTA. Warns (never throws) if a slot's Button doesn't follow that: `small`
|
|
13
|
+
* size, and not `ghost` (which reads as an inline text action, not a nav icon — see Button's own
|
|
14
|
+
* variant-choice doc). */
|
|
15
|
+
function checkSlotButton(node: ReactNode, slotName: 'leading' | 'trailing'): void {
|
|
16
|
+
if (!isValidElement(node) || node.type !== Button) return;
|
|
17
|
+
const props = node.props as ButtonProps;
|
|
18
|
+
if (props.size && props.size !== 'small') {
|
|
19
|
+
console.warn(`[TopNav] ${slotName} button uses size="${props.size}" — TopNav slot buttons should use size="small".`);
|
|
20
|
+
}
|
|
21
|
+
if (props.variant === 'ghost') {
|
|
22
|
+
console.warn(
|
|
23
|
+
`[TopNav] ${slotName} button uses variant="ghost" — that reads as an inline text action, not a nav icon. Use variant="secondary" (TopNav's default) instead.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TopNavProps {
|
|
29
|
+
/** Centered title text. Ignored when `center` is set. */
|
|
30
|
+
title?: string;
|
|
31
|
+
/** Custom content overriding the centered title — e.g. a search field or segmented toggle. */
|
|
32
|
+
center?: ReactNode;
|
|
33
|
+
/** Leading slot — typically a back/close icon Button. Keep it `size="small"` and
|
|
34
|
+
* `variant="secondary"` (TopNav's default) unless there's a specific reason to deviate — never
|
|
35
|
+
* `ghost`, which reads as an inline text action rather than a nav icon. */
|
|
36
|
+
leading?: ReactNode;
|
|
37
|
+
/** Trailing slot — typically an action icon Button. Same size/variant guidance as `leading`. */
|
|
38
|
+
trailing?: ReactNode;
|
|
39
|
+
style?: StyleProp<ViewStyle>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A screen's top bar: fixed-width leading/trailing slots flanking a centered title (or custom
|
|
43
|
+
* `center` content). Slots reserve their layout space even when empty, so the title stays
|
|
44
|
+
* centered whether one, both, or neither side has content. */
|
|
45
|
+
export function TopNav({ title, center, leading, trailing, style }: TopNavProps) {
|
|
46
|
+
checkSlotButton(leading, 'leading');
|
|
47
|
+
checkSlotButton(trailing, 'trailing');
|
|
48
|
+
return (
|
|
49
|
+
<View style={[styles.row, style]}>
|
|
50
|
+
<View style={styles.slot}>{leading}</View>
|
|
51
|
+
<View style={styles.center}>
|
|
52
|
+
{center ?? (title != null && (
|
|
53
|
+
<Text style={styles.title} numberOfLines={1} accessibilityRole="header">
|
|
54
|
+
{title}
|
|
55
|
+
</Text>
|
|
56
|
+
))}
|
|
57
|
+
</View>
|
|
58
|
+
<View style={[styles.slot, styles.slotTrailing]}>{trailing}</View>
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const styles = StyleSheet.create({
|
|
64
|
+
row: {
|
|
65
|
+
width: '100%',
|
|
66
|
+
flexDirection: 'row',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
paddingHorizontal: DS_SPACING[800],
|
|
69
|
+
paddingTop: DS_SPACING[800],
|
|
70
|
+
paddingBottom: DS_SPACING[400],
|
|
71
|
+
backgroundColor: DS_SEMANTIC.surface.white,
|
|
72
|
+
},
|
|
73
|
+
slot: { width: SLOT_SIZE, height: SLOT_SIZE, alignItems: 'flex-start', justifyContent: 'center' },
|
|
74
|
+
slotTrailing: { alignItems: 'flex-end' },
|
|
75
|
+
center: { flex: 1, alignItems: 'center' },
|
|
76
|
+
title: { ...DS_TYPOGRAPHY.labelMd, color: DS_SEMANTIC.text.regular },
|
|
77
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { defineCatalogPage } from '@krapwoo/ds-viewer';
|
|
3
|
+
import { UnderlineTabs } from './UnderlineTabs';
|
|
4
|
+
|
|
5
|
+
function UnderlineTabsDemo() {
|
|
6
|
+
const [value, setValue] = useState('all');
|
|
7
|
+
return (
|
|
8
|
+
<UnderlineTabs
|
|
9
|
+
value={value}
|
|
10
|
+
onChange={setValue}
|
|
11
|
+
options={[
|
|
12
|
+
{ value: 'all', label: 'All' },
|
|
13
|
+
// Leading-icon tab — the layout every other option (icon-less) would otherwise hide.
|
|
14
|
+
{ value: 'nearby', label: 'Nearby', iconName: 'pin' },
|
|
15
|
+
{ value: 'favorites', label: 'Favorites', badge: 2 },
|
|
16
|
+
]}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default defineCatalogPage({
|
|
22
|
+
component: 'UnderlineTabs',
|
|
23
|
+
group: 'Selection',
|
|
24
|
+
// Phone width plus a small phone, where long labels truncate.
|
|
25
|
+
previewWidths: [402, 320],
|
|
26
|
+
description: 'A quieter tab switcher — left-aligned labels over a hairline rule, with a sliding underline indicator. Same options/value/onChange API as SegmentedToggle.',
|
|
27
|
+
whenToUse: "Quiet, secondary navigation within a screen that already has a clear primary focus. For a prominent, primary choice, use SegmentedToggle.",
|
|
28
|
+
a11y: 'Each tab is a Pressable label; the underline is a visual indicator only, so selection is also conveyed by the active label weight.',
|
|
29
|
+
render: () => <UnderlineTabsDemo />,
|
|
30
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { View, Text, Pressable, Animated, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { DS_SEMANTIC, DS_SPACING, DS_RADIUS, DS_TYPOGRAPHY, DS_ICON_SIZE, DS_A11Y_MIN_TOUCH_TARGET } from '../../tokens';
|
|
4
|
+
import { Icon } from '../../icons/Icon.native';
|
|
5
|
+
import type { IconName } from '../../icons';
|
|
6
|
+
import { useSlideAnim } from '../SegmentedToggle/useSlideAnim';
|
|
7
|
+
|
|
8
|
+
export interface UnderlineTabOption {
|
|
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 UnderlineTabsProps {
|
|
18
|
+
/** The tabs. */
|
|
19
|
+
options: UnderlineTabOption[];
|
|
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 INDICATOR_HEIGHT = 2;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Low-prominence tab switcher — left-aligned text labels over a hairline rule, with a thin underline
|
|
31
|
+
* indicator that slides to the selected tab. A quieter stand-in for SegmentedToggle: same
|
|
32
|
+
* options/value/onChange API, but no filled track or lifted thumb. Two or more options.
|
|
33
|
+
*/
|
|
34
|
+
export function UnderlineTabs({ options, value, onChange, style }: UnderlineTabsProps) {
|
|
35
|
+
// -1 when `value` matches no option (e.g. stale state after the options list changed). The
|
|
36
|
+
// indicator is hidden entirely in that case — clamping to tab 0 would underline a tab whose own
|
|
37
|
+
// label doesn't render as selected, a visibly contradictory state.
|
|
38
|
+
const matchedIndex = options.findIndex(o => o.value === value);
|
|
39
|
+
const hasSelection = matchedIndex !== -1;
|
|
40
|
+
const selectedIndex = Math.max(0, matchedIndex);
|
|
41
|
+
// Per-tab measured geometry (x offset + width within the row), so the indicator can size and slide to
|
|
42
|
+
// each variable-width label.
|
|
43
|
+
const [layouts, setLayouts] = useState<Record<number, { x: number; width: number }>>({});
|
|
44
|
+
// Which tab currently holds keyboard focus (null = none).
|
|
45
|
+
const [focusedValue, setFocusedValue] = useState<string | null>(null);
|
|
46
|
+
// translateX + width animate together; width can't use the native driver, so neither does.
|
|
47
|
+
const anim = useSlideAnim(selectedIndex, false);
|
|
48
|
+
const measured = options.every((_, i) => layouts[i] != null);
|
|
49
|
+
|
|
50
|
+
// Unlike SegmentedToggle's analytically-computed thumb position, this indicator slides to each
|
|
51
|
+
// tab's real measured `x` (from onLayout) — already RTL-correct as-is, since onLayout reports actual
|
|
52
|
+
// post-mirroring screen position, not an index-based LTR assumption.
|
|
53
|
+
const indices = options.map((_, i) => i);
|
|
54
|
+
const indicatorX = measured
|
|
55
|
+
? anim.interpolate({ inputRange: indices, outputRange: indices.map(i => layouts[i].x) })
|
|
56
|
+
: 0;
|
|
57
|
+
const indicatorW = measured
|
|
58
|
+
? anim.interpolate({ inputRange: indices, outputRange: indices.map(i => layouts[i].width) })
|
|
59
|
+
: 0;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<View style={[styles.row, style]} accessibilityRole="tablist">
|
|
63
|
+
{options.map((opt, i) => {
|
|
64
|
+
const selected = opt.value === value;
|
|
65
|
+
const color = selected ? DS_SEMANTIC.text.regular : DS_SEMANTIC.text.muted;
|
|
66
|
+
return (
|
|
67
|
+
<Pressable
|
|
68
|
+
key={opt.value}
|
|
69
|
+
onPress={() => onChange(opt.value)}
|
|
70
|
+
onLayout={e => {
|
|
71
|
+
const { x, width } = e.nativeEvent.layout;
|
|
72
|
+
setLayouts(prev =>
|
|
73
|
+
prev[i] && prev[i].x === x && prev[i].width === width ? prev : { ...prev, [i]: { x, width } },
|
|
74
|
+
);
|
|
75
|
+
}}
|
|
76
|
+
onFocus={() => setFocusedValue(opt.value)}
|
|
77
|
+
onBlur={() => setFocusedValue(prev => (prev === opt.value ? null : prev))}
|
|
78
|
+
accessibilityRole="tab"
|
|
79
|
+
accessibilityLabel={opt.badge != null && opt.badge > 0 ? `${opt.label}, ${opt.badge}` : opt.label}
|
|
80
|
+
accessibilityState={{ selected }}
|
|
81
|
+
// Keyboard focus shows the same highlight as a press (same policy as SegmentedToggle/ListItem).
|
|
82
|
+
style={({ pressed }) => [styles.tab, (pressed || focusedValue === opt.value) && styles.tabPressed]}
|
|
83
|
+
>
|
|
84
|
+
{opt.iconName && <Icon name={opt.iconName} size={DS_ICON_SIZE.sm} color={color} />}
|
|
85
|
+
<Text style={[styles.label, { color }]}>{opt.label}</Text>
|
|
86
|
+
{opt.badge != null && opt.badge > 0 && (
|
|
87
|
+
<View style={[styles.badge, { backgroundColor: color }]}>
|
|
88
|
+
<Text style={[styles.badgeText, { color: DS_SEMANTIC.surface.white }]}>{opt.badge}</Text>
|
|
89
|
+
</View>
|
|
90
|
+
)}
|
|
91
|
+
</Pressable>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
{measured && hasSelection && (
|
|
95
|
+
<Animated.View
|
|
96
|
+
pointerEvents="none"
|
|
97
|
+
style={[styles.indicator, { width: indicatorW, transform: [{ translateX: indicatorX }] }]}
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
100
|
+
</View>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const styles = StyleSheet.create({
|
|
105
|
+
row: {
|
|
106
|
+
flexDirection: 'row',
|
|
107
|
+
alignItems: 'flex-end',
|
|
108
|
+
gap: DS_SPACING[800],
|
|
109
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
110
|
+
borderBottomColor: DS_SEMANTIC.element.divider,
|
|
111
|
+
},
|
|
112
|
+
tab: {
|
|
113
|
+
flexDirection: 'row',
|
|
114
|
+
alignItems: 'center',
|
|
115
|
+
gap: DS_SPACING[200],
|
|
116
|
+
paddingTop: DS_SPACING[400],
|
|
117
|
+
paddingBottom: DS_SPACING[600],
|
|
118
|
+
// A fixed floor (not a spacing token — this is an accessibility guideline number, not a design
|
|
119
|
+
// choice) so the tab's compact visual padding still guarantees a touch target.
|
|
120
|
+
minHeight: DS_A11Y_MIN_TOUCH_TARGET,
|
|
121
|
+
},
|
|
122
|
+
tabPressed: {
|
|
123
|
+
backgroundColor: DS_SEMANTIC.interaction.pressed,
|
|
124
|
+
borderRadius: DS_RADIUS.small,
|
|
125
|
+
},
|
|
126
|
+
label: {
|
|
127
|
+
// Matches the section-header label size (labelXs) it stands in for.
|
|
128
|
+
...DS_TYPOGRAPHY.labelXs,
|
|
129
|
+
// Pin the line box to the badge height (18) so a tab is the same height with or without a count
|
|
130
|
+
// badge — otherwise the taller badged tab grows the row and shifts the divider/content below it.
|
|
131
|
+
lineHeight: 18,
|
|
132
|
+
},
|
|
133
|
+
// The sliding underline, sat on the hairline rule beneath the selected tab.
|
|
134
|
+
indicator: {
|
|
135
|
+
position: 'absolute',
|
|
136
|
+
left: 0,
|
|
137
|
+
bottom: -StyleSheet.hairlineWidth,
|
|
138
|
+
height: INDICATOR_HEIGHT,
|
|
139
|
+
borderRadius: INDICATOR_HEIGHT / 2,
|
|
140
|
+
backgroundColor: DS_SEMANTIC.text.regular,
|
|
141
|
+
},
|
|
142
|
+
// Fixed-diameter circle so the count badge stays round. 18 doesn't match a DS_SPACING/DS_ICON_SIZE
|
|
143
|
+
// step (16/20 are the neighbors) — sized to comfortably fit a 1-2 digit count at labelXs.
|
|
144
|
+
badge: {
|
|
145
|
+
width: 18,
|
|
146
|
+
height: 18,
|
|
147
|
+
borderRadius: DS_RADIUS.round,
|
|
148
|
+
alignItems: 'center',
|
|
149
|
+
justifyContent: 'center',
|
|
150
|
+
},
|
|
151
|
+
badgeText: {
|
|
152
|
+
...DS_TYPOGRAPHY.labelXs,
|
|
153
|
+
// Pinned to the badge's own 18px diameter (above) so the count sits centered in the circle.
|
|
154
|
+
lineHeight: 18,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Barrel for the React Native component tree. */
|
|
2
|
+
export * from './AnimatedChevron';
|
|
3
|
+
export * from './Avatar';
|
|
4
|
+
export * from './Badge';
|
|
5
|
+
export * from './Banner';
|
|
6
|
+
export * from './BottomSheet';
|
|
7
|
+
export * from './Button';
|
|
8
|
+
export * from './ButtonGroup';
|
|
9
|
+
export * from './Card';
|
|
10
|
+
export * from './Checkbox';
|
|
11
|
+
export * from './Dialog';
|
|
12
|
+
export * from './Divider';
|
|
13
|
+
export * from './Dock';
|
|
14
|
+
export * from './Dropdown';
|
|
15
|
+
export * from './EmptyState';
|
|
16
|
+
export * from './FieldContainer';
|
|
17
|
+
export * from './InputClearButton';
|
|
18
|
+
export * from './InputField';
|
|
19
|
+
export * from './List';
|
|
20
|
+
export * from './ListItem';
|
|
21
|
+
export * from './Loading';
|
|
22
|
+
export * from './Pill';
|
|
23
|
+
export * from './PillRow';
|
|
24
|
+
export * from './ProgressDots';
|
|
25
|
+
export * from './Radio';
|
|
26
|
+
export * from './SearchField';
|
|
27
|
+
export * from './SectionHeader';
|
|
28
|
+
export * from './SegmentedToggle';
|
|
29
|
+
export * from './Shimmer';
|
|
30
|
+
export * from './Surface';
|
|
31
|
+
export * from './Switch';
|
|
32
|
+
export * from './TextArea';
|
|
33
|
+
export * from './Toast';
|
|
34
|
+
export * from './Tooltip';
|
|
35
|
+
export * from './TopNav';
|
|
36
|
+
export * from './UnderlineTabs';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DS_SEMANTIC } from '../tokens';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one shared status→color lookup behind every status-tinted component (Badge, Banner): a light
|
|
5
|
+
* tinted fill (`shade`) paired with a saturated foreground (`emphasis`), so a badge and a banner of
|
|
6
|
+
* the same status always read as the same color identity. Previously each component maintained its
|
|
7
|
+
* own identical copy of this map — one place to change now keeps them matched by construction.
|
|
8
|
+
* (Toast's variant map lives separately: its variant names differ (`success`/`informational`) and it
|
|
9
|
+
* bundles per-variant icons, so it's a different shape, not a third copy of this one.)
|
|
10
|
+
*/
|
|
11
|
+
export type StatusVariant = 'neutral' | 'info' | 'positive' | 'warning' | 'negative';
|
|
12
|
+
|
|
13
|
+
export const STATUS_BG: Record<StatusVariant, string> = {
|
|
14
|
+
neutral: DS_SEMANTIC.shade.neutral,
|
|
15
|
+
info: DS_SEMANTIC.shade.info,
|
|
16
|
+
positive: DS_SEMANTIC.shade.positive,
|
|
17
|
+
warning: DS_SEMANTIC.shade.warning,
|
|
18
|
+
negative: DS_SEMANTIC.shade.negative,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const STATUS_FG: Record<StatusVariant, string> = {
|
|
22
|
+
neutral: DS_SEMANTIC.emphasis.neutral,
|
|
23
|
+
info: DS_SEMANTIC.emphasis.info,
|
|
24
|
+
positive: DS_SEMANTIC.emphasis.positive,
|
|
25
|
+
warning: DS_SEMANTIC.emphasis.warning,
|
|
26
|
+
negative: DS_SEMANTIC.emphasis.negative,
|
|
27
|
+
};
|