@lotics/ui 47.9.0 → 47.10.1

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/docs/catalog.md CHANGED
@@ -425,7 +425,8 @@ component — children are the body), `Subsection` (+ `SubsectionHeading`/
425
425
  `SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
426
426
  top-level blocks), `SubsectionStack` (a section's `Subsection` groups — fixed 32px beat,
427
427
  space-only while the groups are SHORT, `divided` once they run long), `SectionCard`, `PageHeader` /
428
- `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `SegmentedControl`,
428
+ `PageContent`, `Stack`, `Spacer`, `Divider`, `Accordion`, `Tabs`, `PipelineTabs` (Tabs plus order —
429
+ the stage strip of a lifecycle desk, outcomes set apart), `SegmentedControl`,
429
430
  `Stepper`, `DangerZone` (the destructive section — delete/archive — set apart at the bottom
430
431
  of a record/settings surface).
431
432
 
@@ -909,9 +910,30 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
909
910
  can finish their task without ever opening it. → [composition.md](./composition.md)
910
911
  §"Canvas & content column". `Accordion` + `AccordionHeader`/`AccordionTitle`/`AccordionMeta`:
911
912
  expandable section rows.
912
- - **`tabs`** — `Tabs`: switch between content sections; WAI-ARIA tablist + roving tabindex;
913
- each `TabOption` takes an optional `status` `ColorName` a small attention dot before its
914
- label, for a tab whose area needs work.
913
+ - **`tabs`** — `Tabs`: switch between content sections; WAI-ARIA tablist + roving tabindex.
914
+ Each `TabOption` takes an optional `status` `ColorName` (a small dot before the label, for a
915
+ tab whose area needs work), `count` (the band's size, as a PROP never formatted into the
916
+ label) and `caption` (a second line under the label, one pre-formatted figure; once any tab in
917
+ a strip has one, every tab reserves the line so the strip keeps one height). Also exports
918
+ `TabButton`, the ONE tab anatomy `PipelineTabs` shares, plus `TAB_PADDING_V` /
919
+ `TAB_LABEL_LINE` so a strip drawing something beside a tab can sit it on the label line.
920
+ - **`pipeline_tabs`** — `PipelineTabs` + `PipelineTab` + `PipelineOutcomes`: `Tabs` plus ORDER
921
+ — the stage strip over a lifecycle desk, a compound. The root (`value`/`onValueChange`,
922
+ `accessibilityLabel`) is the `tablist` and owns the keyboard ring; each `PipelineTab` (`value`,
923
+ `label`, `count?`, `caption?`, `status?`) is `Tabs`' own `TabButton`, so the two strips cannot
924
+ drift; `PipelineOutcomes` wraps the terminal stages past a rule with no chevrons between them.
925
+ Children in PIPELINE ORDER, the flow joined by chevrons sat on the label line; a tab inserted
926
+ anywhere (through a Fragment too) joins the ring where it sits. `label` is the reader's word —
927
+ a field option's label or a locale string; the strip renders no words of its own. `status` is
928
+ an optional dot in the `Badge variant="dot"` vocabulary, with two honest uses: the verdict on
929
+ the outcomes only (the flow bare — being at a stage is not a status), or every stage in its
930
+ option's colour when the rows below badge that same field; never a decoration picked per
931
+ stage. Reach for it when each
932
+ stage changes what the register under it IS — its columns, its act, its figure — the
933
+ lifecycle desk's tab strip (→ [templates.md](./templates.md) §"App-screen shapes"). NOT a
934
+ filter: stages that show the same columns over fewer rows are a `ChipGroup` with counts. NOT a
935
+ report: a funnel's rates are `Funnel`'s, a share bar is `Breakdown`'s. NOT one record's ladder:
936
+ that is `Checklist connected` / `Stepper`. Owns its horizontal scroll, never the page's.
915
937
  - **`segmented_control`** — `SegmentedControl`: the exclusive small-set switch. Picking between
916
938
  this, `ChipGroup`, `RadioPicker`, `Select` and `Tabs` is decided by OPTION COUNT and by
917
939
  whether the choice filters a view or sets a value — not by taste. →
@@ -2006,6 +2028,11 @@ a child of the app root, which every overlay's body-level box follows.
2006
2028
  module-level open-overlay counter every overlay primitive reports into; the host's
2007
2029
  shortcut registry reads it synchronously to floor page-level shortcuts while any overlay
2008
2030
  is open. Lives in the primitives — never call it from a screen.
2031
+ - **`use_roving_tabs`** — `useRovingTabs({ count, selectedIndex, onSelect })` →
2032
+ `{ tabStopIndex, onKeyDown, bindRef }`: the WAI-ARIA tabs keyboard contract (one tab stop, arrow
2033
+ keys select AND move focus, Home/End) shared by `Tabs` and `Pipeline`. Reach for it when
2034
+ building a NEW `tablist`; a `radiogroup` (`SegmentedControl`) selects on Space/Enter as well and
2035
+ is not this hook.
2009
2036
  - **`use_section_nav`** — `useSectionNav`: scroll-spy for a LONG record surface with a left
2010
2037
  outline rail — keys in page order → `{scrollRef, onScroll, register(key)→onLayout,
2011
2038
  jumpTo(key), activeKey}`; rail items are `MenuButton`s (`current={activeKey===key}` — a
@@ -1132,6 +1132,11 @@ by what KIND of thing they are.
1132
1132
  screen) or a `SegmentedControl` (a mode of it); if neither fits because the two panels really
1133
1133
  are different content, that tab was **two destinations wearing one name** and the fix is to
1134
1134
  promote both into the strip above.
1135
+ **The one strip that may sit under a `Tabs` is a `PipelineTabs`**, and only because it is not a
1136
+ second copy of anything: its stages change the register's columns and act (the lifecycle desk, →
1137
+ [templates.md](./templates.md)), so it is the section's own content, not a lens on it. The test
1138
+ that keeps this honest is the same one that makes a tab a tab — if every stage showed the same
1139
+ columns, it is a `ChipGroup`.
1135
1140
  **The label confesses it before the layout does: a tab named "X and Y" is two tabs.** Reach for
