@delacour/react-native-ui 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +192 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +435 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +232 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +100 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +33 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +247 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +581 -0
- package/src/components/radio/radio.variants.ts +272 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +48 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +161 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +804 -0
- package/src/components/slider/slider.variants.ts +607 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +286 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +672 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,104 @@
|
|
|
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 { useAccordionItemPart, useAccordionPart } from "./accordion.context";
|
|
7
|
+
import { ACCORDION_INDICATOR_ROTATION, ACCORDION_INDICATOR_TOKEN, accordionVariants } from "./accordion.variants";
|
|
8
|
+
|
|
9
|
+
/** What a render-function indicator is handed. */
|
|
10
|
+
export type AccordionIndicatorState = { isExpanded: boolean };
|
|
11
|
+
|
|
12
|
+
export type AccordionIndicatorProps = Omit<ViewProps, "children" | "style"> & {
|
|
13
|
+
/**
|
|
14
|
+
* Turn with the panel. Set `false` for a glyph that *swaps* rather than
|
|
15
|
+
* rotates — a plus becoming a minus reads as broken when it also spins.
|
|
16
|
+
*/
|
|
17
|
+
isAnimated?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
/**
|
|
20
|
+
* A glyph, or a function handed the item's settled state.
|
|
21
|
+
*
|
|
22
|
+
* The function form exists so a swapping glyph needs no component of its own:
|
|
23
|
+
* reading `isExpanded` means calling a hook, and a hook cannot be called in the
|
|
24
|
+
* parent's JSX. `Slider.Track` takes a function for the same reason.
|
|
25
|
+
*
|
|
26
|
+
* It matters more than convenience here. A trigger finds its indicator by
|
|
27
|
+
* element type, so an indicator wrapped in a component of the caller's own is
|
|
28
|
+
* invisible to it and a second, default one is composed in beside it. Handing
|
|
29
|
+
* the state over removes the reason to write that wrapper at all.
|
|
30
|
+
*/
|
|
31
|
+
children?: ReactNode | ((state: AccordionIndicatorState) => ReactNode);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The glyph saying an item opens, turning as it does.
|
|
36
|
+
*
|
|
37
|
+
* Composed into a trigger that holds none, so a bare `<Accordion.Trigger>` is
|
|
38
|
+
* already complete — `Radio`'s rule for its indicator, and the same reasoning: an
|
|
39
|
+
* item has exactly one, so a caller who wrote none wants the default.
|
|
40
|
+
*
|
|
41
|
+
* **The rotation interpolates off the item's own `progress`**, the very value the
|
|
42
|
+
* panel's height reads, so the glyph and the panel are in phase by construction
|
|
43
|
+
* rather than by two springs tuned to match. It also means a custom glyph turns
|
|
44
|
+
* exactly like the default one — pass anything as children and it rotates, unless
|
|
45
|
+
* `isAnimated={false}` says otherwise.
|
|
46
|
+
*
|
|
47
|
+
* The rotation lives on this node rather than on the trigger's because
|
|
48
|
+
* `Pressable`'s own root `Animated.View` already owns `transform` through a
|
|
49
|
+
* `useAnimatedStyle` of its own, and two animated styles on one node fight for
|
|
50
|
+
* the same prop.
|
|
51
|
+
*
|
|
52
|
+
* Its subtree inherits the accordion's glyph step and the muted token, so a
|
|
53
|
+
* composed `<Icon>` needs nothing said at the call site.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* <Accordion.Indicator isAnimated={false}>
|
|
57
|
+
* {({ isExpanded }) => <Icon icon={isExpanded ? IconMinusSmall : IconPlusSmall} />}
|
|
58
|
+
* </Accordion.Indicator>
|
|
59
|
+
*/
|
|
60
|
+
export function AccordionIndicator({
|
|
61
|
+
isAnimated = true,
|
|
62
|
+
className,
|
|
63
|
+
children,
|
|
64
|
+
...props
|
|
65
|
+
}: AccordionIndicatorProps): ReactElement {
|
|
66
|
+
const { size } = useAccordionPart("Accordion.Indicator");
|
|
67
|
+
const { isExpanded, progress } = useAccordionItemPart("Accordion.Indicator");
|
|
68
|
+
|
|
69
|
+
const slots = accordionVariants({ size });
|
|
70
|
+
const glyphClassName = slots.glyph();
|
|
71
|
+
|
|
72
|
+
const rotationStyle = useAnimatedStyle(() => ({
|
|
73
|
+
transform: [
|
|
74
|
+
{
|
|
75
|
+
rotate: `${interpolate(
|
|
76
|
+
progress.value,
|
|
77
|
+
[0, 1],
|
|
78
|
+
[ACCORDION_INDICATOR_ROTATION.collapsed, ACCORDION_INDICATOR_ROTATION.expanded]
|
|
79
|
+
)}deg`,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
const iconDefaults = useMemo<IconDefaults>(
|
|
85
|
+
() => ({ className: glyphClassName, color: ACCORDION_INDICATOR_TOKEN }),
|
|
86
|
+
[glyphClassName]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Animated.View className={slots.indicator({ className })} style={isAnimated ? rotationStyle : undefined} {...props}>
|
|
91
|
+
<IconDefaultsProvider value={iconDefaults}>{renderGlyph(children, isExpanded)}</IconDefaultsProvider>
|
|
92
|
+
</Animated.View>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
AccordionIndicator.displayName = "DelacourUI.Accordion.Indicator";
|
|
96
|
+
|
|
97
|
+
/** The caller's glyph, the state they asked for, or the default chevron. */
|
|
98
|
+
function renderGlyph(
|
|
99
|
+
children: ReactNode | ((state: AccordionIndicatorState) => ReactNode),
|
|
100
|
+
isExpanded: boolean
|
|
101
|
+
): ReactNode {
|
|
102
|
+
if (typeof children === "function") return children({ isExpanded });
|
|
103
|
+
return children ?? <Icon icon={IconChevronBottom} />;
|
|
104
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { cancelAnimation, useSharedValue, withSpring } from "react-native-reanimated";
|
|
4
|
+
import { type AccordionItemContextValue, AccordionItemProvider, useAccordionPart } from "./accordion.context";
|
|
5
|
+
import {
|
|
6
|
+
ACCORDION_SPRING,
|
|
7
|
+
ACCORDION_UNMEASURED,
|
|
8
|
+
accordionVariants,
|
|
9
|
+
isItemExpanded,
|
|
10
|
+
resolveAccordionItemAxes,
|
|
11
|
+
} from "./accordion.variants";
|
|
12
|
+
|
|
13
|
+
export type AccordionItemProps = Omit<ViewProps, "children"> & {
|
|
14
|
+
/** What this item is called in the accordion's value. Must be unique within one. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** Blocks the trigger and fades the row. Inherited from the enclosing accordion. */
|
|
17
|
+
isDisabled?: boolean;
|
|
18
|
+
className?: string;
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One section of an accordion: a trigger, and the panel it discloses.
|
|
24
|
+
*
|
|
25
|
+
* Owns the two shared values every animated style on this item reads —
|
|
26
|
+
* `progress` and the panel's measured height — and the one spring that drives
|
|
27
|
+
* them. One source for the height, the fade and the indicator's rotation, so the
|
|
28
|
+
* three cannot drift out of step by a frame.
|
|
29
|
+
*
|
|
30
|
+
* `progress` is seeded from the settled state rather than from zero, so an item
|
|
31
|
+
* mounted open does not animate itself open on its first paint — `Switch`'s rule.
|
|
32
|
+
*
|
|
33
|
+
* An item is expected to hold an `Accordion.Content`. One without a panel has
|
|
34
|
+
* nothing to measure and therefore nothing to travel against, so its indicator
|
|
35
|
+
* stays put — which is the right outcome for a row that discloses nothing.
|
|
36
|
+
*/
|
|
37
|
+
export function AccordionItem({ value, isDisabled, className, children, ...props }: AccordionItemProps): ReactElement {
|
|
38
|
+
const { expanded, isDisabled: accordionDisabled, size } = useAccordionPart("Accordion.Item");
|
|
39
|
+
const axes = resolveAccordionItemAxes({ own: { isDisabled }, root: { isDisabled: accordionDisabled } });
|
|
40
|
+
const isExpanded = isItemExpanded(expanded, value);
|
|
41
|
+
|
|
42
|
+
const progress = useSharedValue(isExpanded ? 1 : 0);
|
|
43
|
+
const contentHeight = useSharedValue(ACCORDION_UNMEASURED);
|
|
44
|
+
|
|
45
|
+
// Bumped the first time this item's panel reports a height, purely to make the
|
|
46
|
+
// effect below run again. See `onMeasured` on the item context for why the
|
|
47
|
+
// panel must not start the spring itself.
|
|
48
|
+
const [measurements, setMeasurements] = useState(0);
|
|
49
|
+
const onMeasured = useCallback(() => setMeasurements((count) => count + 1), []);
|
|
50
|
+
|
|
51
|
+
// `measurements` is not read in the body — being unread is the whole point of
|
|
52
|
+
// it, the way `settledDrags` is for a `Switch`. The first expand of a panel
|
|
53
|
+
// that has never been mounted has no height to travel against, so this run
|
|
54
|
+
// bails; the measurement that follows is what brings it back.
|
|
55
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: the extra dependency is the re-run trigger, see above
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (isExpanded && contentHeight.value === ACCORDION_UNMEASURED) return;
|
|
58
|
+
|
|
59
|
+
progress.value = withSpring(isExpanded ? 1 : 0, ACCORDION_SPRING);
|
|
60
|
+
|
|
61
|
+
// Without this an item unmounted mid-travel leaves its spring running.
|
|
62
|
+
return () => cancelAnimation(progress);
|
|
63
|
+
}, [contentHeight, isExpanded, measurements, progress]);
|
|
64
|
+
|
|
65
|
+
const context = useMemo<AccordionItemContextValue>(
|
|
66
|
+
() => ({ contentHeight, isDisabled: axes.isDisabled, isExpanded, onMeasured, progress, value }),
|
|
67
|
+
[axes.isDisabled, contentHeight, isExpanded, onMeasured, progress, value]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<AccordionItemProvider value={context}>
|
|
72
|
+
<View className={accordionVariants({ isDisabled: axes.isDisabled, size }).item({ className })} {...props}>
|
|
73
|
+
{children}
|
|
74
|
+
</View>
|
|
75
|
+
</AccordionItemProvider>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
AccordionItem.displayName = "DelacourUI.Accordion.Item";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import { Text } from "../text";
|
|
3
|
+
import { useAccordionPart } from "./accordion.context";
|
|
4
|
+
import type { AccordionTextProps } from "./accordion.types";
|
|
5
|
+
import { accordionVariants } from "./accordion.variants";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The trigger's primary line — what the item is called.
|
|
9
|
+
*
|
|
10
|
+
* Carries its own colour and type scale, read from the accordion's context: a
|
|
11
|
+
* React Native `View` does not cascade colour to a `Text` descendant, so the
|
|
12
|
+
* trigger cannot set it on their behalf.
|
|
13
|
+
*/
|
|
14
|
+
export function AccordionTitle({ className, ...props }: AccordionTextProps): ReactElement {
|
|
15
|
+
const { size } = useAccordionPart("Accordion.Title");
|
|
16
|
+
return <Text className={accordionVariants({ size }).title({ className })} {...props} />;
|
|
17
|
+
}
|
|
18
|
+
AccordionTitle.displayName = "DelacourUI.Accordion.Title";
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Children, isValidElement, type ReactElement, type ReactNode, useCallback, 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 { useAccordionItemPart, useAccordionPart } from "./accordion.context";
|
|
6
|
+
import { ACCORDION_FOREGROUND_TOKEN, accordionVariants } from "./accordion.variants";
|
|
7
|
+
import { AccordionDescription } from "./accordion-description";
|
|
8
|
+
import { AccordionIndicator } from "./accordion-indicator";
|
|
9
|
+
import { AccordionTitle } from "./accordion-title";
|
|
10
|
+
|
|
11
|
+
export type AccordionTriggerProps = Omit<PressableProps, "children" | "disabled" | "onPress"> & {
|
|
12
|
+
className?: string;
|
|
13
|
+
children?: ReactNode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The row that opens and closes an item.
|
|
18
|
+
*
|
|
19
|
+
* A `Pressable`, so `feedback`, `haptic`, `pressedScale` and the rest come from
|
|
20
|
+
* there rather than being restated here. Two defaults differ: `fade`, because a
|
|
21
|
+
* full-bleed row that scales reads as the whole card flexing rather than as one
|
|
22
|
+
* row responding — `ListGroup.Item`'s reason — and `haptic="selection"`, because
|
|
23
|
+
* an item is a state toggle and the panel landing is the confirmation, which is
|
|
24
|
+
* `Checkbox`'s and `Switch`'s. `haptic={false}` silences it.
|
|
25
|
+
*
|
|
26
|
+
* `onPress` is the one prop `Omit`ed rather than forwarded: the press **is** the
|
|
27
|
+
* toggle, and a side effect belongs on the accordion's `onValueChange`. The trade
|
|
28
|
+
* `Checkbox` already makes.
|
|
29
|
+
*
|
|
30
|
+
* It assembles its own row — titles and descriptions stack in a column, an
|
|
31
|
+
* indicator is moved to the end, and one is composed in when the children hold
|
|
32
|
+
* none — so the anatomy needs no wrapper part between a trigger and its text.
|
|
33
|
+
*
|
|
34
|
+
* `accessibilityState.expanded` is the only thing added to what `Pressable`
|
|
35
|
+
* already announces. There is deliberately no separate heading element wrapping
|
|
36
|
+
* it: state set in two places is state a screen reader reads out twice.
|
|
37
|
+
*/
|
|
38
|
+
export function AccordionTrigger({
|
|
39
|
+
feedback = "fade",
|
|
40
|
+
haptic = "selection",
|
|
41
|
+
className,
|
|
42
|
+
children,
|
|
43
|
+
...props
|
|
44
|
+
}: AccordionTriggerProps): ReactElement {
|
|
45
|
+
const { size, toggle } = useAccordionPart("Accordion.Trigger");
|
|
46
|
+
const { isDisabled, isExpanded, value } = useAccordionItemPart("Accordion.Trigger");
|
|
47
|
+
|
|
48
|
+
const slots = accordionVariants({ size });
|
|
49
|
+
const columnClassName = slots.triggerContent();
|
|
50
|
+
const glyphClassName = slots.glyph();
|
|
51
|
+
|
|
52
|
+
const handlePress = useCallback(() => toggle(value), [toggle, value]);
|
|
53
|
+
|
|
54
|
+
const content = useMemo(() => composeRow(children, columnClassName), [children, columnClassName]);
|
|
55
|
+
|
|
56
|
+
const iconDefaults = useMemo<IconDefaults>(
|
|
57
|
+
() => ({ className: glyphClassName, color: ACCORDION_FOREGROUND_TOKEN }),
|
|
58
|
+
[glyphClassName]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Pressable
|
|
63
|
+
accessibilityState={{ expanded: isExpanded }}
|
|
64
|
+
className={slots.trigger({ className })}
|
|
65
|
+
disabled={isDisabled}
|
|
66
|
+
feedback={feedback}
|
|
67
|
+
haptic={haptic}
|
|
68
|
+
onPress={handlePress}
|
|
69
|
+
{...props}
|
|
70
|
+
>
|
|
71
|
+
<IconDefaultsProvider value={iconDefaults}>{content}</IconDefaultsProvider>
|
|
72
|
+
</Pressable>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
AccordionTrigger.displayName = "DelacourUI.Accordion.Trigger";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Arranges a trigger's children into the row the anatomy describes.
|
|
79
|
+
*
|
|
80
|
+
* Three things happen, and each has a call site it saves:
|
|
81
|
+
*
|
|
82
|
+
* - **Bare strings become a title.** React Native crashes on a raw string outside
|
|
83
|
+
* a `<Text>`, so `<Accordion.Trigger>Shipping</Accordion.Trigger>` would red-box.
|
|
84
|
+
* Consecutive strings collapse into one, because `Item {index}` is a single
|
|
85
|
+
* piece of text and wrapping the parts separately would stack them.
|
|
86
|
+
* - **Titles and descriptions stack in a column.** They are the only children that
|
|
87
|
+
* belong on top of each other; anything else — a leading `Icon`, a `Badge` — is
|
|
88
|
+
* left where it was written, as a sibling on the row. That is what makes a
|
|
89
|
+
* `Accordion.TriggerContent` part unnecessary rather than merely absent.
|
|
90
|
+
* - **The indicator goes last, and is composed in when there is none.** So a
|
|
91
|
+
* trigger reads as its content and still ends with the glyph that says it opens.
|
|
92
|
+
*
|
|
93
|
+
* Lives here rather than with the root: it is the trigger that arranges its own
|
|
94
|
+
* children, and importing it from `./accordion` would close a cycle. See AGENTS.md.
|
|
95
|
+
*/
|
|
96
|
+
function composeRow(children: ReactNode, columnClassName: string): ReactNode {
|
|
97
|
+
const items = Children.toArray(children);
|
|
98
|
+
const row: ReactNode[] = [];
|
|
99
|
+
const trailing: ReactNode[] = [];
|
|
100
|
+
let column: ReactNode[] = [];
|
|
101
|
+
let run: (string | number)[] = [];
|
|
102
|
+
let hasIndicator = false;
|
|
103
|
+
|
|
104
|
+
const flushRun = () => {
|
|
105
|
+
if (run.length === 0) return;
|
|
106
|
+
column.push(<AccordionTitle key={`title-${column.length}`}>{run.join("")}</AccordionTitle>);
|
|
107
|
+
run = [];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const flushColumn = () => {
|
|
111
|
+
if (column.length === 0) return;
|
|
112
|
+
row.push(
|
|
113
|
+
<View className={columnClassName} key={`content-${row.length}`}>
|
|
114
|
+
{column}
|
|
115
|
+
</View>
|
|
116
|
+
);
|
|
117
|
+
column = [];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for (const child of items) {
|
|
121
|
+
if (typeof child === "string" || typeof child === "number") {
|
|
122
|
+
run.push(child);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
flushRun();
|
|
126
|
+
|
|
127
|
+
if (isValidElement(child) && child.type === AccordionIndicator) {
|
|
128
|
+
hasIndicator = true;
|
|
129
|
+
trailing.push(child);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (isValidElement(child) && (child.type === AccordionTitle || child.type === AccordionDescription)) {
|
|
134
|
+
column.push(child);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
flushColumn();
|
|
139
|
+
row.push(child);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
flushRun();
|
|
143
|
+
flushColumn();
|
|
144
|
+
|
|
145
|
+
if (!hasIndicator) trailing.push(<AccordionIndicator key="indicator" />);
|
|
146
|
+
|
|
147
|
+
return [...row, ...trailing];
|
|
148
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createContext, type ReactElement, type ReactNode, use } from "react";
|
|
2
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
3
|
+
import type { AccordionSize, AccordionVariant } from "./accordion.variants";
|
|
4
|
+
|
|
5
|
+
export type AccordionContextValue = {
|
|
6
|
+
variant: AccordionVariant;
|
|
7
|
+
size: AccordionSize;
|
|
8
|
+
/** Every item is inert. An item's own `isDisabled` still overrides it, either way. */
|
|
9
|
+
isDisabled: boolean;
|
|
10
|
+
/** Which items are open, on the **JS thread**. */
|
|
11
|
+
expanded: readonly string[];
|
|
12
|
+
/** Opens or closes one item. Stable for the accordion's lifetime. */
|
|
13
|
+
toggle: (value: string) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type AccordionItemContextValue = {
|
|
17
|
+
/** What this item is called in the accordion's expanded set. */
|
|
18
|
+
value: string;
|
|
19
|
+
isDisabled: boolean;
|
|
20
|
+
/** Whether this item is open, on the **JS thread** — what accessibility reads. */
|
|
21
|
+
isExpanded: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* How far open this item is, on the **UI thread**: `0` closed, `1` open.
|
|
24
|
+
*
|
|
25
|
+
* The one value every animated style on this item reads — the panel's height,
|
|
26
|
+
* the panel's opacity and the indicator's rotation. One source rather than
|
|
27
|
+
* three, so they cannot drift out of step by a frame.
|
|
28
|
+
*
|
|
29
|
+
* It agrees with {@link AccordionItemContextValue.isExpanded} at rest and
|
|
30
|
+
* disagrees for the whole of a spring, which is what keeps the animation off
|
|
31
|
+
* the JS thread entirely.
|
|
32
|
+
*/
|
|
33
|
+
progress: SharedValue<number>;
|
|
34
|
+
/**
|
|
35
|
+
* The panel's natural height in points, from its own `onLayout`.
|
|
36
|
+
*
|
|
37
|
+
* Holds {@link ACCORDION_UNMEASURED} until a panel has reported, which is not
|
|
38
|
+
* the same as a panel that measured zero: the item's spring waits on the first
|
|
39
|
+
* measurement rather than travelling against a height that is not there yet.
|
|
40
|
+
*/
|
|
41
|
+
contentHeight: SharedValue<number>;
|
|
42
|
+
/**
|
|
43
|
+
* Told by the panel that it has measured itself for the first time.
|
|
44
|
+
*
|
|
45
|
+
* Internal machinery, and the reason the item is the **only** owner of the
|
|
46
|
+
* spring. The panel cannot start the travel itself: `onLayout` is dispatched
|
|
47
|
+
* from the native side and can land either side of React's effects, so a panel
|
|
48
|
+
* that started its own spring would sometimes have it cancelled a moment later
|
|
49
|
+
* by the cleanup of the effect it raced. This hands the item a reason to re-run
|
|
50
|
+
* instead — `Slider`'s and `Switch`'s `settledDrags`, for the same reason: a
|
|
51
|
+
* counter whose only job is to give an effect something to fire on.
|
|
52
|
+
*/
|
|
53
|
+
onMeasured: () => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const AccordionContext = createContext<AccordionContextValue | null>(null);
|
|
57
|
+
const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Supplies one accordion's axes and its selection to every item beneath it.
|
|
61
|
+
*
|
|
62
|
+
* Lives in its own module, importing nothing but React and types, so a part can
|
|
63
|
+
* read it without importing `./accordion`. That import would close a cycle, and
|
|
64
|
+
* Metro serves a partially initialised module for a cycle — leaving the context
|
|
65
|
+
* `undefined` at import time and red-boxing the app on a cold start.
|
|
66
|
+
*/
|
|
67
|
+
export function AccordionProvider({
|
|
68
|
+
value,
|
|
69
|
+
children,
|
|
70
|
+
}: {
|
|
71
|
+
value: AccordionContextValue;
|
|
72
|
+
children: ReactNode;
|
|
73
|
+
}): ReactElement {
|
|
74
|
+
return <AccordionContext value={value}>{children}</AccordionContext>;
|
|
75
|
+
}
|
|
76
|
+
AccordionProvider.displayName = "DelacourUI.Accordion.Provider";
|
|
77
|
+
|
|
78
|
+
/** Supplies one item's own state and shared values to its trigger, indicator and panel. */
|
|
79
|
+
export function AccordionItemProvider({
|
|
80
|
+
value,
|
|
81
|
+
children,
|
|
82
|
+
}: {
|
|
83
|
+
value: AccordionItemContextValue;
|
|
84
|
+
children: ReactNode;
|
|
85
|
+
}): ReactElement {
|
|
86
|
+
return <AccordionItemContext value={value}>{children}</AccordionItemContext>;
|
|
87
|
+
}
|
|
88
|
+
AccordionItemProvider.displayName = "DelacourUI.Accordion.ItemProvider";
|
|
89
|
+
|
|
90
|
+
/** The enclosing accordion's state, or null outside an `<Accordion>`. */
|
|
91
|
+
export function useAccordionContext(): AccordionContextValue | null {
|
|
92
|
+
return use(AccordionContext);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Reads the enclosing accordion's axes and selection.
|
|
97
|
+
*
|
|
98
|
+
* Lets a custom child style itself to match, or drive the selection, without the
|
|
99
|
+
* accordion having to pass props down through every item. Throws outside an
|
|
100
|
+
* `<Accordion>` — use {@link useAccordionContext} where the enclosing accordion is
|
|
101
|
+
* optional.
|
|
102
|
+
*/
|
|
103
|
+
export function useAccordion(): AccordionContextValue {
|
|
104
|
+
const context = useAccordionContext();
|
|
105
|
+
if (!context) {
|
|
106
|
+
throw new Error("useAccordion must be called inside an <Accordion>.");
|
|
107
|
+
}
|
|
108
|
+
return context;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The enclosing item's state, or null outside an `<Accordion.Item>`. */
|
|
112
|
+
export function useAccordionItemContext(): AccordionItemContextValue | null {
|
|
113
|
+
return use(AccordionItemContext);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reads the enclosing item's state.
|
|
118
|
+
*
|
|
119
|
+
* This is what a custom indicator reaches for: `isExpanded` to swap a glyph, or
|
|
120
|
+
* `progress` to animate against exactly the travel the panel is running. Throws
|
|
121
|
+
* outside an `<Accordion.Item>`.
|
|
122
|
+
*/
|
|
123
|
+
export function useAccordionItem(): AccordionItemContextValue {
|
|
124
|
+
const context = useAccordionItemContext();
|
|
125
|
+
if (!context) {
|
|
126
|
+
throw new Error("useAccordionItem must be called inside an <Accordion.Item>.");
|
|
127
|
+
}
|
|
128
|
+
return context;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The enclosing accordion's state, for a compound part that cannot work without one.
|
|
133
|
+
*
|
|
134
|
+
* Internal: deliberately not re-exported from `index.ts`. A caller outside the
|
|
135
|
+
* library wants {@link useAccordion}, whose error message names the hook rather
|
|
136
|
+
* than a part.
|
|
137
|
+
*/
|
|
138
|
+
export function useAccordionPart(component: string): AccordionContextValue {
|
|
139
|
+
const context = useAccordionContext();
|
|
140
|
+
if (!context) {
|
|
141
|
+
throw new Error(`${component} must be rendered inside an <Accordion>.`);
|
|
142
|
+
}
|
|
143
|
+
return context;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The enclosing item's state, for a compound part that cannot work without one. */
|
|
147
|
+
export function useAccordionItemPart(component: string): AccordionItemContextValue {
|
|
148
|
+
const context = useAccordionItemContext();
|
|
149
|
+
if (!context) {
|
|
150
|
+
throw new Error(`${component} must be rendered inside an <Accordion.Item>.`);
|
|
151
|
+
}
|
|
152
|
+
return context;
|
|
153
|
+
}
|