@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,133 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { SheetScrollView } from './sheet-frame.tsx';
|
|
5
|
+
import { BottomSheet } from './bottom-sheet.tsx';
|
|
6
|
+
import type { IconProps } from './icons.tsx';
|
|
7
|
+
import { Pressable } from './pressable.tsx';
|
|
8
|
+
import { Text } from './text.tsx';
|
|
9
|
+
import {
|
|
10
|
+
type MaterialOptions,
|
|
11
|
+
useIsolinePlatform,
|
|
12
|
+
useRipple,
|
|
13
|
+
useThemeColors,
|
|
14
|
+
} from '../provider.tsx';
|
|
15
|
+
|
|
16
|
+
export interface MenuSheetItem {
|
|
17
|
+
id: string;
|
|
18
|
+
icon: React.ComponentType<IconProps>;
|
|
19
|
+
label: string;
|
|
20
|
+
onPress: () => void;
|
|
21
|
+
/** Overrides the icon colour, for a state like "starred". */
|
|
22
|
+
color?: string;
|
|
23
|
+
/** Filled icons read as "on" for toggles. */
|
|
24
|
+
fill?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MenuSheetProps extends MaterialOptions {
|
|
28
|
+
visible: boolean;
|
|
29
|
+
onClose: () => void;
|
|
30
|
+
title: string;
|
|
31
|
+
/** A line under the title saying what the actions would act on. */
|
|
32
|
+
subject?: string;
|
|
33
|
+
items: MenuSheetItem[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A sheet of actions, each with its symbol and its name.
|
|
38
|
+
*
|
|
39
|
+
* The overflow half of a toolbar: what does not fit on the pill — or what an
|
|
40
|
+
* icon on its own would not explain — is listed here instead, where there is
|
|
41
|
+
* room for the word that says what it does. Rows are the same size and shape
|
|
42
|
+
* as the pickers' (`SnoozeSheet`, `MoveSheet`), so an action list and a value
|
|
43
|
+
* list do not read as two different kinds of sheet.
|
|
44
|
+
*/
|
|
45
|
+
export function MenuSheet({
|
|
46
|
+
visible,
|
|
47
|
+
onClose,
|
|
48
|
+
title,
|
|
49
|
+
subject,
|
|
50
|
+
items,
|
|
51
|
+
...materialOptions
|
|
52
|
+
}: MenuSheetProps) {
|
|
53
|
+
return (
|
|
54
|
+
<BottomSheet {...materialOptions} visible={visible} onClose={onClose} maxHeight="85%">
|
|
55
|
+
<SheetScrollView style={{ flexGrow: 0 }} keyboardShouldPersistTaps="handled">
|
|
56
|
+
<View className="mb-2 gap-1 px-2">
|
|
57
|
+
<Text variant="heading">{title}</Text>
|
|
58
|
+
{subject ? (
|
|
59
|
+
<Text variant="caption" numberOfLines={1}>
|
|
60
|
+
{subject}
|
|
61
|
+
</Text>
|
|
62
|
+
) : null}
|
|
63
|
+
</View>
|
|
64
|
+
|
|
65
|
+
<MenuSheetRows items={items} onClose={onClose} />
|
|
66
|
+
</SheetScrollView>
|
|
67
|
+
</BottomSheet>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A menu sheet's rows on their own, for a sheet that shows more above its actions. */
|
|
72
|
+
export function MenuSheetRows({
|
|
73
|
+
items,
|
|
74
|
+
onClose,
|
|
75
|
+
}: {
|
|
76
|
+
items: MenuSheetItem[];
|
|
77
|
+
onClose: () => void;
|
|
78
|
+
}) {
|
|
79
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
80
|
+
const colors = useThemeColors();
|
|
81
|
+
const ripple = useRipple();
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<View
|
|
85
|
+
className="gap-1"
|
|
86
|
+
style={
|
|
87
|
+
ios
|
|
88
|
+
? {
|
|
89
|
+
borderRadius: 14,
|
|
90
|
+
overflow: 'hidden',
|
|
91
|
+
backgroundColor: colors.surfaceContainerLowest,
|
|
92
|
+
}
|
|
93
|
+
: undefined
|
|
94
|
+
}
|
|
95
|
+
>
|
|
96
|
+
{items.map((item) => {
|
|
97
|
+
const Icon = item.icon;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<View key={item.id} className="overflow-hidden rounded-2xl">
|
|
101
|
+
<Pressable
|
|
102
|
+
onPress={() => {
|
|
103
|
+
// Closed first, so the sheet is gone by the time an action
|
|
104
|
+
// that leaves selection mode takes the toolbar with it.
|
|
105
|
+
onClose();
|
|
106
|
+
item.onPress();
|
|
107
|
+
}}
|
|
108
|
+
android_ripple={{ color: ripple.onNeutral }}
|
|
109
|
+
style={
|
|
110
|
+
ios
|
|
111
|
+
? {
|
|
112
|
+
borderRadius: 0,
|
|
113
|
+
borderBottomWidth: 1,
|
|
114
|
+
borderBottomColor: colors.outlineVariant,
|
|
115
|
+
}
|
|
116
|
+
: undefined
|
|
117
|
+
}
|
|
118
|
+
accessibilityRole="button"
|
|
119
|
+
className="flex-row items-center gap-3 rounded-2xl px-4 py-4 active:bg-accent"
|
|
120
|
+
>
|
|
121
|
+
<Icon
|
|
122
|
+
size={22}
|
|
123
|
+
color={item.color ?? colors.onSurfaceVariant}
|
|
124
|
+
{...(item.fill ? { fill: item.fill } : null)}
|
|
125
|
+
/>
|
|
126
|
+
<Text className="flex-1 text-base font-medium">{item.label}</Text>
|
|
127
|
+
</Pressable>
|
|
128
|
+
</View>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</View>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, BackHandler, useWindowDimensions, View } from 'react-native';
|
|
3
|
+
import { useAnimatedKeyboard, useAnimatedStyle } from 'react-native-reanimated';
|
|
4
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
5
|
+
|
|
6
|
+
import { SheetFrame, SheetScrollView } from './sheet-frame.tsx';
|
|
7
|
+
import { useSheet } from './sheet-transition.ts';
|
|
8
|
+
import { BottomSheet as WebSheet } from './bottom-sheet-web.tsx';
|
|
9
|
+
import { type MaterialOptions, elevation } from '../provider.tsx';
|
|
10
|
+
|
|
11
|
+
/** Room left above a sheet that has grown to the top, so the scrim still shows. */
|
|
12
|
+
const TOP_GAP = 24;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A bottom sheet for typing into: the same frame as `BottomSheet`, drawn
|
|
16
|
+
* inside the screen rather than in a `Modal`.
|
|
17
|
+
*
|
|
18
|
+
* Android draws edge to edge, so nothing resizes a window for the keyboard
|
|
19
|
+
* any more; a sheet has to lift itself. Reanimated reads the keyboard's
|
|
20
|
+
* height for the app's own window, and a `Modal` is a window of its own — so
|
|
21
|
+
* a sheet with a text field lives here, rendered last in its screen, and
|
|
22
|
+
* rides up on the keyboard as it opens. Its content scrolls once the keyboard
|
|
23
|
+
* leaves it too little room. Sheets without a text field use `BottomSheet`.
|
|
24
|
+
*/
|
|
25
|
+
function NativePanelSheet({
|
|
26
|
+
visible,
|
|
27
|
+
onClose,
|
|
28
|
+
children,
|
|
29
|
+
...materialOptions
|
|
30
|
+
}: {
|
|
31
|
+
visible: boolean;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
children: React.ReactNode;
|
|
34
|
+
} & MaterialOptions) {
|
|
35
|
+
const insets = useSafeAreaInsets();
|
|
36
|
+
const window = useWindowDimensions();
|
|
37
|
+
const keyboard = useAnimatedKeyboard();
|
|
38
|
+
const sheet = useSheet(visible, onClose);
|
|
39
|
+
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (!visible) return;
|
|
42
|
+
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
|
43
|
+
onClose();
|
|
44
|
+
return true;
|
|
45
|
+
});
|
|
46
|
+
return () => subscription.remove();
|
|
47
|
+
}, [visible, onClose]);
|
|
48
|
+
|
|
49
|
+
const lift = useAnimatedStyle(() => ({
|
|
50
|
+
transform: [{ translateY: -keyboard.height.value }],
|
|
51
|
+
}));
|
|
52
|
+
const panel = useAnimatedStyle(() => ({
|
|
53
|
+
// Under the keyboard the gesture inset is covered anyway.
|
|
54
|
+
paddingBottom: keyboard.height.value > 0 ? 12 : insets.bottom + 16,
|
|
55
|
+
maxHeight: window.height - keyboard.height.value - insets.top - TOP_GAP,
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
if (!sheet.mounted) return null;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
// Above the screen's own elevated views: on Android, elevation also orders.
|
|
62
|
+
<View className="absolute inset-0" style={{ zIndex: 50, elevation: elevation.level3 }}>
|
|
63
|
+
<SheetFrame
|
|
64
|
+
{...materialOptions}
|
|
65
|
+
sheet={sheet}
|
|
66
|
+
visible={visible}
|
|
67
|
+
onClose={onClose}
|
|
68
|
+
lift={lift}
|
|
69
|
+
panelStyle={panel}
|
|
70
|
+
>
|
|
71
|
+
<SheetScrollView
|
|
72
|
+
keyboardShouldPersistTaps="handled"
|
|
73
|
+
style={{ flexGrow: 0, marginHorizontal: -16 }}
|
|
74
|
+
contentContainerStyle={{ paddingHorizontal: 16 }}
|
|
75
|
+
>
|
|
76
|
+
{children}
|
|
77
|
+
</SheetScrollView>
|
|
78
|
+
</SheetFrame>
|
|
79
|
+
</View>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PanelSheetProps extends MaterialOptions {
|
|
84
|
+
visible: boolean;
|
|
85
|
+
onClose: () => void;
|
|
86
|
+
children: React.ReactNode;
|
|
87
|
+
}
|
|
88
|
+
export function PanelSheet(props: PanelSheetProps) {
|
|
89
|
+
return Platform.OS === 'web' ? <WebSheet {...props} /> : <NativePanelSheet {...props} />;
|
|
90
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
import { Eye, EyeOff } from './icons.tsx';
|
|
3
|
+
import { Pressable } from './pressable.tsx';
|
|
4
|
+
import { useLabels, useRipple, useThemeColors } from '../provider.tsx';
|
|
5
|
+
|
|
6
|
+
/** Shared affordance for both input styles; credential policy remains caller-owned. */
|
|
7
|
+
export function PasswordVisibility({
|
|
8
|
+
revealed,
|
|
9
|
+
onPress,
|
|
10
|
+
}: {
|
|
11
|
+
revealed: boolean;
|
|
12
|
+
onPress: () => void;
|
|
13
|
+
}) {
|
|
14
|
+
const labels = useLabels();
|
|
15
|
+
const colors = useThemeColors();
|
|
16
|
+
const ripple = useRipple();
|
|
17
|
+
const Icon = revealed ? EyeOff : Eye;
|
|
18
|
+
return (
|
|
19
|
+
<View
|
|
20
|
+
style={{ position: 'absolute', right: 4, top: 0, bottom: 0, justifyContent: 'center' }}
|
|
21
|
+
>
|
|
22
|
+
<Pressable
|
|
23
|
+
onPress={onPress}
|
|
24
|
+
android_ripple={{ color: ripple.onNeutral, borderless: true }}
|
|
25
|
+
accessibilityRole="button"
|
|
26
|
+
accessibilityLabel={revealed ? labels.hidePassword : labels.showPassword}
|
|
27
|
+
style={{
|
|
28
|
+
width: 44,
|
|
29
|
+
height: 44,
|
|
30
|
+
borderRadius: 22,
|
|
31
|
+
alignItems: 'center',
|
|
32
|
+
justifyContent: 'center',
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
<Icon size={20} color={colors.onSurfaceVariant} />
|
|
36
|
+
</Pressable>
|
|
37
|
+
</View>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface AnchorRect {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
export interface PopoverViewport {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
top?: number;
|
|
11
|
+
right?: number;
|
|
12
|
+
bottom?: number;
|
|
13
|
+
left?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Clamp and flip an anchored surface without drawing actions outside the usable window. */
|
|
16
|
+
export function placePopover(
|
|
17
|
+
anchor: AnchorRect,
|
|
18
|
+
size: { width: number; height: number },
|
|
19
|
+
viewport: PopoverViewport,
|
|
20
|
+
placement: 'top' | 'bottom' = 'bottom',
|
|
21
|
+
gap = 8,
|
|
22
|
+
) {
|
|
23
|
+
const left = (viewport.left ?? 0) + 12,
|
|
24
|
+
right = viewport.width - (viewport.right ?? 0) - 12;
|
|
25
|
+
const top = (viewport.top ?? 0) + 12,
|
|
26
|
+
bottom = viewport.height - (viewport.bottom ?? 0) - 12;
|
|
27
|
+
const width = Math.max(0, Math.min(size.width, right - left));
|
|
28
|
+
const height = Math.max(0, Math.min(size.height, bottom - top));
|
|
29
|
+
const below = bottom - (anchor.y + anchor.height + gap),
|
|
30
|
+
above = anchor.y - gap - top;
|
|
31
|
+
const side =
|
|
32
|
+
placement === 'bottom'
|
|
33
|
+
? height > below && above > below
|
|
34
|
+
? 'top'
|
|
35
|
+
: 'bottom'
|
|
36
|
+
: height > above && below > above
|
|
37
|
+
? 'bottom'
|
|
38
|
+
: 'top';
|
|
39
|
+
const y = side === 'bottom' ? anchor.y + anchor.height + gap : anchor.y - gap - height;
|
|
40
|
+
return {
|
|
41
|
+
left: Math.max(left, Math.min(anchor.x + anchor.width - width, right - width)),
|
|
42
|
+
top: Math.max(top, Math.min(y, bottom - height)),
|
|
43
|
+
width,
|
|
44
|
+
maxHeight: bottom - top,
|
|
45
|
+
placement: side,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Modal,
|
|
4
|
+
ScrollView,
|
|
5
|
+
View,
|
|
6
|
+
useWindowDimensions,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
11
|
+
import {
|
|
12
|
+
Surface,
|
|
13
|
+
ThemeScope,
|
|
14
|
+
useIsolinePlatform,
|
|
15
|
+
useLabels,
|
|
16
|
+
type MaterialOptions,
|
|
17
|
+
} from '../provider.tsx';
|
|
18
|
+
import { Pressable } from './pressable.tsx';
|
|
19
|
+
import { placePopover, type AnchorRect } from './popover-layout.ts';
|
|
20
|
+
export type { AnchorRect } from './popover-layout.ts';
|
|
21
|
+
export type PopoverAnchorRef = React.RefObject<React.ComponentRef<typeof View> | null>;
|
|
22
|
+
export interface PopoverProps extends MaterialOptions {
|
|
23
|
+
visible: boolean;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
anchorRef?: PopoverAnchorRef;
|
|
26
|
+
anchor?: AnchorRect;
|
|
27
|
+
placement?: 'top' | 'bottom';
|
|
28
|
+
width?: number;
|
|
29
|
+
accessibilityLabel: string;
|
|
30
|
+
children: React.ReactNode;
|
|
31
|
+
style?: StyleProp<ViewStyle>;
|
|
32
|
+
onShow?: () => void;
|
|
33
|
+
}
|
|
34
|
+
/** Controlled anchored surface. Native Modal provides back dismissal; web adds focus trapping/Escape. */
|
|
35
|
+
export function Popover({
|
|
36
|
+
visible,
|
|
37
|
+
onClose,
|
|
38
|
+
anchorRef,
|
|
39
|
+
anchor,
|
|
40
|
+
placement = 'bottom',
|
|
41
|
+
width = 300,
|
|
42
|
+
accessibilityLabel,
|
|
43
|
+
children,
|
|
44
|
+
style,
|
|
45
|
+
onShow,
|
|
46
|
+
material = 'auto',
|
|
47
|
+
glassStyle,
|
|
48
|
+
tintColor,
|
|
49
|
+
fallbackColor,
|
|
50
|
+
interactive = false,
|
|
51
|
+
}: PopoverProps) {
|
|
52
|
+
const window = useWindowDimensions();
|
|
53
|
+
const insets = useSafeAreaInsets();
|
|
54
|
+
const labels = useLabels();
|
|
55
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
56
|
+
const [measured, setMeasured] = React.useState<AnchorRect | null>(null);
|
|
57
|
+
const [height, setHeight] = React.useState(0);
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
if (!visible) {
|
|
60
|
+
setMeasured(null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
let active = true;
|
|
64
|
+
if (anchor) return;
|
|
65
|
+
if (anchorRef?.current)
|
|
66
|
+
anchorRef.current.measureInWindow((x, y, width, height) => {
|
|
67
|
+
if (active) setMeasured({ x, y, width, height });
|
|
68
|
+
});
|
|
69
|
+
else setMeasured({ x: window.width / 2, y: window.height / 2, width: 0, height: 0 });
|
|
70
|
+
return () => {
|
|
71
|
+
active = false;
|
|
72
|
+
};
|
|
73
|
+
}, [visible, anchor, anchorRef, window.width, window.height]);
|
|
74
|
+
const target = anchor ?? measured;
|
|
75
|
+
const layout = placePopover(
|
|
76
|
+
target ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
77
|
+
{ width, height },
|
|
78
|
+
{ ...window, ...insets },
|
|
79
|
+
placement,
|
|
80
|
+
);
|
|
81
|
+
return (
|
|
82
|
+
<Modal
|
|
83
|
+
visible={visible}
|
|
84
|
+
transparent
|
|
85
|
+
animationType="none"
|
|
86
|
+
onRequestClose={onClose}
|
|
87
|
+
onShow={onShow}
|
|
88
|
+
>
|
|
89
|
+
<ThemeScope style={{ flex: 1 }}>
|
|
90
|
+
<Pressable
|
|
91
|
+
testID="popover-backdrop"
|
|
92
|
+
accessibilityLabel={labels.close}
|
|
93
|
+
accessibilityRole="button"
|
|
94
|
+
onPress={onClose}
|
|
95
|
+
style={{
|
|
96
|
+
position: 'absolute',
|
|
97
|
+
top: 0,
|
|
98
|
+
right: 0,
|
|
99
|
+
bottom: 0,
|
|
100
|
+
left: 0,
|
|
101
|
+
backgroundColor: 'rgba(0,0,0,0.2)',
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
<View
|
|
105
|
+
testID="isoline-popover-panel"
|
|
106
|
+
accessibilityViewIsModal
|
|
107
|
+
onAccessibilityEscape={onClose}
|
|
108
|
+
accessibilityLabel={accessibilityLabel}
|
|
109
|
+
style={{
|
|
110
|
+
position: 'absolute',
|
|
111
|
+
left: layout.left,
|
|
112
|
+
top: layout.top,
|
|
113
|
+
width: layout.width,
|
|
114
|
+
maxHeight: layout.maxHeight,
|
|
115
|
+
opacity: target ? 1 : 0,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<Surface
|
|
119
|
+
material={ios ? material : 'solid'}
|
|
120
|
+
glassStyle={glassStyle}
|
|
121
|
+
tintColor={tintColor}
|
|
122
|
+
fallbackColor={fallbackColor}
|
|
123
|
+
interactive={interactive}
|
|
124
|
+
style={[{ borderRadius: 20, maxHeight: layout.maxHeight }, style]}
|
|
125
|
+
>
|
|
126
|
+
<ScrollView
|
|
127
|
+
keyboardShouldPersistTaps="handled"
|
|
128
|
+
style={{ flexGrow: 0, maxHeight: Math.max(0, layout.maxHeight - 2) }}
|
|
129
|
+
onContentSizeChange={(_width, h) => setHeight(h)}
|
|
130
|
+
contentContainerStyle={{ padding: 12 }}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</ScrollView>
|
|
134
|
+
</Surface>
|
|
135
|
+
</View>
|
|
136
|
+
</ThemeScope>
|
|
137
|
+
</Modal>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Platform,
|
|
4
|
+
Pressable as RNPressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
type PressableProps,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import Animated, { type AnimatedProps } from 'react-native-reanimated';
|
|
10
|
+
|
|
11
|
+
import { useIsolinePlatform } from '../provider.tsx';
|
|
12
|
+
import { cn } from '../utils.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shared touch feedback: contained ripples on Android, a pressed opacity on
|
|
16
|
+
* iOS. Preserve the caller's style callback, handlers and animated styles.
|
|
17
|
+
*/
|
|
18
|
+
type PressableRef = React.ElementRef<typeof RNPressable>;
|
|
19
|
+
export const Pressable: React.ForwardRefExoticComponent<
|
|
20
|
+
PressableProps & React.RefAttributes<PressableRef>
|
|
21
|
+
> = React.forwardRef<React.ElementRef<typeof RNPressable>, PressableProps>(function Pressable(
|
|
22
|
+
{ className, android_ripple, style, disabled, onKeyDown, accessibilityState, ...props },
|
|
23
|
+
ref,
|
|
24
|
+
) {
|
|
25
|
+
const ios = useIsolinePlatform() === 'ios';
|
|
26
|
+
const web = Platform.OS === 'web';
|
|
27
|
+
const classNames = cn(
|
|
28
|
+
// Use browser modality detection, including keyboard use after a tap. Keep
|
|
29
|
+
// the ring inside shaped/connected controls so clipping cannot cut it off.
|
|
30
|
+
web &&
|
|
31
|
+
'outline-0 focus-visible:[outline-style:solid] focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-primary',
|
|
32
|
+
Platform.OS === 'android' &&
|
|
33
|
+
android_ripple &&
|
|
34
|
+
!android_ripple.borderless &&
|
|
35
|
+
'overflow-hidden',
|
|
36
|
+
className,
|
|
37
|
+
);
|
|
38
|
+
return (
|
|
39
|
+
<RNPressable
|
|
40
|
+
ref={ref}
|
|
41
|
+
disabled={disabled}
|
|
42
|
+
onKeyDown={
|
|
43
|
+
web
|
|
44
|
+
? (event) => {
|
|
45
|
+
if (disabled) return;
|
|
46
|
+
onKeyDown?.(event);
|
|
47
|
+
const role = props.role ?? props.accessibilityRole;
|
|
48
|
+
// RN Web's PressResponder only treats role=button as space-activatable.
|
|
49
|
+
// Semantic toggles still need the standard Space behavior on the web.
|
|
50
|
+
if (
|
|
51
|
+
!event.defaultPrevented &&
|
|
52
|
+
event.nativeEvent.key === ' ' &&
|
|
53
|
+
role &&
|
|
54
|
+
['checkbox', 'switch', 'radio', 'menuitemradio', 'menuitemcheckbox'].includes(
|
|
55
|
+
role,
|
|
56
|
+
)
|
|
57
|
+
) {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
const target = event.currentTarget as unknown as { click?: () => void };
|
|
60
|
+
target.click?.();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
: onKeyDown
|
|
64
|
+
}
|
|
65
|
+
{...(web
|
|
66
|
+
? {
|
|
67
|
+
'aria-busy': accessibilityState?.busy,
|
|
68
|
+
'aria-checked': accessibilityState?.checked,
|
|
69
|
+
'aria-disabled': disabled || accessibilityState?.disabled,
|
|
70
|
+
'aria-expanded': accessibilityState?.expanded,
|
|
71
|
+
'aria-selected': accessibilityState?.selected,
|
|
72
|
+
}
|
|
73
|
+
: { accessibilityState })}
|
|
74
|
+
android_ripple={android_ripple}
|
|
75
|
+
// NativeWind's web adapter cannot merge className into a style callback.
|
|
76
|
+
// Supply RN Web's compiled-class marker inside the callback instead.
|
|
77
|
+
className={web ? undefined : classNames}
|
|
78
|
+
style={
|
|
79
|
+
ios || Platform.OS === 'web'
|
|
80
|
+
? (state) => {
|
|
81
|
+
const resolved = typeof style === 'function' ? style(state) : style;
|
|
82
|
+
const opacity = StyleSheet.flatten(resolved)?.opacity;
|
|
83
|
+
// Do not brighten translucent controls or replace animated opacity.
|
|
84
|
+
const pressedOpacity =
|
|
85
|
+
opacity === undefined || typeof opacity === 'number'
|
|
86
|
+
? (opacity ?? 1) * 0.65
|
|
87
|
+
: undefined;
|
|
88
|
+
return [
|
|
89
|
+
web && classNames
|
|
90
|
+
? ({ $$css: true, [classNames]: classNames } as ViewStyle)
|
|
91
|
+
: undefined,
|
|
92
|
+
resolved,
|
|
93
|
+
state.pressed && !disabled && pressedOpacity !== undefined
|
|
94
|
+
? { opacity: pressedOpacity }
|
|
95
|
+
: undefined,
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
: style
|
|
99
|
+
}
|
|
100
|
+
{...props}
|
|
101
|
+
/>
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** For a Pressable whose shape or radius is driven by a Reanimated style. */
|
|
106
|
+
export const AnimatedPressable: React.ComponentType<
|
|
107
|
+
Omit<AnimatedProps<PressableProps>, 'ref'> & React.RefAttributes<PressableRef>
|
|
108
|
+
> = Animated.createAnimatedComponent(Pressable);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { I18nManager, Platform } from 'react-native';
|
|
3
|
+
import { Pressable } from './pressable.tsx';
|
|
4
|
+
|
|
5
|
+
/** Native accessibility supplies radio actions; web adds roving focus and selection. */
|
|
6
|
+
export function useRadioNavigation(
|
|
7
|
+
items: { id: string; disabled?: boolean }[],
|
|
8
|
+
value: string | null,
|
|
9
|
+
onChange: (id: string) => void,
|
|
10
|
+
) {
|
|
11
|
+
const refs = React.useRef<Record<string, React.ComponentRef<typeof Pressable> | null>>({});
|
|
12
|
+
const enabled = items.filter((item) => !item.disabled);
|
|
13
|
+
const first = enabled.find((item) => item.id === value)?.id ?? enabled[0]?.id;
|
|
14
|
+
return (id: string) => ({
|
|
15
|
+
ref: (node: React.ComponentRef<typeof Pressable> | null) => {
|
|
16
|
+
refs.current[id] = node;
|
|
17
|
+
},
|
|
18
|
+
tabIndex: (id === first ? 0 : -1) as 0 | -1,
|
|
19
|
+
...(Platform.OS === 'web'
|
|
20
|
+
? {
|
|
21
|
+
onKeyDown: (
|
|
22
|
+
event: Parameters<
|
|
23
|
+
NonNullable<React.ComponentProps<typeof Pressable>['onKeyDown']>
|
|
24
|
+
>[0],
|
|
25
|
+
) => {
|
|
26
|
+
const key = event.nativeEvent.key;
|
|
27
|
+
if (
|
|
28
|
+
!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(
|
|
29
|
+
key,
|
|
30
|
+
) ||
|
|
31
|
+
!enabled.length
|
|
32
|
+
)
|
|
33
|
+
return;
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
const index = enabled.findIndex((item) => item.id === id);
|
|
36
|
+
const horizontal = key === 'ArrowLeft' || key === 'ArrowRight';
|
|
37
|
+
const delta =
|
|
38
|
+
(key === 'ArrowLeft' || key === 'ArrowUp' ? -1 : 1) *
|
|
39
|
+
(horizontal && I18nManager.isRTL ? -1 : 1);
|
|
40
|
+
const next =
|
|
41
|
+
key === 'Home'
|
|
42
|
+
? enabled[0]
|
|
43
|
+
: key === 'End'
|
|
44
|
+
? enabled[enabled.length - 1]
|
|
45
|
+
: enabled[(index + delta + enabled.length) % enabled.length];
|
|
46
|
+
onChange(next.id);
|
|
47
|
+
refs.current[next.id]?.focus();
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
: {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RefreshControl as RNRefreshControl,
|
|
3
|
+
type RefreshControlProps as RNRefreshControlProps,
|
|
4
|
+
} from 'react-native';
|
|
5
|
+
|
|
6
|
+
import { useThemeColors } from '../provider.tsx';
|
|
7
|
+
|
|
8
|
+
export type RefreshControlProps = RNRefreshControlProps;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* RN's `RefreshControl`, pre-colored with the app's role tokens for the
|
|
12
|
+
* current scheme.
|
|
13
|
+
*
|
|
14
|
+
* Every other prop is forwarded, and that is load-bearing rather than
|
|
15
|
+
* politeness. On Android a `ScrollView` does not render its `refreshControl`
|
|
16
|
+
* beside the list: it clones the element with the scroll view itself as
|
|
17
|
+
* `children` and the list's layout style, and renders that clone as the
|
|
18
|
+
* outer view. A wrapper that accepts only `refreshing` and `onRefresh` drops
|
|
19
|
+
* both — which takes the entire list, empty state and footer included, out of
|
|
20
|
+
* the tree and leaves a blank screen under the header. Keep this a
|
|
21
|
+
* pass-through; anything it swallows, the list loses.
|
|
22
|
+
*/
|
|
23
|
+
export function RefreshControl({
|
|
24
|
+
tintColor,
|
|
25
|
+
colors,
|
|
26
|
+
progressBackgroundColor,
|
|
27
|
+
...rest
|
|
28
|
+
}: RefreshControlProps) {
|
|
29
|
+
const theme = useThemeColors();
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<RNRefreshControl
|
|
33
|
+
{...rest}
|
|
34
|
+
tintColor={tintColor ?? theme.onSurfaceVariant}
|
|
35
|
+
colors={colors ?? [theme.primary]}
|
|
36
|
+
progressBackgroundColor={progressBackgroundColor ?? theme.surfaceContainerHigh}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The space a card list leaves between two rows.
|
|
5
|
+
*
|
|
6
|
+
* Every list that renders rows as separate rounded cards passes this as its
|
|
7
|
+
* `ItemSeparatorComponent`, so the rhythm is one value rather than a number
|
|
8
|
+
* repeated per screen. Taller cards that need to breathe more — the
|
|
9
|
+
* Screener's — space themselves.
|
|
10
|
+
*/
|
|
11
|
+
export function RowGap() {
|
|
12
|
+
return <View className="h-[3px]" />;
|
|
13
|
+
}
|