@isoline-ui/react-native 0.0.0-stage → 1.0.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.
Files changed (167) hide show
  1. package/LICENSE +22 -0
  2. package/NOTICE.md +12 -0
  3. package/README.md +104 -3
  4. package/dist/amount.d.ts +3 -0
  5. package/dist/calendar.d.ts +7 -0
  6. package/dist/components/action-chip.d.ts +9 -0
  7. package/dist/components/amount-field.d.ts +12 -0
  8. package/dist/components/avatar.d.ts +17 -0
  9. package/dist/components/back-button.d.ts +12 -0
  10. package/dist/components/back-icon.d.ts +3 -0
  11. package/dist/components/badge.d.ts +13 -0
  12. package/dist/components/banner.d.ts +14 -0
  13. package/dist/components/bottom-sheet-native.d.ts +11 -0
  14. package/dist/components/bottom-sheet-web.d.ts +18 -0
  15. package/dist/components/bottom-sheet.d.ts +3 -0
  16. package/dist/components/button-group.d.ts +26 -0
  17. package/dist/components/button.d.ts +48 -0
  18. package/dist/components/checkbox.d.ts +12 -0
  19. package/dist/components/color-picker.d.ts +21 -0
  20. package/dist/components/context-menu.d.ts +26 -0
  21. package/dist/components/date-picker.d.ts +64 -0
  22. package/dist/components/dialog-context.d.ts +2 -0
  23. package/dist/components/dialog.d.ts +20 -0
  24. package/dist/components/empty-state.d.ts +15 -0
  25. package/dist/components/expand-indicator.d.ts +27 -0
  26. package/dist/components/expressive-shape.d.ts +18 -0
  27. package/dist/components/fab-menu-legacy.d.ts +31 -0
  28. package/dist/components/fab-menu.d.ts +21 -0
  29. package/dist/components/filter-chip.d.ts +69 -0
  30. package/dist/components/floating-action-button.d.ts +15 -0
  31. package/dist/components/icon-button.d.ts +40 -0
  32. package/dist/components/icons.d.ts +18 -0
  33. package/dist/components/input.d.ts +9 -0
  34. package/dist/components/keyboard-inset-view.d.ts +15 -0
  35. package/dist/components/list-controls.d.ts +38 -0
  36. package/dist/components/loading-indicator.d.ts +9 -0
  37. package/dist/components/menu-sheet.d.ts +36 -0
  38. package/dist/components/panel-sheet.d.ts +8 -0
  39. package/dist/components/password-visibility.d.ts +5 -0
  40. package/dist/components/popover-layout.d.ts +25 -0
  41. package/dist/components/popover.d.ts +20 -0
  42. package/dist/components/pressable.d.ts +12 -0
  43. package/dist/components/radio-navigation.d.ts +11 -0
  44. package/dist/components/refresh-control.d.ts +16 -0
  45. package/dist/components/row-gap.d.ts +9 -0
  46. package/dist/components/search-field.d.ts +22 -0
  47. package/dist/components/select-field.d.ts +16 -0
  48. package/dist/components/settings-list.d.ts +98 -0
  49. package/dist/components/settings-row.d.ts +13 -0
  50. package/dist/components/sheet-frame.d.ts +27 -0
  51. package/dist/components/sheet-lifecycle.d.ts +36 -0
  52. package/dist/components/sheet-transition.d.ts +36 -0
  53. package/dist/components/slider.d.ts +21 -0
  54. package/dist/components/snackbar.d.ts +10 -0
  55. package/dist/components/split-button.d.ts +38 -0
  56. package/dist/components/squish.d.ts +20 -0
  57. package/dist/components/swipeable-row.d.ts +20 -0
  58. package/dist/components/switch.d.ts +8 -0
  59. package/dist/components/tab-bar.d.ts +20 -0
  60. package/dist/components/text-field.d.ts +31 -0
  61. package/dist/components/text.d.ts +9 -0
  62. package/dist/components/time-picker.d.ts +40 -0
  63. package/dist/components/toolbar.d.ts +77 -0
  64. package/dist/components/top-bar.d.ts +27 -0
  65. package/dist/components/undo-bar.d.ts +23 -0
  66. package/dist/components/wavy-progress.d.ts +21 -0
  67. package/dist/components/zoomable-image.d.ts +18 -0
  68. package/dist/expressive-shapes.d.ts +8 -0
  69. package/dist/index.d.ts +62 -0
  70. package/dist/keyboard.d.ts +2 -0
  71. package/dist/material.d.ts +11 -0
  72. package/dist/media.d.ts +11 -0
  73. package/dist/motion.d.ts +96 -0
  74. package/dist/platform.d.ts +3 -0
  75. package/dist/preset-tokens.d.ts +264 -0
  76. package/dist/provider.d.ts +59 -0
  77. package/dist/reduced-motion.d.ts +45 -0
  78. package/dist/selection.d.ts +2 -0
  79. package/dist/shape.d.ts +99 -0
  80. package/dist/sheet-physics.d.ts +37 -0
  81. package/dist/theme.d.ts +103 -0
  82. package/dist/tokens.d.ts +2 -0
  83. package/dist/use-keyboard-height.d.ts +10 -0
  84. package/dist/utils.d.ts +3 -0
  85. package/nativewind.cjs +68 -0
  86. package/package.json +63 -6
  87. package/src/amount.ts +53 -0
  88. package/src/calendar.ts +42 -0
  89. package/src/components/action-chip.tsx +64 -0
  90. package/src/components/amount-field.tsx +49 -0
  91. package/src/components/avatar.tsx +91 -0
  92. package/src/components/back-button.tsx +39 -0
  93. package/src/components/back-icon.tsx +25 -0
  94. package/src/components/badge.tsx +41 -0
  95. package/src/components/banner.tsx +63 -0
  96. package/src/components/bottom-sheet-native.tsx +52 -0
  97. package/src/components/bottom-sheet-web.tsx +193 -0
  98. package/src/components/bottom-sheet.tsx +5 -0
  99. package/src/components/button-group.tsx +326 -0
  100. package/src/components/button.tsx +337 -0
  101. package/src/components/checkbox.tsx +71 -0
  102. package/src/components/color-picker.tsx +115 -0
  103. package/src/components/context-menu.tsx +149 -0
  104. package/src/components/date-picker.tsx +466 -0
  105. package/src/components/dialog-context.ts +3 -0
  106. package/src/components/dialog.tsx +210 -0
  107. package/src/components/empty-state.tsx +49 -0
  108. package/src/components/expand-indicator.tsx +60 -0
  109. package/src/components/expressive-shape.tsx +37 -0
  110. package/src/components/fab-menu-legacy.tsx +181 -0
  111. package/src/components/fab-menu.tsx +90 -0
  112. package/src/components/filter-chip.tsx +136 -0
  113. package/src/components/floating-action-button.tsx +79 -0
  114. package/src/components/icon-button.tsx +127 -0
  115. package/src/components/icons.tsx +43 -0
  116. package/src/components/input.tsx +80 -0
  117. package/src/components/keyboard-inset-view.tsx +28 -0
  118. package/src/components/list-controls.tsx +134 -0
  119. package/src/components/loading-indicator.tsx +183 -0
  120. package/src/components/menu-sheet.tsx +133 -0
  121. package/src/components/panel-sheet.tsx +90 -0
  122. package/src/components/password-visibility.tsx +39 -0
  123. package/src/components/popover-layout.ts +47 -0
  124. package/src/components/popover.tsx +139 -0
  125. package/src/components/pressable.tsx +108 -0
  126. package/src/components/radio-navigation.ts +52 -0
  127. package/src/components/refresh-control.tsx +39 -0
  128. package/src/components/row-gap.tsx +13 -0
  129. package/src/components/search-field.tsx +92 -0
  130. package/src/components/select-field.tsx +105 -0
  131. package/src/components/settings-list.tsx +302 -0
  132. package/src/components/settings-row.tsx +61 -0
  133. package/src/components/sheet-frame.tsx +250 -0
  134. package/src/components/sheet-lifecycle.ts +233 -0
  135. package/src/components/sheet-transition.ts +105 -0
  136. package/src/components/slider.tsx +185 -0
  137. package/src/components/snackbar.tsx +97 -0
  138. package/src/components/split-button.tsx +212 -0
  139. package/src/components/squish.ts +41 -0
  140. package/src/components/swipeable-row.tsx +109 -0
  141. package/src/components/switch.tsx +77 -0
  142. package/src/components/tab-bar.tsx +195 -0
  143. package/src/components/text-field.tsx +264 -0
  144. package/src/components/text.tsx +58 -0
  145. package/src/components/time-picker.tsx +277 -0
  146. package/src/components/toolbar.tsx +406 -0
  147. package/src/components/top-bar.tsx +91 -0
  148. package/src/components/undo-bar.tsx +137 -0
  149. package/src/components/wavy-progress.tsx +150 -0
  150. package/src/components/zoomable-image.tsx +240 -0
  151. package/src/expressive-shapes.ts +25 -0
  152. package/src/index.ts +62 -0
  153. package/src/keyboard.ts +5 -0
  154. package/src/material.ts +16 -0
  155. package/src/media.ts +51 -0
  156. package/src/motion.ts +96 -0
  157. package/src/platform.ts +13 -0
  158. package/src/preset-tokens.ts +265 -0
  159. package/src/provider.tsx +201 -0
  160. package/src/reduced-motion.ts +50 -0
  161. package/src/selection.ts +11 -0
  162. package/src/shape.ts +217 -0
  163. package/src/sheet-physics.ts +73 -0
  164. package/src/theme.ts +180 -0
  165. package/src/tokens.ts +2 -0
  166. package/src/use-keyboard-height.ts +64 -0
  167. 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
+ }