@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
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# @delacour/react-native-ui
|
|
2
|
+
|
|
3
|
+
A React Native component library. Styling is
|
|
4
|
+
[Uniwind](https://github.com/kirillzyusko/uniwind) — Tailwind v4 for React
|
|
5
|
+
Native; interaction is Reanimated and Gesture Handler; haptics are Pulsar.
|
|
6
|
+
|
|
7
|
+
The package **ships raw `.tsx` with no build step.** Uniwind's transform has to
|
|
8
|
+
run inside the consuming app's Metro pipeline, so a precompiled build would
|
|
9
|
+
arrive with its classNames already dead.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @delacour/react-native-ui@alpha
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Native modules are **peer dependencies**, because two copies of a native module
|
|
18
|
+
register twice and break at runtime. The complete list — native modules through
|
|
19
|
+
`expo install`, the rest through your package manager — is derived from the
|
|
20
|
+
components' own imports and kept on the
|
|
21
|
+
[Installation page](https://ui.delacour.co.nz/docs/native/getting-started/installation).
|
|
22
|
+
Four peers are optional and resolve only if you import the subpath that needs them:
|
|
23
|
+
|
|
24
|
+
| Peer | Needed for |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `expo-router` | `@delacour/react-native-ui/expo/navigation-theme` |
|
|
27
|
+
| `@legendapp/list` | `Screen.LegendList` and `Screen.ChatList` |
|
|
28
|
+
| `react-native-screens` | native stack navigation |
|
|
29
|
+
| `@delacour/react-native-charts` | `Chart` |
|
|
30
|
+
|
|
31
|
+
Granular exports are what make that safe: an app that never imports a subpath
|
|
32
|
+
never makes Metro resolve its peers.
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
**1. Styles.** In your app's `global.css`:
|
|
37
|
+
|
|
38
|
+
```css
|
|
39
|
+
@import '@delacour/react-native-ui/styles';
|
|
40
|
+
@source '../../../../packages/native-ui/src';
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Use the real workspace path, not a `node_modules` one — Bun symlinks workspace
|
|
44
|
+
packages and Tailwind's scanner cannot follow symlinks, so classes would be
|
|
45
|
+
silently dropped from production builds.
|
|
46
|
+
|
|
47
|
+
**2. Provider.** Wrap the app's root:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { DelacourProvider } from "@delacour/react-native-ui/provider";
|
|
51
|
+
|
|
52
|
+
<DelacourProvider>{children}</DelacourProvider>;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That is the gesture root every `Pressable` needs above it, the safe-area provider
|
|
56
|
+
and keyboard provider `Screen` reads, the bottom-sheet modal provider, and the
|
|
57
|
+
keyboard state sync that keeps them honest.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
There is **no package-wide barrel.** Import from the subpath:
|
|
62
|
+
|
|
63
|
+
```tsx
|
|
64
|
+
import { Button } from "@delacour/react-native-ui/button";
|
|
65
|
+
import { Icon } from "@delacour/react-native-ui/icon";
|
|
66
|
+
import { IconArrowRight } from "@delacour/react-native-ui/icons/central";
|
|
67
|
+
|
|
68
|
+
// The icon inherits the button's size and its variant's colour.
|
|
69
|
+
<Button haptic="selection" onPress={next}>
|
|
70
|
+
<Button.Label>Continue</Button.Label>
|
|
71
|
+
<Icon icon={IconArrowRight} />
|
|
72
|
+
</Button>;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Components
|
|
76
|
+
|
|
77
|
+
| Component | Import | |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| Accordion | `@delacour/react-native-ui/accordion` | Selection modes, measured panels, indicators |
|
|
80
|
+
| Badge | `@delacour/react-native-ui/badge` | Variants, colours, sizes, dismiss |
|
|
81
|
+
| BottomSheet | `@delacour/react-native-ui/bottom-sheet` | Overlay, snap points, sticky footer, keyboard |
|
|
82
|
+
| Button | `@delacour/react-native-ui/button` | Variants, sizes, icons, loading |
|
|
83
|
+
| Checkbox | `@delacour/react-native-ui/checkbox` | Colours, sizes, indeterminate, groups |
|
|
84
|
+
| Field | `@delacour/react-native-ui/field` | Form layout, grouping, state cascade |
|
|
85
|
+
| Icon | `@delacour/react-native-ui/icon` | Central Icons, with inherited size and colour |
|
|
86
|
+
| Input | `@delacour/react-native-ui/input` | Variants, sizes, prefix and suffix |
|
|
87
|
+
| ListGroup | `@delacour/react-native-ui/list-group` | Grouped rows, dividers, slots |
|
|
88
|
+
| Pressable | `@delacour/react-native-ui/pressable` | Gestures, haptics, `asChild` |
|
|
89
|
+
| Radio | `@delacour/react-native-ui/radio` | Groups, selection, sizes, orientation |
|
|
90
|
+
| Screen | `@delacour/react-native-ui/screen` | Navbar, footer, scrollables, keyboard |
|
|
91
|
+
| Separator | `@delacour/react-native-ui/separator` | Orientations, insets, weight |
|
|
92
|
+
| Slider | `@delacour/react-native-ui/slider` | Range, orientation, colours, steps |
|
|
93
|
+
| Spinner | `@delacour/react-native-ui/spinner` | Sizes, colours, custom glyphs |
|
|
94
|
+
| Switch | `@delacour/react-native-ui/switch` | Drag or tap, colours, sizes, end content |
|
|
95
|
+
| Tabs | `@delacour/react-native-ui/tabs` | Variants, sizes, swipe, scrolling |
|
|
96
|
+
| Text | `@delacour/react-native-ui/text` | Type scale, presets, inline nesting |
|
|
97
|
+
| DelacourProvider | `@delacour/react-native-ui/provider` | The app's root layer stack |
|
|
98
|
+
|
|
99
|
+
Also exported: `hooks/*`, `lib/*`, `expo/navigation-theme`, `icons/central`, and
|
|
100
|
+
the `styles/*` CSS entries.
|
|
101
|
+
|
|
102
|
+
## Theming
|
|
103
|
+
|
|
104
|
+
Tokens are CSS variables under `@variant light` / `@variant dark`. Components
|
|
105
|
+
reference semantic names — `bg-background`, `text-muted-foreground` — never a raw
|
|
106
|
+
palette colour or a `dark:` prefix. For a prop needing a colour *value* rather
|
|
107
|
+
than a class, use `useThemeColor`.
|
|
108
|
+
|
|
109
|
+
## Documentation
|
|
110
|
+
|
|
111
|
+
[`AGENTS.md`](AGENTS.md) is the reference — the rules, the patterns, and the
|
|
112
|
+
reasoning behind each decision. Each component has its own alongside its source
|
|
113
|
+
in [`src/components`](src/components).
|
package/package.json
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@delacour/react-native-ui",
|
|
3
|
+
"version": "0.1.0-alpha.3",
|
|
4
|
+
"description": "React Native UI components \u2014 Uniwind, Reanimated, Gesture Handler",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/delacournz/delacour-ui.git",
|
|
10
|
+
"directory": "packages/native-ui"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react-native",
|
|
14
|
+
"expo",
|
|
15
|
+
"ui",
|
|
16
|
+
"components",
|
|
17
|
+
"uniwind",
|
|
18
|
+
"tailwind",
|
|
19
|
+
"reanimated"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": [
|
|
29
|
+
"**/*.css"
|
|
30
|
+
],
|
|
31
|
+
"exports": {
|
|
32
|
+
"./styles": "./src/styles/index.css",
|
|
33
|
+
"./styles/base": "./src/styles/base.css",
|
|
34
|
+
"./styles/tokens": "./src/styles/tokens.css",
|
|
35
|
+
"./styles/theme": "./src/styles/theme.css",
|
|
36
|
+
"./accordion": "./src/components/accordion/index.ts",
|
|
37
|
+
"./badge": "./src/components/badge/index.ts",
|
|
38
|
+
"./bottom-sheet": "./src/components/bottom-sheet/index.ts",
|
|
39
|
+
"./button": "./src/components/button/index.ts",
|
|
40
|
+
"./chart": "./src/components/chart/index.ts",
|
|
41
|
+
"./checkbox": "./src/components/checkbox/index.ts",
|
|
42
|
+
"./field": "./src/components/field/index.ts",
|
|
43
|
+
"./icon": "./src/components/icon/index.ts",
|
|
44
|
+
"./input": "./src/components/input/index.ts",
|
|
45
|
+
"./list-group": "./src/components/list-group/index.ts",
|
|
46
|
+
"./pressable": "./src/components/pressable/index.ts",
|
|
47
|
+
"./provider": "./src/components/provider/index.ts",
|
|
48
|
+
"./radio": "./src/components/radio/index.ts",
|
|
49
|
+
"./screen": "./src/components/screen/index.ts",
|
|
50
|
+
"./separator": "./src/components/separator/index.ts",
|
|
51
|
+
"./slider": "./src/components/slider/index.ts",
|
|
52
|
+
"./spinner": "./src/components/spinner/index.ts",
|
|
53
|
+
"./switch": "./src/components/switch/index.ts",
|
|
54
|
+
"./tabs": "./src/components/tabs/index.ts",
|
|
55
|
+
"./text": "./src/components/text/index.ts",
|
|
56
|
+
"./expo/navigation-theme": "./src/expo/navigation-theme.tsx",
|
|
57
|
+
"./hooks/use-controllable-state": "./src/hooks/use-controllable-state.ts",
|
|
58
|
+
"./hooks/use-keyboard-state-sync": "./src/hooks/use-keyboard-state-sync.tsx",
|
|
59
|
+
"./hooks/use-navigation-theme": "./src/hooks/use-navigation-theme.ts",
|
|
60
|
+
"./hooks/use-theme-color": "./src/hooks/use-theme-color.ts",
|
|
61
|
+
"./lib/cn": "./src/lib/cn.ts",
|
|
62
|
+
"./lib/color": "./src/lib/color.ts",
|
|
63
|
+
"./lib/compose-refs": "./src/lib/compose-refs.ts",
|
|
64
|
+
"./lib/keyboard-animation": "./src/lib/keyboard-animation.ts",
|
|
65
|
+
"./lib/merge-props": "./src/lib/merge-props.ts",
|
|
66
|
+
"./lib/navigation-theme": "./src/lib/navigation-theme.ts",
|
|
67
|
+
"./lib/slot": "./src/lib/slot.tsx",
|
|
68
|
+
"./lib/tv": "./src/lib/tv.ts",
|
|
69
|
+
"./icons/central": "./src/icons/central.ts"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"check": "biome check ./src",
|
|
73
|
+
"typecheck": "tsc --noEmit",
|
|
74
|
+
"test": "bun test",
|
|
75
|
+
"gen-exports": "bun scripts/gen-exports.ts"
|
|
76
|
+
},
|
|
77
|
+
"dependencies": {
|
|
78
|
+
"clsx": "^2.1.1",
|
|
79
|
+
"tailwind-merge": "^3.6.0",
|
|
80
|
+
"tailwind-variants": "^3.3.1"
|
|
81
|
+
},
|
|
82
|
+
"peerDependencies": {
|
|
83
|
+
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
84
|
+
"@delacour/react-native-charts": ">=0.1.0-alpha.4",
|
|
85
|
+
"@gorhom/bottom-sheet": "^5.2.8",
|
|
86
|
+
"@legendapp/list": ">=3.3",
|
|
87
|
+
"expo-linear-gradient": ">=15",
|
|
88
|
+
"expo-router": ">=57",
|
|
89
|
+
"react": ">=19",
|
|
90
|
+
"react-native": ">=0.81",
|
|
91
|
+
"react-native-gesture-handler": ">=2.28",
|
|
92
|
+
"react-native-keyboard-controller": ">=1.18",
|
|
93
|
+
"react-native-pulsar": "^1.7.0",
|
|
94
|
+
"react-native-reanimated": ">=4",
|
|
95
|
+
"react-native-safe-area-context": ">=5",
|
|
96
|
+
"react-native-screens": ">=4.16",
|
|
97
|
+
"react-native-svg": ">=15",
|
|
98
|
+
"react-native-worklets": ">=0.5",
|
|
99
|
+
"tailwindcss": "^4",
|
|
100
|
+
"uniwind": "^1.11.0"
|
|
101
|
+
},
|
|
102
|
+
"peerDependenciesMeta": {
|
|
103
|
+
"@delacour/react-native-charts": {
|
|
104
|
+
"optional": true
|
|
105
|
+
},
|
|
106
|
+
"@legendapp/list": {
|
|
107
|
+
"optional": true
|
|
108
|
+
},
|
|
109
|
+
"expo-router": {
|
|
110
|
+
"optional": true
|
|
111
|
+
},
|
|
112
|
+
"react-native-screens": {
|
|
113
|
+
"optional": true
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"devDependencies": {
|
|
117
|
+
"@delacour/biome-config": "workspace:*",
|
|
118
|
+
"@delacour/react-native-charts": "workspace:*",
|
|
119
|
+
"@delacour/tsconfig": "workspace:*",
|
|
120
|
+
"@types/bun": "^1.3.8",
|
|
121
|
+
"@types/react": "catalog:",
|
|
122
|
+
"expo-router": "~57.0.15",
|
|
123
|
+
"typescript": "^5.9.3"
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Accordion
|
|
2
|
+
|
|
3
|
+
Rows that each disclose a panel. Compound root plus `Accordion.Item`,
|
|
4
|
+
`Accordion.Trigger`, `Accordion.Title`, `Accordion.Description`,
|
|
5
|
+
`Accordion.Indicator` and `Accordion.Content`. It takes
|
|
6
|
+
[ListGroup](../list-group/AGENTS.md)'s axes and its divider insertion outright,
|
|
7
|
+
so read that section first — what follows is only where a disclosure differs from
|
|
8
|
+
a list of rows.
|
|
9
|
+
|
|
10
|
+
`import { Accordion } from "@delacour/react-native-ui/accordion";`
|
|
11
|
+
|
|
12
|
+
## Files
|
|
13
|
+
|
|
14
|
+
| File | What it holds |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `index.ts` | → `@delacour/react-native-ui/accordion` |
|
|
17
|
+
| `accordion.tsx` | Root, the selection narrow, the divider insertion, the `Object.assign` |
|
|
18
|
+
| `accordion-item.tsx` | `Accordion.Item` — owns `progress`, the measured height and the spring |
|
|
19
|
+
| `accordion-trigger.tsx` | `Accordion.Trigger`, and the row it assembles from its children |
|
|
20
|
+
| `accordion-title.tsx` | `Accordion.Title` |
|
|
21
|
+
| `accordion-description.tsx` | `Accordion.Description` |
|
|
22
|
+
| `accordion-indicator.tsx` | `Accordion.Indicator`, the one rotation |
|
|
23
|
+
| `accordion-content.tsx` | `Accordion.Content` — the measured clip |
|
|
24
|
+
| `accordion.context.tsx` | `AccordionContext` and `AccordionItemContext`, with their hooks |
|
|
25
|
+
| `accordion.types.ts` | Prop types shared by two or more parts |
|
|
26
|
+
| `accordion.variants.ts` | Pure `tv()` slots + the selection maths, no RN imports |
|
|
27
|
+
| `accordion.variants.test.ts` | |
|
|
28
|
+
|
|
29
|
+
## Design
|
|
30
|
+
|
|
31
|
+
- **Variants**: `default`, `secondary`, `tertiary`, `transparent`. **Sizes**:
|
|
32
|
+
`sm`, `md`, `lg` — driving the trigger's metrics, the title and description
|
|
33
|
+
type scale, the indicator's glyph step, the panel's padding *and* the divider
|
|
34
|
+
inset. Six numbers on one axis rather than in six places that can drift, and
|
|
35
|
+
the tests pin the inset against the trigger's own padding as a **pair**.
|
|
36
|
+
- **The corner is `rounded-md`, the 0.8 step of the shared ramp.** shadcn puts a
|
|
37
|
+
grouped surface a step or two below a card, so a group reads as a control
|
|
38
|
+
surface rather than as one. Size does not move it, and neither does a button's
|
|
39
|
+
own corner — `--radius-button-*` is a capsule at half its height and outside
|
|
40
|
+
this ramp. See the corner-scale note in
|
|
41
|
+
[the package AGENTS.md](../../../AGENTS.md).
|
|
42
|
+
- **The variant paints the root and nothing else.** Only the root has a surface;
|
|
43
|
+
a trigger or a panel that also changed with it would be a second source for one
|
|
44
|
+
colour. A test asserts every variant's root is distinct, because two cells
|
|
45
|
+
collapsing means a caller can set the axis and see nothing change.
|
|
46
|
+
|
|
47
|
+
## The measured panel
|
|
48
|
+
|
|
49
|
+
- **A panel's height is measured, never laid out.** `Accordion.Content` clips,
|
|
50
|
+
and its height is an animated style running from zero to whatever its inner
|
|
51
|
+
view's `onLayout` reported. The alternative — unmounting the content and
|
|
52
|
+
letting a `LinearTransition` reflow the tree — makes every sibling on the
|
|
53
|
+
screen a participant in this component's animation, and other implementations
|
|
54
|
+
of this control document exactly that as a requirement. Here the animation is
|
|
55
|
+
contained to the accordion's own node: a real height is changing, so whatever
|
|
56
|
+
sits below follows it smoothly through ordinary layout, without opting into
|
|
57
|
+
anything and without a transition painted on it.
|
|
58
|
+
- **The measured layer is out of flow.** `contentInner` is absolutely positioned,
|
|
59
|
+
so its height is purely its content's and can never be fed back the clip's own
|
|
60
|
+
animated one. `left-0 right-0` is the other half: an absolute child is
|
|
61
|
+
content-width without it, and a paragraph would measure as a single unwrapped
|
|
62
|
+
line.
|
|
63
|
+
- **One shared value per item drives three properties** — the panel's height, the
|
|
64
|
+
panel's opacity and the indicator's rotation, all off `progress` through one
|
|
65
|
+
`ACCORDION_SPRING`. `Switch`'s rule, and the failure it prevents is visible:
|
|
66
|
+
two springs a few hundred stiffness apart leave the chevron lagging the panel
|
|
67
|
+
on every tap. A test pins that both read the same constant.
|
|
68
|
+
- **The spring is critically damped**, which is `Switch`'s reason turned inside
|
|
69
|
+
out. A switch damps its thumb because an overshoot squashes against a capsule
|
|
70
|
+
that clips it; a panel damps its height because an overshoot draws the panel
|
|
71
|
+
*taller* than its content measured, flashing the surface behind it for a frame
|
|
72
|
+
at the end of every expand.
|
|
73
|
+
- **The fade runs ahead of the height.** Opacity ramps across
|
|
74
|
+
`ACCORDION_CONTENT_FADE` rather than tracking `progress` linearly, because a
|
|
75
|
+
panel half transparent at the midpoint of every expand reads as content
|
|
76
|
+
struggling to arrive rather than as a panel opening.
|
|
77
|
+
- **A panel mounts on first expand and stays mounted.** Nothing renders until an
|
|
78
|
+
item is first opened, so a screen of collapsed panels costs an empty view each;
|
|
79
|
+
once opened, the subtree survives every later collapse with its scroll
|
|
80
|
+
position, its form state and its media intact. `hasExpanded` is adjusted during
|
|
81
|
+
render rather than in an effect, so the panel mounts in the same commit as the
|
|
82
|
+
tap — waiting a frame would put the first measurement, and therefore the start
|
|
83
|
+
of the travel, one frame later still.
|
|
84
|
+
- **That is what the three accessibility props on the clip are for.** Mounted
|
|
85
|
+
content is content a screen reader reads and a finger reaches, so a collapsed
|
|
86
|
+
panel sets `accessibilityElementsHidden`, `importantForAccessibility` and
|
|
87
|
+
`pointerEvents`. A component that unmounted its content never has to think
|
|
88
|
+
about this; the trade is worth making, but it is not free.
|
|
89
|
+
- **The first expand starts from the panel's own layout, not from the item's
|
|
90
|
+
effect.** A panel that has never mounted has no height to travel against, so
|
|
91
|
+
springing on the state change would run the whole animation at zero and jump
|
|
92
|
+
the moment a measurement arrived. The item's effect skips exactly that one
|
|
93
|
+
case and `accordion-content.tsx`'s `onLayout` starts it instead. Every later
|
|
94
|
+
toggle, in either direction, is the item's.
|
|
95
|
+
- **`ACCORDION_UNMEASURED` is negative, and that is load-bearing.** A panel that
|
|
96
|
+
measured `0` is a real answer — content that rendered nothing — and an item
|
|
97
|
+
treating it as "still waiting" would never start its spring, leaving the
|
|
98
|
+
indicator pointing the wrong way. Only a value no layout can produce can mean
|
|
99
|
+
*unmeasured*, so the height style floors it.
|
|
100
|
+
|
|
101
|
+
## Selection
|
|
102
|
+
|
|
103
|
+
- **`selectionMode` is a true discriminated union**, so `single` with an array
|
|
104
|
+
`defaultValue` is a compile error. Intersecting the union with a widened
|
|
105
|
+
`defaultValue?: string | string[]` defeats it entirely — every member re-admits
|
|
106
|
+
both shapes, and `onValueChange` ends up typed as a function *returning* a
|
|
107
|
+
union rather than a union of functions, which is what forces a cast in the
|
|
108
|
+
toggle. `AccordionRoot` is nothing but the narrow, and it happens **before
|
|
109
|
+
anything is destructured**; destructure first and the correlation is gone.
|
|
110
|
+
- **The state is one internal `string[]` whichever mode is in play.**
|
|
111
|
+
`useControllableState` cannot be called conditionally, and two hooks would be
|
|
112
|
+
two pieces of state for one answer, so the caller's shape is converted at the
|
|
113
|
+
boundary — `toExpandedList` on the way in, a narrow on `selectionMode` on the
|
|
114
|
+
way out. `value={null}` in single mode is a controlled *empty*, not an absence,
|
|
115
|
+
which is why `undefined` is preserved rather than normalised away.
|
|
116
|
+
- **`isCollapsible` bounds the set, never a single item.** In `multiple` mode an
|
|
117
|
+
item still closes while another is open and only the last one is refused.
|
|
118
|
+
Reading it as "no item may ever close" makes a multiple accordion **add-only** —
|
|
119
|
+
a control that fills up and never empties — and that is the bug this rule
|
|
120
|
+
exists to name.
|
|
121
|
+
- **A refused tap returns its own input, by identity.** `toggleExpandedValue`
|
|
122
|
+
hands back the array it was given when the rules reject the change, so the root
|
|
123
|
+
can skip it: a rejected tap must not re-render and must not report an
|
|
124
|
+
`onValueChange` for a change that did not happen. A real change is always a new
|
|
125
|
+
array, because React bails out of a re-render on an unchanged reference.
|
|
126
|
+
- **`toggle` and the change callback are both ref-backed trampolines**, so the
|
|
127
|
+
accordion's context keeps its identity across every expansion and across a
|
|
128
|
+
caller passing a fresh arrow. Without them every item re-renders on every
|
|
129
|
+
toggle, which is what the implementations that hand a fresh object literal to
|
|
130
|
+
their provider actually do.
|
|
131
|
+
|
|
132
|
+
## The trigger
|
|
133
|
+
|
|
134
|
+
- **A trigger is a `Pressable`**, so `feedback`, `haptic` and the rest are
|
|
135
|
+
inherited rather than restated. Two defaults differ: `fade`, because a
|
|
136
|
+
full-bleed row that scales reads as the whole card flexing — `ListGroup.Item`'s
|
|
137
|
+
reason — and `haptic="selection"`, because an item is a state toggle and the
|
|
138
|
+
panel landing is the confirmation, which is `Checkbox`'s and `Switch`'s.
|
|
139
|
+
`onPress` is the one prop `Omit`ed rather than forwarded: the press *is* the
|
|
140
|
+
toggle, and a side effect belongs on `onValueChange`. `Checkbox`'s trade.
|
|
141
|
+
- **The disabled fade lands on the item, never on the trigger.** The trigger is a
|
|
142
|
+
`Pressable`, whose root `Animated.View` writes `opacity` every frame through a
|
|
143
|
+
`useAnimatedStyle` of its own — a class on that node is overwritten silently,
|
|
144
|
+
the failure `Switch` and `Radio` both record. A test sweeps for it.
|
|
145
|
+
- **The trigger assembles its own row.** Titles and descriptions stack in a
|
|
146
|
+
column, anything else — a leading `Icon`, a `Badge` — stays where it was
|
|
147
|
+
written as a row sibling, and the indicator is moved to the end and composed in
|
|
148
|
+
when the children hold none. That is what makes an `Accordion.TriggerContent`
|
|
149
|
+
part unnecessary rather than merely absent. Bare string children are wrapped in
|
|
150
|
+
an `Accordion.Title`, consecutive strings collapsing into one — the same rule,
|
|
151
|
+
and the same reason, as `Button`.
|
|
152
|
+
- **The indicator rotates off the item's own `progress`**, so a *custom* glyph
|
|
153
|
+
turns exactly like the default one. Passing children and silently dropping the
|
|
154
|
+
animated style is a real trap in other implementations of this control, and the
|
|
155
|
+
opt-out here is explicit: `isAnimated={false}`, for a plus becoming a minus,
|
|
156
|
+
which reads as broken when it also spins. The rotation lives on the indicator's
|
|
157
|
+
node rather than the trigger's because `Pressable` already owns `transform`
|
|
158
|
+
there.
|
|
159
|
+
- **An indicator has to be a direct `Accordion.Indicator` child of the trigger.**
|
|
160
|
+
The trigger finds it by element type, so one wrapped in a component of the
|
|
161
|
+
caller's own is invisible to it and a second, default one is composed in
|
|
162
|
+
beside it — two glyphs on the row, with nothing to explain them.
|
|
163
|
+
`hasThumbChild` carries the same limitation for `Switch`. What closes it here
|
|
164
|
+
is that the indicator takes a **render function**, handed the item's settled
|
|
165
|
+
state: reading `isExpanded` means calling a hook, a hook cannot be called in
|
|
166
|
+
the parent's JSX, and that is the only thing a wrapper was ever for.
|
|
167
|
+
`Slider.Track` takes a function for the same reason.
|
|
168
|
+
- **The indicator takes `muted-foreground`, a step quieter than the title.** The
|
|
169
|
+
chevron is chrome saying the row opens; the title is the content. A test pins
|
|
170
|
+
that the two tokens differ.
|
|
171
|
+
|
|
172
|
+
## Accessibility and structure
|
|
173
|
+
|
|
174
|
+
- **There is no separate heading element.** Wrapping the trigger in one and
|
|
175
|
+
setting `aria-expanded` there as well as `accessibilityState.expanded` on the
|
|
176
|
+
button inside is state written twice, and a screen reader reads it out twice.
|
|
177
|
+
`Pressable` already supplies the role, `accessible` and the state merge.
|
|
178
|
+
- **The root is a plain `View` with no role.** Announcing a container as a
|
|
179
|
+
control puts an actionless element in front of every child — `Checkbox.Group`'s
|
|
180
|
+
rule.
|
|
181
|
+
- **Dividers are inserted, not written out**, `ListGroup`'s implementation
|
|
182
|
+
verbatim in shape: `Children.toArray` drops the nulls a conditional item leaves
|
|
183
|
+
behind, and a hand-placed `Separator` suppresses the automatic one on either
|
|
184
|
+
side. Counting indices against the raw children instead — `index < count - 1` —
|
|
185
|
+
miscounts a conditional item and breaks outright on a fragment.
|
|
186
|
+
- **Reduce motion is left at Reanimated's default `System` policy**, with
|
|
187
|
+
`Switch`, `Checkbox` and `Radio` and against `Spinner`. The state is carried by
|
|
188
|
+
whether the panel is *there*, so completing instantly is the right degradation.
|
|
189
|
+
- **An item is expected to hold an `Accordion.Content`.** One without a panel has
|
|
190
|
+
nothing to measure and therefore nothing to travel against, so its indicator
|
|
191
|
+
stays put — which is the right outcome for a row that discloses nothing, and is
|
|
192
|
+
stated here rather than guarded against.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useCallback, useState } from "react";
|
|
2
|
+
import { type LayoutChangeEvent, StyleSheet, View, type ViewProps } from "react-native";
|
|
3
|
+
import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
|
|
4
|
+
import { useAccordionItemPart, useAccordionPart } from "./accordion.context";
|
|
5
|
+
import { ACCORDION_CONTENT_FADE, ACCORDION_UNMEASURED, accordionVariants } from "./accordion.variants";
|
|
6
|
+
|
|
7
|
+
export type AccordionContentProps = Omit<ViewProps, "style"> & {
|
|
8
|
+
className?: string;
|
|
9
|
+
children?: ReactNode;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The panel an item discloses.
|
|
14
|
+
*
|
|
15
|
+
* **Its height is measured and animated, never laid out.** The outer view clips,
|
|
16
|
+
* and its height runs from zero to whatever the inner view reported. The animation
|
|
17
|
+
* therefore lives on this one node: whatever sits below follows it through
|
|
18
|
+
* ordinary layout, with nothing to opt into. The alternative — unmounting the
|
|
19
|
+
* content and letting a whole-tree layout transition reflow the screen — makes
|
|
20
|
+
* every sibling on the page a participant in this component's animation.
|
|
21
|
+
*
|
|
22
|
+
* The inner view is **out of flow**, so its height is its content's and can never
|
|
23
|
+
* be fed back the clip's own animated one. In flow it is laid out against that
|
|
24
|
+
* height, and a panel measured while the clip sits at zero reports its padding and
|
|
25
|
+
* nothing else.
|
|
26
|
+
*
|
|
27
|
+
* **The content mounts on first expand and stays mounted.** Nothing renders until
|
|
28
|
+
* an item is first opened, so a screen of collapsed panels costs an empty view
|
|
29
|
+
* each; once opened, the subtree survives every later collapse with its scroll
|
|
30
|
+
* position, its form state and its media intact. A panel that unmounted would
|
|
31
|
+
* destroy all three on every tap.
|
|
32
|
+
*
|
|
33
|
+
* That is what the three accessibility props are for. Mounted content is content a
|
|
34
|
+
* screen reader will read and a finger can reach, so a collapsed panel has to say
|
|
35
|
+
* it is not there — an unmounting accordion never has to think about this.
|
|
36
|
+
*
|
|
37
|
+
* **It reports its first measurement and never starts the travel itself.** The
|
|
38
|
+
* item owns the spring alone; see `onMeasured` on the item context for the race
|
|
39
|
+
* that rule exists to close.
|
|
40
|
+
*/
|
|
41
|
+
export function AccordionContent({ className, children, ...props }: AccordionContentProps): ReactElement {
|
|
42
|
+
const { size } = useAccordionPart("Accordion.Content");
|
|
43
|
+
const { contentHeight, isExpanded, onMeasured, progress } = useAccordionItemPart("Accordion.Content");
|
|
44
|
+
const slots = accordionVariants({ size });
|
|
45
|
+
|
|
46
|
+
// Adjusted during render rather than in an effect, so the panel mounts in the
|
|
47
|
+
// same commit as the tap that opened it. Waiting a frame would put the first
|
|
48
|
+
// measurement — and therefore the start of the travel — one frame later still.
|
|
49
|
+
const [hasExpanded, setHasExpanded] = useState(isExpanded);
|
|
50
|
+
if (isExpanded && !hasExpanded) setHasExpanded(true);
|
|
51
|
+
|
|
52
|
+
// Whether this panel has ever reported a height. Until it has, the clip must
|
|
53
|
+
// not constrain anything — see `accordionStyleSheet.measuring` below.
|
|
54
|
+
const [isMeasured, setMeasured] = useState(false);
|
|
55
|
+
|
|
56
|
+
const handleLayout = useCallback(
|
|
57
|
+
(event: LayoutChangeEvent) => {
|
|
58
|
+
const wasMeasured = contentHeight.value > ACCORDION_UNMEASURED;
|
|
59
|
+
contentHeight.value = event.nativeEvent.layout.height;
|
|
60
|
+
|
|
61
|
+
// The panel reports its first measurement and stops there — it must not
|
|
62
|
+
// start the travel itself. `onLayout` is dispatched from the native side
|
|
63
|
+
// and can land either side of React's effects, so a spring started here
|
|
64
|
+
// is sometimes cancelled a moment later by the cleanup of the item's
|
|
65
|
+
// effect it raced, leaving the panel shut and the indicator pointing the
|
|
66
|
+
// wrong way. Telling the item instead leaves one owner of the spring.
|
|
67
|
+
if (wasMeasured) return;
|
|
68
|
+
setMeasured(true);
|
|
69
|
+
onMeasured();
|
|
70
|
+
},
|
|
71
|
+
[contentHeight, onMeasured]
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Height and opacity off the one `progress`, never off a timing of their own —
|
|
75
|
+
// so there is no second clock the panel's position can drift from. The fade
|
|
76
|
+
// closes before the height does, which is what keeps the content legible for
|
|
77
|
+
// most of the travel rather than half transparent at its midpoint.
|
|
78
|
+
const clipStyle = useAnimatedStyle(() => ({
|
|
79
|
+
height: progress.value * Math.max(contentHeight.value, 0),
|
|
80
|
+
opacity: interpolate(
|
|
81
|
+
progress.value,
|
|
82
|
+
[0, ACCORDION_CONTENT_FADE.start, ACCORDION_CONTENT_FADE.end, 1],
|
|
83
|
+
[0, 0, 1, 1]
|
|
84
|
+
),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Animated.View
|
|
89
|
+
accessibilityElementsHidden={!isExpanded}
|
|
90
|
+
className={slots.content()}
|
|
91
|
+
importantForAccessibility={isExpanded ? "auto" : "no-hide-descendants"}
|
|
92
|
+
// Never `auto` while measuring: the clip is stretched over its own item
|
|
93
|
+
// for that commit, and an invisible layer over a trigger is a tap that
|
|
94
|
+
// goes nowhere.
|
|
95
|
+
pointerEvents={isExpanded && isMeasured ? "auto" : "none"}
|
|
96
|
+
style={isMeasured ? clipStyle : accordionStyleSheet.measuring}
|
|
97
|
+
>
|
|
98
|
+
{hasExpanded ? (
|
|
99
|
+
<View className={slots.contentInner({ className })} onLayout={handleLayout} {...props}>
|
|
100
|
+
{children}
|
|
101
|
+
</View>
|
|
102
|
+
) : null}
|
|
103
|
+
</Animated.View>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
AccordionContent.displayName = "DelacourUI.Accordion.Content";
|
|
107
|
+
|
|
108
|
+
const accordionStyleSheet = StyleSheet.create({
|
|
109
|
+
/**
|
|
110
|
+
* What the clip wears until its panel has reported a height.
|
|
111
|
+
*
|
|
112
|
+
* Absolutely positioned over its own item, so it is out of the item's layout and
|
|
113
|
+
* nothing on screen moves while it measures, and transparent, so nothing shows
|
|
114
|
+
* either. What matters is what it is *not*: it is not `height: 0`. A clip
|
|
115
|
+
* already at zero squashes an in-flow child to its padding, and never fires
|
|
116
|
+
* `onLayout` at all for an out-of-flow one — its frame is empty, so no layout
|
|
117
|
+
* event is emitted and the measurement arrives only when something unrelated
|
|
118
|
+
* forces a relayout, minutes later or never. Both were found on a simulator.
|
|
119
|
+
*
|
|
120
|
+
* The switch to the animated clip happens on the commit the measurement lands,
|
|
121
|
+
* while `progress` is still zero — so the panel goes from taking no space to
|
|
122
|
+
* taking none, and the travel starts from there.
|
|
123
|
+
*/
|
|
124
|
+
measuring: { bottom: 0, left: 0, opacity: 0, position: "absolute", right: 0, top: 0 },
|
|
125
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text } from "../text";
|
|
3
|
+
import { useAccordionPart } from "./accordion.context";
|
|
4
|
+
import type { AccordionTextProps } from "./accordion.types";
|
|
5
|
+
import { accordionVariants } from "./accordion.variants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The trigger's secondary line, a step down in scale and on the muted token.
|
|
9
|
+
*
|
|
10
|
+
* Sits under the title inside the trigger's own text column, which the trigger
|
|
11
|
+
* assembles — so the two stack without a wrapper part between them.
|
|
12
|
+
*/
|
|
13
|
+
export function AccordionDescription({ className, ...props }: AccordionTextProps): ReactElement {
|
|
14
|
+
const { size } = useAccordionPart("Accordion.Description");
|
|
15
|
+
return <Text className={accordionVariants({ size }).description({ className })} {...props} />;
|
|
16
|
+
}
|
|
17
|
+
AccordionDescription.displayName = "DelacourUI.Accordion.Description";
|