1136
1141
  the domain's own list of things — the entities its people already name one at a time — and give
1137
1142
  each one a tab; a pair invented to keep the strip short buys nothing, because the pair has to be
package/docs/templates.md CHANGED
@@ -115,7 +115,10 @@ the filters, never a second copy of them; each states its own size and trouble a
115
115
  application, a shipment, a repair. *"What is stuck, and what do I move next?"*
116
116
 
117
117
  - **Tabs: the stages**, and here they earn it — each band carries its own act (confirm, dispatch,
118
- close), its own summary, often its own columns. Roughly five; past that the tail is a lens.
118
+ close), its own summary, often its own columns. Roughly five; past that the tail is a lens. The
119
+ strip is a `PipelineTabs` — `Tabs` plus order: the same tab anatomy drawn as the flow, each stage
120
+ with its dot, count and caption, the won/lost outcomes set apart — rather than a plain `Tabs`,
121
+ because for a pipeline the ORDER is the subject and a wrapping row of names drops it.
119
122
  - **Record: a `Drawer`.** The reader came from the list and returns to it, and the drawer's ◀ ▶
120
123
  sequences the band they were reading.
121
124
  - **Row vs record:** the row carries what the NEXT act needs — the goods, the money, the clock —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "47.9.0",
3
+ "version": "47.10.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./accordion": "./src/accordion.tsx",
@@ -277,6 +277,7 @@
277
277
  "./table": "./src/table.tsx",
278
278
  "./table_fit": "./src/table_fit.ts",
279
279
  "./tabs": "./src/tabs.tsx",
280
+ "./pipeline_tabs": "./src/pipeline_tabs.tsx",
280
281
  "./text": "./src/text.tsx",
281
282
  "./text_disclosure": "./src/text_disclosure.tsx",
282
283
  "./text_ink": "./src/text_ink.ts",
