@isoline-ui/react-native 0.0.0-stage → 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/LICENSE +22 -0
- package/NOTICE.md +12 -0
- package/README.md +104 -3
- package/dist/amount.d.ts +3 -0
- package/dist/calendar.d.ts +7 -0
- package/dist/components/action-chip.d.ts +9 -0
- package/dist/components/amount-field.d.ts +12 -0
- package/dist/components/avatar.d.ts +17 -0
- package/dist/components/back-button.d.ts +12 -0
- package/dist/components/back-icon.d.ts +3 -0
- package/dist/components/badge.d.ts +13 -0
- package/dist/components/banner.d.ts +14 -0
- package/dist/components/bottom-sheet-native.d.ts +11 -0
- package/dist/components/bottom-sheet-web.d.ts +18 -0
- package/dist/components/bottom-sheet.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button.d.ts +48 -0
- package/dist/components/checkbox.d.ts +12 -0
- package/dist/components/color-picker.d.ts +21 -0
- package/dist/components/context-menu.d.ts +26 -0
- package/dist/components/date-picker.d.ts +64 -0
- package/dist/components/dialog-context.d.ts +2 -0
- package/dist/components/dialog.d.ts +20 -0
- package/dist/components/empty-state.d.ts +15 -0
- package/dist/components/expand-indicator.d.ts +27 -0
- package/dist/components/expressive-shape.d.ts +18 -0
- package/dist/components/fab-menu-legacy.d.ts +31 -0
- package/dist/components/fab-menu.d.ts +21 -0
- package/dist/components/filter-chip.d.ts +69 -0
- package/dist/components/floating-action-button.d.ts +15 -0
- package/dist/components/icon-button.d.ts +40 -0
- package/dist/components/icons.d.ts +18 -0
- package/dist/components/input.d.ts +9 -0
- package/dist/components/keyboard-inset-view.d.ts +15 -0
- package/dist/components/list-controls.d.ts +38 -0
- package/dist/components/loading-indicator.d.ts +9 -0
- package/dist/components/menu-sheet.d.ts +36 -0
- package/dist/components/panel-sheet.d.ts +8 -0
- package/dist/components/password-visibility.d.ts +5 -0
- package/dist/components/popover-layout.d.ts +25 -0
- package/dist/components/popover.d.ts +20 -0
- package/dist/components/pressable.d.ts +12 -0
- package/dist/components/radio-navigation.d.ts +11 -0
- package/dist/components/refresh-control.d.ts +16 -0
- package/dist/components/row-gap.d.ts +9 -0
- package/dist/components/search-field.d.ts +22 -0
- package/dist/components/select-field.d.ts +16 -0
- package/dist/components/settings-list.d.ts +98 -0
- package/dist/components/settings-row.d.ts +13 -0
- package/dist/components/sheet-frame.d.ts +27 -0
- package/dist/components/sheet-lifecycle.d.ts +36 -0
- package/dist/components/sheet-transition.d.ts +36 -0
- package/dist/components/slider.d.ts +21 -0
- package/dist/components/snackbar.d.ts +10 -0
- package/dist/components/split-button.d.ts +38 -0
- package/dist/components/squish.d.ts +20 -0
- package/dist/components/swipeable-row.d.ts +20 -0
- package/dist/components/switch.d.ts +8 -0
- package/dist/components/tab-bar.d.ts +20 -0
- package/dist/components/text-field.d.ts +31 -0
- package/dist/components/text.d.ts +9 -0
- package/dist/components/time-picker.d.ts +40 -0
- package/dist/components/toolbar.d.ts +77 -0
- package/dist/components/top-bar.d.ts +27 -0
- package/dist/components/undo-bar.d.ts +23 -0
- package/dist/components/wavy-progress.d.ts +21 -0
- package/dist/components/zoomable-image.d.ts +18 -0
- package/dist/expressive-shapes.d.ts +8 -0
- package/dist/index.d.ts +62 -0
- package/dist/keyboard.d.ts +2 -0
- package/dist/material.d.ts +11 -0
- package/dist/media.d.ts +11 -0
- package/dist/motion.d.ts +96 -0
- package/dist/platform.d.ts +3 -0
- package/dist/preset-tokens.d.ts +264 -0
- package/dist/provider.d.ts +59 -0
- package/dist/reduced-motion.d.ts +45 -0
- package/dist/selection.d.ts +2 -0
- package/dist/shape.d.ts +99 -0
- package/dist/sheet-physics.d.ts +37 -0
- package/dist/theme.d.ts +103 -0
- package/dist/tokens.d.ts +2 -0
- package/dist/use-keyboard-height.d.ts +10 -0
- package/dist/utils.d.ts +3 -0
- package/nativewind.cjs +68 -0
- package/package.json +63 -6
- package/src/amount.ts +53 -0
- package/src/calendar.ts +42 -0
- package/src/components/action-chip.tsx +64 -0
- package/src/components/amount-field.tsx +49 -0
- package/src/components/avatar.tsx +91 -0
- package/src/components/back-button.tsx +39 -0
- package/src/components/back-icon.tsx +25 -0
- package/src/components/badge.tsx +41 -0
- package/src/components/banner.tsx +63 -0
- package/src/components/bottom-sheet-native.tsx +52 -0
- package/src/components/bottom-sheet-web.tsx +193 -0
- package/src/components/bottom-sheet.tsx +5 -0
- package/src/components/button-group.tsx +326 -0
- package/src/components/button.tsx +337 -0
- package/src/components/checkbox.tsx +71 -0
- package/src/components/color-picker.tsx +115 -0
- package/src/components/context-menu.tsx +149 -0
- package/src/components/date-picker.tsx +466 -0
- package/src/components/dialog-context.ts +3 -0
- package/src/components/dialog.tsx +210 -0
- package/src/components/empty-state.tsx +49 -0
- package/src/components/expand-indicator.tsx +60 -0
- package/src/components/expressive-shape.tsx +37 -0
- package/src/components/fab-menu-legacy.tsx +181 -0
- package/src/components/fab-menu.tsx +90 -0
- package/src/components/filter-chip.tsx +136 -0
- package/src/components/floating-action-button.tsx +79 -0
- package/src/components/icon-button.tsx +127 -0
- package/src/components/icons.tsx +43 -0
- package/src/components/input.tsx +80 -0
- package/src/components/keyboard-inset-view.tsx +28 -0
- package/src/components/list-controls.tsx +134 -0
- package/src/components/loading-indicator.tsx +183 -0
- package/src/components/menu-sheet.tsx +133 -0
- package/src/components/panel-sheet.tsx +90 -0
- package/src/components/password-visibility.tsx +39 -0
- package/src/components/popover-layout.ts +47 -0
- package/src/components/popover.tsx +139 -0
- package/src/components/pressable.tsx +108 -0
- package/src/components/radio-navigation.ts +52 -0
- package/src/components/refresh-control.tsx +39 -0
- package/src/components/row-gap.tsx +13 -0
- package/src/components/search-field.tsx +92 -0
- package/src/components/select-field.tsx +105 -0
- package/src/components/settings-list.tsx +302 -0
- package/src/components/settings-row.tsx +61 -0
- package/src/components/sheet-frame.tsx +250 -0
- package/src/components/sheet-lifecycle.ts +233 -0
- package/src/components/sheet-transition.ts +105 -0
- package/src/components/slider.tsx +185 -0
- package/src/components/snackbar.tsx +97 -0
- package/src/components/split-button.tsx +212 -0
- package/src/components/squish.ts +41 -0
- package/src/components/swipeable-row.tsx +109 -0
- package/src/components/switch.tsx +77 -0
- package/src/components/tab-bar.tsx +195 -0
- package/src/components/text-field.tsx +264 -0
- package/src/components/text.tsx +58 -0
- package/src/components/time-picker.tsx +277 -0
- package/src/components/toolbar.tsx +406 -0
- package/src/components/top-bar.tsx +91 -0
- package/src/components/undo-bar.tsx +137 -0
- package/src/components/wavy-progress.tsx +150 -0
- package/src/components/zoomable-image.tsx +240 -0
- package/src/expressive-shapes.ts +25 -0
- package/src/index.ts +62 -0
- package/src/keyboard.ts +5 -0
- package/src/material.ts +16 -0
- package/src/media.ts +51 -0
- package/src/motion.ts +96 -0
- package/src/platform.ts +13 -0
- package/src/preset-tokens.ts +265 -0
- package/src/provider.tsx +201 -0
- package/src/reduced-motion.ts +50 -0
- package/src/selection.ts +11 -0
- package/src/shape.ts +217 -0
- package/src/sheet-physics.ts +73 -0
- package/src/theme.ts +180 -0
- package/src/tokens.ts +2 -0
- package/src/use-keyboard-height.ts +64 -0
- package/src/utils.ts +7 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type * as React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import Animated, { FadeIn } from 'react-native-reanimated';
|
|
4
|
+
|
|
5
|
+
import { ExpressiveShape, type ExpressiveShapeName } from './expressive-shape.tsx';
|
|
6
|
+
import type { IconProps } from './icons.tsx';
|
|
7
|
+
import { Text } from './text.tsx';
|
|
8
|
+
import { useThemeColors } from '../provider.tsx';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An empty list's one thing to look at: a large Expressive shape with the
|
|
12
|
+
* symbol of what would be here, a line saying so, and a line saying how to
|
|
13
|
+
* change it.
|
|
14
|
+
*/
|
|
15
|
+
export function EmptyState({
|
|
16
|
+
icon: Icon,
|
|
17
|
+
shape,
|
|
18
|
+
title,
|
|
19
|
+
body,
|
|
20
|
+
tone = 'primary',
|
|
21
|
+
}: {
|
|
22
|
+
icon: React.ComponentType<IconProps>;
|
|
23
|
+
shape: ExpressiveShapeName;
|
|
24
|
+
title: string;
|
|
25
|
+
body?: string;
|
|
26
|
+
tone?: 'primary' | 'tertiary';
|
|
27
|
+
}) {
|
|
28
|
+
const colors = useThemeColors();
|
|
29
|
+
const fill = tone === 'primary' ? colors.primaryContainer : colors.tertiaryContainer;
|
|
30
|
+
const ink = tone === 'primary' ? colors.onPrimaryContainer : colors.onTertiaryContainer;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Animated.View entering={FadeIn.duration(220)} className="items-center gap-5 px-8 py-12">
|
|
34
|
+
<ExpressiveShape shape={shape} size={132} color={fill}>
|
|
35
|
+
<Icon size={56} color={ink} />
|
|
36
|
+
</ExpressiveShape>
|
|
37
|
+
<View className="items-center gap-2">
|
|
38
|
+
<Text variant="heading" className="text-center">
|
|
39
|
+
{title}
|
|
40
|
+
</Text>
|
|
41
|
+
{body ? (
|
|
42
|
+
<Text variant="muted" className="text-center leading-5">
|
|
43
|
+
{body}
|
|
44
|
+
</Text>
|
|
45
|
+
) : null}
|
|
46
|
+
</View>
|
|
47
|
+
</Animated.View>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import Animated, {
|
|
3
|
+
useAnimatedStyle,
|
|
4
|
+
useSharedValue,
|
|
5
|
+
withSpring,
|
|
6
|
+
} from 'react-native-reanimated';
|
|
7
|
+
|
|
8
|
+
import { ChevronDown } from './icons.tsx';
|
|
9
|
+
import { spatial } from '../motion.ts';
|
|
10
|
+
import { useThemeColors } from '../provider.tsx';
|
|
11
|
+
|
|
12
|
+
/** One size for every disclosure chevron, so no two sections look differently foldable. */
|
|
13
|
+
export const EXPAND_INDICATOR_SIZE = 20;
|
|
14
|
+
|
|
15
|
+
export interface ExpandIndicatorProps {
|
|
16
|
+
expanded: boolean;
|
|
17
|
+
/** Defaults to `onSurfaceVariant`; a chip in its selected tone passes its own. */
|
|
18
|
+
color?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The chevron on anything that opens in place — a message card, its details,
|
|
23
|
+
* the Cc/Bcc fields.
|
|
24
|
+
*
|
|
25
|
+
* Every one of those used to draw its own `ChevronDown` at whatever size
|
|
26
|
+
* suited the spot: 16 on a card, 12 on the details line beneath it, so two
|
|
27
|
+
* disclosures a finger's width apart looked like different kinds of thing.
|
|
28
|
+
* This is the one they share.
|
|
29
|
+
*
|
|
30
|
+
* It turns on the fast spatial spring rather than flipping, and overshoots a
|
|
31
|
+
* little on the way: the Expressive cue that the section is a thing being
|
|
32
|
+
* opened, not a picture being swapped. Reanimated drops the spring for
|
|
33
|
+
* "Remove animations" on its own, and the chevron still ends up pointing the
|
|
34
|
+
* right way — the state is carried by where it ends, not by the movement.
|
|
35
|
+
*
|
|
36
|
+
* Decorative to a screen reader: whatever it sits in owns the tap and says
|
|
37
|
+
* `expanded` itself.
|
|
38
|
+
*/
|
|
39
|
+
export function ExpandIndicator({ expanded, color }: ExpandIndicatorProps) {
|
|
40
|
+
const colors = useThemeColors();
|
|
41
|
+
const turn = useSharedValue(expanded ? 1 : 0);
|
|
42
|
+
|
|
43
|
+
React.useEffect(() => {
|
|
44
|
+
turn.value = withSpring(expanded ? 1 : 0, spatial.fast);
|
|
45
|
+
}, [expanded, turn]);
|
|
46
|
+
|
|
47
|
+
const style = useAnimatedStyle(() => ({
|
|
48
|
+
transform: [{ rotate: `${turn.value * 180}deg` }],
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Animated.View
|
|
53
|
+
style={style}
|
|
54
|
+
accessibilityElementsHidden
|
|
55
|
+
importantForAccessibility="no-hide-descendants"
|
|
56
|
+
>
|
|
57
|
+
<ChevronDown size={EXPAND_INDICATOR_SIZE} color={color ?? colors.onSurfaceVariant} />
|
|
58
|
+
</Animated.View>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import Svg, { Path } from 'react-native-svg';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A still member of the Expressive shape library, with something inside it.
|
|
7
|
+
*
|
|
8
|
+
* Expressive leans on a small set of characterful shapes — the 4-leaf clover,
|
|
9
|
+
* the scalloped cookie — to mark the one thing on a screen worth looking at
|
|
10
|
+
* first: an empty state's symbol, a step in a flow. Drawn from the same radial
|
|
11
|
+
* maths the loading indicator morphs (`~/lib/shape`), so the app's shapes are
|
|
12
|
+
* one family.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export { type ExpressiveShapeName } from '../expressive-shapes.ts';
|
|
16
|
+
import { EXPRESSIVE_SHAPES as PATHS, type ExpressiveShapeName } from '../expressive-shapes.ts';
|
|
17
|
+
|
|
18
|
+
export function ExpressiveShape({
|
|
19
|
+
shape,
|
|
20
|
+
size,
|
|
21
|
+
color,
|
|
22
|
+
children,
|
|
23
|
+
}: {
|
|
24
|
+
shape: ExpressiveShapeName;
|
|
25
|
+
size: number;
|
|
26
|
+
color: string;
|
|
27
|
+
children?: React.ReactNode;
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<View style={{ width: size, height: size }} className="items-center justify-center">
|
|
31
|
+
<Svg width={size} height={size} viewBox="0 0 100 100" style={{ position: 'absolute' }}>
|
|
32
|
+
<Path d={PATHS[shape]} fill={color} />
|
|
33
|
+
</Svg>
|
|
34
|
+
{children}
|
|
35
|
+
</View>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { BackHandler, View } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
FadeIn,
|
|
5
|
+
FadeInDown,
|
|
6
|
+
FadeOut,
|
|
7
|
+
FadeOutDown,
|
|
8
|
+
useAnimatedStyle,
|
|
9
|
+
useSharedValue,
|
|
10
|
+
withSpring,
|
|
11
|
+
} from 'react-native-reanimated';
|
|
12
|
+
|
|
13
|
+
import { Plus, type IconProps } from './icons.tsx';
|
|
14
|
+
import { Pressable } from './pressable.tsx';
|
|
15
|
+
import { Text } from './text.tsx';
|
|
16
|
+
import { useHaptics, useLabels } from '../provider.tsx';
|
|
17
|
+
import { spatial, staggerFor } from '../motion.ts';
|
|
18
|
+
import { useReducedMotion } from '../reduced-motion.ts';
|
|
19
|
+
import { elevation, useRipple, useThemeColors } from '../provider.tsx';
|
|
20
|
+
|
|
21
|
+
export interface FabMenuAction {
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
destructive?: boolean;
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
icon: React.ComponentType<IconProps>;
|
|
27
|
+
run: () => void;
|
|
28
|
+
}
|
|
29
|
+
export interface FabMenuProps {
|
|
30
|
+
bottom: number;
|
|
31
|
+
label: string;
|
|
32
|
+
actions: FabMenuAction[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The button's closed size: the height of the floating navigation bar it
|
|
37
|
+
* sits beside, so the two read as one row — and round, as the bar is.
|
|
38
|
+
*/
|
|
39
|
+
export const FAB_MENU_SIZE = 64;
|
|
40
|
+
const CLOSE_SIZE = 56;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The capture button, as an Expressive FAB menu.
|
|
44
|
+
*
|
|
45
|
+
* Closed, it is the one tinted circle beside the navigation bar (or, in a
|
|
46
|
+
* collection, in the corner). Open, it shrinks into a smaller close button
|
|
47
|
+
* while the ways in — link or text, file, scan, camera — unfold above it one after
|
|
48
|
+
* another, each a full-width pill with its name on it. Everything else dims;
|
|
49
|
+
* nothing about capturing asks where a thing goes. Only ways in: a list is
|
|
50
|
+
* written, not captured, and is started from Collections or a collection's menu.
|
|
51
|
+
*/
|
|
52
|
+
export function LegacyFabMenu({ bottom, label, actions }: FabMenuProps) {
|
|
53
|
+
const labels = useLabels();
|
|
54
|
+
const { selectionHaptic, impactHaptic } = useHaptics();
|
|
55
|
+
const colors = useThemeColors();
|
|
56
|
+
const ripple = useRipple();
|
|
57
|
+
const reducedMotion = useReducedMotion();
|
|
58
|
+
const [open, setOpen] = React.useState(false);
|
|
59
|
+
const progress = useSharedValue(0);
|
|
60
|
+
|
|
61
|
+
React.useEffect(() => {
|
|
62
|
+
// The calmer spatial spring: the button settles with a hint of give
|
|
63
|
+
// rather than wobbling through its size and rotation.
|
|
64
|
+
progress.value = withSpring(open ? 1 : 0, spatial.default);
|
|
65
|
+
if (!open) return;
|
|
66
|
+
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
67
|
+
setOpen(false);
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
return () => subscription.remove();
|
|
71
|
+
}, [open, progress]);
|
|
72
|
+
|
|
73
|
+
const fabStyle = useAnimatedStyle(() => {
|
|
74
|
+
const size = FAB_MENU_SIZE + (CLOSE_SIZE - FAB_MENU_SIZE) * progress.value;
|
|
75
|
+
return {
|
|
76
|
+
width: size,
|
|
77
|
+
height: size,
|
|
78
|
+
// Round, like the pill beside it, at every size it passes through.
|
|
79
|
+
borderRadius: size / 2,
|
|
80
|
+
backgroundColor: progress.value > 0.5 ? colors.primary : colors.primaryContainer,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
const iconStyle = useAnimatedStyle(() => ({
|
|
84
|
+
// An eighth of a turn: the plus becomes a cross without spinning.
|
|
85
|
+
transform: [{ rotate: `${progress.value * 45}deg` }],
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const choose = (run: () => void) => {
|
|
89
|
+
selectionHaptic();
|
|
90
|
+
setOpen(false);
|
|
91
|
+
run();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
{open ? (
|
|
97
|
+
<Animated.View
|
|
98
|
+
entering={FadeIn.duration(150)}
|
|
99
|
+
exiting={FadeOut.duration(120)}
|
|
100
|
+
className="absolute inset-0 bg-black/40"
|
|
101
|
+
>
|
|
102
|
+
<Pressable
|
|
103
|
+
className="flex-1"
|
|
104
|
+
onPress={() => setOpen(false)}
|
|
105
|
+
accessibilityLabel={labels.close}
|
|
106
|
+
/>
|
|
107
|
+
</Animated.View>
|
|
108
|
+
) : null}
|
|
109
|
+
|
|
110
|
+
<View
|
|
111
|
+
pointerEvents="box-none"
|
|
112
|
+
className="absolute right-4 items-end gap-3"
|
|
113
|
+
style={{ bottom }}
|
|
114
|
+
>
|
|
115
|
+
{open
|
|
116
|
+
? actions.map((action, index) => {
|
|
117
|
+
const Icon = action.icon;
|
|
118
|
+
// Nearest the button first, so the menu unfolds upwards.
|
|
119
|
+
const order = actions.length - 1 - index;
|
|
120
|
+
return (
|
|
121
|
+
<Animated.View
|
|
122
|
+
key={action.id}
|
|
123
|
+
entering={FadeInDown.springify()
|
|
124
|
+
.stiffness(spatial.default.stiffness)
|
|
125
|
+
.damping(spatial.default.damping)
|
|
126
|
+
.delay(order * staggerFor(reducedMotion))}
|
|
127
|
+
exiting={FadeOutDown.duration(100)}
|
|
128
|
+
className="overflow-hidden rounded-full"
|
|
129
|
+
style={{ elevation: elevation.level2 }}
|
|
130
|
+
>
|
|
131
|
+
<Pressable
|
|
132
|
+
disabled={action.disabled}
|
|
133
|
+
style={{ opacity: action.disabled ? 0.4 : 1 }}
|
|
134
|
+
onPress={() => choose(action.run)}
|
|
135
|
+
android_ripple={{ color: ripple.onTonal }}
|
|
136
|
+
accessibilityRole="button"
|
|
137
|
+
className="h-14 flex-row items-center gap-3 rounded-full bg-primaryContainer pl-5 pr-6"
|
|
138
|
+
>
|
|
139
|
+
<Icon
|
|
140
|
+
size={24}
|
|
141
|
+
color={action.destructive ? colors.error : colors.onPrimaryContainer}
|
|
142
|
+
/>
|
|
143
|
+
<Text
|
|
144
|
+
className="text-base font-semibold text-onPrimaryContainer"
|
|
145
|
+
style={action.destructive ? { color: colors.error } : undefined}
|
|
146
|
+
>
|
|
147
|
+
{action.label}
|
|
148
|
+
</Text>
|
|
149
|
+
</Pressable>
|
|
150
|
+
</Animated.View>
|
|
151
|
+
);
|
|
152
|
+
})
|
|
153
|
+
: null}
|
|
154
|
+
|
|
155
|
+
<Animated.View
|
|
156
|
+
style={[fabStyle, { elevation: elevation.level3 }]}
|
|
157
|
+
className="overflow-hidden"
|
|
158
|
+
>
|
|
159
|
+
<Pressable
|
|
160
|
+
onPress={() => {
|
|
161
|
+
impactHaptic('light');
|
|
162
|
+
setOpen((value) => !value);
|
|
163
|
+
}}
|
|
164
|
+
android_ripple={{ color: ripple.onTonal }}
|
|
165
|
+
accessibilityRole="button"
|
|
166
|
+
accessibilityLabel={open ? labels.close : label}
|
|
167
|
+
accessibilityState={{ expanded: open }}
|
|
168
|
+
className="flex-1 items-center justify-center"
|
|
169
|
+
>
|
|
170
|
+
<Animated.View style={iconStyle}>
|
|
171
|
+
<Plus
|
|
172
|
+
size={open ? 24 : 28}
|
|
173
|
+
color={open ? colors.onPrimary : colors.onPrimaryContainer}
|
|
174
|
+
/>
|
|
175
|
+
</Animated.View>
|
|
176
|
+
</Pressable>
|
|
177
|
+
</Animated.View>
|
|
178
|
+
</View>
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, View } from 'react-native';
|
|
3
|
+
import { LegacyFabMenu } from './fab-menu-legacy.tsx';
|
|
4
|
+
import { Plus, type IconProps } from './icons.tsx';
|
|
5
|
+
import { FloatingActionButton } from './floating-action-button.tsx';
|
|
6
|
+
import { Popover } from './popover.tsx';
|
|
7
|
+
import { ContextMenuRows } from './context-menu.tsx';
|
|
8
|
+
import {
|
|
9
|
+
useHaptics,
|
|
10
|
+
useLabels,
|
|
11
|
+
useIsolinePlatform,
|
|
12
|
+
type MaterialOptions,
|
|
13
|
+
} from '../provider.tsx';
|
|
14
|
+
export interface FabMenuAction {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
icon: React.ComponentType<IconProps>;
|
|
18
|
+
run: () => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
destructive?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface FabMenuProps extends MaterialOptions {
|
|
23
|
+
/** Material of the anchored menu, independent of the floating trigger. */
|
|
24
|
+
menuMaterial?: MaterialOptions['material'];
|
|
25
|
+
bottom: number;
|
|
26
|
+
label: string;
|
|
27
|
+
actions: FabMenuAction[];
|
|
28
|
+
}
|
|
29
|
+
export const FAB_MENU_SIZE = 64;
|
|
30
|
+
/** Floating primary action with a safe, anchored overflow surface on native and web. */
|
|
31
|
+
export function FabMenu({
|
|
32
|
+
bottom,
|
|
33
|
+
label,
|
|
34
|
+
actions,
|
|
35
|
+
material = 'solid',
|
|
36
|
+
menuMaterial = 'auto',
|
|
37
|
+
glassStyle,
|
|
38
|
+
tintColor,
|
|
39
|
+
fallbackColor,
|
|
40
|
+
interactive = true,
|
|
41
|
+
}: FabMenuProps) {
|
|
42
|
+
const [open, setOpen] = React.useState(false);
|
|
43
|
+
const anchor = React.useRef<React.ComponentRef<typeof View>>(null);
|
|
44
|
+
const labels = useLabels();
|
|
45
|
+
const { impactHaptic } = useHaptics();
|
|
46
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
47
|
+
if (Platform.OS !== 'web' && (!ios || material === 'solid'))
|
|
48
|
+
return <LegacyFabMenu bottom={bottom} label={label} actions={actions} />;
|
|
49
|
+
return (
|
|
50
|
+
<>
|
|
51
|
+
<View
|
|
52
|
+
ref={anchor}
|
|
53
|
+
collapsable={false}
|
|
54
|
+
style={{ position: 'absolute', right: 16, bottom }}
|
|
55
|
+
>
|
|
56
|
+
<FloatingActionButton
|
|
57
|
+
icon={Plus}
|
|
58
|
+
accessibilityLabel={open ? labels.close : label}
|
|
59
|
+
size={FAB_MENU_SIZE}
|
|
60
|
+
material={material}
|
|
61
|
+
glassStyle={glassStyle}
|
|
62
|
+
tintColor={tintColor}
|
|
63
|
+
fallbackColor={fallbackColor}
|
|
64
|
+
interactive={interactive}
|
|
65
|
+
onPress={() => {
|
|
66
|
+
impactHaptic('light');
|
|
67
|
+
setOpen((value) => !value);
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
</View>
|
|
71
|
+
<Popover
|
|
72
|
+
visible={open}
|
|
73
|
+
onClose={() => setOpen(false)}
|
|
74
|
+
anchorRef={anchor}
|
|
75
|
+
placement="top"
|
|
76
|
+
accessibilityLabel={label}
|
|
77
|
+
material={menuMaterial}
|
|
78
|
+
glassStyle={glassStyle}
|
|
79
|
+
tintColor={tintColor}
|
|
80
|
+
fallbackColor={fallbackColor}
|
|
81
|
+
interactive={interactive}
|
|
82
|
+
>
|
|
83
|
+
<ContextMenuRows
|
|
84
|
+
items={actions.map(({ run, ...action }) => ({ ...action, onPress: run }))}
|
|
85
|
+
onClose={() => setOpen(false)}
|
|
86
|
+
/>
|
|
87
|
+
</Popover>
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, View } from 'react-native';
|
|
3
|
+
import Animated from 'react-native-reanimated';
|
|
4
|
+
|
|
5
|
+
import { type IconProps } from './icons.tsx';
|
|
6
|
+
import { Pressable } from './pressable.tsx';
|
|
7
|
+
import { useSquish } from './squish.ts';
|
|
8
|
+
import { Text } from './text.tsx';
|
|
9
|
+
import { corner } from '../shape.ts';
|
|
10
|
+
import { useIsolinePlatform, useRipple, useThemeColors } from '../provider.tsx';
|
|
11
|
+
import { cn } from '../utils.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Material 3 filter chip — the app's one selectable pill.
|
|
15
|
+
*
|
|
16
|
+
* Unselected it reads as an offer, selected it fills with the primary
|
|
17
|
+
* container. Both states are drawn from the same set of style properties;
|
|
18
|
+
* only the colour values change. That is load-bearing on Android, and it is
|
|
19
|
+
* why an unselected chip paints an opaque fill in its own surface's tone
|
|
20
|
+
* rather than simply leaving the background unset:
|
|
21
|
+
*
|
|
22
|
+
* React Native builds a view's background out of layers — fill, border,
|
|
23
|
+
* ripple — held in a single drawable. Adding a *layer* that wasn't there
|
|
24
|
+
* before (a fill appearing on select, a border appearing on deselect)
|
|
25
|
+
* replaces that drawable with a freshly constructed one, and a fresh drawable
|
|
26
|
+
* starts with empty bounds. A view with `overflow: hidden` clips its children
|
|
27
|
+
* to those bounds, so for as long as they stay empty the chip paints its fill
|
|
28
|
+
* and nothing else: the icon and the label are laid out at full size but
|
|
29
|
+
* never drawn. That is the bug this shape avoids — and `overflow: hidden` is
|
|
30
|
+
* not optional here, since a contained ripple has to be clipped to the pill
|
|
31
|
+
* (see `pressable.tsx`).
|
|
32
|
+
*
|
|
33
|
+
* So: a fill and a border in *both* states, and selection expressed as
|
|
34
|
+
* colour. `surface` is what an unselected chip fills with, which has to match
|
|
35
|
+
* whatever the chip is sitting on for "off" to read as unfilled.
|
|
36
|
+
*
|
|
37
|
+
* The same reasoning rules out swapping the leading icon for a checkmark on
|
|
38
|
+
* select: everything about the chip's structure stays put across a toggle,
|
|
39
|
+
* and only colour moves. Selection stays legible without colour from the
|
|
40
|
+
* filled silhouette, the same way it does in `button-group.tsx` — the one
|
|
41
|
+
* selectable control in the app that never lost its label, and the shape this
|
|
42
|
+
* chip is now built to match.
|
|
43
|
+
*
|
|
44
|
+
* The radius is a real dp value rather than `rounded-full`: Tailwind's full
|
|
45
|
+
* radius is 9999, and `button-group.tsx` likewise morphs between numbers from
|
|
46
|
+
* the corner scale.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** The tones a chip can sit on, as the fill an unselected chip paints. */
|
|
50
|
+
const SURFACE_FILL = {
|
|
51
|
+
background: 'bg-background',
|
|
52
|
+
surfaceContainerHigh: 'bg-surfaceContainerHigh',
|
|
53
|
+
/** Bottom sheets and dialogs. */
|
|
54
|
+
popover: 'bg-popover',
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
export interface FilterChipProps {
|
|
58
|
+
label: string;
|
|
59
|
+
selected: boolean;
|
|
60
|
+
onPress: () => void;
|
|
61
|
+
/**
|
|
62
|
+
* A second, deliberate action on the same chip — removing a saved search,
|
|
63
|
+
* say. Optional: a chip that only filters has nothing to hide behind a hold.
|
|
64
|
+
*/
|
|
65
|
+
onLongPress?: () => void;
|
|
66
|
+
/** Optional leading icon. Drawn in both states, or in neither. */
|
|
67
|
+
icon?: React.ComponentType<IconProps>;
|
|
68
|
+
/**
|
|
69
|
+
* Optional trailing icon — a chevron on a chip that opens a choice rather
|
|
70
|
+
* than toggling. Drawn in both states, like `icon`.
|
|
71
|
+
*/
|
|
72
|
+
trailingIcon?: React.ComponentType<IconProps>;
|
|
73
|
+
/**
|
|
74
|
+
* The tone the chip is drawn on. An unselected chip fills with it rather
|
|
75
|
+
* than staying transparent, so its fill is a colour change away from the
|
|
76
|
+
* selected one rather than a new drawable layer.
|
|
77
|
+
*/
|
|
78
|
+
surface?: keyof typeof SURFACE_FILL;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function FilterChip({
|
|
82
|
+
label,
|
|
83
|
+
selected,
|
|
84
|
+
onPress,
|
|
85
|
+
onLongPress,
|
|
86
|
+
icon: Icon,
|
|
87
|
+
trailingIcon: Trailing,
|
|
88
|
+
surface = 'background',
|
|
89
|
+
}: FilterChipProps) {
|
|
90
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
91
|
+
const web = Platform.OS === 'web';
|
|
92
|
+
const Container = (web ? View : Animated.View) as typeof Animated.View;
|
|
93
|
+
const colors = useThemeColors();
|
|
94
|
+
const ripple = useRipple();
|
|
95
|
+
|
|
96
|
+
const foreground = selected ? colors.onPrimaryContainer : colors.onSurfaceVariant;
|
|
97
|
+
const squish = useSquish();
|
|
98
|
+
|
|
99
|
+
// Fill and clip on a plain wrapper, with the same radius on the Pressable
|
|
100
|
+
// inside it, so the contained ripple is clipped to the pill rather than to
|
|
101
|
+
// its bounding box.
|
|
102
|
+
return (
|
|
103
|
+
<Container
|
|
104
|
+
className="shrink-0 overflow-hidden"
|
|
105
|
+
style={[{ borderRadius: ios ? 10 : corner.large }, !web && squish.style]}
|
|
106
|
+
>
|
|
107
|
+
<Pressable
|
|
108
|
+
{...squish.handlers}
|
|
109
|
+
onPress={onPress}
|
|
110
|
+
onLongPress={onLongPress}
|
|
111
|
+
// A 32dp chip is under the 48dp touch target on its own.
|
|
112
|
+
hitSlop={{ top: 8, bottom: 8 }}
|
|
113
|
+
android_ripple={{ color: selected ? ripple.onTonal : ripple.onNeutral }}
|
|
114
|
+
accessibilityRole="button"
|
|
115
|
+
accessibilityState={{ selected }}
|
|
116
|
+
className={cn(
|
|
117
|
+
'min-h-8 flex-row items-center gap-1.5 border px-3 py-1',
|
|
118
|
+
selected
|
|
119
|
+
? 'border-primaryContainer bg-primaryContainer'
|
|
120
|
+
: cn('border-outline', SURFACE_FILL[surface]),
|
|
121
|
+
)}
|
|
122
|
+
style={{ borderRadius: ios ? 10 : corner.large }}
|
|
123
|
+
>
|
|
124
|
+
{Icon ? <Icon size={16} color={foreground} /> : null}
|
|
125
|
+
|
|
126
|
+
{/* Coloured inline from the same value the icon uses, rather than
|
|
127
|
+
through a class, so the two cannot diverge and the label keeps a
|
|
128
|
+
colour even if the class does not resolve. */}
|
|
129
|
+
<Text variant="labelLarge" numberOfLines={1} style={{ color: foreground }}>
|
|
130
|
+
{label}
|
|
131
|
+
</Text>
|
|
132
|
+
{Trailing ? <Trailing size={16} color={foreground} /> : null}
|
|
133
|
+
</Pressable>
|
|
134
|
+
</Container>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
Surface,
|
|
5
|
+
useIsolinePlatform,
|
|
6
|
+
useThemeColors,
|
|
7
|
+
useRipple,
|
|
8
|
+
type MaterialOptions,
|
|
9
|
+
} from '../provider.tsx';
|
|
10
|
+
import { Pressable } from './pressable.tsx';
|
|
11
|
+
import { Text } from './text.tsx';
|
|
12
|
+
import type { IconProps } from './icons.tsx';
|
|
13
|
+
export interface FloatingActionButtonProps extends MaterialOptions {
|
|
14
|
+
icon: React.ComponentType<IconProps>;
|
|
15
|
+
accessibilityLabel: string;
|
|
16
|
+
onPress: () => void;
|
|
17
|
+
label?: string;
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
size?: 56 | 64;
|
|
20
|
+
style?: StyleProp<ViewStyle>;
|
|
21
|
+
}
|
|
22
|
+
/** A reusable floating action; screen placement and navigation remain consumer-owned. */
|
|
23
|
+
export function FloatingActionButton({
|
|
24
|
+
icon: Icon,
|
|
25
|
+
accessibilityLabel,
|
|
26
|
+
onPress,
|
|
27
|
+
label,
|
|
28
|
+
disabled,
|
|
29
|
+
size = 64,
|
|
30
|
+
style,
|
|
31
|
+
material = 'solid',
|
|
32
|
+
glassStyle,
|
|
33
|
+
tintColor,
|
|
34
|
+
fallbackColor,
|
|
35
|
+
interactive = true,
|
|
36
|
+
}: FloatingActionButtonProps) {
|
|
37
|
+
const colors = useThemeColors();
|
|
38
|
+
const ripple = useRipple();
|
|
39
|
+
const glass = useIsolinePlatform() === 'ios' && material !== 'solid';
|
|
40
|
+
const Container = glass ? Surface : View;
|
|
41
|
+
const color = glass ? colors.primary : colors.onPrimaryContainer;
|
|
42
|
+
return (
|
|
43
|
+
<Container
|
|
44
|
+
{...(glass ? { material, glassStyle, tintColor, fallbackColor, interactive } : {})}
|
|
45
|
+
style={[
|
|
46
|
+
{
|
|
47
|
+
borderRadius: size / 2,
|
|
48
|
+
...(!glass ? { backgroundColor: colors.primaryContainer } : {}),
|
|
49
|
+
minWidth: size,
|
|
50
|
+
height: size,
|
|
51
|
+
},
|
|
52
|
+
style,
|
|
53
|
+
]}
|
|
54
|
+
>
|
|
55
|
+
<Pressable
|
|
56
|
+
disabled={disabled}
|
|
57
|
+
accessibilityRole="button"
|
|
58
|
+
accessibilityLabel={accessibilityLabel}
|
|
59
|
+
accessibilityState={{ disabled }}
|
|
60
|
+
onPress={onPress}
|
|
61
|
+
android_ripple={{ color: ripple.onTonal }}
|
|
62
|
+
style={{
|
|
63
|
+
minWidth: size,
|
|
64
|
+
height: size,
|
|
65
|
+
paddingHorizontal: label ? 20 : 0,
|
|
66
|
+
flexDirection: 'row',
|
|
67
|
+
alignItems: 'center',
|
|
68
|
+
justifyContent: 'center',
|
|
69
|
+
gap: 10,
|
|
70
|
+
borderRadius: size / 2,
|
|
71
|
+
opacity: disabled ? 0.4 : 1,
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
<Icon size={26} color={color} />
|
|
75
|
+
{label ? <Text style={{ color, fontWeight: '600' }}>{label}</Text> : null}
|
|
76
|
+
</Pressable>
|
|
77
|
+
</Container>
|
|
78
|
+
);
|
|
79
|
+
}
|