@krapwoo/ds-viewer 0.6.3 → 0.6.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@krapwoo/ds-viewer",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A browsable, always-current catalog for an Expo / React Native design system.",
@@ -6,6 +6,7 @@ import { DS_SEMANTIC } from '../../tokens';
6
6
  export default defineCatalogPage({
7
7
  component: 'AnimatedChevron',
8
8
  group: 'Components',
9
+ whenToUse: 'An expand/collapse affordance that should animate its own state change — it flips vertically in place, so it never points sideways mid-transition. Where a chevron never changes state, a plain icon is enough.',
9
10
  description: 'A chevron that morphs between down (collapsed) and up (expanded) — flipping vertically in place instead of rotating through a sideways-pointing angle, and instead of swapping icons. The morph Banner\'s own collapsible header uses for its disclosure indicator. Not commonly used on its own; exported for building a custom disclosure/accordion toggle.',
10
11
  a11y: 'Purely decorative — no accessibility role of its own. Wrap it in an accessible parent (the way Banner\'s own header Pressable does) if the toggle needs to be announced.',
11
12
  variants: {
@@ -32,6 +32,7 @@ const AVATAR_COMPARISON = grid(
32
32
  export default defineCatalogPage({
33
33
  component: 'Avatar',
34
34
  group: 'Components',
35
+ whenToUse: 'A person or place\'s identity in a circle. Pass whatever you have: image, icon, or initials are tried in that order, so a missing image degrades to initials without leaving a gap.',
35
36
  specimenSize: 'compact',
36
37
  comparison: AVATAR_COMPARISON,
37
38
  description: 'A circular image, or an initials fallback on a solid fill when there\'s no image (or it fails to load).',
@@ -8,48 +8,41 @@ import { BottomSheet } from './BottomSheet';
8
8
  import { DS_SEMANTIC, DS_SPACING, DS_TYPOGRAPHY } from '../../tokens';
9
9
 
10
10
  const styles = StyleSheet.create({
11
- // BottomSheet/Dropdown demos render inside the shared `PhoneFrame` (./PhoneFrame.tsx) so their
12
- // absolute overlays stay contained instead of covering the whole catalog page. No padding here —
13
- // BottomSheet's own content area already supplies the standard 16px.
14
- sheetContent: {},
11
+ // No padding here — BottomSheet's own content area already supplies the standard 16px.
15
12
  cardTitle: { ...DS_TYPOGRAPHY.labelMd, color: DS_SEMANTIC.text.regular },
16
13
  cardBody: { ...DS_TYPOGRAPHY.bodySm, color: DS_SEMANTIC.text.muted, marginTop: DS_SPACING[200] },
17
14
  });
18
15
 
19
- function BottomSheetDemo() {
16
+ const EXTRA_PARAGRAPHS = [
17
+ 'Board at the front car for a faster transfer at Union Sq.',
18
+ 'This line runs express between 96 St and 168 St during rush hours, skipping local stops in between. Weekend service runs local along the full route, with some stations closed for planned maintenance.',
19
+ 'Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible entrances before you travel.',
20
+ ];
21
+
22
+ /** Every example is a real sheet inside a phone: the trigger opens it, the backdrop dismisses it.
23
+ * `OverlayDemo` supplies the phone, so the sheet's absolute overlay stays in its own cell. */
24
+ function Sheet({
25
+ header,
26
+ footer,
27
+ /** Extra paragraphs; enough of them and the content area passes 90% height and starts scrolling. */
28
+ paragraphs = 0,
29
+ }: {
30
+ header?: (close: () => void) => React.ReactNode;
31
+ footer?: (close: () => void) => React.ReactNode;
32
+ paragraphs?: number;
33
+ }) {
20
34
  return (
21
- <OverlayDemo triggerLabel="Open sheet">
35
+ // Open from the start: closed, every example is the same empty phone behind the same trigger,
36
+ // and what each one is named for — the header, the footer, the scrolling — is invisible.
37
+ <OverlayDemo triggerLabel="Open sheet" initiallyOpen>
22
38
  {({ open, close }) => (
23
- <BottomSheet
24
- visible={open}
25
- onDismiss={close}
26
- header={
27
- <TopNav
28
- title="Trip details"
29
- trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="clear" accessibilityLabel="Close" onPress={close} />}
30
- />
31
- }
32
- footer={
33
- <Dock>
34
- <Button label="Confirm" onPress={close} />
35
- </Dock>
36
- }
37
- >
38
- <View style={styles.sheetContent}>
39
- <Text style={styles.cardTitle}>Uptown & The Bronx</Text>
39
+ <BottomSheet visible={open} onDismiss={close} header={header?.(close)} footer={footer?.(close)}>
40
+ <View>
41
+ <Text style={styles.cardTitle}>Uptown &amp; The Bronx</Text>
40
42
  <Text style={styles.cardBody}>Next train in 4 min · every 6–8 min.</Text>
41
- {/* Long enough to overflow the phone frame's fixed height — demonstrates the Dock footer's
42
- `elevated` shadow turning on automatically once this content area actually scrolls. */}
43
- <Text style={styles.cardBody}>Board at the front car for a faster transfer at Union Sq.</Text>
44
- <Text style={styles.cardBody}>
45
- This line runs express between 96 St and 168 St during rush hours, skipping local stops in
46
- between. Weekend service runs local along the full route, with some stations closed for
47
- planned maintenance.
48
- </Text>
49
- <Text style={styles.cardBody}>
50
- Elevators are available at 168 St, 137 St, and 125 St — check the map for accessible
51
- entrances before you travel.
52
- </Text>
43
+ {EXTRA_PARAGRAPHS.slice(0, paragraphs).map((line) => (
44
+ <Text key={line} style={styles.cardBody}>{line}</Text>
45
+ ))}
53
46
  </View>
54
47
  </BottomSheet>
55
48
  )}
@@ -57,6 +50,18 @@ function BottomSheetDemo() {
57
50
  );
58
51
  }
59
52
 
53
+ const topNav = (close: () => void) => (
54
+ <TopNav
55
+ title="Trip details"
56
+ trailing={<Button variant="secondary" size="small" showIcon showLabel={false} iconName="clear" accessibilityLabel="Close" onPress={close} />}
57
+ />
58
+ );
59
+ const dock = (close: () => void) => (
60
+ <Dock>
61
+ <Button label="Confirm" onPress={close} />
62
+ </Dock>
63
+ );
64
+
60
65
  export default defineCatalogPage({
61
66
  component: 'BottomSheet',
62
67
  group: 'Components',
@@ -67,5 +72,19 @@ export default defineCatalogPage({
67
72
  description: 'A sheet that slides up over a dismissible backdrop. Height is driven by its content (not fixed snap points) up to 90% of the available height, then the content area scrolls. Compose header with TopNav and footer with Dock.',
68
73
  whenToUse: 'For longer, browsable content, or anything that benefits from a TopNav/Dock header-footer structure. For a short, focused decision that interrupts the flow, use Dialog.',
69
74
  a11y: 'The backdrop is a Pressable with accessibilityRole="button" and accessibilityLabel="Dismiss"; header/content/footer carry their own accessibility (e.g. TopNav\'s title as accessibilityRole="header").',
70
- render: () => <BottomSheetDemo />,
75
+ variants: {
76
+ items: [
77
+ // Both slots empty: the sheet is only as tall as its content, above its own drag handle.
78
+ { key: 'content-only', name: 'Content only', node: <Sheet /> },
79
+ { key: 'header', name: 'With a TopNav header', node: <Sheet header={topNav} /> },
80
+ { key: 'header-footer', name: 'Header and a Dock footer', node: <Sheet header={topNav} footer={dock} /> },
81
+ ],
82
+ },
83
+ states: {
84
+ items: [
85
+ // Past 90% of the phone's height the content area scrolls, which is what turns on the Dock
86
+ // footer's shadow — overflowing it is the only way to see that happen.
87
+ { key: 'scrolling', name: 'Content long enough to scroll', node: <Sheet header={topNav} footer={dock} paragraphs={3} /> },
88
+ ],
89
+ },
71
90
  });
@@ -6,6 +6,7 @@ import { Button } from '../Button';
6
6
  export default defineCatalogPage({
7
7
  component: 'ButtonGroup',
8
8
  group: 'Components',
9
+ whenToUse: 'Two or three Buttons that should read as one family — horizontal for a trailing-aligned pair (a dialog\'s Cancel and Confirm), vertical for up to three stretched full width. Keep one variant tier and one size across the group. For a screen\'s bottom action area, use Dock.',
9
10
  composedOf: [
10
11
  { component: 'Button', role: "Its children: up to two Buttons side by side, or three stacked.", relationship: 'slot' },
11
12
  ],
@@ -12,19 +12,18 @@ const styles = StyleSheet.create({
12
12
  dialogActions: { marginTop: DS_SPACING[800] },
13
13
  });
14
14
 
15
- function DialogDemo() {
15
+ /** Every example is a real dialog inside a phone, so the backdrop covers the screen it belongs to
16
+ * rather than the catalog page. `OverlayDemo` supplies the phone and the trigger. */
17
+ function Demo({ title, body, actions }: { title: string; body?: string; actions: (close: () => void) => React.ReactNode }) {
16
18
  return (
17
- <OverlayDemo triggerLabel="Open dialog">
19
+ // Open from the start: a closed dialog is just a trigger, so the card each example is named
20
+ // for would never be on screen.
21
+ <OverlayDemo triggerLabel="Open dialog" initiallyOpen>
18
22
  {({ open, close }) => (
19
23
  <Dialog visible={open} onDismiss={close}>
20
- <Text style={styles.cardTitle}>Delete this trip?</Text>
21
- <Text style={styles.cardBody}>This can't be undone.</Text>
22
- <View style={styles.dialogActions}>
23
- <ButtonGroup>
24
- <Button label="Cancel" variant="tertiary" onPress={close} />
25
- <Button label="Delete" onPress={close} />
26
- </ButtonGroup>
27
- </View>
24
+ <Text style={styles.cardTitle}>{title}</Text>
25
+ {body ? <Text style={styles.cardBody}>{body}</Text> : null}
26
+ <View style={styles.dialogActions}>{actions(close)}</View>
28
27
  </Dialog>
29
28
  )}
30
29
  </OverlayDemo>
@@ -34,8 +33,58 @@ function DialogDemo() {
34
33
  export default defineCatalogPage({
35
34
  component: 'Dialog',
36
35
  group: 'Components',
36
+ composedOf: [
37
+ { component: 'ButtonGroup', role: "Lays out the dialog's actions; a dialog's own children decide this, not Dialog.", relationship: 'related' },
38
+ ],
37
39
  description: 'A card centred on screen over a dismissible backdrop — fades and scales in, distinct from BottomSheet\'s bottom-anchored slide. Named Dialog (not Modal) to avoid shadowing React Native\'s own built-in Modal.',
38
40
  whenToUse: 'A short, focused decision that interrupts the flow (confirm/cancel, a single form). For anything longer, browsable, or that needs its own internal scrolling, use BottomSheet.',
39
41
  a11y: 'The backdrop is a Pressable with accessibilityRole="button" and accessibilityLabel="Dismiss"; content you pass as children carries its own accessibility.',
40
- render: () => <DialogDemo />,
42
+ variants: {
43
+ items: [
44
+ {
45
+ key: 'confirm',
46
+ name: 'A confirmation, two actions',
47
+ node: (
48
+ <Demo
49
+ title="Delete this trip?"
50
+ body="This can't be undone."
51
+ actions={(close) => (
52
+ <ButtonGroup>
53
+ <Button label="Cancel" variant="tertiary" onPress={close} />
54
+ <Button label="Delete" onPress={close} />
55
+ </ButtonGroup>
56
+ )}
57
+ />
58
+ ),
59
+ },
60
+ {
61
+ key: 'acknowledge',
62
+ name: 'One action to acknowledge',
63
+ node: (
64
+ <Demo
65
+ title="Trip saved"
66
+ body="You'll find it under Saved trips."
67
+ actions={(close) => <Button label="Got it" onPress={close} />}
68
+ />
69
+ ),
70
+ },
71
+ {
72
+ // Title alone: Dialog adds no body of its own, so a question that needs no explanation
73
+ // keeps the card to two lines.
74
+ key: 'title-only',
75
+ name: 'Title and actions only',
76
+ node: (
77
+ <Demo
78
+ title="Discard changes?"
79
+ actions={(close) => (
80
+ <ButtonGroup>
81
+ <Button label="Keep editing" variant="tertiary" onPress={close} />
82
+ <Button label="Discard" onPress={close} />
83
+ </ButtonGroup>
84
+ )}
85
+ />
86
+ ),
87
+ },
88
+ ],
89
+ },
41
90
  });
@@ -12,6 +12,7 @@ const styles = StyleSheet.create({
12
12
  export default defineCatalogPage({
13
13
  component: 'Divider',
14
14
  group: 'Components',
15
+ whenToUse: 'A single hairline between two pieces of content you are laying out yourself. List already puts one between each pair of its rows, so adding your own there would double them.',
15
16
  specimenSize: 'regular',
16
17
  description: 'A 1px hairline separator at the divider token colour.',
17
18
  a11y: 'A plain, non-interactive View — purely decorative.',
@@ -6,6 +6,7 @@ import { Dock } from './Dock';
6
6
  export default defineCatalogPage({
7
7
  component: 'Dock',
8
8
  group: 'Components',
9
+ whenToUse: 'A screen\'s bottom action area: up to three full-width buttons above the home indicator, with an optional caption. It is not positioned absolutely itself — the composing screen decides whether to pin it or lay it out inline at the end of a column. For actions inside a card or dialog, use ButtonGroup.',
9
10
  composedOf: [
10
11
  { component: 'Button', role: "Its children: up to three full-width Buttons, stacked.", relationship: 'slot' },
11
12
  ],
@@ -13,6 +13,7 @@ const styles = StyleSheet.create({
13
13
  export default defineCatalogPage({
14
14
  component: 'FieldContainer',
15
15
  group: 'Components',
16
+ whenToUse: 'Building a new kind of field. It carries the shared field chrome and reads the ambient Surface tone, so it stays visible on white and on muted backgrounds alike. For the fields that already exist, use InputField or TextArea instead of wrapping this yourself.',
16
17
  composedOf: [
17
18
  { component: 'Surface', role: "Reads the surrounding Surface tone: a recessed fill on white, opaque white inside a muted Surface.", relationship: 'related' },
18
19
  ],
@@ -5,6 +5,7 @@ import { InputClearButton } from './InputClearButton';
5
5
  export default defineCatalogPage({
6
6
  component: 'InputClearButton',
7
7
  group: 'Components',
8
+ whenToUse: 'The clear control inside a text field. InputField and SearchField already include one, so reach for it directly only when building another field type that needs the same affordance.',
8
9
  description: 'The clear (×) button InputField and SearchField both show once a field is active and holds a value — a filled circle-x icon sized to reach the 44pt touch target via hitSlop, not visual size.',
9
10
  a11y: 'A Pressable with accessibilityRole="button" and the given (or default "Clear field") accessibilityLabel; hitSlop of 10 on every side pads its 24×24 visual size out to the 44pt minimum.',
10
11
  variants: {
@@ -9,6 +9,7 @@ import { DS_SEMANTIC } from '../../tokens';
9
9
  export default defineCatalogPage({
10
10
  component: 'List',
11
11
  group: 'Components',
12
+ whenToUse: 'A set of ListItem rows as one rounded surface, with dividers between rows and never after the last. For a single row standing on its own, use ListItem directly.',
12
13
  specimenSize: 'regular',
13
14
  composedOf: [
14
15
  { component: 'Divider', role: "The hairline between consecutive items (none after the last).", relationship: 'built-in' },
@@ -6,6 +6,7 @@ import { DS_SEMANTIC } from '../../tokens';
6
6
  export default defineCatalogPage({
7
7
  component: 'Loading',
8
8
  group: 'Components',
9
+ whenToUse: 'An indeterminate wait of unknown length — `circle` inline, `linear` where a bar suits the layout better. Button and Pill already show this in their own loading states, so don\'t nest one inside them. For a known number of steps, use ProgressDots.',
9
10
  specimenSize: 'regular',
10
11
  description: 'An indeterminate loader — the shape fills up, empties out, then fills again, seamlessly. Two variants: circle (used internally by Button and Pill for their own loading states) and linear.',
11
12
  a11y: 'Exposed to assistive tech as accessibilityRole="progressbar" with accessibilityLabel="Loading" — no extra wiring needed at the call site.',
@@ -5,6 +5,7 @@ import { ProgressDots } from './ProgressDots';
5
5
  export default defineCatalogPage({
6
6
  component: 'ProgressDots',
7
7
  group: 'Components',
8
+ whenToUse: 'A short sequence of steps whose length is known, with the current step widened into a pill. For a wait of unknown length, use Loading.',
8
9
  description: 'A row of dots for a stepped flow; the active dot widens into a pill.',
9
10
  a11y: 'Decorative progress indicator; convey the "step X of Y" position in text for screen readers.',
10
11
  // No `variant` prop exists on ProgressDots — its "Variants" column just shows the one default
@@ -1,29 +1,50 @@
1
1
  import React, { useState } from 'react';
2
2
  import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
- import { SegmentedToggle } from './SegmentedToggle';
3
+ import { SegmentedToggle, type SegmentedToggleOption } from './SegmentedToggle';
4
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
- );
5
+ /** Each example owns its own selection, so the thumb really slides when a segment is pressed. */
6
+ function Demo({ options, initial }: { options: SegmentedToggleOption[]; initial?: string }) {
7
+ const [value, setValue] = useState(initial ?? options[0].value);
8
+ return <SegmentedToggle value={value} onChange={setValue} options={options} />;
20
9
  }
21
10
 
11
+ const TWO: SegmentedToggleOption[] = [
12
+ { value: 'map', label: 'Map' },
13
+ { value: 'list', label: 'List' },
14
+ ];
15
+ const ICONS: SegmentedToggleOption[] = [
16
+ { value: 'map', label: 'Map', iconName: 'map' },
17
+ { value: 'list', label: 'List', iconName: 'menu' },
18
+ { value: 'saved', label: 'Saved', iconName: 'bell' },
19
+ ];
20
+ const MIXED: SegmentedToggleOption[] = [
21
+ { value: 'map', label: 'Map', iconName: 'map' },
22
+ { value: 'list', label: 'List', iconName: 'menu' },
23
+ { value: 'saved', label: 'Saved', iconName: 'bell', badge: 3 },
24
+ // Label-only, beside segments that have icons: the row keeps one height either way.
25
+ { value: 'settings', label: 'Settings' },
26
+ ];
27
+
22
28
  export default defineCatalogPage({
23
29
  component: 'SegmentedToggle',
24
30
  group: 'Components',
25
31
  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.',
26
32
  whenToUse: 'A filled, heavier-weight control for a primary, prominent choice on the screen. For quieter secondary navigation, use UnderlineTabs.',
27
33
  a11y: 'The row is accessibilityRole="tablist"; each segment is a "tab" with accessibilityState.selected reflecting the current value.',
28
- render: () => <SegmentedToggleDemo />,
34
+ variants: {
35
+ itemsFill: true,
36
+ items: [
37
+ { key: 'two', name: 'Two options', node: <Demo options={TWO} /> },
38
+ { key: 'icons', name: 'An icon per segment', node: <Demo options={ICONS} /> },
39
+ { key: 'badge', name: 'A badge, and a label-only segment', node: <Demo options={MIXED} /> },
40
+ ],
41
+ },
42
+ states: {
43
+ itemsFill: true,
44
+ items: [
45
+ // Where the thumb sits is the component's whole job, so show both ends of the track.
46
+ { key: 'first', name: 'First segment selected', node: <Demo options={ICONS} initial="map" /> },
47
+ { key: 'last', name: 'Last segment selected', node: <Demo options={ICONS} initial="saved" /> },
48
+ ],
49
+ },
29
50
  });
@@ -11,6 +11,7 @@ const styles = StyleSheet.create({
11
11
  export default defineCatalogPage({
12
12
  component: 'Breathing',
13
13
  group: 'Components',
14
+ whenToUse: 'The raw pulsing block behind Shimmer, exported so a composite skeleton can pulse in lockstep with plain Shimmers off the one shared clock. For an ordinary loading placeholder, use Shimmer.',
14
15
  specimenSize: 'regular',
15
16
  description:
16
17
  "The shared animated primitive behind Shimmer — a solid block that breathes between a low and high colour on one shared, module-level clock, so every Breathing/Shimmer instance on screen stays phase-locked instead of drifting out of sync. Exported so a custom composite skeleton can reuse the same pulse directly instead of going through Shimmer's fixed text/circle/container shapes.",
@@ -12,6 +12,7 @@ const styles = StyleSheet.create({
12
12
  export default defineCatalogPage({
13
13
  component: 'Surface',
14
14
  group: 'Components',
15
+ a11y: 'No semantics of its own: it renders a plain filled View and passes its tone down through context, so it adds nothing to the accessibility tree and neither groups nor labels what sits inside it.',
15
16
  specimenSize: 'regular',
16
17
  description:
17
18
  "Declares \"this subtree's background is tone\" — renders a plain View filled with the matching token (surface.white or surface.main) and provides that tone to descendants via context, so a tone-aware component inside (e.g. FieldContainer/InputField) can automatically pick a fill that contrasts with it instead of assuming white.",
@@ -10,6 +10,7 @@ function TextAreaDemo() {
10
10
  export default defineCatalogPage({
11
11
  component: 'TextArea',
12
12
  group: 'Components',
13
+ whenToUse: 'Multiline free text that grows from a minimum height. For a single-line labelled row use InputField — both share FieldContainer\'s chrome.',
13
14
  specimenSize: 'regular',
14
15
  composedOf: [
15
16
  { component: 'FieldContainer', role: "The field frame around the multi-line input.", relationship: 'built-in' },
@@ -32,6 +32,7 @@ function TooltipDemo() {
32
32
  export default defineCatalogPage({
33
33
  component: 'Tooltip',
34
34
  group: 'Components',
35
+ whenToUse: 'A brief floating label for a control whose purpose an icon alone doesn\'t carry. It measures the trigger\'s real screen position, so the bubble stays inside the screen\'s edges and the arrow still points at the trigger\'s true centre. For something the reader must acknowledge, use Dialog.',
35
36
  specimenSize: 'regular',
36
37
  description: 'A small floating label anchored above or below its wrapped trigger. Fully controlled — drive `visible` from the trigger\'s own onLongPress/onPressIn, since mobile has no hover.',
37
38
  a11y: 'The bubble is a plain, non-interactive View; the trigger you wrap it around carries its own accessibility.',
@@ -12,6 +12,7 @@ const styles = StyleSheet.create({
12
12
  export default defineCatalogPage({
13
13
  component: 'TopNav',
14
14
  group: 'Components',
15
+ whenToUse: 'A screen\'s top bar, and BottomSheet\'s header slot. Its leading and trailing slots reserve their width even when empty, so the title stays centred whether one, both, or neither side has content. For actions at the bottom of a screen, use Dock.',
15
16
  composedOf: [
16
17
  { component: 'Button', role: "The leading and trailing slots, typically small icon Buttons (back, close, an action).", relationship: 'slot' },
17
18
  ],
@@ -1,28 +1,50 @@
1
1
  import React, { useState } from 'react';
2
2
  import { defineCatalogPage } from '@krapwoo/ds-viewer';
3
- import { UnderlineTabs } from './UnderlineTabs';
3
+ import { UnderlineTabs, type UnderlineTabOption } from './UnderlineTabs';
4
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
- );
5
+ /** Each example owns its own selection, so the underline really slides when a tab is pressed. */
6
+ function Demo({ options, initial }: { options: UnderlineTabOption[]; initial?: string }) {
7
+ const [value, setValue] = useState(initial ?? options[0].value);
8
+ return <UnderlineTabs value={value} onChange={setValue} options={options} />;
19
9
  }
20
10
 
11
+ const LABELS: UnderlineTabOption[] = [
12
+ { value: 'all', label: 'All' },
13
+ { value: 'nearby', label: 'Nearby' },
14
+ { value: 'favorites', label: 'Favorites' },
15
+ ];
16
+ const WITH_ICON: UnderlineTabOption[] = [
17
+ { value: 'all', label: 'All' },
18
+ // A leading icon beside tabs that have none: the row still aligns on one baseline.
19
+ { value: 'nearby', label: 'Nearby', iconName: 'pin' },
20
+ { value: 'favorites', label: 'Favorites' },
21
+ ];
22
+ const WITH_BADGE: UnderlineTabOption[] = [
23
+ { value: 'all', label: 'All' },
24
+ { value: 'nearby', label: 'Nearby', iconName: 'pin' },
25
+ { value: 'favorites', label: 'Favorites', badge: 2 },
26
+ ];
27
+
21
28
  export default defineCatalogPage({
22
29
  component: 'UnderlineTabs',
23
30
  group: 'Components',
24
31
  description: 'A quieter tab switcher — left-aligned labels over a hairline rule, with a sliding underline indicator. Same options/value/onChange API as SegmentedToggle.',
25
32
  whenToUse: "Quiet, secondary navigation within a screen that already has a clear primary focus. For a prominent, primary choice, use SegmentedToggle.",
26
33
  a11y: 'Each tab is a Pressable label; the underline is a visual indicator only, so selection is also conveyed by the active label weight.',
27
- render: () => <UnderlineTabsDemo />,
34
+ variants: {
35
+ itemsFill: true,
36
+ items: [
37
+ { key: 'labels', name: 'Labels only', node: <Demo options={LABELS} /> },
38
+ { key: 'icon', name: 'A leading icon on one tab', node: <Demo options={WITH_ICON} /> },
39
+ { key: 'badge', name: 'A badge on a tab', node: <Demo options={WITH_BADGE} /> },
40
+ ],
41
+ },
42
+ states: {
43
+ itemsFill: true,
44
+ items: [
45
+ // The underline's position is the selection, so show it at both ends of the row.
46
+ { key: 'first', name: 'First tab selected', node: <Demo options={LABELS} initial="all" /> },
47
+ { key: 'last', name: 'Last tab selected', node: <Demo options={LABELS} initial="favorites" /> },
48
+ ],
49
+ },
28
50
  });