@@ -308,6 +309,7 @@
308
309
  "./use_hover": "./src/use_hover.ts",
309
310
  "./use_image_rotation": "./src/use_image_rotation.ts",
310
311
  "./use_persisted_state": "./src/use_persisted_state.ts",
312
+ "./use_roving_tabs": "./src/use_roving_tabs.ts",
311
313
  "./use_paste_files": {
312
314
  "react-native": "./src/use_paste_files.ts",
313
315
  "default": "./src/use_paste_files.web.ts"
@@ -0,0 +1,201 @@
1
+ import { Children, Fragment, createContext, isValidElement, useContext, type ReactNode } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Icon } from "./icon";
4
+ import { colors, type ColorName } from "./colors";
5
+ import { SPACE } from "./spacing";
6
+ import { TabButton, TAB_LABEL_LINE, TAB_PADDING_V } from "./tabs";
7
+ import { useRovingTabs, type RovingTabs } from "./use_roving_tabs";
8
+
9
+ interface PipelineCtx {
10
+ value: string;
11
+ onValueChange: (value: string) => void;
12
+ /** Every tab's value in render order — the flow first, then the outcomes.
13
+ * Index into it is a tab's place in the keyboard ring. */
14
+ order: string[];
15
+ /** Whether any tab carries a caption — then every tab reserves the line. */
16
+ captionSlot: boolean;
17
+ roving: RovingTabs;
18
+ }
19
+
20
+ const PipelineContext = createContext<PipelineCtx | null>(null);
21
+ const OutcomesContext = createContext(false);
22
+
23
+ function usePipeline(component: string): PipelineCtx {
24
+ const ctx = useContext(PipelineContext);
25
+ if (!ctx) throw new Error(`${component} must be rendered inside <PipelineTabs>`);
26
+ return ctx;
27
+ }
28
+
29
+ export interface PipelineTabsProps<T extends string> {
30
+ /** Accessible name of the tablist. */
31
+ accessibilityLabel: string;
32
+ value: T;
33
+ onValueChange: (value: T) => void;
34
+ /** `PipelineTab`s in PIPELINE ORDER — the flow left to right — then one
35
+ * `PipelineOutcomes` holding the stages a record leaves at. The strip never
36
+ * re-ranks: the sequence is the process. */
37
+ children: ReactNode;
38
+ testID?: string;
39
+ }
40
+
41
+ export interface PipelineTabProps<T extends string = string> {
42
+ value: T;
43
+ /** The stage's name, in the reader's language — a field's option label, or
44
+ * a locale string. The strip renders no words of its own. */
45
+ label: string;
46
+ /** How many records sit at this stage — see `TabOption.count`. */
47
+ count?: number;
48
+ /** The second line — see `TabOption.caption`. Pre-formatted: the strip knows
49
+ * no currency from a count. */
50
+ caption?: string;
51
+ /** A dot before the label, in the `Badge variant="dot"` vocabulary. Optional,
52
+ * and two honest uses: the VERDICT on an outcome only (`emerald` won, `red`
53
+ * lost — the flow stays bare, since being at a stage is not a status), or
54
+ * every stage in the colour its own option carries, when the rows below
55
+ * badge that same field and the strip should agree with them. Never a
56
+ * decoration picked per stage. */
57
+ status?: ColorName;
58
+ testID?: string;
59
+ }
60
+
61
+ interface Collected {
62
+ values: string[];
63
+ captionSlot: boolean;
64
+ }
65
+
66
+ /** Every tab under `children`, in render order, through a Fragment or a
67
+ * `PipelineOutcomes` — so a tab inserted anywhere joins the keyboard ring
68
+ * where it sits. */
69
+ function collect(children: ReactNode, into: Collected = { values: [], captionSlot: false }): Collected {
70
+ Children.forEach(children, (child) => {
71
+ if (!isValidElement(child)) return;
72
+ if (child.type === PipelineTab && isValidElement<PipelineTabProps>(child)) {
73
+ into.values.push(child.props.value);
74
+ if (child.props.caption != null) into.captionSlot = true;
75
+ } else if (
76
+ (child.type === PipelineOutcomes || child.type === Fragment) &&
77
+ isValidElement<{ children?: ReactNode }>(child)
78
+ ) {
79
+ collect(child.props.children, into);
80
+ }
81
+ });
82
+ return into;
83
+ }
84
+
85
+ /**
86
+ * `Tabs` plus ORDER — the tab strip over a lifecycle desk, whose tabs are the
87
+ * stages a population of records is spread across, each changing what the
88
+ * register under it IS (its columns, its act, its figure). A compound: the root
89
+ * owns the selection and the keyboard ring, a `PipelineTab` is one tab, and
90
+ * `PipelineOutcomes` groups the stages a record LEAVES at, past a rule, because
91
+ * a record does not walk into them the way it walks from one open stage to the
92
+ * next.
93
+ *
94
+ * Every tab is `Tabs`' own `TabButton` — the same dot, count, caption line,
95
+ * hover wash and underline — so the two strips cannot drift; what this adds is
96
+ * the chevron between stages of the flow, sat on the label line, and the rule
97
+ * before the outcomes. A flat wrapping `Tabs` says nothing about order, and
98
+ * order is what a pipeline is. Owns its horizontal scroll rather than wrapping
99
+ * the flow onto a second row.
100
+ *
101
+ * Not one record's ladder — that is `Checklist connected` or `Stepper`. Not a
102
+ * report — a funnel's rates are `Funnel`'s, a share bar is `Breakdown`'s.
103
+ */
104
+ export function PipelineTabs<T extends string>(props: PipelineTabsProps<T>) {
105
+ const { accessibilityLabel, value, onValueChange, children, testID } = props;
106
+ const { values: order, captionSlot } = collect(children);
107
+ const roving = useRovingTabs({
108
+ count: order.length,
109
+ selectedIndex: order.indexOf(value),
110
+ onSelect: (index) => {
111
+ const next = order[index];
112
+ // The ring is built from the tabs' own `value` props, which are T.
113
+ if (next != null) onValueChange(next as T);
114
+ },
115
+ });
116
+
117
+ return (
118
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} testID={testID}>
119
+ <PipelineContext.Provider
120
+ value={{ value, onValueChange: (v) => onValueChange(v as T), order, captionSlot, roving }}
121
+ >
122
+ <View
123
+ style={{ flexDirection: "row", alignItems: "flex-start", columnGap: 4 }}
124
+ accessibilityRole="tablist"
125
+ accessibilityLabel={accessibilityLabel}
126
+ >
127
+ {children}
128
+ </View>
129
+ </PipelineContext.Provider>
130
+ </ScrollView>
131
+ );
132
+ }
133
+
134
+ /** A box the height of the label line, sat where the label line is, so a
135
+ * joiner drawn beside a tab centres on the words and not on a cell whose
136
+ * centre moves when a caption line is reserved. */
137
+ function OnLabelLine({ width, children }: { width: number; children: ReactNode }) {
138
+ return (
139
+ <View
140
+ style={{
141
+ width,
142
+ height: TAB_PADDING_V + TAB_LABEL_LINE,
143
+ paddingTop: TAB_PADDING_V,
144
+ alignItems: "center",
145
+ justifyContent: "center",
146
+ }}
147
+ >
148
+ {children}
149
+ </View>
150
+ );
151
+ }
152
+
153
+ /**
154
+ * The stages a record leaves the pipeline at — won, lost, and anything else
155
+ * terminal. Drawn past a rule, with no chevrons between them: they are
156
+ * alternatives, not a sequence. Render it last.
157
+ */
158
+ export function PipelineOutcomes({ children }: { children: ReactNode }) {
159
+ usePipeline("PipelineOutcomes");
160
+ return (
161
+ <OutcomesContext.Provider value={true}>
162
+ <OnLabelLine width={SPACE.sm * 2 + 1}>
163
+ <View style={{ width: 1, height: 20, backgroundColor: colors.zinc["200"] }} />
164
+ </OnLabelLine>
165
+ {children}
166
+ </OutcomesContext.Provider>
167
+ );
168
+ }
169
+
170
+ export function PipelineTab<T extends string>(props: PipelineTabProps<T>) {
171
+ const { value, label, count, caption, status, testID } = props;
172
+ const { value: selectedValue, onValueChange, order, captionSlot, roving } = usePipeline("PipelineTab");
173
+ const inOutcomes = useContext(OutcomesContext);
174
+ const index = order.indexOf(value);
175
+
176
+ return (
177
+ <View style={{ flexDirection: "row", alignItems: "flex-start" }}>
178
+ {/* The chevron is what says "then": it sits between two stages of the
179
+ FLOW and nowhere else. The first stage has nothing before it, and an
180
+ outcome is not reached by walking. */}
181
+ {index > 0 && !inOutcomes ? (
182
+ <OnLabelLine width={20}>
183
+ <Icon name="chevron-right" size={14} color={colors.zinc["400"]} />
184
+ </OnLabelLine>
185
+ ) : null}
186
+ <TabButton
187
+ ref={roving.bindRef(index)}
188
+ label={label}
189
+ count={count}
190
+ status={status}
191
+ caption={caption}
192
+ captionSlot={captionSlot}
193
+ selected={value === selectedValue}
194
+ isTabStop={index === roving.tabStopIndex}
195
+ onPress={() => onValueChange(value)}
196
+ onKeyDown={(event) => roving.onKeyDown(event, index)}
197
+ testID={testID}
198
+ />
199
+ </View>
200
+ );
201
+ }
@@ -32,7 +32,12 @@ export interface PressableRowProps {
32
32
  * `Pressable` with role="button" + "Open …" label. Wrap the row body with it
33
33
  * only when the body is non-interactive by construction; a body that can
34
34
  * carry its own controls gets an EMPTY absolutely-positioned door as their
35
- * sibling instead (a button must not contain a button — see `TableRow`). */
35
+ * sibling instead (a button must not contain a button — see `TableRow`).
36
+ *
37
+ * The cell that takes the slack says so: `flex: 1, minWidth: 0`. This is a
38
+ * flex row, and a react-native `View` in one sizes to its max-content and
39
+ * never shrinks (`flexShrink: 0`, unlike the web), so a body left unstated
40
+ * lays out at the width of its longest line and overflows the container. */
36
41
  children: ReactNode;
37
42
  /** Layout only (gap, minHeight overrides). The surface owns its press
38
43
  * states — never pass backgroundColor for hover/selected. */
package/src/tabs.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { useCallback, useRef } from "react";
2
1
  import { View } from "react-native";
2
+ import { useRovingTabs } from "./use_roving_tabs";
3
3
  import { PressableHighlight } from "./pressable_highlight";
4
4
  import { Text } from "./text";
5
5
  import { colors, solid, type ColorName } from "./colors";
@@ -25,6 +25,11 @@ export interface TabOption<T extends string> {
25
25
  * band counts what matched, not what rendered. Omit when every band is
26
26
  * always whole. */
27
27
  count?: number;
28
+ /** A second line under the label — one figure the band is read by (the money
29
+ * in it, what is overdue), already formatted. Once ANY tab in a strip carries
30
+ * one, every tab reserves the line, so the strip keeps one height and the
31
+ * labels one baseline. */
32
+ caption?: string;
28
33
  }
29
34
 
30
35
  interface TabsProps<T extends string> {
@@ -37,43 +42,13 @@ interface TabsProps<T extends string> {
37
42
 
38
43
  export function Tabs<T extends string>(props: TabsProps<T>) {
39
44
  const { accessibilityLabel, options, selectedTab, onSelectTab } = props;
40
- const tabRefs = useRef<Array<View | null>>([]);
41
-
42
- // Roving tabindex + arrow-key navigation per the WAI-ARIA Tabs pattern.
43
- // Home/End jump to the ends; Left/Right cycle.
44
- const handleKeyDown = useCallback(
45
- (event: { key: string; preventDefault?: () => void }, index: number) => {
46
- const last = options.length - 1;
47
- let next = index;
48
- switch (event.key) {
49
- case "ArrowRight":
50
- case "ArrowDown":
51
- next = index === last ? 0 : index + 1;
52
- break;
53
- case "ArrowLeft":
54
- case "ArrowUp":
55
- next = index === 0 ? last : index - 1;
56
- break;
57
- case "Home":
58
- next = 0;
59
- break;
60
- case "End":
61
- next = last;
62
- break;
63
- default:
64
- return;
65
- }
66
- event.preventDefault?.();
67
- onSelectTab?.(options[next].value);
68
- tabRefs.current[next]?.focus();
69
- },
70
- [onSelectTab, options],
71
- );
72
-
73
- // When no tab matches the current selection, the first tab becomes the
74
- // tab-stop so keyboard users can still enter the group.
75
45
  const selectedIndex = options.findIndex((option) => option.value === selectedTab);
76
- const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex;
46
+ const roving = useRovingTabs({
47
+ count: options.length,
48
+ selectedIndex,
49
+ onSelect: (index) => onSelectTab?.(options[index].value),
50
+ });
51
+ const captionSlot = options.some((option) => option.caption != null);
77
52
 
78
53
  return (
79
54
  <View
@@ -83,62 +58,63 @@ export function Tabs<T extends string>(props: TabsProps<T>) {
83
58
  >
84
59
  {options.map((option, index) => (
85
60
  <TabButton
86
- ref={(node: View | null) => {
87
- tabRefs.current[index] = node;
88
- }}
61
+ ref={roving.bindRef(index)}
89
62
  key={option.value}
90
- option={option}
63
+ label={option.label}
64
+ count={option.count}
65
+ status={option.status}
66
+ caption={option.caption}
67
+ captionSlot={captionSlot}
68
+ testID={option.testID}
91
69
  selected={selectedTab === option.value}
92
- isTabStop={index === tabStopIndex}
93
- onSelectTab={onSelectTab}
94
- onKeyDown={(event) => handleKeyDown(event, index)}
70
+ isTabStop={index === roving.tabStopIndex}
71
+ onPress={() => onSelectTab?.(option.value)}
72
+ onKeyDown={(event) => roving.onKeyDown(event, index)}
95
73
  />
96
74
  ))}
97
75
  </View>
98
76
  );
99
77
  }
100
78
 
101
- interface TabButtonProps<T extends string> {
79
+ /** The pill's vertical inset. Exported so a strip that draws something BESIDE
80
+ * a tab (a joiner, a rule) can sit it on the label line rather than the
81
+ * cell's centre, which moves the moment a caption line is reserved. */
82
+ export const TAB_PADDING_V = 8;
83
+ /** The label line's height — the body rung's line box. */
84
+ export const TAB_LABEL_LINE = 24;
85
+ const DOT = 6;
86
+ const DOT_GAP = 6;
87
+
88
+ export interface TabButtonProps {
102
89
  ref: (node: View | null) => void;
103
- option: TabOption<T>;
90
+ label: string;
91
+ count?: number;
92
+ status?: ColorName;
93
+ caption?: string;
94
+ /** Reserve the caption line even when this tab has none — the strip decides,
95
+ * so every tab in it stands the same height. */
96
+ captionSlot?: boolean;
104
97
  selected: boolean;
105
98
  isTabStop: boolean;
106
- onSelectTab?: (value: T) => void;
99
+ onPress: () => void;
107
100
  onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
101
+ testID?: string;
108
102
  }
109
103
 
110
- function TabButton<T extends string>(props: TabButtonProps<T>) {
111
- const { ref, option, selected, isTabStop, onSelectTab, onKeyDown } = props;
112
-
113
- const handlePress = useCallback(() => {
114
- onSelectTab?.(option.value);
115
- }, [option.value, option.label, option.testID, onSelectTab]);
116
-
117
- const inner = (
118
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
119
- {option.status ? (
120
- <View style={{ width: 6, height: 6, borderRadius: 999, backgroundColor: solid(option.status) }} />
121
- ) : null}
122
- <Text
123
- weight={selected ? "medium" : "regular"}
124
- color={selected ? "default" : "muted"}
125
- userSelect="none"
126
- >
127
- {option.label}
128
- </Text>
129
- {option.count != null ? (
130
- <Text color="muted" tabular userSelect="none">
131
- {option.count}
132
- </Text>
133
- ) : null}
134
-
135
- </View>
136
- );
104
+ /**
105
+ * ONE tab the anatomy `Tabs` and `PipelineTabs` share, so the two strips
106
+ * cannot drift by a pixel: the dot, the label, the count, the optional caption
107
+ * line, the single hover wash on the inner pill, and the primary underline.
108
+ */
109
+ export function TabButton(props: TabButtonProps) {
110
+ const { ref, label, count, status, caption, captionSlot = false, selected, isTabStop, onPress, onKeyDown, testID } =
111
+ props;
137
112
 
138
113
  return (
139
114
  <PressableHighlight
140
115
  focusRing
141
- ref={ref} style={{
116
+ ref={ref}
117
+ style={{
142
118
  // WHERE YOU ARE, painted in the INK THAT ACTS — `colors.primary`, which
143
119
  // defaults to near-black. A tab rail is the only thing on a screen that
144
120
  // answers "which section am I in", and a neutral grey underline would be
@@ -153,21 +129,17 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
153
129
  // at page scale, so it reads as one product rather than two brands.
154
130
  borderBottomWidth: 3,
155
131
  borderBottomColor: selected ? colors.primary : "transparent",
132
+ // Transparent HERE so the one hover wash is the inner pill's.
156
133
  backgroundColor: "transparent",
157
134
  paddingBottom: 4,
158
135
  }}
159
- onPress={handlePress}
136
+ onPress={onPress}
160
137
  onKeyDown={onKeyDown}
161
- testID={option.testID}
138
+ testID={testID}
162
139
  accessibilityRole="tab"
163
- accessibilityLabel={option.label}
140
+ accessibilityLabel={label}
164
141
  aria-selected={selected}
165
- // Roving tabindex: the selected tab is the tab-stop, others are reachable
166
- // via arrow keys. When no tab matches the current selection (props.value
167
- // is stale), the first tab is the fallback so the group stays keyboard-
168
- // reachable. Drive it with tabIndex, NOT focusable: RN-Web's Pressable
169
- // ignores `focusable` (it writes its own tabIndex), so a focusable-based
170
- // roving model silently leaves every tab a tab-stop.
142
+ // Roving tabindex see `useRovingTabs` for why it is `tabIndex`.
171
143
  tabIndex={isTabStop ? 0 : -1}
172
144
  >
173
145
  {(state) => {
@@ -176,7 +148,7 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
176
148
  return (
177
149
  <View
178
150
  style={{
179
- paddingVertical: 8,
151
+ paddingVertical: TAB_PADDING_V,
180
152
  paddingHorizontal: 16,
181
153
  borderRadius: CONTROL_RADIUS,
182
154
  backgroundColor: pressed
@@ -186,7 +158,40 @@ function TabButton<T extends string>(props: TabButtonProps<T>) {
186
158
  : "transparent",
187
159
  }}
188
160
  >
189
- {inner}
161
+ <View style={{ flexDirection: "row", alignItems: "center", gap: DOT_GAP, height: TAB_LABEL_LINE }}>
162
+ {status ? (
163
+ <View style={{ width: DOT, height: DOT, borderRadius: 999, backgroundColor: solid(status) }} />
164
+ ) : null}
165
+ <Text
166
+ weight={selected ? "medium" : "regular"}
167
+ color={selected ? "default" : "muted"}
168
+ userSelect="none"
169
+ numberOfLines={1}
170
+ >
171
+ {label}
172
+ </Text>
173
+ {count != null ? (
174
+ <Text color="muted" tabular userSelect="none">
175
+ {count}
176
+ </Text>
177
+ ) : null}
178
+ </View>
179
+ {captionSlot || caption != null ? (
180
+ // Indented past the dot so it starts under the LABEL's first letter
181
+ // — a caption flush with the dot reads as belonging to the dot.
182
+ <Text
183
+ size="xs"
184
+ color="muted"
185
+ tabular
186
+ userSelect="none"
187
+ numberOfLines={1}
188
+ style={{ marginLeft: status ? DOT + DOT_GAP : 0 }}
189
+ >
190
+ {/* A no-break space, not a space: whitespace-only text collapses
191
+ to no height on web, and the reserved line with it. */}
192
+ {caption ?? " "}
193
+ </Text>
194
+ ) : null}
190
195
  </View>
191
196
  );
192
197
  }}
@@ -0,0 +1,75 @@
1
+ import { useCallback, useRef } from "react";
2
+ import type { View } from "react-native";
3
+
4
+ export interface RovingTabsOptions {
5
+ /** How many tabs the strip holds. */
6
+ count: number;
7
+ /** Index of the selected tab, or -1 when the selection matches none. */
8
+ selectedIndex: number;
9
+ /** Arrow keys SELECT as they move — automatic activation, the pattern a tab
10
+ * strip whose panels are cheap to show follows. */
11
+ onSelect: (index: number) => void;
12
+ }
13
+
14
+ export interface RovingTabs {
15
+ /** The ONE tab that is a tab stop. When nothing is selected the first tab
16
+ * takes it, so a strip whose `selected` went stale stays keyboard-reachable. */
17
+ tabStopIndex: number;
18
+ /** `onKeyDown` for the tab at `index`: Left/Right and Up/Down cycle (wrapping),
19
+ * Home/End jump to the ends; each selects AND moves focus. */
20
+ onKeyDown: (event: { key: string; preventDefault?: () => void }, index: number) => void;
21
+ /** `ref` for the tab at `index`, so the hook can move focus to it. */
22
+ bindRef: (index: number) => (node: View | null) => void;
23
+ }
24
+
25
+ /**
26
+ * The WAI-ARIA tabs keyboard contract — roving tabindex plus arrow-key
27
+ * navigation — shared by every strip that is a `tablist` (`Tabs`, `Pipeline`),
28
+ * so the two cannot drift on which keys do what.
29
+ *
30
+ * The tab stop is driven with `tabIndex`, NOT `focusable`: RN-Web's Pressable
31
+ * ignores `focusable` (it writes its own tabIndex), so a focusable-based roving
32
+ * model silently leaves every tab a tab stop.
33
+ */
34
+ export function useRovingTabs(options: RovingTabsOptions): RovingTabs {
35
+ const { count, selectedIndex, onSelect } = options;
36
+ const refs = useRef<Array<View | null>>([]);
37
+
38
+ const onKeyDown = useCallback(
39
+ (event: { key: string; preventDefault?: () => void }, index: number) => {
40
+ const last = count - 1;
41
+ let next = index;
42
+ switch (event.key) {
43
+ case "ArrowRight":
44
+ case "ArrowDown":
45
+ next = index === last ? 0 : index + 1;
46
+ break;
47
+ case "ArrowLeft":
48
+ case "ArrowUp":
49
+ next = index === 0 ? last : index - 1;
50
+ break;
51
+ case "Home":
52
+ next = 0;
53
+ break;
54
+ case "End":
55
+ next = last;
56
+ break;
57
+ default:
58
+ return;
59
+ }
60
+ event.preventDefault?.();
61
+ onSelect(next);
62
+ refs.current[next]?.focus();
63
+ },
64
+ [count, onSelect],
65
+ );
66
+
67
+ const bindRef = useCallback(
68
+ (index: number) => (node: View | null) => {
69
+ refs.current[index] = node;
70
+ },
71
+ [],
72
+ );
73
+
74
+ return { tabStopIndex: selectedIndex === -1 ? 0 : selectedIndex, onKeyDown, bindRef };
75
+ }