@delacour/react-native-ui 0.1.0-alpha.20261007120055 → 0.1.0-alpha.20261007125731
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/components/drawer/AGENTS.md +123 -0
- package/src/components/drawer/drawer-body.tsx +46 -0
- package/src/components/drawer/drawer-close.tsx +76 -0
- package/src/components/drawer/drawer-content.tsx +186 -0
- package/src/components/drawer/drawer-description.tsx +23 -0
- package/src/components/drawer/drawer-footer.tsx +21 -0
- package/src/components/drawer/drawer-header.tsx +42 -0
- package/src/components/drawer/drawer-title.tsx +35 -0
- package/src/components/drawer/drawer-trigger.tsx +54 -0
- package/src/components/drawer/drawer.context.tsx +114 -0
- package/src/components/drawer/drawer.tsx +119 -0
- package/src/components/drawer/drawer.variants.test.ts +392 -0
- package/src/components/drawer/drawer.variants.ts +244 -0
- package/src/components/drawer/index.ts +41 -0
- package/src/components/drawer/use-drawer-pan.ts +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delacour/react-native-ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.20261007125731",
|
|
4
4
|
"description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"./chip": "./src/components/chip/index.ts",
|
|
47
47
|
"./collapsible": "./src/components/collapsible/index.ts",
|
|
48
48
|
"./dialog": "./src/components/dialog/index.ts",
|
|
49
|
+
"./drawer": "./src/components/drawer/index.ts",
|
|
49
50
|
"./empty-state": "./src/components/empty-state/index.ts",
|
|
50
51
|
"./field": "./src/components/field/index.ts",
|
|
51
52
|
"./icon": "./src/components/icon/index.ts",
|
|
@@ -107,8 +108,8 @@
|
|
|
107
108
|
},
|
|
108
109
|
"peerDependencies": {
|
|
109
110
|
"@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
|
|
110
|
-
"@delacour/react-native-bottom-sheet": "0.1.0-alpha.
|
|
111
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
111
|
+
"@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007125731",
|
|
112
|
+
"@delacour/react-native-charts": "0.1.0-alpha.20261007125731",
|
|
112
113
|
"@legendapp/list": ">=3.3",
|
|
113
114
|
"expo-linear-gradient": ">=15",
|
|
114
115
|
"expo-router": ">=57",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Drawer
|
|
2
|
+
|
|
3
|
+
A panel that slides in from an edge and covers the app until dismissed: a navigation menu from the
|
|
4
|
+
start edge, a filter panel from the end, a notifications tray from the top. Compound root plus
|
|
5
|
+
`Drawer.Trigger`, `Drawer.Content`, `Drawer.Header`, `Drawer.Title`, `Drawer.Description`,
|
|
6
|
+
`Drawer.Body`, `Drawer.Footer` and `Drawer.Close`, and `useDrawer()`.
|
|
7
|
+
|
|
8
|
+
`import { Drawer } from "@delacour/react-native-ui/drawer";`
|
|
9
|
+
|
|
10
|
+
It is drawn with the [overlay foundation](../overlay/AGENTS.md) — `Overlay.Portal`,
|
|
11
|
+
`Overlay.Scrim`, `useOverlayPresence`, `useOverlayBackHandler` — so the app has to mount
|
|
12
|
+
`OverlayProvider` once at its root. Without one the drawer draws inline and warns once.
|
|
13
|
+
|
|
14
|
+
## Files
|
|
15
|
+
|
|
16
|
+
| File | What it holds |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| `index.ts` | → `@delacour/react-native-ui/drawer` |
|
|
19
|
+
| `drawer.tsx` | Root — the open state, the ids and the focus refs — and the `Object.assign` compound surface |
|
|
20
|
+
| `drawer.context.tsx` | **Leaf.** `DrawerProvider`, `DrawerPanelProvider`, `useDrawer()`, `useDrawerContext()`, `useDrawerPart()`, `useDrawerPanel()` |
|
|
21
|
+
| `drawer-trigger.tsx` | `Drawer.Trigger` — donates the open with `asChild`, keeps the trigger's ref for focus return |
|
|
22
|
+
| `drawer-content.tsx` | `Drawer.Content` — presence, portal, scrim, edge and extent, insets, slide, swipe, back button, focus |
|
|
23
|
+
| `drawer-header.tsx` | `Drawer.Header` — the title column and a ✕ at the trailing edge |
|
|
24
|
+
| `drawer-title.tsx` | `Drawer.Title` — a `Text.Header` carrying the title `nativeID` and the focus ref |
|
|
25
|
+
| `drawer-description.tsx` | `Drawer.Description` — a muted `Text.Paragraph` |
|
|
26
|
+
| `drawer-body.tsx` | `Drawer.Body` — a `ScrollView`, or a plain column with `isScrollable={false}` |
|
|
27
|
+
| `drawer-footer.tsx` | `Drawer.Footer` — a row under a hairline |
|
|
28
|
+
| `drawer-close.tsx` | `Drawer.Close` — the ✕, or with `asChild` any control that closes |
|
|
29
|
+
| `use-drawer-pan.ts` | `useDrawerPan` — the swipe-to-dismiss pan, its threshold haptic and its release |
|
|
30
|
+
| `drawer.variants.ts` | The slotted `tv()`, the size table, the release constants and the pure geometry — no RN imports |
|
|
31
|
+
| `drawer.variants.test.ts` | Edges both directions, extents, frame extents, offsets, drag, release, exit duration, insets, slots per edge, tokens in both themes |
|
|
32
|
+
|
|
33
|
+
## Design
|
|
34
|
+
|
|
35
|
+
- **A side is logical, an edge is physical, and only the edge is ever measured.** `side` is what a
|
|
36
|
+
caller writes — `start`, `end`, `top`, `bottom` — and `resolveDrawerEdge(side, I18nManager.isRTL)`
|
|
37
|
+
turns it into `left`, `right`, `top` or `bottom`. Transforms and a pan's translation are physical
|
|
38
|
+
in React Native whatever the layout direction, so the offset, the drag, the release and the insets
|
|
39
|
+
are all computed against the edge. `useDrawer()` returns both.
|
|
40
|
+
- **The panel is placed in a left-to-right frame, and its content gets the app's direction back.**
|
|
41
|
+
React Native swaps `left`/`right` styles under RTL by default (`swapLeftAndRightInRTL`), and an app
|
|
42
|
+
may turn that off; either way a `left-0` class would mean something different from the
|
|
43
|
+
`translateX` beside it. The `positioner` slot is an absolute fill with `direction: "ltr"`, so the
|
|
44
|
+
panel's `edge` classes, its corners and its safe-area padding are physical, and `inner` sets the
|
|
45
|
+
app's direction again so text, rows and the header's ✕ lay out as everywhere else. That is also why
|
|
46
|
+
the spec's `edge` variant could stay keyed on the physical edge.
|
|
47
|
+
- **The size is a fraction of the window with a cap.** `sm` 62% to 280, `md` 78% to 320, `lg` 88% to
|
|
48
|
+
400, `full` 94% uncapped — of the width for a start/end drawer, of the height for top/bottom. A
|
|
49
|
+
fraction keeps a strip of the app in view to tap away on; the cap keeps a tablet's drawer a drawer.
|
|
50
|
+
The extent is a fixed width or height rather than a measured one, so the first frame already knows
|
|
51
|
+
how far off-screen the panel starts. **The docked edge's inset is added on top**
|
|
52
|
+
(`resolveDrawerFrameExtent`): the size measures the content, not the band under the status bar. A
|
|
53
|
+
top `sm` drawer once spent 62 of its 280pt there and clipped its third row.
|
|
54
|
+
- **Insets go on every side that meets a screen edge.** `resolveDrawerInsets` pads the docked side and
|
|
55
|
+
the two beside it, and nothing on the side facing the app, so a start drawer clears the status bar,
|
|
56
|
+
the home indicator and a landscape notch, and a top drawer does not pad for the home indicator it
|
|
57
|
+
never reaches.
|
|
58
|
+
- **Docked corners square, free corners round.** `bg-popover` — a layer over the app, as `Dialog` and
|
|
59
|
+
`BottomSheet` paint — with `rounded-lg`, the card step, only on the two corners facing the app.
|
|
60
|
+
- **The swipe continues, it does not spring back.** The pan waits for `DRAWER_PAN_ACTIVE_OFFSET` (10pt)
|
|
61
|
+
along the panel's axis and fails on the same travel across it, so a tap reaches a row and a vertical
|
|
62
|
+
scroll in a start/end drawer's body never moves the drawer. Toward the edge the panel follows the
|
|
63
|
+
finger; away it rubber-bands toward 40pt and never reaches it. On release, past 40% of the extent or
|
|
64
|
+
on a fling toward the edge faster than 800 pt/s dismisses — a fling away always restores. A dismiss
|
|
65
|
+
carries on from where the finger left the panel at the release speed (`resolveDrawerExitDuration`,
|
|
66
|
+
80–220 ms, linear) and closes the state only when it lands; the presence exit then runs off-screen
|
|
67
|
+
and unmounts. A restore eases back in the entrance time.
|
|
68
|
+
- **The scrim thins with the drag.** Its opacity is `progress × (1 − dragFraction)`. `Overlay.Scrim`
|
|
69
|
+
takes a writable shared value, so an animated reaction writes the product into one of the drawer's
|
|
70
|
+
own rather than handing it a derived value.
|
|
71
|
+
- **A haptic marks the threshold, nothing else.** `selection` each time the drag crosses 40% — the
|
|
72
|
+
moment letting go changes what happens — in both directions, from the worklet through `playHaptic`.
|
|
73
|
+
None on open.
|
|
74
|
+
- **The pan's worklets live in the hook.** `useDrawerPan` builds them inside a `useMemo`, so they may
|
|
75
|
+
call `drawer.variants`' module-scope worklets; those are flat — a module-scope worklet never calls
|
|
76
|
+
another function — which is why each restates the edge's sign rather than sharing a helper.
|
|
77
|
+
Per-gesture memory (`isDragging`, `isPastThreshold`) is shared values. A touch that never activated
|
|
78
|
+
returns early from `onFinalize`, so a tap during a dismiss cannot restart a restore under it.
|
|
79
|
+
- **Reduce motion fades, and the swipe still works.** Under `useReducedMotion()` the open and close do
|
|
80
|
+
not slide — the panel fades in place on `progress` — but a drag still moves it: that is direct
|
|
81
|
+
manipulation, not animation. `isMotionCalm` does not still it, per the foundation.
|
|
82
|
+
- **`isDismissible={false}` turns off every way out the drawer did not offer** — the scrim (which still
|
|
83
|
+
takes the touch), the back button, the escape gesture and the swipe. `Drawer.Close` still closes it.
|
|
84
|
+
`isSwipeDismissible={false}` turns off the swipe alone.
|
|
85
|
+
- **Every path to closed is one `setOpen(false)`**, and the root drops a set that does not change the
|
|
86
|
+
value, so a scrim tap during a swipe's exit does not report `onOpenChange(false)` twice.
|
|
87
|
+
- **The header writes its own ✕, in the flow.** A row: the title column takes the slack and the ✕
|
|
88
|
+
sits at the trailing edge, so a long title wraps before it and RTL needs no rule. `isCloseHidden`
|
|
89
|
+
drops it. There is no `title` or `description` slot — both are text presets with no layout of their
|
|
90
|
+
own, and `tv` emits `undefined` for an empty slot; the parts merge `className` with `cn()`.
|
|
91
|
+
- **The body scrolls by default and takes the slack, and the footer is `mt-auto`**, so the footer sits
|
|
92
|
+
at the panel's end however short the content — even with no body, which the simulator caught: a
|
|
93
|
+
header-and-footer bottom drawer left its footer floating under the header. A top or bottom drawer's pan shares the vertical axis with a scroll — keep that
|
|
94
|
+
content short, or use a `BottomSheet`.
|
|
95
|
+
- **It draws in the `modal` band**, over the navigator's header and over an open bottom sheet.
|
|
96
|
+
- **Triggers and closes donate the press** with `asChild`, for `BottomSheet.Trigger`'s reason.
|
|
97
|
+
- **Accessibility.** The panel is `accessibilityViewIsModal`, `role="dialog"`, labelled by the title's
|
|
98
|
+
`nativeID`; focus moves to the title once the entrance finishes and back to the trigger once the
|
|
99
|
+
exit does. `onAccessibilityEscape` and the back button close it when dismissible. The scrim is not
|
|
100
|
+
accessible. The ✕ is labelled `"Close"`.
|
|
101
|
+
- **No blur backdrop.** Frosted scrims need `expo-blur`, not a peer; a follow-up for every overlay.
|
|
102
|
+
|
|
103
|
+
## Preview capture (not yet shot)
|
|
104
|
+
|
|
105
|
+
No demo is marked `capture` until a capture tool is available. When one is, these are the settings,
|
|
106
|
+
and the flows are in `.argent/flows/previews/drawer/`:
|
|
107
|
+
|
|
108
|
+
| Demo | Capture |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `navigation` | `{ flow: "drawer/navigation", frame: "device", hero: true }` |
|
|
111
|
+
| `filters` | `{ flow: "drawer/filters", frame: "device" }` |
|
|
112
|
+
| `sizes` | `{ flow: "drawer/sizes", frame: "device" }` |
|
|
113
|
+
| `notifications` | `{ flow: "drawer/notifications", frame: "device" }` |
|
|
114
|
+
|
|
115
|
+
## Testing
|
|
116
|
+
|
|
117
|
+
`bun test` reaches the edge resolution in both directions, the extents on a small and a wide window, the frame extent with the docked inset,
|
|
118
|
+
the offset, the drag and its rubber band, the drag fraction, the release (threshold, fling toward,
|
|
119
|
+
fling away), the exit duration, the insets per edge and the slots per edge with their tokens in both
|
|
120
|
+
themes. All four sides from every dismissal path, the swipe's continue-and-restore, the scrolling
|
|
121
|
+
body, the z-order over a sheet, VoiceOver and reduce motion are verified on a simulator through
|
|
122
|
+
`/drawer` in the playground. RTL needs an app restarted with `I18nManager.forceRTL(true)`; the `rtl`
|
|
123
|
+
demo shows the resolution the drawer will make.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { ScrollView, type ScrollViewProps, View, type ViewProps } from "react-native";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { drawerVariants } from "./drawer.variants";
|
|
5
|
+
|
|
6
|
+
export type DrawerBodyProps =
|
|
7
|
+
/** Scrolls when its content is taller than the panel. The default. */
|
|
8
|
+
| ({ isScrollable?: true; className?: string; contentContainerClassName?: string } & ScrollViewProps)
|
|
9
|
+
/** A plain column, for content that fits — a few chips and a slider. */
|
|
10
|
+
| ({ isScrollable: false; className?: string } & ViewProps);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The panel's content, between the header and the footer, taking the height
|
|
14
|
+
* left over.
|
|
15
|
+
*
|
|
16
|
+
* Scrolls by default. The panel's pan waits for travel along its own axis and
|
|
17
|
+
* fails on travel across it, so a vertical scroll in a start or end drawer
|
|
18
|
+
* never moves the drawer. A top or bottom drawer's pan runs along the same axis
|
|
19
|
+
* as a vertical scroll — keep their content short, or reach for a `BottomSheet`.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <Drawer.Body>
|
|
23
|
+
* <ListGroup>…</ListGroup>
|
|
24
|
+
* </Drawer.Body>
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* <Drawer.Body isScrollable={false}>…</Drawer.Body>
|
|
28
|
+
*/
|
|
29
|
+
export function DrawerBody(props: DrawerBodyProps): ReactElement {
|
|
30
|
+
const slots = drawerVariants();
|
|
31
|
+
|
|
32
|
+
if (props.isScrollable === false) {
|
|
33
|
+
const { isScrollable: _isScrollable, className, ...rest } = props;
|
|
34
|
+
return <View className={cn(slots.body(), slots.bodyContent(), className)} {...rest} />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { isScrollable: _isScrollable, className, contentContainerClassName, ...rest } = props;
|
|
38
|
+
return (
|
|
39
|
+
<ScrollView
|
|
40
|
+
className={slots.body({ className })}
|
|
41
|
+
contentContainerClassName={slots.bodyContent({ className: contentContainerClassName })}
|
|
42
|
+
{...rest}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
DrawerBody.displayName = "DelacourUI.Drawer.Body";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ReactElement, useCallback } from "react";
|
|
2
|
+
import { IconCrossSmall } from "../../icons/central";
|
|
3
|
+
import { Slot } from "../../lib/slot";
|
|
4
|
+
import { Icon } from "../icon";
|
|
5
|
+
import { Pressable, type PressableProps } from "../pressable";
|
|
6
|
+
import { useDrawerPart } from "./drawer.context";
|
|
7
|
+
import { DRAWER_CLOSE_HIT_SLOP, drawerVariants } from "./drawer.variants";
|
|
8
|
+
|
|
9
|
+
export type DrawerCloseProps =
|
|
10
|
+
/** Donates the close to the single child — a `Button` in the footer, a navigation row. */
|
|
11
|
+
| ({ asChild: true; children: ReactElement } & Omit<PressableProps, "asChild" | "children">)
|
|
12
|
+
/** The ✕ glyph. */
|
|
13
|
+
| ({ asChild?: false; accessibilityLabel?: string } & Omit<PressableProps, "asChild" | "children">);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Closes the drawer.
|
|
17
|
+
*
|
|
18
|
+
* Without children it is a ✕: this library's `Pressable` with `fade` feedback
|
|
19
|
+
* — a scale on a glyph this small reads as a jitter — 8pt of slop because a
|
|
20
|
+
* bare glyph has no capsule to bring it toward 44pt, and `"Close"` as its
|
|
21
|
+
* label. `Drawer.Header` writes one at its trailing edge unless told not to.
|
|
22
|
+
*
|
|
23
|
+
* With `asChild` it donates the close to its child, chained ahead of the
|
|
24
|
+
* child's own `onPress` — a navigation row that should also shut the menu.
|
|
25
|
+
* Either way it is the same `setOpen(false)` the scrim, the swipe and the back
|
|
26
|
+
* button take, and it closes even a non-dismissible drawer: it is one of the
|
|
27
|
+
* drawer's own actions.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* <Drawer.Close asChild>
|
|
31
|
+
* <Button variant="secondary">Done</Button>
|
|
32
|
+
* </Drawer.Close>
|
|
33
|
+
*/
|
|
34
|
+
export function DrawerClose(props: DrawerCloseProps): ReactElement {
|
|
35
|
+
const { close } = useDrawerPart("Drawer.Close");
|
|
36
|
+
const { onPress } = props;
|
|
37
|
+
|
|
38
|
+
const handlePress = useCallback(() => {
|
|
39
|
+
close();
|
|
40
|
+
onPress?.();
|
|
41
|
+
}, [close, onPress]);
|
|
42
|
+
|
|
43
|
+
if (props.asChild) {
|
|
44
|
+
const { asChild: _asChild, children, onPress: _onPress, ...rest } = props;
|
|
45
|
+
return (
|
|
46
|
+
<Slot onPress={handlePress} {...rest}>
|
|
47
|
+
{children}
|
|
48
|
+
</Slot>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
asChild: _asChild,
|
|
54
|
+
accessibilityLabel = "Close",
|
|
55
|
+
className,
|
|
56
|
+
feedback = "fade",
|
|
57
|
+
hitSlop = DRAWER_CLOSE_HIT_SLOP,
|
|
58
|
+
onPress: _onPress,
|
|
59
|
+
...rest
|
|
60
|
+
} = props;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<Pressable
|
|
64
|
+
accessibilityLabel={accessibilityLabel}
|
|
65
|
+
accessibilityRole="button"
|
|
66
|
+
className={drawerVariants().close({ className })}
|
|
67
|
+
feedback={feedback}
|
|
68
|
+
hitSlop={hitSlop}
|
|
69
|
+
onPress={handlePress}
|
|
70
|
+
{...rest}
|
|
71
|
+
>
|
|
72
|
+
<Icon color="muted-foreground" icon={IconCrossSmall} />
|
|
73
|
+
</Pressable>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
DrawerClose.displayName = "DelacourUI.Drawer.Close";
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { type ReactElement, useCallback, useEffect, useId, useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
AccessibilityInfo,
|
|
4
|
+
findNodeHandle,
|
|
5
|
+
I18nManager,
|
|
6
|
+
useWindowDimensions,
|
|
7
|
+
View,
|
|
8
|
+
type ViewProps,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import { GestureDetector } from "react-native-gesture-handler";
|
|
11
|
+
import Animated, { useAnimatedReaction, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
|
12
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
13
|
+
import { Overlay, useOverlayBackHandler, useOverlayPresence } from "../overlay";
|
|
14
|
+
import { type DrawerFocusTarget, DrawerPanelProvider, type DrawerPanelValue, useDrawerPart } from "./drawer.context";
|
|
15
|
+
import {
|
|
16
|
+
type DrawerSide,
|
|
17
|
+
type DrawerSize,
|
|
18
|
+
drawerVariants,
|
|
19
|
+
resolveDrawerDragFraction,
|
|
20
|
+
resolveDrawerEdge,
|
|
21
|
+
resolveDrawerExtent,
|
|
22
|
+
resolveDrawerFrameExtent,
|
|
23
|
+
resolveDrawerInsets,
|
|
24
|
+
resolveDrawerOffset,
|
|
25
|
+
} from "./drawer.variants";
|
|
26
|
+
import { useDrawerPan } from "./use-drawer-pan";
|
|
27
|
+
|
|
28
|
+
export type DrawerContentProps = ViewProps & {
|
|
29
|
+
className?: string;
|
|
30
|
+
/** Classes for the scrim behind the panel. */
|
|
31
|
+
scrimClassName?: string;
|
|
32
|
+
/** The edge it opens from. `start` and `end` follow the layout direction. Default `"start"`. */
|
|
33
|
+
side?: DrawerSide;
|
|
34
|
+
/** The panel's width (start, end) or height (top, bottom): `sm` 62% to 280, `md` 78% to 320, `lg` 88% to 400, `full` 94%. Default `"md"`. */
|
|
35
|
+
size?: DrawerSize;
|
|
36
|
+
/** Whether a swipe toward the edge dismisses it. Default true. */
|
|
37
|
+
isSwipeDismissible?: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Moves VoiceOver / TalkBack focus to a mounted element. A no-op when it has gone. */
|
|
41
|
+
function focus(target: DrawerFocusTarget | null): void {
|
|
42
|
+
if (target === null) return;
|
|
43
|
+
const handle = findNodeHandle(target as Parameters<typeof findNodeHandle>[0]);
|
|
44
|
+
if (handle !== null) AccessibilityInfo.setAccessibilityFocus(handle);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The panel, its scrim and the portal they draw through.
|
|
49
|
+
*
|
|
50
|
+
* Renders nothing until the drawer opens, and stays mounted through the exit
|
|
51
|
+
* (`useOverlayPresence`), so the portal, the z-order and the back button all
|
|
52
|
+
* follow presence. It draws in the `modal` band — over the navigator's header
|
|
53
|
+
* and over an open bottom sheet.
|
|
54
|
+
*
|
|
55
|
+
* - **Geometry.** `side` resolves to a physical edge under `I18nManager.isRTL`;
|
|
56
|
+
* `size` to an extent along that edge's axis. The panel pads the safe-area
|
|
57
|
+
* insets on every side that meets a screen edge.
|
|
58
|
+
* - **Motion.** Opening and closing slide the panel along `progress`; under
|
|
59
|
+
* reduce motion it fades in place instead. A swipe toward the edge drags it
|
|
60
|
+
* and thins the scrim with it; see `useDrawerPan` for the release.
|
|
61
|
+
* - **Dismissal.** A scrim tap, Android back, the iOS escape gesture and the
|
|
62
|
+
* swipe close a dismissible drawer. With `isDismissible={false}` the scrim
|
|
63
|
+
* still takes the touch and only the drawer's own actions close it.
|
|
64
|
+
* - **Accessibility.** The panel is modal to assistive technology, a `dialog`
|
|
65
|
+
* labelled by the title; focus moves to the title once it has entered and
|
|
66
|
+
* back to the trigger, if still mounted, once it has gone.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* <Drawer.Content side="end" size="lg">
|
|
70
|
+
* <Drawer.Header>…</Drawer.Header>
|
|
71
|
+
* <Drawer.Body>…</Drawer.Body>
|
|
72
|
+
* </Drawer.Content>
|
|
73
|
+
*/
|
|
74
|
+
export function DrawerContent({
|
|
75
|
+
className,
|
|
76
|
+
scrimClassName,
|
|
77
|
+
side = "start",
|
|
78
|
+
size = "md",
|
|
79
|
+
isSwipeDismissible = true,
|
|
80
|
+
children,
|
|
81
|
+
style,
|
|
82
|
+
...props
|
|
83
|
+
}: DrawerContentProps): ReactElement | null {
|
|
84
|
+
const { isOpen, close, isDismissible, titleId, titleRef, triggerRef } = useDrawerPart("Drawer.Content");
|
|
85
|
+
const id = useId();
|
|
86
|
+
|
|
87
|
+
const isRTL = I18nManager.isRTL;
|
|
88
|
+
const edge = resolveDrawerEdge(side, isRTL);
|
|
89
|
+
const isHorizontal = edge === "left" || edge === "right";
|
|
90
|
+
const window = useWindowDimensions();
|
|
91
|
+
const safeArea = useSafeAreaInsets();
|
|
92
|
+
const insets = resolveDrawerInsets(edge, safeArea);
|
|
93
|
+
const extent = resolveDrawerFrameExtent(
|
|
94
|
+
edge,
|
|
95
|
+
resolveDrawerExtent(size, isHorizontal ? window.width : window.height),
|
|
96
|
+
insets
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const handleEntered = useCallback(() => focus(titleRef.current), [titleRef]);
|
|
100
|
+
const handleExited = useCallback(() => focus(triggerRef.current), [triggerRef]);
|
|
101
|
+
const { isPresent, isReduced, progress } = useOverlayPresence({
|
|
102
|
+
isOpen,
|
|
103
|
+
onEntered: handleEntered,
|
|
104
|
+
onExited: handleExited,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
useOverlayBackHandler({ id, isEnabled: isPresent && isDismissible, onBack: close });
|
|
108
|
+
|
|
109
|
+
const drag = useSharedValue(0);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (isOpen) drag.value = 0;
|
|
112
|
+
}, [isOpen, drag]);
|
|
113
|
+
|
|
114
|
+
const pan = useDrawerPan({
|
|
115
|
+
drag,
|
|
116
|
+
edge,
|
|
117
|
+
extent,
|
|
118
|
+
isEnabled: isSwipeDismissible && isDismissible,
|
|
119
|
+
onDismiss: close,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// The scrim fades with presence and thins as a drag carries the panel out.
|
|
123
|
+
// A shared value of its own, because `Overlay.Scrim` takes a writable one.
|
|
124
|
+
const scrimProgress = useSharedValue(0);
|
|
125
|
+
useAnimatedReaction(
|
|
126
|
+
() => progress.value * (1 - resolveDrawerDragFraction(edge, drag.value, extent)),
|
|
127
|
+
(visibility) => {
|
|
128
|
+
scrimProgress.value = visibility;
|
|
129
|
+
},
|
|
130
|
+
[edge, extent]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const panelStyle = useAnimatedStyle(() => {
|
|
134
|
+
const slide = isReduced ? { translateX: 0, translateY: 0 } : resolveDrawerOffset(edge, extent, progress.value);
|
|
135
|
+
return {
|
|
136
|
+
opacity: isReduced ? progress.value : 1,
|
|
137
|
+
transform: [
|
|
138
|
+
{ translateX: slide.translateX + (isHorizontal ? drag.value : 0) },
|
|
139
|
+
{ translateY: slide.translateY + (isHorizontal ? 0 : drag.value) },
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
}, [edge, extent, isHorizontal, isReduced]);
|
|
143
|
+
|
|
144
|
+
const panel = useMemo<DrawerPanelValue>(() => ({ edge, side }), [edge, side]);
|
|
145
|
+
|
|
146
|
+
if (!isPresent) return null;
|
|
147
|
+
|
|
148
|
+
const slots = drawerVariants({ edge });
|
|
149
|
+
const onEscape = isDismissible ? close : undefined;
|
|
150
|
+
const frame = {
|
|
151
|
+
height: isHorizontal ? undefined : extent,
|
|
152
|
+
paddingBottom: insets.bottom,
|
|
153
|
+
paddingLeft: insets.left,
|
|
154
|
+
paddingRight: insets.right,
|
|
155
|
+
paddingTop: insets.top,
|
|
156
|
+
width: isHorizontal ? extent : undefined,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<Overlay.Portal id={id} layer="modal">
|
|
161
|
+
<Overlay.Scrim
|
|
162
|
+
className={slots.scrim({ className: scrimClassName })}
|
|
163
|
+
onDismiss={onEscape}
|
|
164
|
+
progress={scrimProgress}
|
|
165
|
+
/>
|
|
166
|
+
<View className={slots.positioner()} pointerEvents="box-none" style={{ direction: "ltr" }}>
|
|
167
|
+
<GestureDetector gesture={pan}>
|
|
168
|
+
<Animated.View
|
|
169
|
+
accessibilityLabelledBy={titleId}
|
|
170
|
+
accessibilityViewIsModal
|
|
171
|
+
className={slots.content({ className })}
|
|
172
|
+
onAccessibilityEscape={onEscape}
|
|
173
|
+
role="dialog"
|
|
174
|
+
style={[frame, panelStyle, style]}
|
|
175
|
+
{...props}
|
|
176
|
+
>
|
|
177
|
+
<View className={slots.inner()} style={{ direction: isRTL ? "rtl" : "ltr" }}>
|
|
178
|
+
<DrawerPanelProvider value={panel}>{children}</DrawerPanelProvider>
|
|
179
|
+
</View>
|
|
180
|
+
</Animated.View>
|
|
181
|
+
</GestureDetector>
|
|
182
|
+
</View>
|
|
183
|
+
</Overlay.Portal>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
DrawerContent.displayName = "DelacourUI.Drawer.Content";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { Text, type TextPresetProps } from "../text";
|
|
4
|
+
import { useDrawerPart } from "./drawer.context";
|
|
5
|
+
|
|
6
|
+
export type DrawerDescriptionProps = TextPresetProps;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Supporting copy under the title.
|
|
10
|
+
*
|
|
11
|
+
* *Is* a `Text.Paragraph`, muted by default so the title and this read as a
|
|
12
|
+
* hierarchy. `color` is an ordinary prop. It has no slot in `drawerVariants` —
|
|
13
|
+
* see the note there.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <Drawer.Description>Signed in as aria@harbour.studio</Drawer.Description>
|
|
17
|
+
*/
|
|
18
|
+
export function DrawerDescription({ className, color = "muted", ...props }: DrawerDescriptionProps): ReactElement {
|
|
19
|
+
const { descriptionId } = useDrawerPart("Drawer.Description");
|
|
20
|
+
|
|
21
|
+
return <Text.Paragraph className={cn(className)} color={color} nativeID={descriptionId} {...props} />;
|
|
22
|
+
}
|
|
23
|
+
DrawerDescription.displayName = "DelacourUI.Drawer.Description";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { drawerVariants } from "./drawer.variants";
|
|
4
|
+
|
|
5
|
+
export type DrawerFooterProps = ViewProps & { className?: string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Actions at the panel's far end, under a hairline: a row, right-aligned in a
|
|
9
|
+
* left-to-right layout. It pushes itself to the panel's end with `mt-auto`, so
|
|
10
|
+
* it sits at the bottom however short the content is — with or without a body.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <Drawer.Footer>
|
|
14
|
+
* <Button variant="secondary" onPress={reset}>Reset</Button>
|
|
15
|
+
* <Drawer.Close asChild><Button>Show results</Button></Drawer.Close>
|
|
16
|
+
* </Drawer.Footer>
|
|
17
|
+
*/
|
|
18
|
+
export function DrawerFooter({ className, ...props }: DrawerFooterProps): ReactElement {
|
|
19
|
+
return <View className={drawerVariants().footer({ className })} {...props} />;
|
|
20
|
+
}
|
|
21
|
+
DrawerFooter.displayName = "DelacourUI.Drawer.Footer";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { drawerVariants } from "./drawer.variants";
|
|
4
|
+
import { DrawerClose } from "./drawer-close";
|
|
5
|
+
|
|
6
|
+
export type DrawerHeaderProps = ViewProps & {
|
|
7
|
+
className?: string;
|
|
8
|
+
/** Hide the ✕ the header writes at its trailing edge. Default false. */
|
|
9
|
+
isCloseHidden?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The top of the panel: `Drawer.Title` and `Drawer.Description` in a column
|
|
14
|
+
* that takes the row's slack, and a ✕ at the trailing edge.
|
|
15
|
+
*
|
|
16
|
+
* The ✕ is in the flow, not pinned to a corner, so a long title wraps before
|
|
17
|
+
* it rather than under it, and it lands on the correct side under RTL with no
|
|
18
|
+
* rule of its own. `isCloseHidden` drops it — a navigation drawer whose rows
|
|
19
|
+
* all close it, or a drawer with a `Close` in its footer.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <Drawer.Header>
|
|
23
|
+
* <Drawer.Title>Filters</Drawer.Title>
|
|
24
|
+
* <Drawer.Description>12 results</Drawer.Description>
|
|
25
|
+
* </Drawer.Header>
|
|
26
|
+
*/
|
|
27
|
+
export function DrawerHeader({
|
|
28
|
+
className,
|
|
29
|
+
isCloseHidden = false,
|
|
30
|
+
children,
|
|
31
|
+
...props
|
|
32
|
+
}: DrawerHeaderProps): ReactElement {
|
|
33
|
+
const slots = drawerVariants();
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<View className={slots.header({ className })} {...props}>
|
|
37
|
+
<View className={slots.heading()}>{children}</View>
|
|
38
|
+
{isCloseHidden ? null : <DrawerClose />}
|
|
39
|
+
</View>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
DrawerHeader.displayName = "DelacourUI.Drawer.Header";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ReactElement, useCallback } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { Text, type TextPresetProps } from "../text";
|
|
4
|
+
import { type DrawerFocusTarget, useDrawerPart } from "./drawer.context";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A `Text.Header`'s props less `ref`: the drawer holds the title's ref itself,
|
|
8
|
+
* to move accessibility focus onto it.
|
|
9
|
+
*/
|
|
10
|
+
export type DrawerTitleProps = Omit<TextPresetProps, "ref">;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The drawer's heading.
|
|
14
|
+
*
|
|
15
|
+
* *Is* a `Text.Header`. It carries the `nativeID` the panel is labelled by on
|
|
16
|
+
* Android, and it is where accessibility focus lands once the panel has
|
|
17
|
+
* finished entering, so a screen reader announces what the drawer is first.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* <Drawer.Title>Menu</Drawer.Title>
|
|
21
|
+
*/
|
|
22
|
+
export function DrawerTitle({ className, ...props }: DrawerTitleProps): ReactElement {
|
|
23
|
+
const { titleId, titleRef } = useDrawerPart("Drawer.Title");
|
|
24
|
+
const setTitleRef = useCallback(
|
|
25
|
+
(node: DrawerFocusTarget | null) => {
|
|
26
|
+
titleRef.current = node;
|
|
27
|
+
},
|
|
28
|
+
[titleRef]
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Text.Header accessibilityRole="header" className={cn(className)} nativeID={titleId} ref={setTitleRef} {...props} />
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
DrawerTitle.displayName = "DelacourUI.Drawer.Title";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type ReactElement, useCallback } from "react";
|
|
2
|
+
import { composeRefs } from "../../lib/compose-refs";
|
|
3
|
+
import { Slot } from "../../lib/slot";
|
|
4
|
+
import { Pressable, type PressableProps } from "../pressable";
|
|
5
|
+
import { useDrawerPart } from "./drawer.context";
|
|
6
|
+
|
|
7
|
+
export type DrawerTriggerProps = PressableProps;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The control that opens the drawer.
|
|
11
|
+
*
|
|
12
|
+
* On its own it is this library's `Pressable`. **`asChild` donates the press
|
|
13
|
+
* rather than wrapping the child**, the way `BottomSheet.Trigger` does: two tap
|
|
14
|
+
* gestures in an ancestor/descendant pair are not simultaneous, so a `Button`
|
|
15
|
+
* wrapped in a pressable trigger would win the touch and the drawer would never
|
|
16
|
+
* open. With `asChild` the open is chained ahead of the child's own `onPress`.
|
|
17
|
+
*
|
|
18
|
+
* The trigger's ref is kept so accessibility focus can return to it when the
|
|
19
|
+
* drawer closes.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <Drawer.Trigger asChild>
|
|
23
|
+
* <Button accessibilityLabel="Menu" size="icon-md" variant="ghost">
|
|
24
|
+
* <Icon icon={IconMenu} />
|
|
25
|
+
* </Button>
|
|
26
|
+
* </Drawer.Trigger>
|
|
27
|
+
*/
|
|
28
|
+
export function DrawerTrigger({ asChild = false, children, onPress, ref, ...props }: DrawerTriggerProps): ReactElement {
|
|
29
|
+
const { setOpen, triggerRef } = useDrawerPart("Drawer.Trigger");
|
|
30
|
+
|
|
31
|
+
const handlePress = useCallback(() => {
|
|
32
|
+
setOpen(true);
|
|
33
|
+
onPress?.();
|
|
34
|
+
}, [setOpen, onPress]);
|
|
35
|
+
|
|
36
|
+
const composedRef = composeRefs(ref, (node) => {
|
|
37
|
+
triggerRef.current = node;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (asChild) {
|
|
41
|
+
return (
|
|
42
|
+
<Slot onPress={handlePress} ref={composedRef} {...props}>
|
|
43
|
+
{children}
|
|
44
|
+
</Slot>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Pressable accessibilityRole="button" onPress={handlePress} ref={composedRef} {...props}>
|
|
50
|
+
{children}
|
|
51
|
+
</Pressable>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
DrawerTrigger.displayName = "DelacourUI.Drawer.Trigger";
|