@delacour/react-native-ui 0.1.0-alpha.20261007120055 → 0.1.0-alpha.20261007130133

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.
Files changed (32) hide show
  1. package/package.json +5 -3
  2. package/src/components/drawer/AGENTS.md +123 -0
  3. package/src/components/drawer/drawer-body.tsx +46 -0
  4. package/src/components/drawer/drawer-close.tsx +76 -0
  5. package/src/components/drawer/drawer-content.tsx +186 -0
  6. package/src/components/drawer/drawer-description.tsx +23 -0
  7. package/src/components/drawer/drawer-footer.tsx +21 -0
  8. package/src/components/drawer/drawer-header.tsx +42 -0
  9. package/src/components/drawer/drawer-title.tsx +35 -0
  10. package/src/components/drawer/drawer-trigger.tsx +54 -0
  11. package/src/components/drawer/drawer.context.tsx +114 -0
  12. package/src/components/drawer/drawer.tsx +119 -0
  13. package/src/components/drawer/drawer.variants.test.ts +392 -0
  14. package/src/components/drawer/drawer.variants.ts +244 -0
  15. package/src/components/drawer/index.ts +41 -0
  16. package/src/components/drawer/use-drawer-pan.ts +108 -0
  17. package/src/components/feedback/AGENTS.md +95 -0
  18. package/src/components/feedback/feedback-action.tsx +16 -0
  19. package/src/components/feedback/feedback-cancel.tsx +34 -0
  20. package/src/components/feedback/feedback-close.tsx +21 -0
  21. package/src/components/feedback/feedback-content.tsx +24 -0
  22. package/src/components/feedback/feedback-field.tsx +70 -0
  23. package/src/components/feedback/feedback-footer.tsx +21 -0
  24. package/src/components/feedback/feedback-panel.tsx +54 -0
  25. package/src/components/feedback/feedback-submit.tsx +74 -0
  26. package/src/components/feedback/feedback-title.tsx +34 -0
  27. package/src/components/feedback/feedback-trigger.tsx +20 -0
  28. package/src/components/feedback/feedback.context.tsx +73 -0
  29. package/src/components/feedback/feedback.tsx +135 -0
  30. package/src/components/feedback/feedback.variants.test.ts +185 -0
  31. package/src/components/feedback/feedback.variants.ts +128 -0
  32. package/src/components/feedback/index.ts +24 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20261007120055",
3
+ "version": "0.1.0-alpha.20261007130133",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,7 +46,9 @@
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",
51
+ "./feedback": "./src/components/feedback/index.ts",
50
52
  "./field": "./src/components/field/index.ts",
51
53
  "./icon": "./src/components/icon/index.ts",
52
54
  "./input": "./src/components/input/index.ts",
@@ -107,8 +109,8 @@
107
109
  },
108
110
  "peerDependencies": {
109
111
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
110
- "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007120055",
111
- "@delacour/react-native-charts": "0.1.0-alpha.20261007120055",
112
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007130133",
113
+ "@delacour/react-native-charts": "0.1.0-alpha.20261007130133",
112
114
  "@legendapp/list": ">=3.3",
113
115
  "expo-linear-gradient": ">=15",
114
116
  "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";