@delacour/react-native-ui 0.1.0-alpha.20260925062901 → 0.1.0-alpha.20260925063207

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20260925062901",
3
+ "version": "0.1.0-alpha.20260925063207",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,6 +41,7 @@
41
41
  "./chart": "./src/components/chart/index.ts",
42
42
  "./checkbox": "./src/components/checkbox/index.ts",
43
43
  "./chip": "./src/components/chip/index.ts",
44
+ "./collapsible": "./src/components/collapsible/index.ts",
44
45
  "./empty-state": "./src/components/empty-state/index.ts",
45
46
  "./field": "./src/components/field/index.ts",
46
47
  "./icon": "./src/components/icon/index.ts",
@@ -91,7 +92,7 @@
91
92
  },
92
93
  "peerDependencies": {
93
94
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
94
- "@delacour/react-native-charts": "0.1.0-alpha.20260925062901",
95
+ "@delacour/react-native-charts": "0.1.0-alpha.20260925063207",
95
96
  "@gorhom/bottom-sheet": "^5.2.8",
96
97
  "@legendapp/list": ">=3.3",
97
98
  "expo-linear-gradient": ">=15",
@@ -0,0 +1,92 @@
1
+ # Collapsible
2
+
3
+ One section of content, shown and hidden by its own trigger. Compound root plus
4
+ `Collapsible.Trigger`, `Collapsible.Title`, `Collapsible.Description`,
5
+ `Collapsible.Indicator` and `Collapsible.Content`. It is a single
6
+ [Accordion](../accordion/AGENTS.md) item without the group — the same row, the
7
+ same measured panel, the same spring — so read that file's **The measured panel**
8
+ section first. What follows is only where a standalone disclosure differs.
9
+
10
+ `import { Collapsible } from "@delacour/react-native-ui/collapsible";`
11
+
12
+ ## Files
13
+
14
+ | File | What it holds |
15
+ | --- | --- |
16
+ | `index.ts` | → `@delacour/react-native-ui/collapsible` |
17
+ | `collapsible.tsx` | Root — owns the open state, `progress`, the measured height and the spring; the `Object.assign` |
18
+ | `collapsible-trigger.tsx` | `Collapsible.Trigger`, and the row it assembles from its children |
19
+ | `collapsible-title.tsx` | `Collapsible.Title` |
20
+ | `collapsible-description.tsx` | `Collapsible.Description` |
21
+ | `collapsible-indicator.tsx` | `Collapsible.Indicator`, the one rotation |
22
+ | `collapsible-content.tsx` | `Collapsible.Content` — the measured clip |
23
+ | `collapsible.context.tsx` | `CollapsibleContext`, `useCollapsible` and the internal part hook |
24
+ | `collapsible.types.ts` | `CollapsibleTextProps`, shared by the title, description and trigger |
25
+ | `collapsible.variants.ts` | Pure `tv()` slots, the constants, the toggle and the accessibility resolver — no RN imports |
26
+ | `collapsible.variants.test.ts` | |
27
+
28
+ ## Design
29
+
30
+ - **It looks exactly like a one-item `Accordion`, and a test holds it there.**
31
+ Variants (`default`, `secondary`, `tertiary`, `transparent`), sizes (`sm`,
32
+ `md`, `lg`), the trigger's metrics, the type scale, the glyph step and the
33
+ panel's inset are `Accordion`'s class for class. A collapsible and an accordion
34
+ on one screen are the same control at two scales; if either retunes, the test
35
+ fails until the other follows.
36
+ - **Its constants are restated, not imported from `../accordion`.** The registry
37
+ derives an item's dependencies from its imports, so importing even the
38
+ accordion's leaf would make `delacour add collapsible` copy an accordion in
39
+ too. The spring, fade, rotation, variants, sizes and glyph step are written
40
+ again here and pinned equal by `collapsible.variants.test.ts` — the test file
41
+ is the only thing that imports both, and tests are not shipped.
42
+ - **The root owns the state and the travel.** With no item layer, what
43
+ `Accordion.Item` owns — `progress`, `contentHeight`, the spring, the
44
+ `onMeasured` re-run counter — lives on the root. The race that counter closes
45
+ (`onLayout` landing either side of React's effects) is the same one, so the
46
+ panel reports its first measurement and never starts the spring itself.
47
+ - **The disabled fade lands on the root.** Never on the trigger, which is a
48
+ `Pressable` whose `Animated.View` writes `opacity` every frame; the accordion
49
+ fades its item for the same reason, and a collapsible's root is its item.
50
+
51
+ ## State
52
+
53
+ - **`isOpen` / `defaultOpen` / `onOpenChange`, `BottomSheet`'s names.** One
54
+ boolean through `useControllableState`, so it is controlled or uncontrolled
55
+ from one hook and `onOpenChange` hears the trigger and `toggle()` alike.
56
+ - **Disabled refuses the tap in both directions.** `toggleCollapsibleOpen`
57
+ returns the current state while disabled, so a disabled section that is open
58
+ stays open. A disabled control is one that cannot be used, not one that undoes
59
+ itself. The trigger is also a disabled `Pressable`; the pure rule is the second
60
+ line, and it is what keeps a caller's own `useCollapsible().toggle()` honest.
61
+ - **A refused toggle reports nothing.** The root compares the next state with
62
+ the current and skips `setOpen`, so `onOpenChange` never fires for a change
63
+ that did not happen.
64
+ - **`toggle` and the change callback are ref-backed**, so the context keeps its
65
+ identity across a caller passing a fresh arrow. `Accordion`'s trampoline.
66
+
67
+ ## The trigger
68
+
69
+ - **A `Pressable` with `fade` and `haptic="selection"`**, `Accordion.Trigger`'s
70
+ two defaults for its two reasons. `onPress` is `Omit`ed: the press *is* the
71
+ toggle, and a side effect belongs on `onOpenChange`, which also hears changes
72
+ the trigger never sees.
73
+ - **It assembles its own row** — bare strings become one `Collapsible.Title`,
74
+ titles and descriptions stack in a column, anything else stays on the row, and
75
+ the indicator moves to the end or is composed in when absent. Same rule, same
76
+ limitation: an indicator must be a *direct* `Collapsible.Indicator` child, so
77
+ the indicator takes a render function (`({ isOpen }) => …`) rather than asking
78
+ for a wrapper.
79
+
80
+ ## Accessibility
81
+
82
+ - **`resolveCollapsibleAccessibility` is the one source.** The trigger announces
83
+ `expanded` and `disabled` from it; the panel takes
84
+ `accessibilityElementsHidden` and `importantForAccessibility` from it. Pure, so
85
+ `bun test` holds the matrix.
86
+ - **A closed panel is out of the tree, not just clipped.** It stays mounted after
87
+ its first open, and mounted content is content a screen reader reads. A
88
+ disabled but open panel is still on screen and still read.
89
+ - **The root is a plain `View` with no role**, the accordion's rule.
90
+ - **Reduce motion is Reanimated's default `System` policy.** With it on, the
91
+ spring completes instantly: the panel snaps between its two states. The state
92
+ is carried by whether the panel is there, so that is the right degradation.
@@ -0,0 +1,100 @@
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 { useCollapsiblePart } from "./collapsible.context";
5
+ import {
6
+ COLLAPSIBLE_CONTENT_FADE,
7
+ COLLAPSIBLE_UNMEASURED,
8
+ collapsibleVariants,
9
+ resolveCollapsibleAccessibility,
10
+ } from "./collapsible.variants";
11
+
12
+ export type CollapsibleContentProps = Omit<ViewProps, "style"> & {
13
+ className?: string;
14
+ children?: ReactNode;
15
+ };
16
+
17
+ /**
18
+ * The body the trigger discloses.
19
+ *
20
+ * **Its height is measured and animated, never laid out.** The outer view clips,
21
+ * and its height runs from zero to whatever the inner view reported, so the
22
+ * animation lives on this one node and whatever sits below follows it through
23
+ * ordinary layout — nothing on the screen has to opt into anything.
24
+ *
25
+ * **The content mounts on first open and stays mounted**, so a form keeps what was
26
+ * typed and a list keeps where it was scrolled across every later close. A closed
27
+ * panel is therefore taken out of the accessibility tree and stops taking touches,
28
+ * rather than merely clipped to zero.
29
+ *
30
+ * It reports its first measurement and never starts the travel itself — the root
31
+ * owns the spring alone. See `onMeasured` on the context.
32
+ *
33
+ * `className` lands on the inner, measured layer, so padding set there is part of
34
+ * what the panel measures.
35
+ */
36
+ export function CollapsibleContent({ className, children, ...props }: CollapsibleContentProps): ReactElement {
37
+ const { contentHeight, isDisabled, isOpen, onMeasured, progress, size } = useCollapsiblePart("Collapsible.Content");
38
+ const slots = collapsibleVariants({ size });
39
+ const { content: a11y } = resolveCollapsibleAccessibility({ isDisabled, isOpen });
40
+
41
+ // Adjusted during render rather than in an effect, so the panel mounts in the
42
+ // same commit as the tap that opened it.
43
+ const [hasOpened, setHasOpened] = useState(isOpen);
44
+ if (isOpen && !hasOpened) setHasOpened(true);
45
+
46
+ const [isMeasured, setMeasured] = useState(false);
47
+
48
+ const handleLayout = useCallback(
49
+ (event: LayoutChangeEvent) => {
50
+ const wasMeasured = contentHeight.value > COLLAPSIBLE_UNMEASURED;
51
+ contentHeight.value = event.nativeEvent.layout.height;
52
+ if (wasMeasured) return;
53
+ setMeasured(true);
54
+ onMeasured();
55
+ },
56
+ [contentHeight, onMeasured]
57
+ );
58
+
59
+ const clipStyle = useAnimatedStyle(() => ({
60
+ height: progress.value * Math.max(contentHeight.value, 0),
61
+ opacity: interpolate(
62
+ progress.value,
63
+ [0, COLLAPSIBLE_CONTENT_FADE.start, COLLAPSIBLE_CONTENT_FADE.end, 1],
64
+ [0, 0, 1, 1]
65
+ ),
66
+ }));
67
+
68
+ // Never `auto` while measuring: the clip is stretched over the collapsible for
69
+ // that commit, and an invisible layer over the trigger is a tap that goes nowhere.
70
+ const pointerEvents = isOpen && isMeasured ? "auto" : "none";
71
+
72
+ return (
73
+ <Animated.View
74
+ accessibilityElementsHidden={a11y.accessibilityElementsHidden}
75
+ className={slots.content()}
76
+ importantForAccessibility={a11y.importantForAccessibility}
77
+ pointerEvents={pointerEvents}
78
+ style={isMeasured ? clipStyle : collapsibleStyleSheet.measuring}
79
+ >
80
+ {hasOpened ? (
81
+ <View className={slots.contentInner({ className })} onLayout={handleLayout} {...props}>
82
+ {children}
83
+ </View>
84
+ ) : null}
85
+ </Animated.View>
86
+ );
87
+ }
88
+ CollapsibleContent.displayName = "DelacourUI.Collapsible.Content";
89
+
90
+ const collapsibleStyleSheet = StyleSheet.create({
91
+ /**
92
+ * What the clip wears until the panel has reported a height.
93
+ *
94
+ * Absolutely positioned and transparent, so nothing on screen moves while it
95
+ * measures — and deliberately not `height: 0`, because an out-of-flow child
96
+ * mounted into a zero-height parent never fires `onLayout` at all. Found on a
97
+ * simulator by `Accordion.Content`, which carries the same sheet.
98
+ */
99
+ measuring: { bottom: 0, left: 0, opacity: 0, position: "absolute", right: 0, top: 0 },
100
+ });
@@ -0,0 +1,17 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "../text";
3
+ import { useCollapsiblePart } from "./collapsible.context";
4
+ import type { CollapsibleTextProps } from "./collapsible.types";
5
+ import { collapsibleVariants } from "./collapsible.variants";
6
+
7
+ /**
8
+ * The trigger's secondary line, a step down in scale and on the muted token.
9
+ *
10
+ * Stacks under the title inside the column the trigger assembles, so the two
11
+ * need no wrapper part between them.
12
+ */
13
+ export function CollapsibleDescription({ className, ...props }: CollapsibleTextProps): ReactElement {
14
+ const { size } = useCollapsiblePart("Collapsible.Description");
15
+ return <Text className={collapsibleVariants({ size }).description({ className })} {...props} />;
16
+ }
17
+ CollapsibleDescription.displayName = "DelacourUI.Collapsible.Description";
@@ -0,0 +1,92 @@
1
+ import { type ReactElement, type ReactNode, useMemo } from "react";
2
+ import type { ViewProps } from "react-native";
3
+ import Animated, { interpolate, useAnimatedStyle } from "react-native-reanimated";
4
+ import { IconChevronBottom } from "../../icons/central";
5
+ import { Icon, type IconDefaults, IconDefaultsProvider } from "../icon";
6
+ import { useCollapsiblePart } from "./collapsible.context";
7
+ import {
8
+ COLLAPSIBLE_INDICATOR_ROTATION,
9
+ COLLAPSIBLE_INDICATOR_TOKEN,
10
+ collapsibleVariants,
11
+ } from "./collapsible.variants";
12
+
13
+ /** What a render-function indicator is handed. */
14
+ export type CollapsibleIndicatorState = { isOpen: boolean };
15
+
16
+ export type CollapsibleIndicatorProps = Omit<ViewProps, "children" | "style"> & {
17
+ /**
18
+ * Turn with the panel. Set `false` for a glyph that *swaps* rather than
19
+ * rotates — a plus becoming a minus reads as broken when it also spins.
20
+ */
21
+ isAnimated?: boolean;
22
+ className?: string;
23
+ /**
24
+ * A glyph, or a function handed the settled state.
25
+ *
26
+ * The function form exists so a swapping glyph needs no component of its own.
27
+ * The trigger finds its indicator by element type, so one wrapped in a
28
+ * component of the caller's own is invisible to it and a second, default one is
29
+ * composed in beside it — handing the state over removes the reason to write
30
+ * that wrapper.
31
+ */
32
+ children?: ReactNode | ((state: CollapsibleIndicatorState) => ReactNode);
33
+ };
34
+
35
+ /**
36
+ * The glyph saying the section opens, turning as it does.
37
+ *
38
+ * Composed into a trigger that holds none, so a bare `<Collapsible.Trigger>` is
39
+ * already complete.
40
+ *
41
+ * **The rotation interpolates off the collapsible's own `progress`**, the value
42
+ * the panel's height reads, so the glyph and the panel are in phase by
43
+ * construction — and a custom glyph turns exactly like the default one. The
44
+ * rotation lives on this node rather than the trigger's because `Pressable`
45
+ * already owns `transform` there.
46
+ *
47
+ * @example
48
+ * <Collapsible.Indicator isAnimated={false}>
49
+ * {({ isOpen }) => <Icon icon={isOpen ? IconMinusSmall : IconPlusSmall} />}
50
+ * </Collapsible.Indicator>
51
+ */
52
+ export function CollapsibleIndicator({
53
+ isAnimated = true,
54
+ className,
55
+ children,
56
+ ...props
57
+ }: CollapsibleIndicatorProps): ReactElement {
58
+ const { isOpen, progress, size } = useCollapsiblePart("Collapsible.Indicator");
59
+
60
+ const slots = collapsibleVariants({ size });
61
+ const glyphClassName = slots.glyph();
62
+
63
+ const rotationStyle = useAnimatedStyle(() => ({
64
+ transform: [
65
+ {
66
+ rotate: `${interpolate(
67
+ progress.value,
68
+ [0, 1],
69
+ [COLLAPSIBLE_INDICATOR_ROTATION.collapsed, COLLAPSIBLE_INDICATOR_ROTATION.expanded]
70
+ )}deg`,
71
+ },
72
+ ],
73
+ }));
74
+
75
+ const iconDefaults = useMemo<IconDefaults>(
76
+ () => ({ className: glyphClassName, color: COLLAPSIBLE_INDICATOR_TOKEN }),
77
+ [glyphClassName]
78
+ );
79
+
80
+ return (
81
+ <Animated.View className={slots.indicator({ className })} style={isAnimated ? rotationStyle : undefined} {...props}>
82
+ <IconDefaultsProvider value={iconDefaults}>{renderGlyph(children, isOpen)}</IconDefaultsProvider>
83
+ </Animated.View>
84
+ );
85
+ }
86
+ CollapsibleIndicator.displayName = "DelacourUI.Collapsible.Indicator";
87
+
88
+ /** The caller's glyph, the state they asked for, or the default chevron. */
89
+ function renderGlyph(children: CollapsibleIndicatorProps["children"], isOpen: boolean): ReactNode {
90
+ if (typeof children === "function") return children({ isOpen });
91
+ return children ?? <Icon icon={IconChevronBottom} />;
92
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text } from "../text";
3
+ import { useCollapsiblePart } from "./collapsible.context";
4
+ import type { CollapsibleTextProps } from "./collapsible.types";
5
+ import { collapsibleVariants } from "./collapsible.variants";
6
+
7
+ /**
8
+ * The trigger's primary line — what the section is called.
9
+ *
10
+ * Carries its own colour and type scale, read from the collapsible's context: a
11
+ * React Native `View` does not cascade colour to a `Text` descendant.
12
+ */
13
+ export function CollapsibleTitle({ className, ...props }: CollapsibleTextProps): ReactElement {
14
+ const { size } = useCollapsiblePart("Collapsible.Title");
15
+ return <Text className={collapsibleVariants({ size }).title({ className })} {...props} />;
16
+ }
17
+ CollapsibleTitle.displayName = "DelacourUI.Collapsible.Title";
@@ -0,0 +1,133 @@
1
+ import { Children, isValidElement, type ReactElement, type ReactNode, useMemo } from "react";
2
+ import { View } from "react-native";
3
+ import { type IconDefaults, IconDefaultsProvider } from "../icon";
4
+ import { Pressable, type PressableProps } from "../pressable";
5
+ import { useCollapsiblePart } from "./collapsible.context";
6
+ import {
7
+ COLLAPSIBLE_FOREGROUND_TOKEN,
8
+ collapsibleVariants,
9
+ resolveCollapsibleAccessibility,
10
+ } from "./collapsible.variants";
11
+ import { CollapsibleDescription } from "./collapsible-description";
12
+ import { CollapsibleIndicator } from "./collapsible-indicator";
13
+ import { CollapsibleTitle } from "./collapsible-title";
14
+
15
+ export type CollapsibleTriggerProps = Omit<PressableProps, "children" | "disabled" | "onPress"> & {
16
+ className?: string;
17
+ children?: ReactNode;
18
+ };
19
+
20
+ /**
21
+ * The row that opens and closes the section.
22
+ *
23
+ * A `Pressable`, so `feedback`, `haptic` and the rest are inherited. Two defaults
24
+ * differ, for `Accordion.Trigger`'s reasons: `fade`, because a full-bleed row that
25
+ * scales reads as the whole card flexing, and `haptic="selection"`, because the
26
+ * press is a state toggle.
27
+ *
28
+ * `onPress` is `Omit`ed rather than forwarded: the press **is** the toggle, and a
29
+ * side effect belongs on the root's `onOpenChange`, which also hears a controlled
30
+ * change the trigger never sees.
31
+ *
32
+ * It assembles its own row — titles and descriptions stack in a column, anything
33
+ * else stays where it was written, an indicator moves to the end, and one is
34
+ * composed in when the children hold none.
35
+ */
36
+ export function CollapsibleTrigger({
37
+ feedback = "fade",
38
+ haptic = "selection",
39
+ className,
40
+ children,
41
+ ...props
42
+ }: CollapsibleTriggerProps): ReactElement {
43
+ const { isDisabled, isOpen, size, toggle } = useCollapsiblePart("Collapsible.Trigger");
44
+ const { trigger: a11y } = resolveCollapsibleAccessibility({ isDisabled, isOpen });
45
+
46
+ const slots = collapsibleVariants({ size });
47
+ const columnClassName = slots.triggerContent();
48
+ const glyphClassName = slots.glyph();
49
+
50
+ const content = useMemo(() => composeRow(children, columnClassName), [children, columnClassName]);
51
+
52
+ const iconDefaults = useMemo<IconDefaults>(
53
+ () => ({ className: glyphClassName, color: COLLAPSIBLE_FOREGROUND_TOKEN }),
54
+ [glyphClassName]
55
+ );
56
+
57
+ return (
58
+ <Pressable
59
+ accessibilityState={{ expanded: a11y.expanded }}
60
+ className={slots.trigger({ className })}
61
+ disabled={a11y.disabled}
62
+ feedback={feedback}
63
+ haptic={haptic}
64
+ onPress={toggle}
65
+ {...props}
66
+ >
67
+ <IconDefaultsProvider value={iconDefaults}>{content}</IconDefaultsProvider>
68
+ </Pressable>
69
+ );
70
+ }
71
+ CollapsibleTrigger.displayName = "DelacourUI.Collapsible.Trigger";
72
+
73
+ /**
74
+ * Arranges the trigger's children into its row.
75
+ *
76
+ * Bare strings become one `Collapsible.Title` (a raw string outside a `<Text>`
77
+ * red-boxes), titles and descriptions stack in a column, anything else stays on
78
+ * the row where it was written, and the indicator goes last — composed in when
79
+ * there is none. `Accordion.Trigger`'s arrangement, rule for rule.
80
+ */
81
+ function composeRow(children: ReactNode, columnClassName: string): ReactNode {
82
+ const items = Children.toArray(children);
83
+ const row: ReactNode[] = [];
84
+ const trailing: ReactNode[] = [];
85
+ let column: ReactNode[] = [];
86
+ let run: (string | number)[] = [];
87
+ let hasIndicator = false;
88
+
89
+ const flushRun = () => {
90
+ if (run.length === 0) return;
91
+ column.push(<CollapsibleTitle key={`title-${column.length}`}>{run.join("")}</CollapsibleTitle>);
92
+ run = [];
93
+ };
94
+
95
+ const flushColumn = () => {
96
+ if (column.length === 0) return;
97
+ row.push(
98
+ <View className={columnClassName} key={`content-${row.length}`}>
99
+ {column}
100
+ </View>
101
+ );
102
+ column = [];
103
+ };
104
+
105
+ for (const child of items) {
106
+ if (typeof child === "string" || typeof child === "number") {
107
+ run.push(child);
108
+ continue;
109
+ }
110
+ flushRun();
111
+
112
+ if (isValidElement(child) && child.type === CollapsibleIndicator) {
113
+ hasIndicator = true;
114
+ trailing.push(child);
115
+ continue;
116
+ }
117
+
118
+ if (isValidElement(child) && (child.type === CollapsibleTitle || child.type === CollapsibleDescription)) {
119
+ column.push(child);
120
+ continue;
121
+ }
122
+
123
+ flushColumn();
124
+ row.push(child);
125
+ }
126
+
127
+ flushRun();
128
+ flushColumn();
129
+
130
+ if (!hasIndicator) trailing.push(<CollapsibleIndicator key="indicator" />);
131
+
132
+ return [...row, ...trailing];
133
+ }
@@ -0,0 +1,86 @@
1
+ import { createContext, type ReactElement, type ReactNode, use } from "react";
2
+ import type { SharedValue } from "react-native-reanimated";
3
+ import type { CollapsibleSize, CollapsibleVariant } from "./collapsible.variants";
4
+
5
+ export type CollapsibleContextValue = {
6
+ variant: CollapsibleVariant;
7
+ size: CollapsibleSize;
8
+ /** The trigger is inert and the surface faded. An open section stays open. */
9
+ isDisabled: boolean;
10
+ /** Whether the panel is open, on the **JS thread** — what accessibility reads. */
11
+ isOpen: boolean;
12
+ /** Opens or closes the panel. Refused while disabled. Stable for the collapsible's lifetime. */
13
+ toggle: () => void;
14
+ /**
15
+ * How far open the panel is, on the **UI thread**: `0` closed, `1` open.
16
+ *
17
+ * The one value every animated style reads — the panel's height, its opacity
18
+ * and the indicator's rotation — so they cannot drift out of step by a frame.
19
+ */
20
+ progress: SharedValue<number>;
21
+ /** The panel's natural height in points, or {@link COLLAPSIBLE_UNMEASURED} until it has reported. */
22
+ contentHeight: SharedValue<number>;
23
+ /**
24
+ * Told by the panel that it has measured itself for the first time.
25
+ *
26
+ * Internal machinery: the root is the only owner of the spring, and this gives
27
+ * its effect a reason to re-run once there is a height to travel against. The
28
+ * panel cannot start the spring itself — `onLayout` lands either side of
29
+ * React's effects, and a spring started there is sometimes cancelled by the
30
+ * cleanup of the effect it raced. `Accordion.Item`'s `onMeasured`, verbatim.
31
+ */
32
+ onMeasured: () => void;
33
+ };
34
+
35
+ const CollapsibleContext = createContext<CollapsibleContextValue | null>(null);
36
+
37
+ /**
38
+ * Supplies one collapsible's axes, state and shared values to its parts.
39
+ *
40
+ * Lives in its own module, importing nothing but React and types, so a part can
41
+ * read it without importing `./collapsible` — which would close a cycle.
42
+ */
43
+ export function CollapsibleProvider({
44
+ value,
45
+ children,
46
+ }: {
47
+ value: CollapsibleContextValue;
48
+ children: ReactNode;
49
+ }): ReactElement {
50
+ return <CollapsibleContext value={value}>{children}</CollapsibleContext>;
51
+ }
52
+ CollapsibleProvider.displayName = "DelacourUI.Collapsible.Provider";
53
+
54
+ /** The enclosing collapsible's state, or null outside a `<Collapsible>`. */
55
+ export function useCollapsibleContext(): CollapsibleContextValue | null {
56
+ return use(CollapsibleContext);
57
+ }
58
+
59
+ /**
60
+ * Reads the enclosing collapsible's state.
61
+ *
62
+ * What a custom child reaches for: `isOpen` to swap a glyph or a label,
63
+ * `progress` to animate against exactly the travel the panel is running, or
64
+ * `toggle` to open it from somewhere other than the trigger. Throws outside a
65
+ * `<Collapsible>` — use {@link useCollapsibleContext} where one is optional.
66
+ */
67
+ export function useCollapsible(): CollapsibleContextValue {
68
+ const context = useCollapsibleContext();
69
+ if (!context) {
70
+ throw new Error("useCollapsible must be called inside a <Collapsible>.");
71
+ }
72
+ return context;
73
+ }
74
+
75
+ /**
76
+ * The enclosing collapsible's state, for a compound part that cannot work without one.
77
+ *
78
+ * Internal: deliberately not re-exported from `index.ts`.
79
+ */
80
+ export function useCollapsiblePart(component: string): CollapsibleContextValue {
81
+ const context = useCollapsibleContext();
82
+ if (!context) {
83
+ throw new Error(`${component} must be rendered inside a <Collapsible>.`);
84
+ }
85
+ return context;
86
+ }
@@ -0,0 +1,165 @@
1
+ import { type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { cancelAnimation, useSharedValue, withSpring } from "react-native-reanimated";
4
+ import { useControllableState } from "../../hooks/use-controllable-state";
5
+ import { type CollapsibleContextValue, CollapsibleProvider } from "./collapsible.context";
6
+ import {
7
+ COLLAPSIBLE_DEFAULT_SIZE,
8
+ COLLAPSIBLE_DEFAULT_VARIANT,
9
+ COLLAPSIBLE_SPRING,
10
+ COLLAPSIBLE_UNMEASURED,
11
+ type CollapsibleSize,
12
+ type CollapsibleVariant,
13
+ collapsibleVariants,
14
+ toggleCollapsibleOpen,
15
+ } from "./collapsible.variants";
16
+ import { CollapsibleContent } from "./collapsible-content";
17
+ import { CollapsibleDescription } from "./collapsible-description";
18
+ import { CollapsibleIndicator } from "./collapsible-indicator";
19
+ import { CollapsibleTitle } from "./collapsible-title";
20
+ import { CollapsibleTrigger } from "./collapsible-trigger";
21
+
22
+ export type CollapsibleProps = Omit<ViewProps, "children"> & {
23
+ variant?: CollapsibleVariant;
24
+ size?: CollapsibleSize;
25
+ /** Controlled open state. Leave it off and the collapsible holds its own. */
26
+ isOpen?: boolean;
27
+ /** Initial open state while uncontrolled. */
28
+ defaultOpen?: boolean;
29
+ /** Called with the next state whenever the trigger (or `toggle()`) opens or closes it. */
30
+ onOpenChange?: (isOpen: boolean) => void;
31
+ /**
32
+ * Stops the trigger opening or closing it, fades the surface and announces the
33
+ * trigger disabled. An open section stays open.
34
+ */
35
+ isDisabled?: boolean;
36
+ className?: string;
37
+ children?: ReactNode;
38
+ };
39
+
40
+ function CollapsibleRoot({
41
+ variant = COLLAPSIBLE_DEFAULT_VARIANT,
42
+ size = COLLAPSIBLE_DEFAULT_SIZE,
43
+ isOpen: isOpenProp,
44
+ defaultOpen = false,
45
+ onOpenChange,
46
+ isDisabled = false,
47
+ className,
48
+ children,
49
+ ...props
50
+ }: CollapsibleProps): ReactElement {
51
+ // A ref-backed trampoline, so a caller passing a fresh arrow every render does
52
+ // not change the context's identity and re-render every part.
53
+ const changeRef = useRef(onOpenChange);
54
+ changeRef.current = onOpenChange;
55
+ const handleChange = useCallback((next: boolean) => changeRef.current?.(next), []);
56
+
57
+ const [isOpen, setOpen] = useControllableState<boolean>({
58
+ defaultValue: defaultOpen,
59
+ onChange: handleChange,
60
+ value: isOpenProp,
61
+ });
62
+
63
+ // Read by `toggle` rather than closed over, so one stable callback serves the
64
+ // collapsible for its whole life.
65
+ const stateRef = useRef({ isDisabled, isOpen });
66
+ stateRef.current = { isDisabled, isOpen };
67
+
68
+ const toggle = useCallback(() => {
69
+ const current = stateRef.current;
70
+ const next = toggleCollapsibleOpen(current);
71
+ // Refused: a disabled tap must not report an `onOpenChange` for a change
72
+ // that did not happen.
73
+ if (next === current.isOpen) return;
74
+ setOpen(next);
75
+ }, [setOpen]);
76
+
77
+ // Seeded from the settled state, so a collapsible mounted open does not
78
+ // animate itself open on its first paint.
79
+ const progress = useSharedValue(isOpen ? 1 : 0);
80
+ const contentHeight = useSharedValue(COLLAPSIBLE_UNMEASURED);
81
+
82
+ // Bumped the first time the panel reports a height, purely to re-run the
83
+ // effect below. See `onMeasured` on the context.
84
+ const [measurements, setMeasurements] = useState(0);
85
+ const onMeasured = useCallback(() => setMeasurements((count) => count + 1), []);
86
+
87
+ // The first open of a panel that has never mounted has no height to travel
88
+ // against, so that run bails and the measurement that follows brings it back.
89
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `measurements` is the re-run trigger, see above
90
+ useEffect(() => {
91
+ if (isOpen && contentHeight.value === COLLAPSIBLE_UNMEASURED) return;
92
+
93
+ progress.value = withSpring(isOpen ? 1 : 0, COLLAPSIBLE_SPRING);
94
+
95
+ return () => cancelAnimation(progress);
96
+ }, [contentHeight, isOpen, measurements, progress]);
97
+
98
+ const context = useMemo<CollapsibleContextValue>(
99
+ () => ({ contentHeight, isDisabled, isOpen, onMeasured, progress, size, toggle, variant }),
100
+ [contentHeight, isDisabled, isOpen, onMeasured, progress, size, toggle, variant]
101
+ );
102
+
103
+ return (
104
+ <CollapsibleProvider value={context}>
105
+ <View className={collapsibleVariants({ isDisabled, size, variant }).root({ className })} {...props}>
106
+ {children}
107
+ </View>
108
+ </CollapsibleProvider>
109
+ );
110
+ }
111
+
112
+ /**
113
+ * One section of content, shown and hidden by its own trigger.
114
+ *
115
+ * The standalone form of an accordion item — the same row, the same measured
116
+ * panel, the same spring — for a single disclosure that belongs to nothing else:
117
+ * "Show details", an order summary, the advanced half of a form.
118
+ *
119
+ * **The panel's height is measured and animated**, so the animation lives on the
120
+ * panel's own node and whatever sits below follows it with nothing opted in. Its
121
+ * height, its fade and the indicator's rotation all run off one shared value, so
122
+ * they cannot drift by a frame, and a tap mid-travel reverses the spring rather
123
+ * than restarting it.
124
+ *
125
+ * **The panel mounts on first open and stays mounted**, so what was typed or
126
+ * scrolled inside it survives every later close. A closed panel is taken out of
127
+ * the accessibility tree and stops taking touches.
128
+ *
129
+ * **State works either way from one hook**: pass `isOpen` to control it, or
130
+ * `defaultOpen` (or nothing) and let it hold its own. `onOpenChange` hears both.
131
+ *
132
+ * `isDisabled` stops the trigger and fades the surface. It does not close a
133
+ * section that is already open — a disabled control is one that cannot be used,
134
+ * not one that undoes itself.
135
+ *
136
+ * @example
137
+ * <Collapsible>
138
+ * <Collapsible.Trigger>
139
+ * <Collapsible.Title>What is included</Collapsible.Title>
140
+ * <Collapsible.Description>Three items</Collapsible.Description>
141
+ * </Collapsible.Trigger>
142
+ * <Collapsible.Content>
143
+ * <Text.Paragraph>Unlimited projects, 100 GB of storage, email support.</Text.Paragraph>
144
+ * </Collapsible.Content>
145
+ * </Collapsible>
146
+ *
147
+ * @example
148
+ * <Collapsible isOpen={isOpen} onOpenChange={setOpen} variant="transparent">
149
+ * <Collapsible.Trigger>Advanced</Collapsible.Trigger>
150
+ * <Collapsible.Content>{fields}</Collapsible.Content>
151
+ * </Collapsible>
152
+ */
153
+ export const Collapsible = Object.assign(CollapsibleRoot, {
154
+ /** The row that opens the section. A `Pressable`, so it inherits the whole vocabulary. */
155
+ Trigger: CollapsibleTrigger,
156
+ /** The trigger's primary line. Bare string children become one automatically. */
157
+ Title: CollapsibleTitle,
158
+ /** The trigger's secondary line, stacked under the title. */
159
+ Description: CollapsibleDescription,
160
+ /** The glyph that turns as the panel opens. Composed in when a trigger holds none. */
161
+ Indicator: CollapsibleIndicator,
162
+ /** The measured, clipped panel. Mounts on first open and stays mounted. */
163
+ Content: CollapsibleContent,
164
+ displayName: "DelacourUI.Collapsible",
165
+ });
@@ -0,0 +1,9 @@
1
+ import type { TextProps } from "react-native";
2
+
3
+ /**
4
+ * The shape of the trigger's two text lines, `Title` and `Description`.
5
+ *
6
+ * Shared by both parts and by the trigger, which wraps bare string children in a
7
+ * `Collapsible.Title` — three modules, so it lives in a leaf.
8
+ */
9
+ export type CollapsibleTextProps = TextProps & { className?: string };
@@ -0,0 +1,302 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { declarationCount } from "../../styles/theme-tokens.test";
5
+ import {
6
+ ACCORDION_CONTENT_FADE,
7
+ ACCORDION_GLYPH_STEP,
8
+ ACCORDION_INDICATOR_ROTATION,
9
+ ACCORDION_SIZES,
10
+ ACCORDION_SPRING,
11
+ ACCORDION_VARIANTS,
12
+ accordionVariants,
13
+ } from "../accordion/accordion.variants";
14
+ import { ICON_SIZES } from "../icon/icon.variants";
15
+ import {
16
+ COLLAPSIBLE_CONTENT_FADE,
17
+ COLLAPSIBLE_DEFAULT_SIZE,
18
+ COLLAPSIBLE_DEFAULT_VARIANT,
19
+ COLLAPSIBLE_FOREGROUND_TOKEN,
20
+ COLLAPSIBLE_GLYPH_STEP,
21
+ COLLAPSIBLE_INDICATOR_ROTATION,
22
+ COLLAPSIBLE_INDICATOR_TOKEN,
23
+ COLLAPSIBLE_SIZES,
24
+ COLLAPSIBLE_SPRING,
25
+ COLLAPSIBLE_UNMEASURED,
26
+ COLLAPSIBLE_VARIANTS,
27
+ collapsibleVariants,
28
+ resolveCollapsibleAccessibility,
29
+ toggleCollapsibleOpen,
30
+ } from "./collapsible.variants";
31
+
32
+ const TOKENS_CSS = readFileSync(join(import.meta.dirname, "../../styles/tokens.css"), "utf-8");
33
+
34
+ /** The minimum a touch target may be, in points. Apple's number, and Android's. */
35
+ const MINIMUM_TARGET = 44;
36
+
37
+ /** Tailwind's spacing step, in points — `min-h-12` is twelve of them. */
38
+ const SPACING_STEP = 4;
39
+
40
+ /** A `--spacing-*` token's value in points, read from `tokens.css`. */
41
+ function spacingPx(token: string): number {
42
+ const match = TOKENS_CSS.match(new RegExp(`--spacing-${token}:\\s*(\\d+)px`));
43
+ if (!match) throw new Error(`tokens.css declares no --spacing-${token}`);
44
+ return Number(match[1]);
45
+ }
46
+
47
+ /** A slot's class string, with `tv`'s empty-slot `undefined` flattened. */
48
+ function cls(value: string | undefined): string {
49
+ return value ?? "";
50
+ }
51
+
52
+ /** The points a `px-4` / `min-h-14` utility resolves to. */
53
+ function utilityPx(value: string, prefix: string): number {
54
+ const match = value.match(new RegExp(`\\b${prefix}-(\\d+(?:\\.\\d+)?)\\b`));
55
+ if (!match) throw new Error(`no \`${prefix}-*\` in "${value}"`);
56
+ return Number(match[1]) * SPACING_STEP;
57
+ }
58
+
59
+ type Size = (typeof COLLAPSIBLE_SIZES)[number];
60
+
61
+ /** Every slot for one size, at the default variant, enabled. */
62
+ function slotsFor(size: Size) {
63
+ return collapsibleVariants({ isDisabled: false, size, variant: COLLAPSIBLE_DEFAULT_VARIANT });
64
+ }
65
+
66
+ /** Every slot a `View` wears, for one size. A `Text` slot carries a treatment on purpose. */
67
+ function viewSlots(size: Size): string[] {
68
+ const slots = slotsFor(size);
69
+ return [
70
+ cls(slots.root()),
71
+ cls(slots.trigger()),
72
+ cls(slots.triggerContent()),
73
+ cls(slots.indicator()),
74
+ cls(slots.content()),
75
+ cls(slots.contentInner()),
76
+ ];
77
+ }
78
+
79
+ describe("toggleCollapsibleOpen", () => {
80
+ test("a tap flips the state", () => {
81
+ expect(toggleCollapsibleOpen({ isDisabled: false, isOpen: false })).toBe(true);
82
+ expect(toggleCollapsibleOpen({ isDisabled: false, isOpen: true })).toBe(false);
83
+ });
84
+
85
+ test("a disabled collapsible refuses the tap in both directions", () => {
86
+ // Disabled means the control cannot be used, not that it undoes itself: an
87
+ // open section stays open, and a closed one stays closed.
88
+ expect(toggleCollapsibleOpen({ isDisabled: true, isOpen: false })).toBe(false);
89
+ expect(toggleCollapsibleOpen({ isDisabled: true, isOpen: true })).toBe(true);
90
+ });
91
+ });
92
+
93
+ describe("resolveCollapsibleAccessibility", () => {
94
+ test("the trigger announces expanded and disabled from the settled state", () => {
95
+ expect(resolveCollapsibleAccessibility({ isDisabled: false, isOpen: true })).toEqual({
96
+ trigger: { disabled: false, expanded: true },
97
+ content: { accessibilityElementsHidden: false, importantForAccessibility: "auto" },
98
+ });
99
+ });
100
+
101
+ test("a closed panel is taken out of the accessibility tree", () => {
102
+ // The panel stays mounted after its first expand, so without this a screen
103
+ // reader would read out a section that is not on screen.
104
+ expect(resolveCollapsibleAccessibility({ isDisabled: false, isOpen: false }).content).toEqual({
105
+ accessibilityElementsHidden: true,
106
+ importantForAccessibility: "no-hide-descendants",
107
+ });
108
+ });
109
+
110
+ test("disabling a section does not hide it", () => {
111
+ // An open, disabled section is still content on screen.
112
+ const resolved = resolveCollapsibleAccessibility({ isDisabled: true, isOpen: true });
113
+ expect(resolved.trigger.disabled).toBe(true);
114
+ expect(resolved.content.accessibilityElementsHidden).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe("collapsibleVariants defaults", () => {
119
+ test("the tv's defaults are the constants the component falls back to", () => {
120
+ expect(cls(collapsibleVariants({}).trigger())).toBe(cls(slotsFor(COLLAPSIBLE_DEFAULT_SIZE).trigger()));
121
+ expect(cls(collapsibleVariants({}).root())).toBe(
122
+ cls(collapsibleVariants({ size: COLLAPSIBLE_DEFAULT_SIZE, variant: COLLAPSIBLE_DEFAULT_VARIANT }).root())
123
+ );
124
+ expect(COLLAPSIBLE_SIZES).toContain(COLLAPSIBLE_DEFAULT_SIZE);
125
+ expect(COLLAPSIBLE_VARIANTS).toContain(COLLAPSIBLE_DEFAULT_VARIANT);
126
+ });
127
+ });
128
+
129
+ describe("collapsibleVariants geometry", () => {
130
+ test("the content is inset to the trigger's padding at every size", () => {
131
+ // A panel whose text started at a different margin than the title above it
132
+ // reads as two columns rather than one disclosure.
133
+ for (const size of COLLAPSIBLE_SIZES) {
134
+ const slots = slotsFor(size);
135
+ expect(utilityPx(cls(slots.contentInner()), "px")).toBe(utilityPx(cls(slots.trigger()), "px"));
136
+ }
137
+ });
138
+
139
+ test("a trigger is a real touch target at every size", () => {
140
+ for (const size of COLLAPSIBLE_SIZES) {
141
+ expect(utilityPx(cls(slotsFor(size).trigger()), "min-h")).toBeGreaterThanOrEqual(MINIMUM_TARGET);
142
+ }
143
+ });
144
+
145
+ test("the size ladder ascends", () => {
146
+ const heights = COLLAPSIBLE_SIZES.map((size) => utilityPx(cls(slotsFor(size).trigger()), "min-h"));
147
+ expect(heights).toEqual([...heights].sort((a, b) => a - b));
148
+ expect(new Set(heights).size).toBe(heights.length);
149
+ });
150
+
151
+ test("every glyph step is a size the icon scale has", () => {
152
+ for (const size of COLLAPSIBLE_SIZES) {
153
+ const step = COLLAPSIBLE_GLYPH_STEP[size];
154
+ expect(ICON_SIZES).toContain(step);
155
+ expect(cls(slotsFor(size).glyph())).toBe(`size-icon-${step}`);
156
+ expect(spacingPx(`icon-${step}`)).toBeGreaterThan(0);
157
+ }
158
+ });
159
+ });
160
+
161
+ describe("collapsibleVariants slots", () => {
162
+ test("no slot a View wears carries a text treatment", () => {
163
+ // A React Native View does not cascade colour to a Text descendant. Rule 1.
164
+ for (const size of COLLAPSIBLE_SIZES) {
165
+ for (const value of viewSlots(size)) {
166
+ expect(value).not.toMatch(/\b(text|font)-/);
167
+ }
168
+ }
169
+ });
170
+
171
+ test("the title and description carry the treatment instead", () => {
172
+ for (const size of COLLAPSIBLE_SIZES) {
173
+ const slots = slotsFor(size);
174
+ expect(cls(slots.title())).toMatch(/\btext-/);
175
+ expect(cls(slots.description())).toMatch(/\btext-/);
176
+ }
177
+ });
178
+
179
+ test("the root clips, so an expanding panel keeps the rounded corners", () => {
180
+ for (const size of COLLAPSIBLE_SIZES) {
181
+ expect(cls(slotsFor(size).root())).toMatch(/\boverflow-hidden\b/);
182
+ }
183
+ });
184
+
185
+ test("the content clips, because its height is animated below its content's", () => {
186
+ for (const size of COLLAPSIBLE_SIZES) {
187
+ expect(cls(slotsFor(size).content())).toMatch(/\boverflow-hidden\b/);
188
+ }
189
+ });
190
+
191
+ test("the measured layer is out of flow and spans the width", () => {
192
+ for (const size of COLLAPSIBLE_SIZES) {
193
+ const inner = cls(slotsFor(size).contentInner());
194
+ for (const utility of ["absolute", "top-0", "left-0", "right-0"]) {
195
+ expect(inner).toMatch(new RegExp(`\\b${utility}\\b`));
196
+ }
197
+ }
198
+ });
199
+
200
+ test("the trigger spans the surface and its text column can shrink", () => {
201
+ for (const size of COLLAPSIBLE_SIZES) {
202
+ const slots = slotsFor(size);
203
+ expect(cls(slots.trigger())).toMatch(/\bw-full\b/);
204
+ expect(cls(slots.triggerContent())).toMatch(/\bflex-1\b/);
205
+ expect(cls(slots.triggerContent())).toMatch(/\bmin-w-0\b/);
206
+ }
207
+ });
208
+
209
+ test("the disabled fade lands on the root, never on the trigger", () => {
210
+ // The trigger is a `Pressable`, whose root Animated.View writes `opacity`
211
+ // every frame — a class there is overwritten silently.
212
+ for (const size of COLLAPSIBLE_SIZES) {
213
+ const slots = collapsibleVariants({ isDisabled: true, size, variant: COLLAPSIBLE_DEFAULT_VARIANT });
214
+ expect(cls(slots.root())).toMatch(/\bopacity-50\b/);
215
+ expect(cls(slots.trigger())).not.toMatch(/\bopacity-/);
216
+ }
217
+ });
218
+
219
+ test("every variant paints a distinct root, and nothing else", () => {
220
+ const roots = COLLAPSIBLE_VARIANTS.map((variant) =>
221
+ cls(collapsibleVariants({ isDisabled: false, size: COLLAPSIBLE_DEFAULT_SIZE, variant }).root())
222
+ );
223
+ expect(new Set(roots).size).toBe(roots.length);
224
+ for (const variant of COLLAPSIBLE_VARIANTS) {
225
+ const slots = collapsibleVariants({ isDisabled: false, size: COLLAPSIBLE_DEFAULT_SIZE, variant });
226
+ expect(cls(slots.trigger())).toBe(cls(slotsFor(COLLAPSIBLE_DEFAULT_SIZE).trigger()));
227
+ expect(cls(slots.contentInner())).toBe(cls(slotsFor(COLLAPSIBLE_DEFAULT_SIZE).contentInner()));
228
+ }
229
+ });
230
+
231
+ test("nothing draws a shadow", () => {
232
+ for (const size of COLLAPSIBLE_SIZES) {
233
+ for (const value of viewSlots(size)) {
234
+ expect(value).not.toMatch(/\bshadow/);
235
+ }
236
+ }
237
+ });
238
+ });
239
+
240
+ describe("collapsible colour tokens", () => {
241
+ test("every token it names is declared in both themes", () => {
242
+ for (const token of [COLLAPSIBLE_FOREGROUND_TOKEN, COLLAPSIBLE_INDICATOR_TOKEN]) {
243
+ expect(declarationCount(token)).toBe(2);
244
+ }
245
+ });
246
+
247
+ test("the indicator is quieter than the title beside it", () => {
248
+ expect(COLLAPSIBLE_INDICATOR_TOKEN).not.toBe(COLLAPSIBLE_FOREGROUND_TOKEN);
249
+ });
250
+ });
251
+
252
+ describe("collapsible animation", () => {
253
+ test("the panel settles without overshooting its own measured height", () => {
254
+ const ratio = COLLAPSIBLE_SPRING.damping / (2 * Math.sqrt(COLLAPSIBLE_SPRING.stiffness * COLLAPSIBLE_SPRING.mass));
255
+ expect(ratio).toBeGreaterThanOrEqual(1);
256
+ expect(ratio).toBeLessThan(2);
257
+ });
258
+
259
+ test("unmeasured is a height no layout can report", () => {
260
+ expect(COLLAPSIBLE_UNMEASURED).toBeLessThan(0);
261
+ });
262
+
263
+ test("the fade runs ahead of the height, inside the travel", () => {
264
+ const { start, end } = COLLAPSIBLE_CONTENT_FADE;
265
+ expect(start).toBeGreaterThanOrEqual(0);
266
+ expect(end).toBeGreaterThan(start);
267
+ expect(end).toBeLessThan(1);
268
+ });
269
+ });
270
+
271
+ describe("collapsible and accordion move alike", () => {
272
+ // Restated rather than imported, so `delacour add collapsible` does not copy an
273
+ // accordion in with it — and pinned here, so the two disclosures on one screen
274
+ // cannot drift apart in feel, metrics or paint.
275
+ test("the same spring, fade and indicator travel", () => {
276
+ expect(COLLAPSIBLE_SPRING).toEqual(ACCORDION_SPRING);
277
+ expect(COLLAPSIBLE_CONTENT_FADE).toEqual(ACCORDION_CONTENT_FADE);
278
+ expect(COLLAPSIBLE_INDICATOR_ROTATION).toEqual(ACCORDION_INDICATOR_ROTATION);
279
+ });
280
+
281
+ test("the same axes", () => {
282
+ expect([...COLLAPSIBLE_VARIANTS]).toEqual([...ACCORDION_VARIANTS]);
283
+ expect([...COLLAPSIBLE_SIZES]).toEqual([...ACCORDION_SIZES]);
284
+ expect(COLLAPSIBLE_GLYPH_STEP).toEqual(ACCORDION_GLYPH_STEP);
285
+ });
286
+
287
+ test("the same trigger, panel and surface at every size and variant", () => {
288
+ for (const size of COLLAPSIBLE_SIZES) {
289
+ const own = slotsFor(size);
290
+ const accordion = accordionVariants({ isDisabled: false, size, variant: "default" });
291
+ expect(cls(own.trigger())).toBe(cls(accordion.trigger()));
292
+ expect(cls(own.contentInner())).toBe(cls(accordion.contentInner()));
293
+ expect(cls(own.title())).toBe(cls(accordion.title()));
294
+ expect(cls(own.description())).toBe(cls(accordion.description()));
295
+ }
296
+ for (const variant of COLLAPSIBLE_VARIANTS) {
297
+ const own = collapsibleVariants({ isDisabled: false, size: COLLAPSIBLE_DEFAULT_SIZE, variant });
298
+ const accordion = accordionVariants({ isDisabled: false, size: COLLAPSIBLE_DEFAULT_SIZE, variant });
299
+ expect(cls(own.root())).toBe(cls(accordion.root()));
300
+ }
301
+ });
302
+ });
@@ -0,0 +1,207 @@
1
+ import type { VariantProps } from "tailwind-variants";
2
+ import { tv } from "../../lib/tv";
3
+ import type { IconSize } from "../icon/icon.variants";
4
+
5
+ /**
6
+ * How the surface is painted.
7
+ *
8
+ * `ListGroup`'s and `Accordion`'s set, because a collapsible is the same kind of
9
+ * surface and the three sit beside each other on a screen. A test pins the set
10
+ * against `Accordion`'s.
11
+ */
12
+ export const COLLAPSIBLE_VARIANTS = ["default", "secondary", "tertiary", "transparent"] as const;
13
+
14
+ export const COLLAPSIBLE_SIZES = ["sm", "md", "lg"] as const;
15
+
16
+ export type CollapsibleVariant = (typeof COLLAPSIBLE_VARIANTS)[number];
17
+ export type CollapsibleSize = (typeof COLLAPSIBLE_SIZES)[number];
18
+
19
+ /** The axes a collapsible falls back to when nothing names one. Pinned against `defaultVariants`. */
20
+ export const COLLAPSIBLE_DEFAULT_VARIANT: CollapsibleVariant = "default";
21
+ export const COLLAPSIBLE_DEFAULT_SIZE: CollapsibleSize = "md";
22
+
23
+ /**
24
+ * The step every glyph in a collapsible is drawn at — the indicator, or an icon a
25
+ * caller composes into the trigger. One step, so a chevron and a leading glyph on
26
+ * the same row are the same mark. The `glyph` slot writes the class out, because
27
+ * Tailwind's scanner is static, and a test pins the two together.
28
+ */
29
+ export const COLLAPSIBLE_GLYPH_STEP: Record<CollapsibleSize, IconSize> = { sm: "xs", md: "sm", lg: "md" };
30
+
31
+ /** Theme token an `Icon` composed into the trigger inherits. */
32
+ export const COLLAPSIBLE_FOREGROUND_TOKEN = "foreground";
33
+
34
+ /** Theme token the indicator inherits — a step quieter than the title, and a test pins that the two differ. */
35
+ export const COLLAPSIBLE_INDICATOR_TOKEN = "muted-foreground";
36
+
37
+ /**
38
+ * The one spring a collapsible runs.
39
+ *
40
+ * **The panel's height, its opacity and the indicator's rotation all read it**,
41
+ * off a single `progress` shared value, so they cannot drift out of step by a
42
+ * frame. **Critically damped**, because an overshoot in *height* draws the panel
43
+ * taller than its content measured and flashes the surface behind it at the end
44
+ * of every expand.
45
+ *
46
+ * Restated rather than imported from `Accordion`, so `delacour add collapsible`
47
+ * copies one folder and not two. A test pins the two equal, so the two
48
+ * disclosures cannot drift apart in feel.
49
+ */
50
+ export const COLLAPSIBLE_SPRING = { damping: 26, mass: 0.4, stiffness: 400 } as const;
51
+
52
+ /** Where the indicator points at each end of the travel, in degrees — a half turn. */
53
+ export const COLLAPSIBLE_INDICATOR_ROTATION = { collapsed: 0, expanded: 180 } as const;
54
+
55
+ /**
56
+ * What the measured height holds before the panel has ever reported its layout.
57
+ *
58
+ * Negative rather than zero: a panel that measured `0` is a real answer, and
59
+ * treating it as "still waiting" would never start the spring. Only a value no
60
+ * layout can produce can mean *unmeasured*, so the height style floors it.
61
+ */
62
+ export const COLLAPSIBLE_UNMEASURED = -1;
63
+
64
+ /**
65
+ * The window of the travel the panel's opacity ramps across — ahead of the
66
+ * height, so the content is legible for most of an expand rather than half
67
+ * transparent at its midpoint.
68
+ */
69
+ export const COLLAPSIBLE_CONTENT_FADE = { start: 0.1, end: 0.6 } as const;
70
+
71
+ /**
72
+ * Styling for every part of a collapsible.
73
+ *
74
+ * One slotted `tv()`, because the parts cannot import the root without closing a
75
+ * cycle (AGENTS.md rule 3) yet all of them read the same `size`.
76
+ *
77
+ * **The slots are `Accordion`'s, class for class**, and a test asserts it: a
78
+ * collapsible is one accordion item without the group, and the two on one screen
79
+ * have to read as the same control. What differs is where the state lives, not
80
+ * how it looks.
81
+ *
82
+ * **The disabled fade lands on `root`**, never on `trigger`. The trigger is a
83
+ * `Pressable`, whose `Animated.View` writes `opacity` every frame, so a class on
84
+ * that node is overwritten silently. A collapsible has no item layer between the
85
+ * two, so the root wears it.
86
+ *
87
+ * **`overflow-hidden` on `root`** keeps an expanding panel inside the rounded
88
+ * corner; **on `content`** it is the disclosure itself, since that slot's height
89
+ * is an animated style running below its content's.
90
+ *
91
+ * **`contentInner` is out of flow**, so its height is purely its content's and can
92
+ * never be fed back the clip's own animated one. `left-0 right-0` is the other
93
+ * half: an absolute child is content-width without it, and a paragraph would
94
+ * measure as a single unwrapped line.
95
+ *
96
+ * No slot worn by a `View` carries `text-*` (rule 1). Free of React Native
97
+ * imports so it stays reachable from `bun test`.
98
+ */
99
+ export const collapsibleVariants = tv({
100
+ slots: {
101
+ /** The clipped, rounded surface. Wears the disabled fade. */
102
+ root: "overflow-hidden border border-transparent",
103
+ /** The row that opens the panel. A `Pressable`, so it carries no opacity of its own. */
104
+ trigger: "w-full flex-row items-center",
105
+ /** The trigger's text column, taking whatever width the indicator leaves. */
106
+ triggerContent: "min-w-0 flex-1 justify-center gap-0.5",
107
+ /** The box the indicator turns inside. Its rotation is an animated style. */
108
+ indicator: "items-center justify-center",
109
+ /** Edge length any glyph in a collapsible inherits. */
110
+ glyph: "",
111
+ /** The trigger's primary line. Carries its own colour — a `View` cannot cascade one. */
112
+ title: "font-medium text-foreground",
113
+ /** The trigger's secondary line, a step down in scale and on the muted token. */
114
+ description: "text-muted-foreground",
115
+ /** The clip. Its height is an animated style, never a class. */
116
+ content: "w-full overflow-hidden",
117
+ /** The measured layer, out of flow so no clip height can squash what it reports. */
118
+ contentInner: "absolute top-0 right-0 left-0",
119
+ },
120
+ variants: {
121
+ variant: {
122
+ default: { root: "border-border bg-card" },
123
+ secondary: { root: "bg-secondary" },
124
+ tertiary: { root: "bg-tertiary" },
125
+ transparent: { root: "bg-transparent" },
126
+ },
127
+ size: {
128
+ sm: {
129
+ root: "rounded-md",
130
+ trigger: "min-h-12 gap-2.5 px-3 py-3",
131
+ contentInner: "px-3 pb-3",
132
+ glyph: "size-icon-xs",
133
+ title: "text-sm",
134
+ description: "text-xs",
135
+ },
136
+ md: {
137
+ root: "rounded-md",
138
+ trigger: "min-h-14 gap-3 px-4 py-3.5",
139
+ contentInner: "px-4 pb-4",
140
+ glyph: "size-icon-sm",
141
+ title: "text-base",
142
+ description: "text-sm",
143
+ },
144
+ lg: {
145
+ root: "rounded-md",
146
+ trigger: "min-h-16 gap-3.5 px-5 py-4",
147
+ contentInner: "px-5 pb-5",
148
+ glyph: "size-icon-md",
149
+ title: "text-lg",
150
+ description: "text-base",
151
+ },
152
+ },
153
+ // The empty `false` branch is load-bearing typing: a map with only `true`
154
+ // types the prop as `true` rather than `boolean`.
155
+ isDisabled: { true: { root: "opacity-50" }, false: {} },
156
+ },
157
+ defaultVariants: {
158
+ variant: COLLAPSIBLE_DEFAULT_VARIANT,
159
+ size: COLLAPSIBLE_DEFAULT_SIZE,
160
+ isDisabled: false,
161
+ },
162
+ });
163
+
164
+ export type CollapsibleVariantProps = VariantProps<typeof collapsibleVariants>;
165
+
166
+ /** The state a collapsible settles at rest. */
167
+ export type CollapsibleState = { isOpen: boolean; isDisabled: boolean };
168
+
169
+ /**
170
+ * The whole state transition a tap on the trigger makes.
171
+ *
172
+ * A disabled collapsible refuses the tap **in both directions** — it does not
173
+ * close a section that is already open. A disabled control is one that cannot be
174
+ * used, not one that undoes itself. The trigger is also a disabled `Pressable`,
175
+ * so this is the second line rather than the first; it is what keeps a tap that
176
+ * slips through the gesture layer, or a caller's own `toggle()`, honest.
177
+ */
178
+ export function toggleCollapsibleOpen({ isOpen, isDisabled }: CollapsibleState): boolean {
179
+ return isDisabled ? isOpen : !isOpen;
180
+ }
181
+
182
+ /** What the trigger and the panel tell assistive technology. */
183
+ export type CollapsibleAccessibility = {
184
+ trigger: { expanded: boolean; disabled: boolean };
185
+ content: {
186
+ accessibilityElementsHidden: boolean;
187
+ importantForAccessibility: "auto" | "no-hide-descendants";
188
+ };
189
+ };
190
+
191
+ /**
192
+ * Settles what the trigger and the panel announce, from the settled state.
193
+ *
194
+ * **A closed panel is taken out of the tree**, not merely clipped to zero. The
195
+ * panel stays mounted after its first expand, and mounted content is content a
196
+ * screen reader reads — so a closed one has to say it is not there. Disabling a
197
+ * section does not hide it: an open, disabled panel is still on screen.
198
+ */
199
+ export function resolveCollapsibleAccessibility({ isOpen, isDisabled }: CollapsibleState): CollapsibleAccessibility {
200
+ return {
201
+ trigger: { disabled: isDisabled, expanded: isOpen },
202
+ content: {
203
+ accessibilityElementsHidden: !isOpen,
204
+ importantForAccessibility: isOpen ? "auto" : "no-hide-descendants",
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,32 @@
1
+ export { Collapsible, type CollapsibleProps } from "./collapsible";
2
+ export {
3
+ type CollapsibleContextValue,
4
+ CollapsibleProvider,
5
+ useCollapsible,
6
+ useCollapsibleContext,
7
+ } from "./collapsible.context";
8
+ export type { CollapsibleTextProps } from "./collapsible.types";
9
+ export {
10
+ COLLAPSIBLE_CONTENT_FADE,
11
+ COLLAPSIBLE_DEFAULT_SIZE,
12
+ COLLAPSIBLE_DEFAULT_VARIANT,
13
+ COLLAPSIBLE_FOREGROUND_TOKEN,
14
+ COLLAPSIBLE_GLYPH_STEP,
15
+ COLLAPSIBLE_INDICATOR_ROTATION,
16
+ COLLAPSIBLE_INDICATOR_TOKEN,
17
+ COLLAPSIBLE_SIZES,
18
+ COLLAPSIBLE_SPRING,
19
+ COLLAPSIBLE_UNMEASURED,
20
+ COLLAPSIBLE_VARIANTS,
21
+ type CollapsibleAccessibility,
22
+ type CollapsibleSize,
23
+ type CollapsibleState,
24
+ type CollapsibleVariant,
25
+ type CollapsibleVariantProps,
26
+ collapsibleVariants,
27
+ resolveCollapsibleAccessibility,
28
+ toggleCollapsibleOpen,
29
+ } from "./collapsible.variants";
30
+ export type { CollapsibleContentProps } from "./collapsible-content";
31
+ export type { CollapsibleIndicatorProps, CollapsibleIndicatorState } from "./collapsible-indicator";
32
+ export type { CollapsibleTriggerProps } from "./collapsible-trigger";