@delacour/react-native-ui 0.1.0-alpha.3
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 +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +192 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +435 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +232 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +100 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +33 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +247 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +581 -0
- package/src/components/radio/radio.variants.ts +272 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +48 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +161 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +804 -0
- package/src/components/slider/slider.variants.ts +607 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +286 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +672 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# Tabs
|
|
2
|
+
|
|
3
|
+
A row of tabs and the panels they switch between. Compound root plus `Tabs.List`,
|
|
4
|
+
`Tabs.ScrollView`, `Tabs.Indicator`, `Tabs.Trigger`, `Tabs.Label`,
|
|
5
|
+
`Tabs.Separator` and `Tabs.Content`.
|
|
6
|
+
|
|
7
|
+
`import { Tabs } from "@delacour/react-native-ui/tabs";`
|
|
8
|
+
|
|
9
|
+
## Files
|
|
10
|
+
|
|
11
|
+
| File | What it holds |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `index.ts` | → `@delacour/react-native-ui/tabs` |
|
|
14
|
+
| `tabs.tsx` | Root — owns the order walk, the value flow and the pan |
|
|
15
|
+
| `tabs-list.tsx` | `Tabs.List` — the track, and the measurement registry |
|
|
16
|
+
| `tabs-scroll-view.tsx` | `Tabs.ScrollView` — the scroller and its UI-thread auto-scroll |
|
|
17
|
+
| `tabs-indicator.tsx` | `Tabs.Indicator`, the one sliding layer |
|
|
18
|
+
| `tabs-trigger.tsx` | `Tabs.Trigger` — a `Pressable`, and the frame the indicator reads |
|
|
19
|
+
| `tabs-label.tsx` | `Tabs.Label` |
|
|
20
|
+
| `tabs-separator.tsx` | `Tabs.Separator`, a `Separator` behind an animated opacity |
|
|
21
|
+
| `tabs-content.tsx` | `Tabs.Content`, one panel |
|
|
22
|
+
| `tabs-pager.tsx` | The clipped viewport and the translating row — internal |
|
|
23
|
+
| `tabs.context.tsx` | Five contexts, split by what changes, with their hooks |
|
|
24
|
+
| `tabs.types.ts` | Prop types shared by two or more parts |
|
|
25
|
+
| `tabs.variants.ts` | Pure `tv()` slots + the gesture and geometry worklets, no RN imports |
|
|
26
|
+
| `tabs.variants.test.ts` | |
|
|
27
|
+
|
|
28
|
+
## Design
|
|
29
|
+
|
|
30
|
+
- **Variants**: `primary` (a fully rounded capsule inside a muted track),
|
|
31
|
+
`secondary` (an underline rule). **Sizes**: `sm`, `md`,
|
|
32
|
+
`lg` — the trigger's floor and padding, the gap ladder, the label's `Text` step
|
|
33
|
+
and the glyph step a composed `Icon` inherits, on one axis.
|
|
34
|
+
|
|
35
|
+
## Value flow and tab order
|
|
36
|
+
|
|
37
|
+
- **The whole component moves off one shared value**, `position`, a float index
|
|
38
|
+
into the panels' order. A press springs it, a drag writes it directly, and the
|
|
39
|
+
pager's translation, the indicator's frame and every separator's opacity are
|
|
40
|
+
three readings of it. Two clocks is how a capsule ends up a frame behind the
|
|
41
|
+
panel it is meant to be sitting on.
|
|
42
|
+
- **The panels are the order of record, and they alone must be direct children.**
|
|
43
|
+
The root walks its own children for `Tabs.Content`, exactly as
|
|
44
|
+
[`ListGroup`](../list-group/AGENTS.md) finds a hand-placed `Separator`, and
|
|
45
|
+
their source order is what `position` indexes into. Triggers are **not**
|
|
46
|
+
walked: each finds its place by looking its `value` up in that list, so it can
|
|
47
|
+
sit behind a wrapper or come out of a `.map()`. Walking triggers instead breaks
|
|
48
|
+
the first time anyone writes `<FilterTabs />` — the walk sees a component it
|
|
49
|
+
does not recognise and silently drops tabs. A bar with no panels at all is a
|
|
50
|
+
real shape (a filter row driving a list elsewhere) and falls back to trigger
|
|
51
|
+
registration, which is one commit late and therefore never preferred when
|
|
52
|
+
panels exist.
|
|
53
|
+
- **The one mistake source cannot catch is caught in the measurements.** Triggers
|
|
54
|
+
written in a different order from the panels put the indicator on the wrong
|
|
55
|
+
tab, with nothing in the source to point at; `isTriggerOrderConsistent` checks
|
|
56
|
+
the measured `x` values ascend and the list warns by name in development. Every
|
|
57
|
+
trigger reporting an `x` of 0 catches the other shape — a trigger wrapped in a
|
|
58
|
+
`View`, measuring itself against the wrapper rather than the row.
|
|
59
|
+
- **`isSwipeable={false}` is one `.enabled(false)` on the pan and nothing else.**
|
|
60
|
+
Every panel mounts either way. Gating mounting on it would make one boolean
|
|
61
|
+
change both the gesture and the mounting model, and the two are not the same
|
|
62
|
+
setting — you cannot drag to a panel that is not there. Lazy mounting is the
|
|
63
|
+
caller's, per panel, through `Tabs.Content`'s function children. There is no
|
|
64
|
+
`isLazy`, because "lazy" is not one thing: a form wants to stay mounted, a
|
|
65
|
+
video wants to stop, and a report wants never to have been built.
|
|
66
|
+
- **The value flow has exactly three writers and one repair.** The pan's
|
|
67
|
+
`onUpdate`, the pan's `onFinalize`, and a reconcile effect; a JS-side ref records
|
|
68
|
+
where the last of them aimed. `resolveReconcileMode` decides between `none`,
|
|
69
|
+
`spring` and `jump` — and `jump` is the case a float index alone cannot see: a
|
|
70
|
+
tab inserted before the active one moves its index without changing the
|
|
71
|
+
selection, and springing there would animate an edit the user did not make. A
|
|
72
|
+
reducer bump rides alongside every commit for
|
|
73
|
+
[`Switch`](../switch/AGENTS.md)'s reason: a controlled parent that rejects a
|
|
74
|
+
change re-renders nothing, so a reconcile waiting on the parent's commit would
|
|
75
|
+
never run.
|
|
76
|
+
- **The reconcile effect registers no cleanup, and that is load-bearing.**
|
|
77
|
+
[`Checkbox`](../checkbox/AGENTS.md) and [`Switch`](../switch/AGENTS.md) both end
|
|
78
|
+
their spring effects with `cancelAnimation`, and both can afford to: their
|
|
79
|
+
effect starts a fresh animation on *every* run, so a cleanup cancelling the
|
|
80
|
+
previous one always has a replacement behind it. This one does not — its `none`
|
|
81
|
+
branch returns early — and the commit that runs the cleanup is the very one the
|
|
82
|
+
gesture just caused. A swipe would start its settle spring, `commitFromPan`
|
|
83
|
+
would re-render, the previous run's cleanup would cancel that spring, and the
|
|
84
|
+
new run would take `none` and restart nothing: the pager froze part way between
|
|
85
|
+
two panels, intermittently, according to whether the commit beat the spring.
|
|
86
|
+
Before copying a `cancelAnimation` cleanup into a new component, check that
|
|
87
|
+
every path through the effect starts an animation. An in-flight spring on an
|
|
88
|
+
unmounted component is harmless — the shared value goes with it.
|
|
89
|
+
- **`onValueChange` fires once, from `onFinalize`, before the spring finishes.** The
|
|
90
|
+
trigger highlights the moment the finger lifts rather than 300ms later.
|
|
91
|
+
Driving it off `position` crossing a midpoint would fire it several times per
|
|
92
|
+
drag and re-render every panel while the finger was still down.
|
|
93
|
+
- **An accepted swipe keeps its momentum.** `commitFromPan` records the target
|
|
94
|
+
*before* it sets the state, so the reconcile sees no divergence and leaves the
|
|
95
|
+
gesture's own velocity-seeded spring alone. Rejecting the change is what makes
|
|
96
|
+
it spring back.
|
|
97
|
+
|
|
98
|
+
## The measured indicator
|
|
99
|
+
|
|
100
|
+
- **The measurement registry is a JS map published as one shared value, coalesced
|
|
101
|
+
to one write per tick.** One value holding three pre-built arrays, not three
|
|
102
|
+
values holding one each: `interpolate` throws when its ranges differ in length.
|
|
103
|
+
Without the `queueMicrotask` coalescing an eight-tab bar writes eight times in
|
|
104
|
+
one layout pass and the UI thread can render between two of them, showing an
|
|
105
|
+
indicator built from four new positions and four old ones. A re-layout
|
|
106
|
+
reporting identical numbers is dropped, because reassigning a shared value
|
|
107
|
+
invalidates every worklet reading it. `resolveMeasurementTracks` is
|
|
108
|
+
all-or-nothing — a half-measured bar would collapse the indicator to nothing
|
|
109
|
+
every time `position` crossed the missing tab.
|
|
110
|
+
- **The indicator animates `width` and `translateX`, never `scaleX`.** A scale is
|
|
111
|
+
cheaper and wrong three times over: `rounded-full` stretches to an ellipse at
|
|
112
|
+
the 2.3× a short tab to a long one asks for, a border thickens on two edges
|
|
113
|
+
only, and any children a caller passes are squashed with it — which no
|
|
114
|
+
counter-scale can fix across an arbitrary subtree. The cost is bounded because
|
|
115
|
+
the indicator is absolutely positioned: its size change never dirties a
|
|
116
|
+
sibling, so the row's layout stays settled.
|
|
117
|
+
[`Checkbox`](../checkbox/AGENTS.md) already animates a width every toggle.
|
|
118
|
+
|
|
119
|
+
## The pan gesture and the pager
|
|
120
|
+
|
|
121
|
+
- **The pan claims sideways and gives up vertical**, `activeOffsetX` under
|
|
122
|
+
`failOffsetY` so a diagonal drag resolves to exactly one of them. That is the
|
|
123
|
+
whole mechanism, and it is what lets a pager live inside
|
|
124
|
+
[`Screen.ScrollArea`](../screen/AGENTS.md). `blocksExternalGesture` is **not**
|
|
125
|
+
the alternative — `Screen.ScrollArea` renders React Native's own `ScrollView`,
|
|
126
|
+
which has no handler tag to resolve, so the call is dropped without an error.
|
|
127
|
+
[`Slider.Track`](../slider/AGENTS.md) found that first, and reaches the opposite
|
|
128
|
+
conclusion for the opposite reason: a slider must claim even a stationary tap,
|
|
129
|
+
so it takes `minDistance(0)` and no axis offsets at all.
|
|
130
|
+
- **The settle lives in `onFinalize`, and the spring is cancelled in `onStart`.**
|
|
131
|
+
Both halves were wrong once and the symptom was the same: a pager frozen half
|
|
132
|
+
way between two panels. `onBegin` fires for *every* touch the pager sees — a
|
|
133
|
+
tap on a panel, the first moment of a vertical scroll — and most of those go on
|
|
134
|
+
to FAIL against `failOffsetY`. Cancelling the settle spring there killed an
|
|
135
|
+
animation for a gesture that never became a drag, and because `onEnd` only runs
|
|
136
|
+
for a pan that actually activated, nothing restarted it. `onFinalize` is the
|
|
137
|
+
one callback that runs on every path out of a gesture, END, FAILED and
|
|
138
|
+
CANCELLED alike, from any state — the rule
|
|
139
|
+
[`Slider.Track`](../slider/AGENTS.md) already states — so it owns the settle,
|
|
140
|
+
guarded by a flag so a touch that never activated cannot retarget a spring it
|
|
141
|
+
never disturbed. Reproduce the old bug by swiping and then scrolling the page
|
|
142
|
+
before the spring has finished.
|
|
143
|
+
- **The drag's origin is back-computed at activation, not captured at
|
|
144
|
+
touch-down.** `translationX` counts from touch-down but the pan does not
|
|
145
|
+
activate until the finger has crossed `activateX`, so an origin taken at
|
|
146
|
+
activation would apply that travel twice and jump the pager on the first frame.
|
|
147
|
+
`resolvePanOrigin` returns the value that makes `resolvePanPosition` yield the
|
|
148
|
+
pager's current position for the translation so far, which is also what lets a
|
|
149
|
+
mid-spring grab pick up exactly where the capsule is.
|
|
150
|
+
- **A horizontal scrollable *inside* a panel is the caller's to settle**, because
|
|
151
|
+
only they know which should win. The pager publishes its pan on
|
|
152
|
+
`useTabsMotion()`, and the caller writes
|
|
153
|
+
`Gesture.Native().blocksExternalGesture(panGesture)`. A hook rather than a
|
|
154
|
+
prop, the trade `useScreenFooterKeyboardClearance` already makes.
|
|
155
|
+
|
|
156
|
+
## The auto-scrolling bar
|
|
157
|
+
|
|
158
|
+
- **The bar's auto-scroll runs on the UI thread**, through the package's only
|
|
159
|
+
`useAnimatedRef` and `scrollTo`, and it interpolates the *fractional* trigger
|
|
160
|
+
geometry — so the row tracks a finger through a drag rather than jumping once
|
|
161
|
+
the swipe settles. A hand-scroll always wins, and the flag saying so clears on
|
|
162
|
+
**momentum end** rather than on the finger lifting: retargeting into a coast is
|
|
163
|
+
a tug-of-war the user feels as the bar snapping backwards mid-flick.
|
|
164
|
+
`resolveScrollOffset` clamps at both ends, and `none` returns the current
|
|
165
|
+
offset rather than 0 — "leave the bar alone", not "send it home".
|
|
166
|
+
|
|
167
|
+
## Separators
|
|
168
|
+
|
|
169
|
+
- **A separator fades only while the pager is crossing it.** At rest a bar shows
|
|
170
|
+
every rule it has; a drag dips the one it travels over and brings it back. The
|
|
171
|
+
first shape hid every rule *flanking* the active tab, which is a different
|
|
172
|
+
thing and reads badly — on a three-tab bar it leaves exactly one rule visible
|
|
173
|
+
over on the far side, and the set changed on every tab change so the bar never
|
|
174
|
+
looked still. Measuring from the gap's own midpoint makes the fade mean one
|
|
175
|
+
thing: the capsule is on top of this rule right now.
|
|
176
|
+
- **A faded separator still takes its width**, so the row never reflows as the
|
|
177
|
+
fade runs. That also means separators make a bar wider — enough to tip a row
|
|
178
|
+
that only just fits into one that scrolls, at which point `scrollAlign` starts
|
|
179
|
+
moving it for no reason a user can see. Reach for `Tabs.ScrollView` when the
|
|
180
|
+
tabs genuinely do not fit, not by default.
|
|
181
|
+
|
|
182
|
+
## Triggers and labels
|
|
183
|
+
|
|
184
|
+
- **A render prop must not change a trigger's size.** A trigger's width *is* the
|
|
185
|
+
indicator's geometry, so content that appears only on selection — a badge, a
|
|
186
|
+
count — re-measures the row and shifts the whole bar every time the tab
|
|
187
|
+
changes. Swap a treatment rather than adding or removing content: the
|
|
188
|
+
playground's composition demo switches a `Badge`'s `variant` and keeps it
|
|
189
|
+
mounted throughout.
|
|
190
|
+
- **The label's colour crossfades; it is a style, not a class.** It interpolates
|
|
191
|
+
between the two values `TABS_FOREGROUND_TOKEN` names, off the same `position`
|
|
192
|
+
the capsule and the panels read — so it fades *with* the capsule arriving
|
|
193
|
+
rather than flipping the moment a midpoint is crossed. One function covers a
|
|
194
|
+
finger dragging, a flick and a plain tap without knowing which is happening,
|
|
195
|
+
because all three write that one value.
|
|
196
|
+
[`Checkbox`](../checkbox/AGENTS.md)'s animated border makes the same trade for
|
|
197
|
+
the same reason: a colour that travels cannot be a class.
|
|
198
|
+
`resolveTabSelectedness` is the ramp, and it is pure, so `bun test` reaches it.
|
|
199
|
+
- **The `label` slot therefore names no colour at all, and a test enforces that.**
|
|
200
|
+
Two sources for one colour is how a class and a style end up disagreeing for a
|
|
201
|
+
frame on every commit. `TABS_FOREGROUND_TOKEN` is the whole matrix now, swept
|
|
202
|
+
for distinctness and asserted present in both themes — the decision stayed
|
|
203
|
+
reachable from `bun test`, it just moved from class strings to token names.
|
|
204
|
+
- **`visualIndex` survives for the things that cannot fade.** A composed `Icon`
|
|
205
|
+
takes its colour as a resolved *value* rather than a style, so it has no way to
|
|
206
|
+
be half way between two; it swaps at the midpoint, with a hysteresis band
|
|
207
|
+
because a finger held exactly there would otherwise flip it every frame and
|
|
208
|
+
each flip is a commit. That leaves a visible mismatch on a trigger holding both
|
|
209
|
+
a glyph and a label — the glyph steps while the text fades. Fixing it means
|
|
210
|
+
crossfading two stacked copies of the trigger's content, which renders a
|
|
211
|
+
caller's children twice; not worth it until a design needs it.
|
|
212
|
+
- **The trigger's `TextClassProvider` carries no colour either**, following the
|
|
213
|
+
slot. A bare `<Text>` composed into a trigger takes the page colour; reach for
|
|
214
|
+
`Tabs.Label` when it should read as part of the tab.
|
|
215
|
+
- **The axis ladder is `own ?? root ?? default`,
|
|
216
|
+
[`Checkbox`](../checkbox/AGENTS.md)'s and not
|
|
217
|
+
[`Radio.Group`](../radio/AGENTS.md)'s.** A radio group puts itself first because
|
|
218
|
+
escaping a disabled form group is a bug; a tab bar's disabled state is
|
|
219
|
+
transient chrome and the per-trigger case is the common one, so it has to
|
|
220
|
+
survive the bar-wide one being set. There is no `Field` rung and no
|
|
221
|
+
`isInvalid`: tabs are navigation, nothing here has a value that can be wrong,
|
|
222
|
+
and a `Field` around a `Tabs` would grey out a navigation bar from a form's
|
|
223
|
+
state.
|
|
224
|
+
- **The disabled fade lands on the label, never on the trigger.** The `trigger`
|
|
225
|
+
slot is worn by [`Pressable`](../pressable/AGENTS.md)'s own `Animated.View`,
|
|
226
|
+
whose `useAnimatedStyle` writes `opacity` every frame —
|
|
227
|
+
[`Radio`](../radio/AGENTS.md)'s lesson, and it bites twice here because
|
|
228
|
+
`feedback` defaults to `fade`.
|
|
229
|
+
- **Selection changes the label's colour and nothing else.** A weight change
|
|
230
|
+
would re-measure the label, which moves the frame the indicator is sitting on,
|
|
231
|
+
on every tap. A test asserts the two class sets differ by exactly one `text-*`.
|
|
232
|
+
|
|
233
|
+
## Sizing and surface
|
|
234
|
+
|
|
235
|
+
- **No `--spacing-tabs-*`, and that is a decision.** A tab lines up against no
|
|
236
|
+
chrome that forces a height, so the trigger takes `min-h-*` from Tailwind's own
|
|
237
|
+
scale — a floor, so a large accessibility step grows the row instead of
|
|
238
|
+
clipping the label. [`Badge`](../badge/AGENTS.md)'s "a size is padding, never a
|
|
239
|
+
height", and [`Checkbox`](../checkbox/AGENTS.md)'s argument against minting a
|
|
240
|
+
private scale.
|
|
241
|
+
- **The track and the capsule are both `rounded-full`, and there is no radius
|
|
242
|
+
arithmetic at all.** A pill inside a pill is concentric at *any* padding, which
|
|
243
|
+
is the property `resolveCheckboxFillRadius` has to subtract a border width to get
|
|
244
|
+
for a rounded rectangle. The first shape of this component did subtract a
|
|
245
|
+
padding from a named `rounded-*` step per size — three maps to keep in step,
|
|
246
|
+
and a rectangle that still read as boxy against its own track. Do not
|
|
247
|
+
reintroduce them.
|
|
248
|
+
- **The capsule is painted on `elevated`, a token minted for it.** The surface
|
|
249
|
+
has to sit *above* `muted` in both themes and nothing in the neutral ramp did:
|
|
250
|
+
`card` is the same white as `background` in light and **darker** than `muted`
|
|
251
|
+
in dark, so a capsule painted on it reads as raised in one theme and sunken in
|
|
252
|
+
the other. The selected label takes `elevated-foreground` accordingly — rule 11,
|
|
253
|
+
applied to the surface the label is actually drawn on.
|
|
254
|
+
- **There is no third variant.** An accent-filled capsule with no track was
|
|
255
|
+
tried and dropped: next to `primary` it differed only by its fill and the
|
|
256
|
+
absence of a track, which is a variant a caller picks by accident rather than
|
|
257
|
+
on purpose. A caller who wants one writes `className` on `Tabs.Indicator`,
|
|
258
|
+
and takes the label's colour with it.
|
|
259
|
+
|
|
260
|
+
## Accessibility
|
|
261
|
+
|
|
262
|
+
- **Reduce motion takes the default `System` policy**,
|
|
263
|
+
[`Checkbox`](../checkbox/AGENTS.md)'s call rather than
|
|
264
|
+
[`Spinner`](../spinner/AGENTS.md)'s. The state change is the point and the
|
|
265
|
+
travel is decoration. One wrinkle worth writing down: **the drag itself is
|
|
266
|
+
unaffected and must be** — `onUpdate` writes `position.value` directly, with no
|
|
267
|
+
`withSpring` to shorten. A direct manipulation is the user's own hand, not
|
|
268
|
+
motion played at them; only the release settle degrades.
|
|
269
|
+
|
|
270
|
+
## Deliberate omissions
|
|
271
|
+
|
|
272
|
+
- **There is no `Tabs.ListBackground` and no `background` prop.**
|
|
273
|
+
[`Screen.Navbar.Background`](../screen/AGENTS.md) exists because that part
|
|
274
|
+
overlays content and its backing has to travel with the keyboard translation. A
|
|
275
|
+
tab track is a static box in the flow, `bg-muted` on the `list` slot is the
|
|
276
|
+
whole feature, and `variant` already says whether there is one.
|
|
277
|
+
- **There is no public pager, no `orientation`, and no router prop.** The root
|
|
278
|
+
wraps its own panels the way [`ListGroup`](../list-group/AGENTS.md) inserts its
|
|
279
|
+
own dividers; a vertical tab bar is a sidebar, a different component whose
|
|
280
|
+
measurements are not in x; and this library takes no navigation dependency, so
|
|
281
|
+
`onValueChange` is where an app calls its router —
|
|
282
|
+
[`Screen.Navbar.BackButton`](../screen/AGENTS.md)'s trade.
|
|
283
|
+
- **No `hitSlop`, and no wash layers.** A trigger is already a wide row, and slop
|
|
284
|
+
on top of it would overlap the trigger beside it and make a tap between two
|
|
285
|
+
ambiguous — [`Checkbox`](../checkbox/AGENTS.md)'s rule. The capsule sliding
|
|
286
|
+
under the label is the feedback; `feedback="fade"` on top of it is the press.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export { Tabs, type TabsProps } from "./tabs";
|
|
2
|
+
export {
|
|
3
|
+
type TabsContextValue,
|
|
4
|
+
TabsListProvider,
|
|
5
|
+
type TabsListValue,
|
|
6
|
+
TabsMotionProvider,
|
|
7
|
+
type TabsMotionValue,
|
|
8
|
+
TabsProvider,
|
|
9
|
+
TabsScrollableProvider,
|
|
10
|
+
type TabsTriggerContextValue,
|
|
11
|
+
TabsTriggerProvider,
|
|
12
|
+
useTabs,
|
|
13
|
+
useTabsContext,
|
|
14
|
+
useTabsIsScrollable,
|
|
15
|
+
useTabsList,
|
|
16
|
+
useTabsListContext,
|
|
17
|
+
useTabsMotion,
|
|
18
|
+
useTabsMotionContext,
|
|
19
|
+
useTabsTrigger,
|
|
20
|
+
useTabsTriggerContext,
|
|
21
|
+
} from "./tabs.context";
|
|
22
|
+
export type { TabsLabelProps } from "./tabs.types";
|
|
23
|
+
export {
|
|
24
|
+
isTriggerOrderConsistent,
|
|
25
|
+
resolveContentAccessibility,
|
|
26
|
+
resolveInitialValue,
|
|
27
|
+
resolveMeasurementTracks,
|
|
28
|
+
resolvePagerTranslate,
|
|
29
|
+
resolvePanOrigin,
|
|
30
|
+
resolvePanPosition,
|
|
31
|
+
resolveReconcileMode,
|
|
32
|
+
resolveScrollOffset,
|
|
33
|
+
resolveSeparatorIndices,
|
|
34
|
+
resolveSeparatorOpacity,
|
|
35
|
+
resolveSettleIndex,
|
|
36
|
+
resolveTabIndex,
|
|
37
|
+
resolveTabOrder,
|
|
38
|
+
resolveTabSelectedness,
|
|
39
|
+
resolveTabsTriggerState,
|
|
40
|
+
resolveVisualIndex,
|
|
41
|
+
shouldEmitTabChange,
|
|
42
|
+
TABS_DEFAULT_SCROLL_ALIGN,
|
|
43
|
+
TABS_DEFAULT_SIZE,
|
|
44
|
+
TABS_DEFAULT_VARIANT,
|
|
45
|
+
TABS_FOREGROUND_TOKEN,
|
|
46
|
+
TABS_LABEL_TEXT_SIZE,
|
|
47
|
+
TABS_PAN,
|
|
48
|
+
TABS_SCROLL_ALIGNS,
|
|
49
|
+
TABS_SCROLL_INSET,
|
|
50
|
+
TABS_SEPARATOR_FADE,
|
|
51
|
+
TABS_SETTLE_SPRING,
|
|
52
|
+
TABS_SIZES,
|
|
53
|
+
TABS_VARIANTS,
|
|
54
|
+
TABS_VISUAL_HYSTERESIS,
|
|
55
|
+
type TabMeasurement,
|
|
56
|
+
type TabsReconcileMode,
|
|
57
|
+
type TabsScrollAlign,
|
|
58
|
+
type TabsSize,
|
|
59
|
+
type TabsVariant,
|
|
60
|
+
type TabsVariantProps,
|
|
61
|
+
type TabTracks,
|
|
62
|
+
tabsVariants,
|
|
63
|
+
} from "./tabs.variants";
|
|
64
|
+
export type { TabsContentProps, TabsContentRenderProps } from "./tabs-content";
|
|
65
|
+
export type { TabsIndicatorProps, TabsIndicatorRenderProps } from "./tabs-indicator";
|
|
66
|
+
export type { TabsListProps } from "./tabs-list";
|
|
67
|
+
export type { TabsScrollViewProps } from "./tabs-scroll-view";
|
|
68
|
+
export type { TabsSeparatorProps } from "./tabs-separator";
|
|
69
|
+
export type { TabsTriggerProps, TabsTriggerRenderProps } from "./tabs-trigger";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { useTabsPart } from "./tabs.context";
|
|
4
|
+
import { resolveContentAccessibility, tabsVariants } from "./tabs.variants";
|
|
5
|
+
|
|
6
|
+
export type TabsContentRenderProps = {
|
|
7
|
+
/** Whether this panel's tab is the settled selection. */
|
|
8
|
+
isSelected: boolean;
|
|
9
|
+
/** What this panel is called. */
|
|
10
|
+
value: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type TabsContentProps = Omit<ViewProps, "children"> & {
|
|
14
|
+
/** Identifies the panel to the trigger that selects it. Required. */
|
|
15
|
+
value: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
/**
|
|
18
|
+
* The panel, or a function called with its own selected state.
|
|
19
|
+
*
|
|
20
|
+
* The function is how a panel is made lazy — `{({ isSelected }) => isSelected ?
|
|
21
|
+
* <Expensive /> : null}` — at the caller's discretion, per panel. There is no
|
|
22
|
+
* `isLazy` prop because "lazy" is not one thing: a form wants to stay mounted,
|
|
23
|
+
* a video wants to stop, and a report wants never to have been built.
|
|
24
|
+
*/
|
|
25
|
+
children?: ReactNode | ((props: TabsContentRenderProps) => ReactNode);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One panel, exactly one viewport wide.
|
|
30
|
+
*
|
|
31
|
+
* **Panels must be direct children of `<Tabs>`.** Their source order is the order
|
|
32
|
+
* of record — it is what the pager translates through and what the indicator
|
|
33
|
+
* interpolates over — and the root reads it by walking its own children, the way
|
|
34
|
+
* `ListGroup` finds the rows it puts dividers between.
|
|
35
|
+
*
|
|
36
|
+
* **Every panel is mounted, whether or not the bar is swipeable.** You cannot drag
|
|
37
|
+
* to a panel that does not exist, so mounting is not a setting `isSwipeable` gets
|
|
38
|
+
* to change as a side effect; and a panel that unmounted on every tab change would
|
|
39
|
+
* lose a half-filled form, a scroll position and a playing video with it. What an
|
|
40
|
+
* unmounting panel never has to think about is the cost of staying: mounted
|
|
41
|
+
* content is content a screen reader will read and a finger can reach, so an
|
|
42
|
+
* unselected panel says it is not there through both platforms' props at once.
|
|
43
|
+
*/
|
|
44
|
+
export function TabsContent({ value, className, children, ...props }: TabsContentProps): ReactElement {
|
|
45
|
+
const { value: selected, size, variant } = useTabsPart("Tabs.Content");
|
|
46
|
+
const isSelected = selected === value;
|
|
47
|
+
const slots = tabsVariants({ size, variant });
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
// No `accessibilityRole`: React Native has no `tabpanel`, and the roles it
|
|
51
|
+
// does have all describe controls. The panel is a container, and the two
|
|
52
|
+
// props below are what actually keep an unselected one out of the way.
|
|
53
|
+
<View
|
|
54
|
+
className={slots.page({ className })}
|
|
55
|
+
// Driven by the settled selection, never by the visual one: a screen reader
|
|
56
|
+
// must not walk into a panel that is halfway across the screen.
|
|
57
|
+
{...resolveContentAccessibility(isSelected)}
|
|
58
|
+
pointerEvents={isSelected ? "auto" : "none"}
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
{typeof children === "function" ? children({ isSelected, value }) : children}
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
TabsContent.displayName = "DelacourUI.Tabs.Content";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from "react";
|
|
2
|
+
import type { ViewProps } from "react-native";
|
|
3
|
+
import Animated, { Extrapolation, interpolate, type SharedValue, useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { useTabsListPart, useTabsMotionPart, useTabsPart } from "./tabs.context";
|
|
5
|
+
import { type TabsSize, type TabsVariant, tabsVariants } from "./tabs.variants";
|
|
6
|
+
|
|
7
|
+
export type TabsIndicatorRenderProps = {
|
|
8
|
+
/** Where the pager sits in tab-index space, on the UI thread. */
|
|
9
|
+
position: SharedValue<number>;
|
|
10
|
+
variant: TabsVariant;
|
|
11
|
+
size: TabsSize;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type TabsIndicatorProps = Omit<ViewProps, "children"> & {
|
|
15
|
+
className?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Composed inside the capsule, which keeps its own fill, position and size.
|
|
18
|
+
*
|
|
19
|
+
* To change the capsule itself — an outline instead of a fill, a different
|
|
20
|
+
* colour — pass a `className`; it merges last, so `bg-transparent` beats the
|
|
21
|
+
* variant's own fill. Children are for what a class cannot say: a gradient, a
|
|
22
|
+
* second layer, something that reads `position` for itself.
|
|
23
|
+
*/
|
|
24
|
+
children?: ReactNode | ((props: TabsIndicatorRenderProps) => ReactNode);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The one layer that slides, and the only place a tab's motion is drawn.
|
|
29
|
+
*
|
|
30
|
+
* Write it out as the first child of the row it belongs to — inside
|
|
31
|
+
* `Tabs.ScrollView` when there is one, inside `Tabs.List` when there is not. It is
|
|
32
|
+
* absolutely positioned in that row's own space, which is exactly the space every
|
|
33
|
+
* `Tabs.Trigger` measures itself into, so the two need no arithmetic between them.
|
|
34
|
+
*
|
|
35
|
+
* **It animates `width` alongside `translateX`, deliberately rather than
|
|
36
|
+
* `scaleX`.** A scale is cheaper — a pure transform, no layout pass — and it is
|
|
37
|
+
* wrong here three times over: `rounded-full` stretches into an ellipse at the
|
|
38
|
+
* 2.3× a short tab to a long one asks for, a border thickens on two edges and not
|
|
39
|
+
* the other two, and any children a caller passes are squashed with it, which no
|
|
40
|
+
* counter-scale can fix across an arbitrary subtree. The cost is one layout of
|
|
41
|
+
* this node's own box per frame, and it is bounded: the indicator is absolutely
|
|
42
|
+
* positioned, so its size change never dirties a sibling and the row's layout
|
|
43
|
+
* stays settled. `Checkbox` already animates a width every toggle for the same
|
|
44
|
+
* kind of reason.
|
|
45
|
+
*
|
|
46
|
+
* If a caller ever does put a deep subtree inside one, the fix is to give that
|
|
47
|
+
* subtree a fixed size and let the indicator's width move around it rather than
|
|
48
|
+
* through it.
|
|
49
|
+
*
|
|
50
|
+
* **Children are composed inside it, not in place of it** — `Radio.Indicator`'s
|
|
51
|
+
* shape, where the ring stays the radio's and only the dot is the caller's. The
|
|
52
|
+
* capsule's own fill comes from the variant and its `className` merges last, so
|
|
53
|
+
* an outline treatment is `className="border-2 border-primary bg-transparent"`
|
|
54
|
+
* rather than a child.
|
|
55
|
+
*
|
|
56
|
+
* **It reads the UI thread and nothing else**, which is why the shared values it
|
|
57
|
+
* needs come from a context of their own: the bar's selection context changes
|
|
58
|
+
* identity on every tab change, and this component must not re-render for one.
|
|
59
|
+
*/
|
|
60
|
+
export function TabsIndicator({ className, children, style, ...props }: TabsIndicatorProps): ReactElement {
|
|
61
|
+
const { variant, size } = useTabsPart("Tabs.Indicator");
|
|
62
|
+
const { position } = useTabsMotionPart("Tabs.Indicator");
|
|
63
|
+
const { tracks } = useTabsListPart("Tabs.Indicator");
|
|
64
|
+
|
|
65
|
+
const frameStyle = useAnimatedStyle(() => {
|
|
66
|
+
const current = tracks.value;
|
|
67
|
+
// Nothing measured yet. `opacity: 0` rather than an early return that names
|
|
68
|
+
// no opacity: a style which stops naming a prop leaves the last value it
|
|
69
|
+
// wrote in place, so the indicator would freeze wherever it last sat instead
|
|
70
|
+
// of getting out of the way.
|
|
71
|
+
if (!current) return { opacity: 0, transform: [{ translateX: 0 }], width: 0 };
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
opacity: 1,
|
|
75
|
+
transform: [{ translateX: interpolate(position.value, current.index, current.x, Extrapolation.CLAMP) }],
|
|
76
|
+
width: interpolate(position.value, current.index, current.width, Extrapolation.CLAMP),
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const slots = tabsVariants({ size, variant });
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Animated.View
|
|
84
|
+
// A decoration, and announcing it would put an actionless element in front
|
|
85
|
+
// of every tab a screen reader walks.
|
|
86
|
+
accessibilityElementsHidden
|
|
87
|
+
className={slots.indicator({ className })}
|
|
88
|
+
importantForAccessibility="no-hide-descendants"
|
|
89
|
+
pointerEvents="none"
|
|
90
|
+
style={[frameStyle, style]}
|
|
91
|
+
{...props}
|
|
92
|
+
>
|
|
93
|
+
{typeof children === "function" ? children({ position, size, variant }) : children}
|
|
94
|
+
</Animated.View>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
TabsIndicator.displayName = "DelacourUI.Tabs.Indicator";
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { interpolateColor, useAnimatedStyle } from "react-native-reanimated";
|
|
3
|
+
import { useThemeColor } from "../../hooks/use-theme-color";
|
|
4
|
+
import { Text } from "../text";
|
|
5
|
+
import { useTabsMotionPart, useTabsPart, useTabsTriggerPart } from "./tabs.context";
|
|
6
|
+
import type { TabsLabelProps } from "./tabs.types";
|
|
7
|
+
import { resolveTabSelectedness, TABS_FOREGROUND_TOKEN, TABS_LABEL_TEXT_SIZE, tabsVariants } from "./tabs.variants";
|
|
8
|
+
|
|
9
|
+
export type { TabsLabelProps };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A trigger's text, inside the trigger's own tap target.
|
|
13
|
+
*
|
|
14
|
+
* Renders `Text.Label` and names a size, never a scale of its own — the weight
|
|
15
|
+
* belongs to the preset, and restating it here would be a second definition of
|
|
16
|
+
* `Text.Label` that could drift from it. Naming the size is what lets the label
|
|
17
|
+
* still track the bar's own axis, since `Text`'s size axis is built to beat its
|
|
18
|
+
* preset.
|
|
19
|
+
*
|
|
20
|
+
* **Its colour is an animated style, not a class, because it fades.** The label
|
|
21
|
+
* interpolates between the two values `TABS_FOREGROUND_TOKEN` names, off the same
|
|
22
|
+
* `position` the capsule and the panels read — so it crossfades *with* the
|
|
23
|
+
* capsule arriving rather than flipping the moment the midpoint is crossed. That
|
|
24
|
+
* covers a finger dragging, a flick and a plain tap without knowing which is
|
|
25
|
+
* happening, because all three write the same value. `Checkbox`'s border makes
|
|
26
|
+
* the same trade: a colour that travels cannot be a class, so it interpolates
|
|
27
|
+
* between two tokens and the decision stays in a pure map `bun test` can sweep.
|
|
28
|
+
*
|
|
29
|
+
* The `label` slot therefore carries **no** colour. Two sources for one colour is
|
|
30
|
+
* how a class and a style end up disagreeing for a frame on every commit.
|
|
31
|
+
*
|
|
32
|
+
* **`numberOfLines` defaults to one.** A label that wrapped would change the
|
|
33
|
+
* row's height, which changes every measured frame, which moves the indicator —
|
|
34
|
+
* so the bar would reflow around its own longest word. It is an ordinary prop, so
|
|
35
|
+
* `numberOfLines={undefined}` opts out.
|
|
36
|
+
*/
|
|
37
|
+
export function TabsLabel({ className, size, numberOfLines = 1, style, ...props }: TabsLabelProps): ReactElement {
|
|
38
|
+
const { size: barSize, variant } = useTabsPart("Tabs.Label");
|
|
39
|
+
const { index, isDisabled } = useTabsTriggerPart("Tabs.Label");
|
|
40
|
+
const { position } = useTabsMotionPart("Tabs.Label");
|
|
41
|
+
|
|
42
|
+
// Destructured to primitives before the worklet closes over them: a fresh
|
|
43
|
+
// object each render would rebuild the animated style every render.
|
|
44
|
+
const tokens = TABS_FOREGROUND_TOKEN[variant];
|
|
45
|
+
const unselectedColor = useThemeColor(tokens.unselected) ?? "transparent";
|
|
46
|
+
const selectedColor = useThemeColor(tokens.selected) ?? "transparent";
|
|
47
|
+
|
|
48
|
+
const colorStyle = useAnimatedStyle(() => ({
|
|
49
|
+
color: interpolateColor(resolveTabSelectedness(index, position.value), [0, 1], [unselectedColor, selectedColor]),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Text.Label
|
|
54
|
+
className={tabsVariants({ isDisabled, size: barSize, variant }).label({ className })}
|
|
55
|
+
numberOfLines={numberOfLines}
|
|
56
|
+
size={size ?? TABS_LABEL_TEXT_SIZE[barSize]}
|
|
57
|
+
style={[colorStyle, style]}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
TabsLabel.displayName = "DelacourUI.Tabs.Label";
|