@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 +31 -4
- package/docs/composition.md +5 -0
- package/docs/templates.md +4 -1
- package/package.json +3 -1
- package/src/pipeline_tabs.tsx +201 -0
- package/src/pressable_row.tsx +6 -1
- package/src/tabs.tsx +91 -86
- package/src/use_roving_tabs.ts +75 -0
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`, `
|
|
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
|
-
|
|
914
|
-
|
|
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
|
package/docs/composition.md
CHANGED
|
@@ -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.
|
|
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
|
+
}
|
package/src/pressable_row.tsx
CHANGED
|
@@ -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
|
|
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={(
|
|
87
|
-
tabRefs.current[index] = node;
|
|
88
|
-
}}
|
|
61
|
+
ref={roving.bindRef(index)}
|
|
89
62
|
key={option.value}
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
onKeyDown={(event) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
onPress: () => void;
|
|
107
100
|
onKeyDown: (event: { key: string; preventDefault?: () => void }) => void;
|
|
101
|
+
testID?: string;
|
|
108
102
|
}
|
|
109
103
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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}
|
|
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={
|
|
136
|
+
onPress={onPress}
|
|
160
137
|
onKeyDown={onKeyDown}
|
|
161
|
-
testID={
|
|
138
|
+
testID={testID}
|
|
162
139
|
accessibilityRole="tab"
|
|
163
|
-
accessibilityLabel={
|
|
140
|
+
accessibilityLabel={label}
|
|
164
141
|
aria-selected={selected}
|
|
165
|
-
// Roving tabindex
|
|
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:
|
|
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
|
-
{
|
|
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
|
+
}
|