@vvedantb/native-ui 0.8.0 → 0.10.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.
@@ -0,0 +1,303 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import { Pressable, ScrollView, Text, View, useWindowDimensions, type ViewStyle } from "react-native";
3
+ import { FadeInUp, FadeOutDown } from "react-native-reanimated";
4
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
5
+ import { IconDots, IconX, type IconProps } from "@tabler/icons-react-native";
6
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./DropdownMenu";
7
+ import { FloatingSurface } from "./FloatingSurface";
8
+ import { MIN_TOUCH_TARGET, sizeStyle, touchSlop, typeStyle } from "./controlStyles";
9
+ import { palette, withAlpha } from "./palette";
10
+ import { useLargerText } from "./theme";
11
+ import { tokens } from "./tokens";
12
+ import { useScheme } from "./useScheme";
13
+ import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
14
+
15
+ /**
16
+ * One control in the bar, as the web `BulkBarAction`. Each action carries the
17
+ * handler it runs, so confirm dialogs stay with the caller.
18
+ */
19
+ export interface BulkBarAction {
20
+ key: string;
21
+ /** Full label: the More menu item and the accessible name. */
22
+ label: string;
23
+ /** Shorter label shown in the bar on wide screens (defaults to `label`). */
24
+ shortLabel?: string;
25
+ /** A Tabler icon, drawn at 17 in the button colour. */
26
+ icon: ComponentType<IconProps>;
27
+ /** Label and icon in the danger colour. */
28
+ destructive?: boolean;
29
+ /** The web Alt-to-skip hint. No effect on a phone (no Alt key). */
30
+ showSkipHint?: boolean;
31
+ /** Native only: turns this action off even with a selection. */
32
+ disabled?: boolean;
33
+ onClick: () => void;
34
+ }
35
+
36
+ export interface BulkActionBarProps {
37
+ isSelecting: boolean;
38
+ selectedCount: number;
39
+ totalCount: number;
40
+ onExitSelect: () => void;
41
+ onSelectAll: () => void;
42
+ onClearSelection: () => void;
43
+ primaryActions: ReadonlyArray<BulkBarAction>;
44
+ /** Behind a More (dots) button, in a `DropdownMenu`. */
45
+ moreActions?: ReadonlyArray<BulkBarAction>;
46
+ /** On the right, after a divider. */
47
+ destructiveAction?: BulkBarAction;
48
+ /** The web motion key. Here the bar's `testID`. */
49
+ barKey?: string;
50
+ /**
51
+ * Space under the bar, in points, before its 12 gap. Default: the bottom
52
+ * safe-area inset. Above a `FloatingTabBar`, pass
53
+ * `tabBarReserve(largerText, insets.bottom)`.
54
+ */
55
+ bottomOffset?: number;
56
+ }
57
+
58
+ /** The web `sm` breakpoint: below it, bar buttons are icon-only and "selected" is hidden. */
59
+ export const BULK_BAR_LABEL_MIN_WIDTH = 640;
60
+
61
+ /** Web `h-9` buttons with a 17 icon and `text-sm`; with larger text 48 high, a 22 icon and `text-lg`. */
62
+ function barMetrics(largerText: boolean) {
63
+ return largerText
64
+ ? { height: 48, icon: 22, text: sizeStyle("text-lg"), caption: sizeStyle("text-base") }
65
+ : { height: 36, icon: 17, text: sizeStyle("text-sm"), caption: sizeStyle("text-xs") };
66
+ }
67
+
68
+ /**
69
+ * The floating selection bar, as the web `BulkActionBar`: a pill-ended glass
70
+ * bar (`FloatingSurface`, radius 16, `px-2.5 py-2`, `gap-1`) centred at the
71
+ * bottom of its container, 12 above `bottomOffset`. It holds the selected
72
+ * count chip, Select all (n) / Clear, a divider, the primary actions, a More
73
+ * menu, the destructive action, and Cancel selection (X). Actions are off with
74
+ * no selection. Below 640 wide (the web `sm`) the actions are icon-only. Put it
75
+ * last in a full-size container; it scrolls sideways when it does not fit.
76
+ */
77
+ export function BulkActionBar({
78
+ isSelecting,
79
+ selectedCount,
80
+ totalCount,
81
+ onExitSelect,
82
+ onSelectAll,
83
+ onClearSelection,
84
+ primaryActions,
85
+ moreActions,
86
+ destructiveAction,
87
+ barKey,
88
+ bottomOffset,
89
+ }: BulkActionBarProps) {
90
+ const scheme = useScheme();
91
+ const metrics = barMetrics(useLargerText());
92
+ const insets = useSafeAreaInsets();
93
+ const { width } = useWindowDimensions();
94
+ if (!isSelecting) return null;
95
+ const wide = width >= BULK_BAR_LABEL_MIN_WIDTH;
96
+ const hasSelection = selectedCount > 0;
97
+ const allSelected = totalCount > 0 && selectedCount >= totalCount;
98
+ const muted = tokens.color.muted[scheme];
99
+
100
+ return (
101
+ <NativeOnlyAnimatedView
102
+ testID={barKey}
103
+ pointerEvents="box-none"
104
+ entering={FadeInUp.duration(150)}
105
+ exiting={FadeOutDown.duration(150)}
106
+ style={{
107
+ position: "absolute",
108
+ left: 0,
109
+ right: 0,
110
+ bottom: 12 + (bottomOffset ?? insets.bottom),
111
+ alignItems: "center",
112
+ paddingHorizontal: 16,
113
+ }}
114
+ >
115
+ <FloatingSurface radius={tokens.radius.lg} style={{ maxWidth: width - 32 }}>
116
+ <ScrollView
117
+ horizontal
118
+ bounces={false}
119
+ showsHorizontalScrollIndicator={false}
120
+ contentContainerStyle={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 10, paddingVertical: 8 }}
121
+ >
122
+ <View
123
+ accessible
124
+ accessibilityLabel={`${selectedCount} selected`}
125
+ style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingLeft: 4, paddingRight: 2 }}
126
+ >
127
+ <View
128
+ style={{
129
+ borderRadius: tokens.radius.md,
130
+ paddingHorizontal: 8,
131
+ paddingVertical: 2,
132
+ backgroundColor: tokens.color.surfaceSecondary[scheme],
133
+ }}
134
+ >
135
+ <Text
136
+ className="font-instrument-sans-semibold"
137
+ style={[metrics.caption, typeStyle("semibold"), { fontVariant: ["tabular-nums"], color: tokens.color.foreground[scheme] }]}
138
+ >
139
+ {selectedCount}
140
+ </Text>
141
+ </View>
142
+ {wide ? (
143
+ <Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color: muted }]}>
144
+ selected
145
+ </Text>
146
+ ) : null}
147
+ </View>
148
+
149
+ {totalCount > 0 ? (
150
+ <BarPressable height={metrics.height} paddingX={8} onPress={allSelected ? onClearSelection : onSelectAll}>
151
+ <Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color: muted }]}>
152
+ {allSelected ? "Clear" : `Select all (${totalCount})`}
153
+ </Text>
154
+ </BarPressable>
155
+ ) : null}
156
+
157
+ <BarDivider />
158
+
159
+ {primaryActions.map((action) => (
160
+ <BarButton key={action.key} action={action} disabled={!hasSelection} wide={wide} />
161
+ ))}
162
+
163
+ {moreActions && moreActions.length > 0 ? (
164
+ <DropdownMenu>
165
+ <DropdownMenuTrigger
166
+ accessibilityLabel="More actions"
167
+ disabled={!hasSelection}
168
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, metrics.height)}
169
+ style={({ pressed }) => barButtonStyle(metrics.height, 0, pressed, !hasSelection, false, scheme)}
170
+ >
171
+ <IconDots size={metrics.icon} color={muted} />
172
+ </DropdownMenuTrigger>
173
+ <DropdownMenuContent align="end" sideOffset={8}>
174
+ {moreActions.map((action) => (
175
+ <DropdownMenuItem
176
+ key={action.key}
177
+ icon={action.icon}
178
+ destructive={action.destructive}
179
+ disabled={!hasSelection || Boolean(action.disabled)}
180
+ onSelect={action.onClick}
181
+ >
182
+ {action.label}
183
+ </DropdownMenuItem>
184
+ ))}
185
+ </DropdownMenuContent>
186
+ </DropdownMenu>
187
+ ) : null}
188
+
189
+ {destructiveAction ? (
190
+ <>
191
+ <BarDivider />
192
+ <BarButton action={destructiveAction} disabled={!hasSelection} wide={wide} />
193
+ </>
194
+ ) : null}
195
+
196
+ <BarDivider />
197
+
198
+ <BarPressable height={metrics.height} paddingX={0} label="Cancel selection" onPress={onExitSelect}>
199
+ <IconX size={metrics.icon} color={muted} />
200
+ </BarPressable>
201
+ </ScrollView>
202
+ </FloatingSurface>
203
+ </NativeOnlyAnimatedView>
204
+ );
205
+ }
206
+
207
+ /**
208
+ * A bar button box: the web `h-9 rounded-lg`, at least 44 wide and slopped to
209
+ * 44 high, `surface-secondary` while pressed (a 10% danger tint for a
210
+ * destructive one), faded to 30% when off.
211
+ */
212
+ function barButtonStyle(
213
+ height: number,
214
+ paddingX: number,
215
+ pressed: boolean,
216
+ disabled: boolean,
217
+ destructive: boolean,
218
+ scheme: "light" | "dark",
219
+ ): ViewStyle {
220
+ const pressedFill = destructive ? withAlpha(palette.danger[scheme], 0.1) : tokens.color.surfaceSecondary[scheme];
221
+ return {
222
+ height,
223
+ minWidth: MIN_TOUCH_TARGET,
224
+ paddingHorizontal: paddingX,
225
+ flexDirection: "row",
226
+ alignItems: "center",
227
+ justifyContent: "center",
228
+ gap: 6,
229
+ borderRadius: tokens.radius.lg,
230
+ backgroundColor: pressed && !disabled ? pressedFill : "transparent",
231
+ opacity: disabled ? 0.3 : 1,
232
+ };
233
+ }
234
+
235
+ function BarPressable({
236
+ height,
237
+ paddingX,
238
+ label,
239
+ disabled = false,
240
+ destructive = false,
241
+ onPress,
242
+ children,
243
+ }: {
244
+ height: number;
245
+ paddingX: number;
246
+ label?: string;
247
+ disabled?: boolean;
248
+ destructive?: boolean;
249
+ onPress: () => void;
250
+ children: ReactNode;
251
+ }) {
252
+ const scheme = useScheme();
253
+ return (
254
+ <Pressable
255
+ accessibilityRole="button"
256
+ accessibilityLabel={label}
257
+ accessibilityState={{ disabled }}
258
+ disabled={disabled}
259
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, height)}
260
+ onPress={onPress}
261
+ style={({ pressed }) => barButtonStyle(height, paddingX, pressed, disabled, destructive, scheme)}
262
+ >
263
+ {children}
264
+ </Pressable>
265
+ );
266
+ }
267
+
268
+ /** One action: the icon, and on wide screens the short label (`text-sm` medium). */
269
+ function BarButton({ action, disabled, wide }: { action: BulkBarAction; disabled: boolean; wide: boolean }) {
270
+ const scheme = useScheme();
271
+ const metrics = barMetrics(useLargerText());
272
+ const Icon = action.icon;
273
+ const color = action.destructive ? palette.danger[scheme] : tokens.color.muted[scheme];
274
+ return (
275
+ <BarPressable
276
+ height={metrics.height}
277
+ paddingX={10}
278
+ label={action.label}
279
+ disabled={disabled || Boolean(action.disabled)}
280
+ destructive={action.destructive}
281
+ onPress={action.onClick}
282
+ >
283
+ <Icon size={metrics.icon} color={color} />
284
+ {wide ? (
285
+ <Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color }]}>
286
+ {action.shortLabel ?? action.label}
287
+ </Text>
288
+ ) : null}
289
+ </BarPressable>
290
+ );
291
+ }
292
+
293
+ /** The web vertical `Separator`: `mx-1.5 h-5 bg-border`. */
294
+ function BarDivider() {
295
+ const scheme = useScheme();
296
+ return (
297
+ <View
298
+ accessible={false}
299
+ importantForAccessibility="no"
300
+ style={{ width: 1, height: 20, marginHorizontal: 6, backgroundColor: tokens.color.border[scheme] }}
301
+ />
302
+ );
303
+ }
@@ -0,0 +1,37 @@
1
+ import { Children, Fragment, isValidElement, type ReactNode } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { tokens } from "./tokens";
4
+ import { useScheme } from "./useScheme";
5
+
6
+ export type DividedListProps = ViewProps & {
7
+ children: ReactNode;
8
+ /** Left inset of each divider, to start it under the row text. Default 0 (edge to edge). */
9
+ inset?: number;
10
+ className?: string;
11
+ };
12
+
13
+ /**
14
+ * Flat rows with a hairline between each pair, as the web `divide-y
15
+ * divide-border` lists (Eva's inbox rows). Rows keep their own padding; the
16
+ * list draws no fill, radius or outer border. `null` children are skipped, so
17
+ * no divider is left hanging.
18
+ */
19
+ export function DividedList({ children, inset = 0, className, style, ...props }: DividedListProps) {
20
+ const scheme = useScheme();
21
+ const rows = Children.toArray(children).filter(isValidElement);
22
+ return (
23
+ <View className={className} style={style} {...props}>
24
+ {rows.map((row, index) => (
25
+ <Fragment key={row.key ?? index}>
26
+ {index > 0 ? (
27
+ <View
28
+ accessible={false}
29
+ style={{ height: 1, marginLeft: inset, backgroundColor: tokens.color.border[scheme] }}
30
+ />
31
+ ) : null}
32
+ {row}
33
+ </Fragment>
34
+ ))}
35
+ </View>
36
+ );
37
+ }