react-native-terra-ui 0.11.16 → 0.12.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/CHANGELOG.md +27 -0
- package/lib/module/components/accordion/Accordion.js +109 -0
- package/lib/module/components/accordion/Accordion.js.map +1 -0
- package/lib/module/components/accordion/context.js +34 -0
- package/lib/module/components/accordion/context.js.map +1 -0
- package/lib/module/components/accordion/hooks/use-accordion-core.js +46 -0
- package/lib/module/components/accordion/hooks/use-accordion-core.js.map +1 -0
- package/lib/module/components/accordion/index.js +5 -0
- package/lib/module/components/accordion/index.js.map +1 -0
- package/lib/module/components/accordion/parts/AccordionContent.js +98 -0
- package/lib/module/components/accordion/parts/AccordionContent.js.map +1 -0
- package/lib/module/components/accordion/parts/AccordionIndicator.js +60 -0
- package/lib/module/components/accordion/parts/AccordionIndicator.js.map +1 -0
- package/lib/module/components/accordion/parts/AccordionItem.js +53 -0
- package/lib/module/components/accordion/parts/AccordionItem.js.map +1 -0
- package/lib/module/components/accordion/parts/AccordionTrigger.js +85 -0
- package/lib/module/components/accordion/parts/AccordionTrigger.js.map +1 -0
- package/lib/module/components/accordion/types.js +4 -0
- package/lib/module/components/accordion/types.js.map +1 -0
- package/lib/module/components/accordion/utils.js +15 -0
- package/lib/module/components/accordion/utils.js.map +1 -0
- package/lib/module/components/badge/Badge.js +219 -64
- package/lib/module/components/badge/Badge.js.map +1 -1
- package/lib/module/components/badge/WithBadge.js +94 -0
- package/lib/module/components/badge/WithBadge.js.map +1 -0
- package/lib/module/components/badge/index.js +1 -0
- package/lib/module/components/badge/index.js.map +1 -1
- package/lib/module/components/badge/utils.js +3 -3
- package/lib/module/components/header/parts/HeaderBar.js +34 -7
- package/lib/module/components/header/parts/HeaderBar.js.map +1 -1
- package/lib/module/components/index.js +1 -0
- package/lib/module/components/index.js.map +1 -1
- package/lib/module/components/page-indicator/hooks/use-page-indicator-window.js +60 -0
- package/lib/module/components/page-indicator/hooks/use-page-indicator-window.js.map +1 -0
- package/lib/module/components/page-indicator/utils.js +60 -0
- package/lib/module/components/page-indicator/utils.js.map +1 -1
- package/lib/module/components/page-indicator/variants/DotIndicator/index.js +13 -2
- package/lib/module/components/page-indicator/variants/DotIndicator/index.js.map +1 -1
- package/lib/module/components/page-indicator/variants/PillIndicator/index.js +29 -7
- package/lib/module/components/page-indicator/variants/PillIndicator/index.js.map +1 -1
- package/lib/typescript/src/components/accordion/Accordion.d.ts +33 -0
- package/lib/typescript/src/components/accordion/Accordion.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/context.d.ts +25 -0
- package/lib/typescript/src/components/accordion/context.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/hooks/use-accordion-core.d.ts +11 -0
- package/lib/typescript/src/components/accordion/hooks/use-accordion-core.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/index.d.ts +8 -0
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/parts/AccordionContent.d.ts +8 -0
- package/lib/typescript/src/components/accordion/parts/AccordionContent.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/parts/AccordionIndicator.d.ts +16 -0
- package/lib/typescript/src/components/accordion/parts/AccordionIndicator.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/parts/AccordionItem.d.ts +16 -0
- package/lib/typescript/src/components/accordion/parts/AccordionItem.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/parts/AccordionTrigger.d.ts +11 -0
- package/lib/typescript/src/components/accordion/parts/AccordionTrigger.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/types.d.ts +65 -0
- package/lib/typescript/src/components/accordion/types.d.ts.map +1 -0
- package/lib/typescript/src/components/accordion/utils.d.ts +8 -0
- package/lib/typescript/src/components/accordion/utils.d.ts.map +1 -0
- package/lib/typescript/src/components/badge/Badge.d.ts +13 -9
- package/lib/typescript/src/components/badge/Badge.d.ts.map +1 -1
- package/lib/typescript/src/components/badge/WithBadge.d.ts +18 -0
- package/lib/typescript/src/components/badge/WithBadge.d.ts.map +1 -0
- package/lib/typescript/src/components/badge/index.d.ts +2 -1
- package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
- package/lib/typescript/src/components/badge/types.d.ts +35 -12
- package/lib/typescript/src/components/badge/types.d.ts.map +1 -1
- package/lib/typescript/src/components/badge/utils.d.ts +3 -3
- package/lib/typescript/src/components/badge/utils.d.ts.map +1 -1
- package/lib/typescript/src/components/header/parts/HeaderBar.d.ts.map +1 -1
- package/lib/typescript/src/components/index.d.ts +1 -0
- package/lib/typescript/src/components/index.d.ts.map +1 -1
- package/lib/typescript/src/components/page-indicator/hooks/use-page-indicator-window.d.ts +34 -0
- package/lib/typescript/src/components/page-indicator/hooks/use-page-indicator-window.d.ts.map +1 -0
- package/lib/typescript/src/components/page-indicator/utils.d.ts +41 -0
- package/lib/typescript/src/components/page-indicator/utils.d.ts.map +1 -1
- package/lib/typescript/src/components/page-indicator/variants/DotIndicator/index.d.ts.map +1 -1
- package/lib/typescript/src/components/page-indicator/variants/PillIndicator/index.d.ts +1 -1
- package/lib/typescript/src/components/page-indicator/variants/PillIndicator/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/accordion/Accordion.tsx +116 -0
- package/src/components/accordion/context.ts +45 -0
- package/src/components/accordion/hooks/use-accordion-core.ts +51 -0
- package/src/components/accordion/index.ts +14 -0
- package/src/components/accordion/parts/AccordionContent.tsx +96 -0
- package/src/components/accordion/parts/AccordionIndicator.tsx +67 -0
- package/src/components/accordion/parts/AccordionItem.tsx +55 -0
- package/src/components/accordion/parts/AccordionTrigger.tsx +88 -0
- package/src/components/accordion/types.ts +71 -0
- package/src/components/accordion/utils.ts +12 -0
- package/src/components/badge/Badge.tsx +177 -59
- package/src/components/badge/WithBadge.tsx +79 -0
- package/src/components/badge/index.ts +2 -1
- package/src/components/badge/types.ts +36 -12
- package/src/components/badge/utils.ts +3 -3
- package/src/components/header/parts/HeaderBar.tsx +41 -8
- package/src/components/index.ts +1 -0
- package/src/components/page-indicator/hooks/use-page-indicator-window.ts +84 -0
- package/src/components/page-indicator/utils.ts +70 -0
- package/src/components/page-indicator/variants/DotIndicator/index.tsx +12 -1
- package/src/components/page-indicator/variants/PillIndicator/index.tsx +32 -2
- package/lib/module/components/badge/parts/BadgeIndicator.js +0 -212
- package/lib/module/components/badge/parts/BadgeIndicator.js.map +0 -1
- package/lib/typescript/src/components/badge/parts/BadgeIndicator.d.ts +0 -19
- package/lib/typescript/src/components/badge/parts/BadgeIndicator.d.ts.map +0 -1
- package/src/components/badge/parts/BadgeIndicator.tsx +0 -178
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { Children, type ComponentRef, forwardRef, type ReactNode, useEffect, useState } from "react";
|
|
2
|
+
import { type LayoutChangeEvent, View, type ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
|
5
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
6
|
+
import { scheduleOnRN } from "react-native-worklets";
|
|
7
|
+
|
|
8
|
+
import { Text } from "../../text";
|
|
9
|
+
import { useAccordionItem } from "../context";
|
|
10
|
+
import { ACCORDION_TIMING } from "../utils";
|
|
11
|
+
|
|
12
|
+
export interface AccordionContentProps extends ViewProps {
|
|
13
|
+
/** The panel body. Strings and numbers are wrapped in body text. */
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderBody(children: ReactNode): ReactNode {
|
|
18
|
+
return Children.map(children, (child) => {
|
|
19
|
+
if (typeof child === "string") {
|
|
20
|
+
return child.trim().length > 0 ? (
|
|
21
|
+
<Text variant="body-md" color="text.secondary">
|
|
22
|
+
{child}
|
|
23
|
+
</Text>
|
|
24
|
+
) : null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof child === "number") {
|
|
27
|
+
return (
|
|
28
|
+
<Text variant="body-md" color="text.secondary">
|
|
29
|
+
{child}
|
|
30
|
+
</Text>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return child;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const AccordionContent = forwardRef<ComponentRef<typeof View>, AccordionContentProps>(
|
|
38
|
+
function AccordionContent({ children, style, onLayout: onLayoutProp, ...rest }, ref) {
|
|
39
|
+
const { isExpanded } = useAccordionItem();
|
|
40
|
+
|
|
41
|
+
// The body stays mounted while open and while the close animation runs,
|
|
42
|
+
// then unmounts so a long list of closed items costs nothing.
|
|
43
|
+
const [isMounted, setIsMounted] = useState(isExpanded);
|
|
44
|
+
const progress = useSharedValue(isExpanded ? 1 : 0);
|
|
45
|
+
// The body's natural height, read from its own layout. 0 until the first
|
|
46
|
+
// measurement lands.
|
|
47
|
+
const measured = useSharedValue(0);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (isExpanded) setIsMounted(true);
|
|
51
|
+
progress.value = withTiming(isExpanded ? 1 : 0, ACCORDION_TIMING, (finished) => {
|
|
52
|
+
if (finished && !isExpanded) scheduleOnRN(setIsMounted, false);
|
|
53
|
+
});
|
|
54
|
+
}, [isExpanded, progress]);
|
|
55
|
+
|
|
56
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
57
|
+
measured.value = event.nativeEvent.layout.height;
|
|
58
|
+
onLayoutProp?.(event);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const clip = useAnimatedStyle(() => {
|
|
62
|
+
// Before the first measurement an item that starts open takes its auto
|
|
63
|
+
// height, and one that is just opening holds at 0 for the frame it takes
|
|
64
|
+
// to measure.
|
|
65
|
+
if (measured.value === 0) {
|
|
66
|
+
return { height: progress.value === 1 ? undefined : 0, opacity: progress.value };
|
|
67
|
+
}
|
|
68
|
+
return { height: measured.value * progress.value, opacity: progress.value };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!isMounted) return null;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Animated.View style={[styles.clip, clip]}>
|
|
75
|
+
{/* Not absolutely positioned: RN children do not shrink by default, so
|
|
76
|
+
this keeps its natural height inside the clipping parent and its
|
|
77
|
+
layout reports the height to animate towards. */}
|
|
78
|
+
<View ref={ref} {...rest} onLayout={onLayout} style={[styles.body, style]}>
|
|
79
|
+
{renderBody(children)}
|
|
80
|
+
</View>
|
|
81
|
+
</Animated.View>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const styles = StyleSheet.create((theme) => ({
|
|
89
|
+
clip: {
|
|
90
|
+
overflow: "hidden",
|
|
91
|
+
},
|
|
92
|
+
body: {
|
|
93
|
+
gap: theme.spacing["2"],
|
|
94
|
+
paddingBottom: theme.spacing["4"],
|
|
95
|
+
},
|
|
96
|
+
}));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type ComponentRef, forwardRef, type ReactNode, useEffect } from "react";
|
|
2
|
+
import type { View, ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
|
5
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
6
|
+
|
|
7
|
+
import type { ColorToken } from "#theme/types";
|
|
8
|
+
import { Icon } from "../../icon";
|
|
9
|
+
import { useAccordionItem } from "../context";
|
|
10
|
+
import { ACCORDION_TIMING } from "../utils";
|
|
11
|
+
|
|
12
|
+
export interface AccordionIndicatorProps extends Omit<ViewProps, "children"> {
|
|
13
|
+
/** Chevron color. Ignored with custom `children`. Defaults to `'text.secondary'`. */
|
|
14
|
+
color?: ColorToken;
|
|
15
|
+
/** Chevron size in dp. Ignored with custom `children`. Defaults to `20`. */
|
|
16
|
+
size?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Replaces the chevron. Custom content does not rotate — read `isExpanded`
|
|
19
|
+
* from the item's render function or `useAccordionItem()` to swap it instead.
|
|
20
|
+
*/
|
|
21
|
+
children?: ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const AccordionIndicator = forwardRef<ComponentRef<typeof View>, AccordionIndicatorProps>(
|
|
25
|
+
function AccordionIndicator({ color = "text.secondary", size = 20, children, style, ...rest }, ref) {
|
|
26
|
+
const { isExpanded } = useAccordionItem();
|
|
27
|
+
// Seeded from the current state so an item that starts open shows its
|
|
28
|
+
// chevron already turned, with no animation on mount.
|
|
29
|
+
const progress = useSharedValue(isExpanded ? 1 : 0);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
progress.value = withTiming(isExpanded ? 1 : 0, ACCORDION_TIMING);
|
|
33
|
+
}, [isExpanded, progress]);
|
|
34
|
+
|
|
35
|
+
// `terra.chevron-right` is the chevron the library ships, so it is turned a
|
|
36
|
+
// quarter to point down when closed and a further half to point up when open.
|
|
37
|
+
const rotation = useAnimatedStyle(() => ({
|
|
38
|
+
transform: [{ rotate: `${90 + progress.value * 180}deg` }],
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
const hasCustomContent = children !== undefined && children !== null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Animated.View
|
|
45
|
+
ref={ref}
|
|
46
|
+
accessibilityElementsHidden
|
|
47
|
+
importantForAccessibility="no-hide-descendants"
|
|
48
|
+
style={[styles.indicator, hasCustomContent ? null : rotation, style]}
|
|
49
|
+
{...rest}
|
|
50
|
+
>
|
|
51
|
+
{hasCustomContent ? children : <Icon name="terra.chevron-right" color={color} size={size} />}
|
|
52
|
+
</Animated.View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
const styles = StyleSheet.create(() => ({
|
|
60
|
+
// Pushes the indicator to the row's end even when the title is a custom node
|
|
61
|
+
// that does not take the free space itself.
|
|
62
|
+
indicator: {
|
|
63
|
+
marginStart: "auto",
|
|
64
|
+
alignItems: "center",
|
|
65
|
+
justifyContent: "center",
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type ComponentRef, forwardRef, type ReactNode, useCallback, useMemo } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
5
|
+
|
|
6
|
+
import { AccordionItemContext, useAccordionContext } from "../context";
|
|
7
|
+
import type { AccordionItemState } from "../types";
|
|
8
|
+
|
|
9
|
+
export interface AccordionItemProps extends Omit<ViewProps, "children"> {
|
|
10
|
+
/** Identifies the item in the accordion's `value`. Unique within one accordion. */
|
|
11
|
+
value: string;
|
|
12
|
+
/** Disables this item only. Defaults to `false`. */
|
|
13
|
+
isDisabled?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The item's trigger and content, or a function that receives the item's
|
|
16
|
+
* `{ isExpanded, isDisabled }` and returns them.
|
|
17
|
+
*/
|
|
18
|
+
children?: ReactNode | ((state: AccordionItemState) => ReactNode);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const AccordionItem = forwardRef<ComponentRef<typeof View>, AccordionItemProps>(function AccordionItem(
|
|
22
|
+
{ value, isDisabled: isDisabledProp = false, children, style, ...rest },
|
|
23
|
+
ref
|
|
24
|
+
) {
|
|
25
|
+
const { expanded, toggle: toggleValue, isDisabled: isAccordionDisabled } = useAccordionContext();
|
|
26
|
+
|
|
27
|
+
const isExpanded = expanded.includes(value);
|
|
28
|
+
const isDisabled = isAccordionDisabled || isDisabledProp;
|
|
29
|
+
|
|
30
|
+
const toggle = useCallback(() => {
|
|
31
|
+
if (!isDisabled) toggleValue(value);
|
|
32
|
+
}, [isDisabled, toggleValue, value]);
|
|
33
|
+
|
|
34
|
+
const context = useMemo(
|
|
35
|
+
() => ({ value, isExpanded, isDisabled, toggle }),
|
|
36
|
+
[value, isExpanded, isDisabled, toggle]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<AccordionItemContext.Provider value={context}>
|
|
41
|
+
<View ref={ref} style={[styles.item, style]} {...rest}>
|
|
42
|
+
{typeof children === "function" ? children({ isExpanded, isDisabled }) : children}
|
|
43
|
+
</View>
|
|
44
|
+
</AccordionItemContext.Provider>
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
// Present so the Unistyles Babel plugin rewrites the `View` above; a themed
|
|
51
|
+
// style passed in through `style` would otherwise freeze at the theme it was
|
|
52
|
+
// created under.
|
|
53
|
+
const styles = StyleSheet.create(() => ({
|
|
54
|
+
item: {},
|
|
55
|
+
}));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Children, type ComponentRef, forwardRef, type ReactNode } from "react";
|
|
2
|
+
import { type GestureResponderEvent, Pressable, type PressableProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { StyleSheet } from "react-native-unistyles";
|
|
5
|
+
|
|
6
|
+
import { Text } from "../../text";
|
|
7
|
+
import { useAccordionItem } from "../context";
|
|
8
|
+
|
|
9
|
+
export interface AccordionTriggerProps extends Omit<PressableProps, "children" | "disabled"> {
|
|
10
|
+
/**
|
|
11
|
+
* The header row. Strings and numbers become the item's title; put an
|
|
12
|
+
* `Accordion.Indicator` last to show the open state.
|
|
13
|
+
*/
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function renderTitle(children: ReactNode): ReactNode {
|
|
18
|
+
return Children.map(children, (child) => {
|
|
19
|
+
if (typeof child === "string") {
|
|
20
|
+
return child.trim().length > 0 ? (
|
|
21
|
+
<Text variant="body-lg" weight="medium" style={styles.title}>
|
|
22
|
+
{child}
|
|
23
|
+
</Text>
|
|
24
|
+
) : null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof child === "number") {
|
|
27
|
+
return (
|
|
28
|
+
<Text variant="body-lg" weight="medium" style={styles.title}>
|
|
29
|
+
{child}
|
|
30
|
+
</Text>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return child;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const AccordionTrigger = forwardRef<ComponentRef<typeof Pressable>, AccordionTriggerProps>(
|
|
38
|
+
function AccordionTrigger({ children, onPress, style, ...rest }, ref) {
|
|
39
|
+
const { isExpanded, isDisabled, toggle } = useAccordionItem();
|
|
40
|
+
|
|
41
|
+
const handlePress = (event: GestureResponderEvent) => {
|
|
42
|
+
onPress?.(event);
|
|
43
|
+
toggle();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Pressable
|
|
48
|
+
ref={ref}
|
|
49
|
+
accessibilityRole="button"
|
|
50
|
+
accessibilityState={{ expanded: isExpanded, disabled: isDisabled }}
|
|
51
|
+
disabled={isDisabled}
|
|
52
|
+
onPress={handlePress}
|
|
53
|
+
style={(state) => [
|
|
54
|
+
styles.trigger,
|
|
55
|
+
state.pressed && styles.pressed,
|
|
56
|
+
isDisabled && styles.disabled,
|
|
57
|
+
typeof style === "function" ? style(state) : style,
|
|
58
|
+
]}
|
|
59
|
+
{...rest}
|
|
60
|
+
>
|
|
61
|
+
{renderTitle(children)}
|
|
62
|
+
</Pressable>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const styles = StyleSheet.create((theme) => ({
|
|
70
|
+
trigger: {
|
|
71
|
+
flexDirection: "row",
|
|
72
|
+
alignItems: "center",
|
|
73
|
+
gap: theme.spacing["3"],
|
|
74
|
+
minHeight: 48,
|
|
75
|
+
paddingVertical: theme.spacing["4"],
|
|
76
|
+
},
|
|
77
|
+
pressed: {
|
|
78
|
+
opacity: theme.opacity.pressed,
|
|
79
|
+
},
|
|
80
|
+
disabled: {
|
|
81
|
+
opacity: theme.opacity.disabled,
|
|
82
|
+
},
|
|
83
|
+
// Takes the free space so an indicator after it sits at the row's end, and
|
|
84
|
+
// wraps a long title instead of pushing the indicator off.
|
|
85
|
+
title: {
|
|
86
|
+
flex: 1,
|
|
87
|
+
},
|
|
88
|
+
}));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ViewProps } from "react-native";
|
|
3
|
+
|
|
4
|
+
export type AccordionSelectionMode = "single" | "multiple";
|
|
5
|
+
|
|
6
|
+
export type AccordionVariant = "plain" | "surface";
|
|
7
|
+
|
|
8
|
+
interface AccordionBaseProps extends Omit<ViewProps, "children"> {
|
|
9
|
+
/**
|
|
10
|
+
* Visual treatment. `'plain'` draws the items straight onto whatever sits
|
|
11
|
+
* behind the accordion; `'surface'` puts them in a filled, rounded container.
|
|
12
|
+
* Defaults to `'plain'`.
|
|
13
|
+
*/
|
|
14
|
+
variant?: AccordionVariant;
|
|
15
|
+
/** Draws a hairline rule between items. Defaults to `true`. */
|
|
16
|
+
divided?: boolean;
|
|
17
|
+
/** Disables every item. Defaults to `false`. */
|
|
18
|
+
isDisabled?: boolean;
|
|
19
|
+
/** `Accordion.Item` elements. */
|
|
20
|
+
children?: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AccordionSingleProps extends AccordionBaseProps {
|
|
24
|
+
/**
|
|
25
|
+
* `'single'` keeps at most one item open; opening another closes the current
|
|
26
|
+
* one. Defaults to `'single'`.
|
|
27
|
+
*/
|
|
28
|
+
selectionMode?: "single";
|
|
29
|
+
/**
|
|
30
|
+
* The open item, controlled. `null` means none is open — `undefined` leaves
|
|
31
|
+
* the accordion uncontrolled.
|
|
32
|
+
*/
|
|
33
|
+
value?: string | null;
|
|
34
|
+
/** The item open on first render, uncontrolled. Defaults to `undefined`. */
|
|
35
|
+
defaultValue?: string;
|
|
36
|
+
/** Called with the newly open item, or `null` when the open item closes. */
|
|
37
|
+
onValueChange?: (value: string | null) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Lets the open item close by pressing its trigger again. `false` keeps one
|
|
40
|
+
* item open at all times once any has been opened. Defaults to `true`.
|
|
41
|
+
*/
|
|
42
|
+
isCollapsible?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AccordionMultipleProps extends AccordionBaseProps {
|
|
46
|
+
/** `'multiple'` lets any number of items be open at once. */
|
|
47
|
+
selectionMode: "multiple";
|
|
48
|
+
/** The open items, controlled. */
|
|
49
|
+
value?: string[];
|
|
50
|
+
/** The items open on first render, uncontrolled. Defaults to `[]`. */
|
|
51
|
+
defaultValue?: string[];
|
|
52
|
+
/** Called with every open item after one is toggled. */
|
|
53
|
+
onValueChange?: (value: string[]) => void;
|
|
54
|
+
/** Multiple mode can always close an item. */
|
|
55
|
+
isCollapsible?: never;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type AccordionProps = AccordionSingleProps | AccordionMultipleProps;
|
|
59
|
+
|
|
60
|
+
export interface AccordionItemState {
|
|
61
|
+
/** Whether the item's content is open. */
|
|
62
|
+
isExpanded: boolean;
|
|
63
|
+
/** Whether the item, or the whole accordion, is disabled. */
|
|
64
|
+
isDisabled: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AccordionItemContextValue extends AccordionItemState {
|
|
68
|
+
value: string;
|
|
69
|
+
/** Opens or closes this item, following the accordion's selection rules. */
|
|
70
|
+
toggle: () => void;
|
|
71
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Easing, ReduceMotion, type WithTimingConfig } from "react-native-reanimated";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared by the content's height and the indicator's rotation so the chevron
|
|
5
|
+
* finishes turning as the panel finishes opening. Honours the system
|
|
6
|
+
* reduce-motion setting, which makes both jump straight to their end state.
|
|
7
|
+
*/
|
|
8
|
+
export const ACCORDION_TIMING: WithTimingConfig = {
|
|
9
|
+
duration: 250,
|
|
10
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
|
|
11
|
+
reduceMotion: ReduceMotion.System,
|
|
12
|
+
};
|
|
@@ -1,78 +1,196 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { View } from "react-native";
|
|
1
|
+
import { Text as RNText, View } from "react-native";
|
|
3
2
|
|
|
4
|
-
import { StyleSheet } from "react-native-unistyles";
|
|
3
|
+
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
5
4
|
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
5
|
+
import type { ColorToken, RoleTextVariant } from "#theme/types";
|
|
6
|
+
import { resolveThemeColor } from "#utils/resolve-theme-color";
|
|
7
|
+
import { FONT_WEIGHT_VALUE, SYSTEM_FONT } from "#utils/typography";
|
|
8
|
+
import type { BadgeProps, BadgeSize } from "./types";
|
|
9
|
+
import { getCustomBadgeColors, hasLabel } from "./utils";
|
|
10
|
+
|
|
11
|
+
const SCHEME_NAMES = new Set(["primary", "secondary", "success", "warning", "danger"]);
|
|
12
|
+
|
|
13
|
+
type BadgeScheme = "primary" | "secondary" | "success" | "warning" | "danger";
|
|
14
|
+
|
|
15
|
+
/** Label foreground per scheme — mirrors the spec's token table. */
|
|
16
|
+
const FG_TOKEN: Record<BadgeScheme, ColorToken> = {
|
|
17
|
+
primary: "accent.solid.fg",
|
|
18
|
+
secondary: "neutral.solid.fg",
|
|
19
|
+
success: "success.solid.fg",
|
|
20
|
+
warning: "warning.solid.fg",
|
|
21
|
+
danger: "danger.solid.fg",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const LABEL_VARIANT: Record<BadgeSize, RoleTextVariant> = {
|
|
25
|
+
sm: "label-sm",
|
|
26
|
+
md: "label-md",
|
|
27
|
+
lg: "label-lg",
|
|
28
|
+
};
|
|
9
29
|
|
|
10
30
|
/**
|
|
11
|
-
*
|
|
31
|
+
* A dot, or a pill carrying a count or short label.
|
|
12
32
|
*
|
|
13
33
|
* ```tsx
|
|
14
|
-
* <
|
|
15
|
-
*
|
|
16
|
-
* </Badge>
|
|
34
|
+
* <Text>Appearing</Text>
|
|
35
|
+
* <Badge color="secondary" size="sm" label={56} />
|
|
17
36
|
* ```
|
|
18
37
|
*
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
21
|
-
*
|
|
38
|
+
* Stands on its own, inline in a row beside the text it counts. To pin one to
|
|
39
|
+
* the corner of an icon or button, wrap that element in `WithBadge`, which
|
|
40
|
+
* renders this and owns only the position.
|
|
41
|
+
*
|
|
42
|
+
* `useUnistyles` is called unconditionally, which is what subscribes this
|
|
43
|
+
* component to theme changes. The background below comes from a variant, which
|
|
44
|
+
* unistyles applies straight to the native node on a theme switch; without a
|
|
45
|
+
* re-render React would go on believing the node holds the previous theme's
|
|
46
|
+
* value. `Chip` documents the same hazard on its root.
|
|
22
47
|
*/
|
|
23
|
-
const Badge =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
48
|
+
export const Badge = ({
|
|
49
|
+
label,
|
|
50
|
+
color = "danger",
|
|
51
|
+
size = "md",
|
|
52
|
+
ring = false,
|
|
53
|
+
accessibilityLabel,
|
|
54
|
+
style,
|
|
55
|
+
...rest
|
|
56
|
+
}: BadgeProps) => {
|
|
57
|
+
const { theme } = useUnistyles();
|
|
58
|
+
// A literal never matches one of the five variant options, so it's cast to
|
|
59
|
+
// satisfy `useVariants`'s literal-union type while still being compared
|
|
60
|
+
// against the real value below.
|
|
61
|
+
styles.useVariants({ color: color as BadgeScheme, size, ring });
|
|
62
|
+
|
|
63
|
+
const custom = SCHEME_NAMES.has(color) ? null : getCustomBadgeColors(color);
|
|
64
|
+
const labelled = hasLabel(label);
|
|
65
|
+
const foreground = custom ? custom.fg : FG_TOKEN[color as BadgeScheme];
|
|
66
|
+
|
|
67
|
+
// An explicit label wins even on a dot; a dot with nothing to announce is
|
|
68
|
+
// decorative and hidden rather than read out as an unlabelled element.
|
|
69
|
+
const announced = accessibilityLabel ?? (labelled ? String(label) : undefined);
|
|
70
|
+
const accessibility = announced
|
|
71
|
+
? { accessible: true, accessibilityLabel: announced }
|
|
72
|
+
: {
|
|
73
|
+
accessibilityElementsHidden: true,
|
|
74
|
+
importantForAccessibility: "no-hide-descendants" as const,
|
|
75
|
+
"aria-hidden": true,
|
|
76
|
+
};
|
|
40
77
|
|
|
41
78
|
return (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
79
|
+
<View
|
|
80
|
+
style={[
|
|
81
|
+
styles.indicator,
|
|
82
|
+
labelled ? styles.pill : styles.dot,
|
|
83
|
+
custom ? { backgroundColor: custom.bg } : null,
|
|
84
|
+
style,
|
|
85
|
+
]}
|
|
86
|
+
{...rest}
|
|
87
|
+
{...accessibility}
|
|
88
|
+
>
|
|
89
|
+
{labelled ? (
|
|
90
|
+
<RNText
|
|
91
|
+
style={[styles.label, { color: resolveThemeColor(foreground, theme) }]}
|
|
92
|
+
maxFontSizeMultiplier={theme.typography.variants[LABEL_VARIANT[size]].maxFontSizeMultiplier}
|
|
93
|
+
numberOfLines={1}
|
|
94
|
+
>
|
|
95
|
+
{label}
|
|
96
|
+
</RNText>
|
|
49
97
|
) : null}
|
|
50
98
|
</View>
|
|
51
99
|
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export { Badge };
|
|
100
|
+
};
|
|
55
101
|
|
|
56
102
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
57
103
|
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
104
|
+
// `color` is a plain variant rather than a compound one: `Badge` renders solid
|
|
105
|
+
// only, so there is no second paint axis for it to interact with.
|
|
106
|
+
//
|
|
107
|
+
// React Native's box model is border-box, so the ring below eats into the
|
|
108
|
+
// badge rather than growing it — the dot's outer diameter stays exactly what
|
|
109
|
+
// `size` declares, with no compensation arithmetic, ring or not.
|
|
110
|
+
const styles = StyleSheet.create((theme) => {
|
|
111
|
+
const c = theme.color;
|
|
112
|
+
// A custom font encodes its weight in the family name, so only the system
|
|
113
|
+
// font takes a numeric `fontWeight` — the same branch `Chip` makes. Inlined
|
|
114
|
+
// rather than extracted: the unistyles Babel plugin rewrites this factory,
|
|
115
|
+
// and an imported function called from inside it is not in scope at runtime.
|
|
116
|
+
const labelFont = theme.typography.fonts.semibold;
|
|
117
|
+
const isSystemLabelFont = labelFont === SYSTEM_FONT;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
indicator: {
|
|
121
|
+
alignItems: "center",
|
|
122
|
+
justifyContent: "center",
|
|
123
|
+
borderRadius: theme.radius.full,
|
|
124
|
+
borderColor: c["surface.canvas"],
|
|
125
|
+
variants: {
|
|
126
|
+
// No styles of its own; it only has to exist for the compound rows
|
|
127
|
+
// below to match on. The ring's width depends on size as well.
|
|
128
|
+
size: { sm: {}, md: {}, lg: {} },
|
|
129
|
+
ring: {
|
|
130
|
+
true: {},
|
|
131
|
+
false: { borderWidth: 0 },
|
|
132
|
+
},
|
|
133
|
+
color: {
|
|
134
|
+
primary: { backgroundColor: c["accent.solid.bg"] },
|
|
135
|
+
secondary: { backgroundColor: c["neutral.solid.bg"] },
|
|
136
|
+
success: { backgroundColor: c["success.solid.bg"] },
|
|
137
|
+
warning: { backgroundColor: c["warning.solid.bg"] },
|
|
138
|
+
danger: { backgroundColor: c["danger.solid.bg"] },
|
|
139
|
+
},
|
|
72
140
|
},
|
|
141
|
+
compoundVariants: [
|
|
142
|
+
{ ring: true, size: "sm", styles: { borderWidth: 1.5 } },
|
|
143
|
+
{ ring: true, size: "md", styles: { borderWidth: 2 } },
|
|
144
|
+
{ ring: true, size: "lg", styles: { borderWidth: 2 } },
|
|
145
|
+
],
|
|
73
146
|
},
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
147
|
+
// These diameters are mirrored by `DOT_SIZE` in `./utils.ts`, which the
|
|
148
|
+
// anchor arithmetic reads. The Babel plugin needs them literal here, so a
|
|
149
|
+
// test asserts the two stay equal.
|
|
150
|
+
dot: {
|
|
151
|
+
variants: {
|
|
152
|
+
size: {
|
|
153
|
+
sm: { width: 12, height: 12 },
|
|
154
|
+
md: { width: 16, height: 16 },
|
|
155
|
+
lg: { width: 20, height: 20 },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
pill: {
|
|
160
|
+
variants: {
|
|
161
|
+
size: {
|
|
162
|
+
sm: { height: 18, minWidth: 18, paddingHorizontal: 5 },
|
|
163
|
+
md: { height: 22, minWidth: 22, paddingHorizontal: 6 },
|
|
164
|
+
lg: { height: 26, minWidth: 26, paddingHorizontal: 8 },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
// Spelled out rather than derived from `LABEL_VARIANT`: the unistyles Babel
|
|
169
|
+
// plugin reads this object statically, so computed variant keys are not
|
|
170
|
+
// safe here even though they would resolve correctly under test.
|
|
171
|
+
label: {
|
|
172
|
+
textAlign: "center",
|
|
173
|
+
fontFamily: isSystemLabelFont ? undefined : labelFont,
|
|
174
|
+
fontWeight: isSystemLabelFont ? FONT_WEIGHT_VALUE.semibold : undefined,
|
|
175
|
+
variants: {
|
|
176
|
+
size: {
|
|
177
|
+
sm: {
|
|
178
|
+
fontSize: theme.typography.variants["label-sm"].fontSize,
|
|
179
|
+
lineHeight: theme.typography.variants["label-sm"].lineHeight,
|
|
180
|
+
letterSpacing: theme.typography.variants["label-sm"].letterSpacing,
|
|
181
|
+
},
|
|
182
|
+
md: {
|
|
183
|
+
fontSize: theme.typography.variants["label-md"].fontSize,
|
|
184
|
+
lineHeight: theme.typography.variants["label-md"].lineHeight,
|
|
185
|
+
letterSpacing: theme.typography.variants["label-md"].letterSpacing,
|
|
186
|
+
},
|
|
187
|
+
lg: {
|
|
188
|
+
fontSize: theme.typography.variants["label-lg"].fontSize,
|
|
189
|
+
lineHeight: theme.typography.variants["label-lg"].lineHeight,
|
|
190
|
+
letterSpacing: theme.typography.variants["label-lg"].letterSpacing,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
});
|