panelui-native 0.68.0 → 0.71.0
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 +12 -2
- package/lib/module/components/accordion/index.js +28 -9
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/button/index.js +14 -7
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/collapsible/index.js +280 -0
- package/lib/module/components/collapsible/index.js.map +1 -0
- package/lib/module/components/flow/index.js +66 -3
- package/lib/module/components/flow/index.js.map +1 -1
- package/lib/module/components/live-line-chart/index.js +103 -9
- package/lib/module/components/live-line-chart/index.js.map +1 -1
- package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
- package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
- package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
- package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
- package/lib/module/components/marquee/index.js +194 -0
- package/lib/module/components/marquee/index.js.map +1 -0
- package/lib/module/components/meter/index.js +38 -20
- package/lib/module/components/meter/index.js.map +1 -1
- package/lib/module/components/meter/meter-scale.js +62 -8
- package/lib/module/components/meter/meter-scale.js.map +1 -1
- package/lib/module/components/planner/index.js +133 -59
- package/lib/module/components/planner/index.js.map +1 -1
- package/lib/module/components/planner/planner-announcement.js +30 -0
- package/lib/module/components/planner/planner-announcement.js.map +1 -0
- package/lib/module/components/planner/planner-entries.js +15 -7
- package/lib/module/components/planner/planner-entries.js.map +1 -1
- package/lib/module/components/planner/planner-grid-navigation.js +33 -0
- package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
- package/lib/module/components/planner/planner-lifecycle.js +45 -0
- package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
- package/lib/module/components/popover/index.js +2 -2
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/focus-restoration-store.js +52 -0
- package/lib/module/primitives/focus-restoration-store.js.map +1 -0
- package/lib/module/primitives/portal.js +36 -2
- package/lib/module/primitives/portal.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +4 -0
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/collapsible/index.d.ts +76 -0
- package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/index.d.ts +7 -1
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
- package/lib/typescript/src/components/live-line-chart/index.d.ts +21 -7
- package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
- package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
- package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/components/marquee/index.d.ts +73 -0
- package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
- package/lib/typescript/src/components/meter/index.d.ts +7 -4
- package/lib/typescript/src/components/meter/index.d.ts.map +1 -1
- package/lib/typescript/src/components/meter/meter-scale.d.ts +39 -0
- package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-announcement.d.ts +15 -0
- package/lib/typescript/src/components/planner/planner-announcement.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-entries.d.ts +1 -1
- package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +4 -0
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
- package/lib/typescript/src/components/planner/planner-lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/focus-restoration-store.d.ts +24 -0
- package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
- package/lib/typescript/src/primitives/portal.d.ts +4 -0
- package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
- package/package.json +36 -3
- package/src/components/accordion/index.tsx +31 -8
- package/src/components/button/index.tsx +11 -4
- package/src/components/collapsible/index.tsx +278 -0
- package/src/components/flow/index.tsx +93 -14
- package/src/components/live-line-chart/index.tsx +130 -13
- package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
- package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
- package/src/components/marquee/index.tsx +232 -0
- package/src/components/meter/index.tsx +49 -26
- package/src/components/meter/meter-scale.ts +84 -8
- package/src/components/planner/index.tsx +182 -71
- package/src/components/planner/planner-announcement.ts +37 -0
- package/src/components/planner/planner-entries.ts +16 -7
- package/src/components/planner/planner-grid-navigation.ts +45 -0
- package/src/components/planner/planner-lifecycle.ts +76 -0
- package/src/components/popover/index.tsx +3 -3
- package/src/index.ts +13 -0
- package/src/primitives/focus-restoration-store.ts +70 -0
- package/src/primitives/portal.tsx +34 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface FocusDocument {
|
|
2
|
+
activeElement?: FocusTarget | null;
|
|
3
|
+
body?: FocusTarget | null;
|
|
4
|
+
documentElement?: FocusTarget | null;
|
|
5
|
+
}
|
|
6
|
+
interface FocusTarget {
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
isConnected?: boolean;
|
|
9
|
+
focus: (options?: {
|
|
10
|
+
preventScroll?: boolean;
|
|
11
|
+
}) => void;
|
|
12
|
+
getAttribute?: (name: string) => string | null;
|
|
13
|
+
}
|
|
14
|
+
type RestoreFocus = () => boolean;
|
|
15
|
+
/** Restores nested overlays in last-opened order, even when parents close first. */
|
|
16
|
+
export declare class FocusRestorationStore {
|
|
17
|
+
private owners;
|
|
18
|
+
acquire(owner: string, restore: RestoreFocus): (() => void);
|
|
19
|
+
private restoreReleasedOwners;
|
|
20
|
+
}
|
|
21
|
+
/** Captures the current browser focus without evaluating DOM APIs on native. */
|
|
22
|
+
export declare function captureFocusRestore(document?: FocusDocument | undefined): RestoreFocus;
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=focus-restoration-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"focus-restoration-store.d.ts","sourceRoot":"","sources":["../../../../src/primitives/focus-restoration-store.ts"],"names":[],"mappings":"AAAA,UAAU,aAAa;IACrB,aAAa,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IACnC,IAAI,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1B,eAAe,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;CACtC;AAED,UAAU,WAAW;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,KAAK,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC;IACvD,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CAChD;AAED,KAAK,YAAY,GAAG,MAAM,OAAO,CAAC;AAQlC,oFAAoF;AACpF,qBAAa,qBAAqB;IAChC,OAAO,CAAC,MAAM,CAAoB;IAElC,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,CAAC,MAAM,IAAI,CAAC;IAW3D,OAAO,CAAC,qBAAqB;CAQ9B;AAED,gFAAgF;AAChF,wBAAgB,mBAAmB,CACjC,QAAQ,4BAAwD,GAC/D,YAAY,CAoBd"}
|
|
@@ -10,6 +10,10 @@ export declare function Portal({ children }: {
|
|
|
10
10
|
export declare function ModalPortal({ children }: {
|
|
11
11
|
children: ReactNode;
|
|
12
12
|
}): import("react").JSX.Element;
|
|
13
|
+
/** A non-isolating portal that returns browser focus to what opened it. */
|
|
14
|
+
export declare function FocusRestorePortal({ children }: {
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
13
17
|
/** Whether any modal portal currently owns focus. */
|
|
14
18
|
export declare function useModalIsolationActive(): boolean;
|
|
15
19
|
/** Mount point for portaled content. PanelUIProvider renders one automatically. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../../src/primitives/portal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"portal.d.ts","sourceRoot":"","sources":["../../../../src/primitives/portal.tsx"],"names":[],"mappings":"AAAA,OAAO,EAOL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAsCf,wBAAgB,cAAc,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAgBnE;AAUD,4EAA4E;AAC5E,wBAAgB,MAAM,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,QAU3D;AAED,wDAAwD;AACxD,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAehE;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,EAAE,QAAQ,EAAE,EAAE;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,+BAOvE;AAED,qDAAqD;AACrD,wBAAgB,uBAAuB,IAAI,OAAO,CAGjD;AAkBD,mFAAmF;AACnF,wBAAgB,UAAU,gCAWzB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "panelui-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.71.0",
|
|
4
4
|
"description": "High-performance React Native UI components for Expo. Semantic design tokens, powered by Uniwind (Tailwind v4) and Reanimated.",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"module": "./lib/module/index.js",
|
|
@@ -22,6 +22,36 @@
|
|
|
22
22
|
"react-native": "./src/hooks/*.ts",
|
|
23
23
|
"default": "./lib/module/hooks/*.js"
|
|
24
24
|
},
|
|
25
|
+
"./provider": {
|
|
26
|
+
"types": "./lib/typescript/src/providers/panel-ui-provider.d.ts",
|
|
27
|
+
"react-native": "./src/providers/panel-ui-provider.tsx",
|
|
28
|
+
"default": "./lib/module/providers/panel-ui-provider.js"
|
|
29
|
+
},
|
|
30
|
+
"./theme": {
|
|
31
|
+
"types": "./lib/typescript/src/theme/use-theme.d.ts",
|
|
32
|
+
"react-native": "./src/theme/use-theme.ts",
|
|
33
|
+
"default": "./lib/module/theme/use-theme.js"
|
|
34
|
+
},
|
|
35
|
+
"./primitives/animated-pressable": {
|
|
36
|
+
"types": "./lib/typescript/src/primitives/animated-pressable.d.ts",
|
|
37
|
+
"react-native": "./src/primitives/animated-pressable.tsx",
|
|
38
|
+
"default": "./lib/module/primitives/animated-pressable.js"
|
|
39
|
+
},
|
|
40
|
+
"./primitives/keyboard-avoider": {
|
|
41
|
+
"types": "./lib/typescript/src/primitives/keyboard-avoider.d.ts",
|
|
42
|
+
"react-native": "./src/primitives/keyboard-avoider.tsx",
|
|
43
|
+
"default": "./lib/module/primitives/keyboard-avoider.js"
|
|
44
|
+
},
|
|
45
|
+
"./primitives/scrim": {
|
|
46
|
+
"types": "./lib/typescript/src/primitives/scrim.d.ts",
|
|
47
|
+
"react-native": "./src/primitives/scrim.tsx",
|
|
48
|
+
"default": "./lib/module/primitives/scrim.js"
|
|
49
|
+
},
|
|
50
|
+
"./primitives/scroll-progress": {
|
|
51
|
+
"types": "./lib/typescript/src/primitives/scroll-progress.d.ts",
|
|
52
|
+
"react-native": "./src/primitives/scroll-progress.tsx",
|
|
53
|
+
"default": "./lib/module/primitives/scroll-progress.js"
|
|
54
|
+
},
|
|
25
55
|
"./utils/cn": {
|
|
26
56
|
"types": "./lib/typescript/src/utils/cn.d.ts",
|
|
27
57
|
"react-native": "./src/utils/cn.ts",
|
|
@@ -52,12 +82,15 @@
|
|
|
52
82
|
"sideEffects": false,
|
|
53
83
|
"scripts": {
|
|
54
84
|
"build": "npm run clean && bob build",
|
|
55
|
-
"prepack": "npm run build && npm run verify:package",
|
|
85
|
+
"prepack": "npm run check:subpaths && npm run build && npm run verify:package",
|
|
56
86
|
"test": "node --experimental-strip-types --test test/*.test.mjs",
|
|
57
87
|
"benchmark:bar-chart": "node scripts/benchmark-bar-chart.mjs",
|
|
88
|
+
"benchmark:planner": "node --experimental-strip-types scripts/benchmark-planner.mjs",
|
|
58
89
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.type-tests.json",
|
|
59
90
|
"clean": "rm -rf lib",
|
|
60
|
-
"verify:package": "node scripts/verify-package.mjs"
|
|
91
|
+
"verify:package": "node scripts/verify-package.mjs",
|
|
92
|
+
"generate:subpaths": "node scripts/generate-subpath-exports.mjs --write",
|
|
93
|
+
"check:subpaths": "node scripts/generate-subpath-exports.mjs"
|
|
61
94
|
},
|
|
62
95
|
"keywords": [
|
|
63
96
|
"react-native",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* also changes the shape of `value`: a string when single, an array when
|
|
11
11
|
* multiple.
|
|
12
12
|
*
|
|
13
|
+
* With the operating system set to reduce motion the section arrives at its new
|
|
14
|
+
* height and the chevron at its new angle without travelling to either. The
|
|
15
|
+
* disclosure is the point and it still happens; the movement is what goes.
|
|
16
|
+
*
|
|
13
17
|
* A closed section costs nothing, because its body is unmounted. That is the
|
|
14
18
|
* right default and the wrong one for a body with state in it: a half-filled
|
|
15
19
|
* form, a list scrolled to the middle, a video part-way through. Collapsing
|
|
@@ -25,6 +29,7 @@ import {
|
|
|
25
29
|
isValidElement,
|
|
26
30
|
useCallback,
|
|
27
31
|
useContext,
|
|
32
|
+
useEffect,
|
|
28
33
|
useMemo,
|
|
29
34
|
useState,
|
|
30
35
|
type ReactNode,
|
|
@@ -33,7 +38,8 @@ import { Pressable, View, type Text as RNText, type ViewProps } from 'react-nati
|
|
|
33
38
|
import Animated, {
|
|
34
39
|
LinearTransition,
|
|
35
40
|
useAnimatedStyle,
|
|
36
|
-
|
|
41
|
+
useReducedMotion,
|
|
42
|
+
useSharedValue,
|
|
37
43
|
withTiming,
|
|
38
44
|
} from 'react-native-reanimated';
|
|
39
45
|
import { tv } from 'tailwind-variants';
|
|
@@ -49,6 +55,12 @@ export type AccordionVariant =
|
|
|
49
55
|
|
|
50
56
|
export type AccordionSelectionMode = 'single' | 'multiple';
|
|
51
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How long the height change and the chevron take. One constant for both, so
|
|
60
|
+
* the panel and the arrow that describes it finish together.
|
|
61
|
+
*/
|
|
62
|
+
const TRANSITION_DURATION = 200;
|
|
63
|
+
|
|
52
64
|
const accordionVariants = tv({
|
|
53
65
|
slots: {
|
|
54
66
|
root: 'w-full flex-col overflow-hidden',
|
|
@@ -162,6 +174,7 @@ const AccordionRoot = forwardRef<View, AccordionProps>(
|
|
|
162
174
|
},
|
|
163
175
|
ref
|
|
164
176
|
) => {
|
|
177
|
+
const reducedMotion = useReducedMotion();
|
|
165
178
|
const [internal, setInternal] = useState<string[]>(() => toArray(defaultValue));
|
|
166
179
|
const isControlled = value !== undefined;
|
|
167
180
|
const expanded = isControlled ? toArray(value) : internal;
|
|
@@ -197,7 +210,10 @@ const AccordionRoot = forwardRef<View, AccordionProps>(
|
|
|
197
210
|
<AccordionContext.Provider value={context}>
|
|
198
211
|
<Animated.View
|
|
199
212
|
ref={ref}
|
|
200
|
-
layout
|
|
213
|
+
// Dropping the layout animation is what honours Reduce Motion here:
|
|
214
|
+
// the section still opens, it just arrives at its new height rather
|
|
215
|
+
// than travelling to it.
|
|
216
|
+
layout={reducedMotion ? undefined : LinearTransition.duration(TRANSITION_DURATION)}
|
|
201
217
|
className={root({ className })}
|
|
202
218
|
{...props}
|
|
203
219
|
>
|
|
@@ -227,6 +243,7 @@ export interface AccordionItemProps extends ViewProps {
|
|
|
227
243
|
const AccordionItem = forwardRef<View, AccordionItemProps>(
|
|
228
244
|
({ className, value, isDisabled = false, children, ...props }, ref) => {
|
|
229
245
|
const { expanded, variant } = useAccordion('Accordion.Item');
|
|
246
|
+
const reducedMotion = useReducedMotion();
|
|
230
247
|
const isExpanded = expanded.includes(value);
|
|
231
248
|
const { item } = accordionVariants({ variant });
|
|
232
249
|
|
|
@@ -239,7 +256,7 @@ const AccordionItem = forwardRef<View, AccordionItemProps>(
|
|
|
239
256
|
<AccordionItemContext.Provider value={context}>
|
|
240
257
|
<Animated.View
|
|
241
258
|
ref={ref}
|
|
242
|
-
layout={LinearTransition.duration(
|
|
259
|
+
layout={reducedMotion ? undefined : LinearTransition.duration(TRANSITION_DURATION)}
|
|
243
260
|
className={item({ className })}
|
|
244
261
|
{...props}
|
|
245
262
|
>
|
|
@@ -294,13 +311,19 @@ const AccordionIndicator = forwardRef<View, AccordionIndicatorProps>(
|
|
|
294
311
|
const { variant } = useAccordion('Accordion.Indicator');
|
|
295
312
|
const { isExpanded } = useAccordionItem('Accordion.Indicator');
|
|
296
313
|
const { indicator } = accordionVariants({ variant });
|
|
314
|
+
const reducedMotion = useReducedMotion();
|
|
315
|
+
const progress = useSharedValue(isExpanded ? 1 : 0);
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
progress.value = reducedMotion
|
|
319
|
+
? isExpanded
|
|
320
|
+
? 1
|
|
321
|
+
: 0
|
|
322
|
+
: withTiming(isExpanded ? 1 : 0, { duration: TRANSITION_DURATION });
|
|
323
|
+
}, [isExpanded, reducedMotion, progress]);
|
|
297
324
|
|
|
298
|
-
const rotation = useDerivedValue(
|
|
299
|
-
() => withTiming(isExpanded ? 180 : 0, { duration: 200 }),
|
|
300
|
-
[isExpanded]
|
|
301
|
-
);
|
|
302
325
|
const style = useAnimatedStyle(() => ({
|
|
303
|
-
transform: [{ rotate: `${
|
|
326
|
+
transform: [{ rotate: `${progress.value * 180}deg` }],
|
|
304
327
|
}));
|
|
305
328
|
|
|
306
329
|
return (
|
|
@@ -15,7 +15,10 @@ import { Spinner } from '../spinner';
|
|
|
15
15
|
const buttonVariants = tv({
|
|
16
16
|
slots: {
|
|
17
17
|
root: 'flex-row items-center justify-center gap-2 rounded-lg border border-transparent',
|
|
18
|
-
label
|
|
18
|
+
// Let a constrained label wrap, and centre every line of an action name.
|
|
19
|
+
// An explicit Tailwind text step would also impose a fixed line height,
|
|
20
|
+
// which can be shorter than the glyphs after Dynamic Type scales them.
|
|
21
|
+
label: 'min-w-0 shrink text-center font-medium',
|
|
19
22
|
spinner: '',
|
|
20
23
|
},
|
|
21
24
|
variants: {
|
|
@@ -54,9 +57,13 @@ const buttonVariants = tv({
|
|
|
54
57
|
},
|
|
55
58
|
},
|
|
56
59
|
size: {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
// These padding + intrinsic-font pairs equal the old 36/44/48dp boxes
|
|
61
|
+
// at the default scale. `min-h-*`, rather than `h-*`, lets the same box
|
|
62
|
+
// grow when the label's system-scaled glyphs or wrapped lines need it.
|
|
63
|
+
sm: { root: 'min-h-9 min-w-9 gap-1.5 px-2.5 py-2', label: 'text-[14px]' },
|
|
64
|
+
md: { root: 'min-h-11 min-w-11 px-4 py-2.5', label: 'text-[16px]' },
|
|
65
|
+
lg: { root: 'min-h-12 px-6 py-2.5', label: 'text-[18px]' },
|
|
66
|
+
// Icon-only controls have no scalable label, so their square is stable.
|
|
60
67
|
icon: { root: 'h-11 w-11 px-0' },
|
|
61
68
|
},
|
|
62
69
|
fullWidth: {
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapsible — one section of content that a header shows and hides.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <Collapsible variant="surface">
|
|
6
|
+
* <Collapsible.Trigger>
|
|
7
|
+
* Delivery options
|
|
8
|
+
* <Collapsible.Indicator />
|
|
9
|
+
* </Collapsible.Trigger>
|
|
10
|
+
* <Collapsible.Content>
|
|
11
|
+
* <Text>Anything, of any height.</Text>
|
|
12
|
+
* </Collapsible.Content>
|
|
13
|
+
* </Collapsible>
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* ## Why this and not a one-item Accordion
|
|
17
|
+
*
|
|
18
|
+
* The two look the same and differ in what happens to the body when it closes.
|
|
19
|
+
* An accordion unmounts it, which is right when a closed section should cost
|
|
20
|
+
* nothing and there is nothing inside worth keeping.
|
|
21
|
+
*
|
|
22
|
+
* This one keeps the body mounted and animates its height, so what is inside
|
|
23
|
+
* survives being closed — a part-filled form still has what was typed into it,
|
|
24
|
+
* a list is still scrolled to where it was, a video is still part-way through.
|
|
25
|
+
* That is the case this component exists for; reach for `Accordion` when
|
|
26
|
+
* sections are mutually exclusive or the body is expensive and rarely opened.
|
|
27
|
+
*
|
|
28
|
+
* ## The body is mounted whether or not it is open
|
|
29
|
+
*
|
|
30
|
+
* A height cannot be animated from `auto`, so the content has to be measured
|
|
31
|
+
* before it can be travelled to, and measuring it means rendering it. There is
|
|
32
|
+
* no lazy version of that: a body that has never been opened has still been
|
|
33
|
+
* rendered once. For a body heavy enough that this matters, an `Accordion` with
|
|
34
|
+
* a single item unmounts it instead.
|
|
35
|
+
*
|
|
36
|
+
* ## Reduced motion
|
|
37
|
+
*
|
|
38
|
+
* With the operating system set to reduce motion the panel snaps between its
|
|
39
|
+
* two states and the chevron turns without travelling. The disclosure still
|
|
40
|
+
* happens — it is the movement that is dropped, not the change.
|
|
41
|
+
*/
|
|
42
|
+
import {
|
|
43
|
+
createContext,
|
|
44
|
+
forwardRef,
|
|
45
|
+
useCallback,
|
|
46
|
+
useContext,
|
|
47
|
+
useEffect,
|
|
48
|
+
useMemo,
|
|
49
|
+
useState,
|
|
50
|
+
type ReactNode,
|
|
51
|
+
} from 'react';
|
|
52
|
+
import { Pressable, View, type Text as RNText, type ViewProps } from 'react-native';
|
|
53
|
+
import Animated, {
|
|
54
|
+
useAnimatedStyle,
|
|
55
|
+
useReducedMotion,
|
|
56
|
+
useSharedValue,
|
|
57
|
+
withTiming,
|
|
58
|
+
} from 'react-native-reanimated';
|
|
59
|
+
import { tv } from 'tailwind-variants';
|
|
60
|
+
import { ChevronDownIcon } from '../../icons';
|
|
61
|
+
import { Collapse } from '../../primitives/collapse';
|
|
62
|
+
import { Text, type TextProps, textChildren } from '../../primitives/text';
|
|
63
|
+
|
|
64
|
+
export type CollapsibleVariant = 'default' | 'surface' | 'ghost';
|
|
65
|
+
|
|
66
|
+
/** Matches the chevron in `Accordion`, so the two read as the same control. */
|
|
67
|
+
const INDICATOR_DURATION = 200;
|
|
68
|
+
|
|
69
|
+
const collapsibleVariants = tv({
|
|
70
|
+
slots: {
|
|
71
|
+
root: 'w-full flex-col',
|
|
72
|
+
trigger: 'flex-row items-center justify-between gap-4 py-4',
|
|
73
|
+
title: 'flex-1 text-base font-medium text-foreground',
|
|
74
|
+
indicator: 'items-center justify-center',
|
|
75
|
+
content: 'pb-4',
|
|
76
|
+
contentText: 'text-sm text-muted-foreground',
|
|
77
|
+
},
|
|
78
|
+
variants: {
|
|
79
|
+
variant: {
|
|
80
|
+
default: { trigger: 'px-1', content: 'px-1' },
|
|
81
|
+
surface: {
|
|
82
|
+
root: 'overflow-hidden rounded-2xl border border-border bg-surface',
|
|
83
|
+
trigger: 'px-4',
|
|
84
|
+
content: 'px-4',
|
|
85
|
+
},
|
|
86
|
+
ghost: { trigger: 'px-0', content: 'px-0' },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
defaultVariants: {
|
|
90
|
+
variant: 'default',
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
interface CollapsibleContextValue {
|
|
95
|
+
open: boolean;
|
|
96
|
+
toggle: () => void;
|
|
97
|
+
isDisabled: boolean;
|
|
98
|
+
variant: CollapsibleVariant;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const CollapsibleContext = createContext<CollapsibleContextValue | null>(null);
|
|
102
|
+
|
|
103
|
+
function useCollapsible(component: string): CollapsibleContextValue {
|
|
104
|
+
const context = useContext(CollapsibleContext);
|
|
105
|
+
if (!context) throw new Error(`${component} must be used within a <Collapsible>`);
|
|
106
|
+
return context;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface CollapsibleProps extends ViewProps {
|
|
110
|
+
className?: string;
|
|
111
|
+
variant?: CollapsibleVariant;
|
|
112
|
+
/** Whether the body is showing, controlled. */
|
|
113
|
+
open?: boolean;
|
|
114
|
+
defaultOpen?: boolean;
|
|
115
|
+
onOpenChange?: (open: boolean) => void;
|
|
116
|
+
/** Stops the trigger opening or closing it, and marks it disabled to a screen reader. */
|
|
117
|
+
isDisabled?: boolean;
|
|
118
|
+
children?: ReactNode;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const CollapsibleRoot = forwardRef<View, CollapsibleProps>(
|
|
122
|
+
(
|
|
123
|
+
{
|
|
124
|
+
className,
|
|
125
|
+
variant = 'default',
|
|
126
|
+
open,
|
|
127
|
+
defaultOpen = false,
|
|
128
|
+
onOpenChange,
|
|
129
|
+
isDisabled = false,
|
|
130
|
+
children,
|
|
131
|
+
...props
|
|
132
|
+
},
|
|
133
|
+
ref
|
|
134
|
+
) => {
|
|
135
|
+
const [internal, setInternal] = useState(defaultOpen);
|
|
136
|
+
const isControlled = open !== undefined;
|
|
137
|
+
const isOpen = isControlled ? open : internal;
|
|
138
|
+
|
|
139
|
+
const toggle = useCallback(() => {
|
|
140
|
+
const next = !isOpen;
|
|
141
|
+
if (!isControlled) setInternal(next);
|
|
142
|
+
onOpenChange?.(next);
|
|
143
|
+
}, [isOpen, isControlled, onOpenChange]);
|
|
144
|
+
|
|
145
|
+
const context = useMemo(
|
|
146
|
+
() => ({ open: isOpen, toggle, isDisabled, variant }),
|
|
147
|
+
[isOpen, toggle, isDisabled, variant]
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const { root } = collapsibleVariants({ variant });
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<CollapsibleContext.Provider value={context}>
|
|
154
|
+
<View ref={ref} className={root({ className })} {...props}>
|
|
155
|
+
{children}
|
|
156
|
+
</View>
|
|
157
|
+
</CollapsibleContext.Provider>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
CollapsibleRoot.displayName = 'Collapsible';
|
|
162
|
+
|
|
163
|
+
export interface CollapsibleTriggerProps extends ViewProps {
|
|
164
|
+
className?: string;
|
|
165
|
+
children?: ReactNode;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The pressable header row. Bare strings are wrapped in the title style. */
|
|
169
|
+
const CollapsibleTrigger = forwardRef<View, CollapsibleTriggerProps>(
|
|
170
|
+
({ className, children, ...props }, ref) => {
|
|
171
|
+
const { open, toggle, isDisabled, variant } = useCollapsible('Collapsible.Trigger');
|
|
172
|
+
const { trigger, title } = collapsibleVariants({ variant });
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<Pressable
|
|
176
|
+
ref={ref}
|
|
177
|
+
accessibilityRole="button"
|
|
178
|
+
accessibilityState={{ expanded: open, disabled: isDisabled }}
|
|
179
|
+
disabled={isDisabled}
|
|
180
|
+
onPress={toggle}
|
|
181
|
+
className={trigger({ className })}
|
|
182
|
+
{...props}
|
|
183
|
+
>
|
|
184
|
+
{textChildren(children, (text) => (
|
|
185
|
+
<Text className={title()}>{text}</Text>
|
|
186
|
+
))}
|
|
187
|
+
</Pressable>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
);
|
|
191
|
+
CollapsibleTrigger.displayName = 'Collapsible.Trigger';
|
|
192
|
+
|
|
193
|
+
const CollapsibleTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
|
|
194
|
+
const { variant } = useCollapsible('Collapsible.Title');
|
|
195
|
+
const { title } = collapsibleVariants({ variant });
|
|
196
|
+
return <Text ref={ref} className={title({ className })} {...props} />;
|
|
197
|
+
});
|
|
198
|
+
CollapsibleTitle.displayName = 'Collapsible.Title';
|
|
199
|
+
|
|
200
|
+
export interface CollapsibleIndicatorProps extends ViewProps {
|
|
201
|
+
className?: string;
|
|
202
|
+
/** Replaces the default chevron. */
|
|
203
|
+
children?: ReactNode;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Chevron that rotates 180° while the body is open. */
|
|
207
|
+
const CollapsibleIndicator = forwardRef<View, CollapsibleIndicatorProps>(
|
|
208
|
+
({ className, children, ...props }, ref) => {
|
|
209
|
+
const { open, variant } = useCollapsible('Collapsible.Indicator');
|
|
210
|
+
const { indicator } = collapsibleVariants({ variant });
|
|
211
|
+
const reducedMotion = useReducedMotion();
|
|
212
|
+
const progress = useSharedValue(open ? 1 : 0);
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
progress.value = reducedMotion
|
|
216
|
+
? open
|
|
217
|
+
? 1
|
|
218
|
+
: 0
|
|
219
|
+
: withTiming(open ? 1 : 0, { duration: INDICATOR_DURATION });
|
|
220
|
+
}, [open, reducedMotion, progress]);
|
|
221
|
+
|
|
222
|
+
const style = useAnimatedStyle(() => ({
|
|
223
|
+
transform: [{ rotate: `${progress.value * 180}deg` }],
|
|
224
|
+
}));
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<Animated.View style={style}>
|
|
228
|
+
<View ref={ref} className={indicator({ className })} {...props}>
|
|
229
|
+
{children ?? <ChevronDownIcon size={18} />}
|
|
230
|
+
</View>
|
|
231
|
+
</Animated.View>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
CollapsibleIndicator.displayName = 'Collapsible.Indicator';
|
|
236
|
+
|
|
237
|
+
export interface CollapsibleContentProps extends ViewProps {
|
|
238
|
+
className?: string;
|
|
239
|
+
children?: ReactNode;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The body, opened and closed by animating its own height.
|
|
244
|
+
*
|
|
245
|
+
* It stays mounted while closed, which is the point of the component — see the
|
|
246
|
+
* note at the top of the file. A closed body is hidden from the accessibility
|
|
247
|
+
* tree as well as from the eye, because a screen reader reading out a section
|
|
248
|
+
* that is not on screen is worse than one that skips it.
|
|
249
|
+
*/
|
|
250
|
+
const CollapsibleContent = forwardRef<View, CollapsibleContentProps>(
|
|
251
|
+
({ className, children, ...props }, ref) => {
|
|
252
|
+
const { open, variant } = useCollapsible('Collapsible.Content');
|
|
253
|
+
const { content, contentText } = collapsibleVariants({ variant });
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<Collapse open={open} className={content({ className })}>
|
|
257
|
+
<View
|
|
258
|
+
ref={ref}
|
|
259
|
+
accessibilityElementsHidden={!open}
|
|
260
|
+
importantForAccessibility={open ? 'auto' : 'no-hide-descendants'}
|
|
261
|
+
{...props}
|
|
262
|
+
>
|
|
263
|
+
{textChildren(children, (text) => (
|
|
264
|
+
<Text className={contentText()}>{text}</Text>
|
|
265
|
+
))}
|
|
266
|
+
</View>
|
|
267
|
+
</Collapse>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
);
|
|
271
|
+
CollapsibleContent.displayName = 'Collapsible.Content';
|
|
272
|
+
|
|
273
|
+
export const Collapsible = Object.assign(CollapsibleRoot, {
|
|
274
|
+
Trigger: CollapsibleTrigger,
|
|
275
|
+
Title: CollapsibleTitle,
|
|
276
|
+
Indicator: CollapsibleIndicator,
|
|
277
|
+
Content: CollapsibleContent,
|
|
278
|
+
});
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
* the edges are ordinary elements and cost a render per drag frame. That is a
|
|
32
32
|
* real cost and it is the right trade.
|
|
33
33
|
*
|
|
34
|
+
* An `animated` edge keeps that arrangement and animates the one property the
|
|
35
|
+
* geometry does not own: the dash offset. Its `d` still arrives by re-render,
|
|
36
|
+
* so a dragged node reshapes the edge while the dashes keep marching.
|
|
37
|
+
*
|
|
34
38
|
* JavaScript is told a drag has finished through `onNodeDragEnd`. Positions
|
|
35
39
|
* are otherwise yours to leave alone: pass `position` once and the canvas
|
|
36
40
|
* takes it from there, or keep it in state and pass it back to drive nodes
|
|
@@ -65,10 +69,12 @@ import {
|
|
|
65
69
|
import { useCSSVariable } from 'uniwind';
|
|
66
70
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
67
71
|
import Animated, {
|
|
72
|
+
cancelAnimation,
|
|
68
73
|
runOnJS,
|
|
69
74
|
useAnimatedProps,
|
|
70
75
|
useAnimatedStyle,
|
|
71
76
|
useDerivedValue,
|
|
77
|
+
useReducedMotion,
|
|
72
78
|
useSharedValue,
|
|
73
79
|
withRepeat,
|
|
74
80
|
withTiming,
|
|
@@ -159,6 +165,15 @@ const CONNECT_RADIUS = 44;
|
|
|
159
165
|
*/
|
|
160
166
|
const GROUP_EDGE_STANDOFF = 5;
|
|
161
167
|
|
|
168
|
+
/** Dash and gap length for a broken edge, in graph points. */
|
|
169
|
+
const EDGE_DASH = 6;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* How long an animated edge takes to travel one dash-and-gap. Slow enough to
|
|
173
|
+
* read as flow rather than flicker, fast enough to look live.
|
|
174
|
+
*/
|
|
175
|
+
const EDGE_MARCH_DURATION = 600;
|
|
176
|
+
|
|
162
177
|
export type FlowEdgeVariant = 'bezier' | 'smoothstep' | 'step' | 'straight';
|
|
163
178
|
|
|
164
179
|
export interface FlowViewport {
|
|
@@ -1496,7 +1511,9 @@ export interface FlowEdgeProps {
|
|
|
1496
1511
|
variant?: FlowEdgeVariant;
|
|
1497
1512
|
/**
|
|
1498
1513
|
* Mark the edge as carrying something — a request, a build, a dependency
|
|
1499
|
-
* that is live rather than declared. Draws it dashed
|
|
1514
|
+
* that is live rather than declared. Draws it dashed and marches the dashes
|
|
1515
|
+
* from source to target. Falls back to a still dashed edge when the
|
|
1516
|
+
* operating system is set to reduce motion.
|
|
1500
1517
|
*/
|
|
1501
1518
|
animated?: boolean;
|
|
1502
1519
|
/** Draw it broken rather than solid. */
|
|
@@ -1551,6 +1568,63 @@ function FlowEdge(props: FlowEdgeProps) {
|
|
|
1551
1568
|
return null;
|
|
1552
1569
|
}
|
|
1553
1570
|
|
|
1571
|
+
/**
|
|
1572
|
+
* A dashed edge whose dashes travel from source to target.
|
|
1573
|
+
*
|
|
1574
|
+
* The dash offset is the only animated property here, and the geometry is
|
|
1575
|
+
* deliberately not one: `d` arrives as an ordinary prop and changes by
|
|
1576
|
+
* re-render when a node moves. A path in React Native draws reliably when one
|
|
1577
|
+
* property animates and the rest are plain, so splitting them this way lets a
|
|
1578
|
+
* dragged node reshape the edge without ever interrupting the march.
|
|
1579
|
+
*
|
|
1580
|
+
* One dash and one gap of travel per cycle, which is why the loop is seamless:
|
|
1581
|
+
* the end state is the start state shifted by exactly one period.
|
|
1582
|
+
*/
|
|
1583
|
+
function FlowMarchingEdge({
|
|
1584
|
+
d,
|
|
1585
|
+
stroke,
|
|
1586
|
+
width,
|
|
1587
|
+
}: {
|
|
1588
|
+
d: string;
|
|
1589
|
+
stroke: string;
|
|
1590
|
+
width: number;
|
|
1591
|
+
}) {
|
|
1592
|
+
const offset = useSharedValue(0);
|
|
1593
|
+
const reducedMotion = useReducedMotion();
|
|
1594
|
+
|
|
1595
|
+
useEffect(() => {
|
|
1596
|
+
if (reducedMotion) {
|
|
1597
|
+
offset.value = 0;
|
|
1598
|
+
return undefined;
|
|
1599
|
+
}
|
|
1600
|
+
// Negative, so the dashes run the way the path was drawn: source to target.
|
|
1601
|
+
offset.value = withRepeat(
|
|
1602
|
+
withTiming(-EDGE_DASH * 2, {
|
|
1603
|
+
duration: EDGE_MARCH_DURATION,
|
|
1604
|
+
easing: Easing.linear,
|
|
1605
|
+
}),
|
|
1606
|
+
-1,
|
|
1607
|
+
false
|
|
1608
|
+
);
|
|
1609
|
+
return () => cancelAnimation(offset);
|
|
1610
|
+
}, [offset, reducedMotion]);
|
|
1611
|
+
|
|
1612
|
+
const dashProps = useAnimatedProps(() => ({ strokeDashoffset: offset.value }));
|
|
1613
|
+
|
|
1614
|
+
return (
|
|
1615
|
+
<AnimatedPath
|
|
1616
|
+
animatedProps={dashProps}
|
|
1617
|
+
d={d}
|
|
1618
|
+
fill="none"
|
|
1619
|
+
stroke={stroke}
|
|
1620
|
+
strokeWidth={width}
|
|
1621
|
+
strokeLinecap="round"
|
|
1622
|
+
strokeLinejoin="round"
|
|
1623
|
+
strokeDasharray={`${EDGE_DASH} ${EDGE_DASH}`}
|
|
1624
|
+
/>
|
|
1625
|
+
);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1554
1628
|
/** The part that actually draws an edge, inside the canvas's one `<Svg>`. */
|
|
1555
1629
|
function FlowEdgePath({
|
|
1556
1630
|
from,
|
|
@@ -1612,7 +1686,7 @@ function FlowEdgePath({
|
|
|
1612
1686
|
const b = { x: outB.x + origin.x, y: outB.y + origin.y };
|
|
1613
1687
|
|
|
1614
1688
|
const d = edgePath(variant, a, sideA, b, sideB, curvature, radius, gap);
|
|
1615
|
-
const dash = dashed || animated ?
|
|
1689
|
+
const dash = dashed || animated ? EDGE_DASH : 0;
|
|
1616
1690
|
const stroke = color ?? tint;
|
|
1617
1691
|
|
|
1618
1692
|
const head = arrow
|
|
@@ -1624,15 +1698,19 @@ function FlowEdgePath({
|
|
|
1624
1698
|
|
|
1625
1699
|
return (
|
|
1626
1700
|
<>
|
|
1627
|
-
|
|
1628
|
-
d={d}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1701
|
+
{animated ? (
|
|
1702
|
+
<FlowMarchingEdge d={d} stroke={stroke} width={width} />
|
|
1703
|
+
) : (
|
|
1704
|
+
<Path
|
|
1705
|
+
d={d}
|
|
1706
|
+
fill="none"
|
|
1707
|
+
stroke={stroke}
|
|
1708
|
+
strokeWidth={width}
|
|
1709
|
+
strokeLinecap="round"
|
|
1710
|
+
strokeLinejoin="round"
|
|
1711
|
+
strokeDasharray={dash ? `${dash} ${dash}` : undefined}
|
|
1712
|
+
/>
|
|
1713
|
+
)}
|
|
1636
1714
|
{head ? <Path d={head} fill={stroke} /> : null}
|
|
1637
1715
|
</>
|
|
1638
1716
|
);
|
|
@@ -1703,9 +1781,10 @@ function FlowEdgeLayer() {
|
|
|
1703
1781
|
origin={origin}
|
|
1704
1782
|
/>
|
|
1705
1783
|
))}
|
|
1706
|
-
{/* The connection line is the
|
|
1707
|
-
|
|
1708
|
-
|
|
1784
|
+
{/* The connection line follows a finger, so it is the path that animates
|
|
1785
|
+
`d` — and only `d`, which is the one thing that works while the
|
|
1786
|
+
geometry is what moves. An animated edge inverts the split: static
|
|
1787
|
+
`d`, animated dash offset. Either is fine; both at once is not. */}
|
|
1709
1788
|
<AnimatedPath
|
|
1710
1789
|
animatedProps={lineProps}
|
|
1711
1790
|
fill="none"
|