@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 +3 -2
- package/src/components/collapsible/AGENTS.md +92 -0
- package/src/components/collapsible/collapsible-content.tsx +100 -0
- package/src/components/collapsible/collapsible-description.tsx +17 -0
- package/src/components/collapsible/collapsible-indicator.tsx +92 -0
- package/src/components/collapsible/collapsible-title.tsx +17 -0
- package/src/components/collapsible/collapsible-trigger.tsx +133 -0
- package/src/components/collapsible/collapsible.context.tsx +86 -0
- package/src/components/collapsible/collapsible.tsx +165 -0
- package/src/components/collapsible/collapsible.types.ts +9 -0
- package/src/components/collapsible/collapsible.variants.test.ts +302 -0
- package/src/components/collapsible/collapsible.variants.ts +207 -0
- package/src/components/collapsible/index.ts +32 -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.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.
|
|
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";
|