@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.
- package/README.md +145 -7
- package/package.json +35 -8
- package/src/BulkActionBar.tsx +303 -0
- package/src/DividedList.tsx +37 -0
- package/src/DropdownMenu.tsx +439 -0
- package/src/FloatingFAB.tsx +4 -1
- package/src/ai/activity-step-detail.tsx +95 -0
- package/src/ai/choice-question.tsx +297 -0
- package/src/ai/code-block.tsx +50 -7
- package/src/ai/index.ts +10 -0
- package/src/ai/message.tsx +59 -12
- package/src/ai/prompt-input.tsx +79 -28
- package/src/ai/trace.tsx +32 -1
- package/src/auth/AuthScreen.tsx +32 -4
- package/src/auth/SecondFactorScreen.tsx +87 -0
- package/src/auth/SignInScreen.tsx +262 -1
- package/src/auth/index.ts +23 -1
- package/src/auth/totp.ts +202 -0
- package/src/configureTheme.ts +60 -0
- package/src/entries/markdown.ts +5 -0
- package/src/entries/primitives.ts +22 -0
- package/src/entries/theme.ts +2 -0
- package/src/examples/ParityGallery.tsx +56 -0
- package/src/floating.ts +2 -4
- package/src/highlight.ts +117 -0
- package/src/index.ts +24 -0
- package/src/markdown/Markdown.tsx +191 -0
- package/src/push.ts +186 -0
|
@@ -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
|
+
}
|