@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,439 @@
|
|
|
1
|
+
import * as Slot from "@rn-primitives/slot";
|
|
2
|
+
import { createContext, useContext, useRef, useState, type ComponentType, type ReactNode } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Modal,
|
|
5
|
+
Pressable,
|
|
6
|
+
ScrollView,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
Text,
|
|
9
|
+
View,
|
|
10
|
+
useWindowDimensions,
|
|
11
|
+
type GestureResponderEvent,
|
|
12
|
+
type PressableProps,
|
|
13
|
+
type ViewProps,
|
|
14
|
+
} from "react-native";
|
|
15
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
16
|
+
import { IconCheck, type IconProps } from "@tabler/icons-react-native";
|
|
17
|
+
import { FloatingSurface } from "./FloatingSurface";
|
|
18
|
+
import { cn } from "./cn";
|
|
19
|
+
import { MIN_TOUCH_TARGET, sizeStyle, typeStyle } from "./controlStyles";
|
|
20
|
+
import { palette } from "./palette";
|
|
21
|
+
import { useLargerText } from "./theme";
|
|
22
|
+
import { tokens } from "./tokens";
|
|
23
|
+
import { useReduceMotion } from "./useReduceMotion";
|
|
24
|
+
import { useScheme } from "./useScheme";
|
|
25
|
+
import { useControllableState } from "./primitives/use-controllable-state";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sizes from the web menu classes (`_menu-classes.ts`): content `min-w-[12rem]
|
|
29
|
+
* p-1.5 rounded-lg`, `sideOffset` 4 and `collisionPadding` 8; items `px-2.5
|
|
30
|
+
* py-2 rounded-menu-item` (8) with a 16 icon `gap-2`; radio items `pl-8` with
|
|
31
|
+
* the check at `left-2.5`; the label `px-2.5 py-1.5`; the separator `-mx-1.5
|
|
32
|
+
* my-1 h-px`. Items are at least 44 high (the web `max-sm:min-h-10` is 40).
|
|
33
|
+
*/
|
|
34
|
+
export const DROPDOWN_MENU = {
|
|
35
|
+
minWidth: 192,
|
|
36
|
+
padding: 6,
|
|
37
|
+
radius: tokens.radius.lg,
|
|
38
|
+
sideOffset: 4,
|
|
39
|
+
edge: 8,
|
|
40
|
+
itemRadius: 8,
|
|
41
|
+
itemPaddingX: 10,
|
|
42
|
+
itemPaddingY: 8,
|
|
43
|
+
itemGap: 8,
|
|
44
|
+
indicatorInset: 32,
|
|
45
|
+
indicatorLeft: 10,
|
|
46
|
+
labelPaddingY: 6,
|
|
47
|
+
separatorMargin: 4,
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
/** A view's box in window coordinates, from `measureInWindow`. */
|
|
51
|
+
export type DropdownMenuRect = { x: number; y: number; width: number; height: number };
|
|
52
|
+
|
|
53
|
+
type MenuContextValue = {
|
|
54
|
+
open: boolean;
|
|
55
|
+
setOpen: (open: boolean) => void;
|
|
56
|
+
trigger: DropdownMenuRect | null;
|
|
57
|
+
setTrigger: (rect: DropdownMenuRect) => void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const MenuContext = createContext<MenuContextValue | null>(null);
|
|
61
|
+
|
|
62
|
+
function useMenu(part: string): MenuContextValue {
|
|
63
|
+
const context = useContext(MenuContext);
|
|
64
|
+
if (!context) throw new Error(`${part} must be inside a DropdownMenu.`);
|
|
65
|
+
return context;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type DropdownMenuProps = {
|
|
69
|
+
/** Open state when the parent controls it. */
|
|
70
|
+
open?: boolean;
|
|
71
|
+
/** Initial open state when `open` is not set. Default `false`. */
|
|
72
|
+
defaultOpen?: boolean;
|
|
73
|
+
/** Called with the next open state (trigger, item, outside tap, Android back). */
|
|
74
|
+
onOpenChange?: (open: boolean) => void;
|
|
75
|
+
children?: ReactNode;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A small menu anchored to its trigger, the native `DropdownMenu`: holds the
|
|
80
|
+
* open state (controlled with `open` / `onOpenChange`, or `defaultOpen`) and
|
|
81
|
+
* renders no view.
|
|
82
|
+
*/
|
|
83
|
+
export function DropdownMenu({ open, defaultOpen = false, onOpenChange, children }: DropdownMenuProps) {
|
|
84
|
+
const [value, setValue] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange });
|
|
85
|
+
const [trigger, setTrigger] = useState<DropdownMenuRect | null>(null);
|
|
86
|
+
return (
|
|
87
|
+
<MenuContext.Provider value={{ open: value, setOpen: setValue, trigger, setTrigger }}>
|
|
88
|
+
{children}
|
|
89
|
+
</MenuContext.Provider>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type DropdownMenuTriggerProps = PressableProps & {
|
|
94
|
+
/**
|
|
95
|
+
* Merge the press into the single child (for example a `Button` or
|
|
96
|
+
* `IconButton`) instead of wrapping it. The child must pass `ref` on to its
|
|
97
|
+
* native view, so the menu can measure it.
|
|
98
|
+
*/
|
|
99
|
+
asChild?: boolean;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Opens the menu, or closes it when open. On press it measures itself
|
|
104
|
+
* (`measureInWindow`) and the menu opens under it. Reports `expanded`.
|
|
105
|
+
*/
|
|
106
|
+
export function DropdownMenuTrigger({ asChild, onPress, accessibilityState, ...props }: DropdownMenuTriggerProps) {
|
|
107
|
+
const { open, setOpen, setTrigger } = useMenu("DropdownMenuTrigger");
|
|
108
|
+
const ref = useRef<View>(null);
|
|
109
|
+
const Component = asChild ? Slot.Pressable : Pressable;
|
|
110
|
+
return (
|
|
111
|
+
<Component
|
|
112
|
+
ref={ref}
|
|
113
|
+
accessibilityRole="button"
|
|
114
|
+
{...props}
|
|
115
|
+
accessibilityState={{ ...accessibilityState, expanded: open }}
|
|
116
|
+
onPress={(event: GestureResponderEvent) => {
|
|
117
|
+
onPress?.(event);
|
|
118
|
+
if (open) return setOpen(false);
|
|
119
|
+
const view = ref.current;
|
|
120
|
+
if (!view) return setOpen(true);
|
|
121
|
+
view.measureInWindow((x, y, width, height) => {
|
|
122
|
+
setTrigger({ x, y, width, height });
|
|
123
|
+
setOpen(true);
|
|
124
|
+
});
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type DropdownMenuAlign = "start" | "end";
|
|
131
|
+
|
|
132
|
+
export type DropdownMenuPosition = {
|
|
133
|
+
top?: number;
|
|
134
|
+
bottom?: number;
|
|
135
|
+
left?: number;
|
|
136
|
+
right?: number;
|
|
137
|
+
maxHeight: number;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Where the menu goes: under the trigger, `sideOffset` away, its start edge on
|
|
142
|
+
* the trigger's start (`"start"`) or its end edge on the trigger's end
|
|
143
|
+
* (`"end"`), kept 8 inside the safe area. It flips above the trigger when its
|
|
144
|
+
* content does not fit below and there is more room above. Without a trigger
|
|
145
|
+
* box it sits at the top of the safe area.
|
|
146
|
+
*/
|
|
147
|
+
export function dropdownMenuPosition({
|
|
148
|
+
trigger,
|
|
149
|
+
window,
|
|
150
|
+
insets,
|
|
151
|
+
align,
|
|
152
|
+
sideOffset = DROPDOWN_MENU.sideOffset,
|
|
153
|
+
contentHeight,
|
|
154
|
+
}: {
|
|
155
|
+
trigger: DropdownMenuRect | null;
|
|
156
|
+
window: { width: number; height: number };
|
|
157
|
+
insets: { top: number; bottom: number; left: number; right: number };
|
|
158
|
+
align: DropdownMenuAlign;
|
|
159
|
+
sideOffset?: number;
|
|
160
|
+
contentHeight: number;
|
|
161
|
+
}): DropdownMenuPosition {
|
|
162
|
+
const edge = DROPDOWN_MENU.edge;
|
|
163
|
+
const minTop = insets.top + edge;
|
|
164
|
+
const maxBottom = window.height - insets.bottom - edge;
|
|
165
|
+
const minLeft = insets.left + edge;
|
|
166
|
+
const minRight = insets.right + edge;
|
|
167
|
+
if (!trigger) return { top: minTop, left: minLeft, maxHeight: maxBottom - minTop };
|
|
168
|
+
const horizontal =
|
|
169
|
+
align === "end"
|
|
170
|
+
? { right: Math.max(minRight, window.width - (trigger.x + trigger.width)) }
|
|
171
|
+
: { left: Math.max(minLeft, trigger.x) };
|
|
172
|
+
const below = trigger.y + trigger.height + sideOffset;
|
|
173
|
+
const above = trigger.y - sideOffset;
|
|
174
|
+
const roomBelow = maxBottom - below;
|
|
175
|
+
const roomAbove = above - minTop;
|
|
176
|
+
if (contentHeight > roomBelow && roomAbove > roomBelow) {
|
|
177
|
+
return { ...horizontal, bottom: window.height - above, maxHeight: roomAbove };
|
|
178
|
+
}
|
|
179
|
+
return { ...horizontal, top: below, maxHeight: roomBelow };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type DropdownMenuContentProps = ViewProps & {
|
|
183
|
+
/** Which trigger edge the menu lines up with. Default `"start"`. */
|
|
184
|
+
align?: DropdownMenuAlign;
|
|
185
|
+
/** Gap from the trigger. Default 4, as on the web. */
|
|
186
|
+
sideOffset?: number;
|
|
187
|
+
className?: string;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The menu in a transparent RN `Modal`: the opaque overlay surface of
|
|
192
|
+
* `Dialog` and `Sheet` (`FloatingSurface` without blur, with the floating
|
|
193
|
+
* shadow) at radius 16, at least 192 wide, `p-1.5`, scrolling when taller than
|
|
194
|
+
* the room. A tap outside, Android back and the VoiceOver escape gesture close
|
|
195
|
+
* it. With Reduce Motion on, the fade is off.
|
|
196
|
+
*/
|
|
197
|
+
export function DropdownMenuContent({
|
|
198
|
+
align = "start",
|
|
199
|
+
sideOffset,
|
|
200
|
+
className,
|
|
201
|
+
style,
|
|
202
|
+
children,
|
|
203
|
+
...props
|
|
204
|
+
}: DropdownMenuContentProps) {
|
|
205
|
+
const { open, setOpen, trigger } = useMenu("DropdownMenuContent");
|
|
206
|
+
const window = useWindowDimensions();
|
|
207
|
+
const insets = useSafeAreaInsets();
|
|
208
|
+
const reduceMotion = useReduceMotion();
|
|
209
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
210
|
+
const close = () => setOpen(false);
|
|
211
|
+
const position = dropdownMenuPosition({ trigger, window, insets, align, sideOffset, contentHeight });
|
|
212
|
+
return (
|
|
213
|
+
<Modal
|
|
214
|
+
visible={open}
|
|
215
|
+
transparent
|
|
216
|
+
animationType={reduceMotion ? "none" : "fade"}
|
|
217
|
+
statusBarTranslucent
|
|
218
|
+
onRequestClose={close}
|
|
219
|
+
>
|
|
220
|
+
<Pressable
|
|
221
|
+
testID="DropdownMenu-backdrop"
|
|
222
|
+
accessibilityRole="button"
|
|
223
|
+
accessibilityLabel="Close menu"
|
|
224
|
+
onPress={close}
|
|
225
|
+
style={StyleSheet.absoluteFill}
|
|
226
|
+
/>
|
|
227
|
+
<View
|
|
228
|
+
accessibilityRole="menu"
|
|
229
|
+
accessibilityViewIsModal
|
|
230
|
+
onAccessibilityEscape={close}
|
|
231
|
+
style={{
|
|
232
|
+
position: "absolute",
|
|
233
|
+
...position,
|
|
234
|
+
minWidth: DROPDOWN_MENU.minWidth,
|
|
235
|
+
maxWidth: window.width - insets.left - insets.right - 2 * DROPDOWN_MENU.edge,
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<FloatingSurface blur={false} radius={DROPDOWN_MENU.radius} style={{ maxHeight: position.maxHeight }}>
|
|
239
|
+
<ScrollView
|
|
240
|
+
bounces={false}
|
|
241
|
+
showsVerticalScrollIndicator={false}
|
|
242
|
+
onContentSizeChange={(_width, height) => setContentHeight(height)}
|
|
243
|
+
contentContainerStyle={{ padding: DROPDOWN_MENU.padding }}
|
|
244
|
+
>
|
|
245
|
+
<View className={cn(className)} style={style} {...props}>
|
|
246
|
+
{children}
|
|
247
|
+
</View>
|
|
248
|
+
</ScrollView>
|
|
249
|
+
</FloatingSurface>
|
|
250
|
+
</View>
|
|
251
|
+
</Modal>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Item type and icon size: the web `text-sm` with a 16 icon, or `text-lg` with a 22 icon and 56 rows with larger text. */
|
|
256
|
+
function itemMetrics(largerText: boolean) {
|
|
257
|
+
return largerText
|
|
258
|
+
? { text: sizeStyle("text-lg"), icon: 22, minHeight: 56 }
|
|
259
|
+
: { text: sizeStyle("text-sm"), icon: 16, minHeight: MIN_TOUCH_TARGET };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
type MenuIcon = ComponentType<IconProps>;
|
|
263
|
+
|
|
264
|
+
type MenuItemBaseProps = Omit<PressableProps, "onPress" | "children" | "style"> & {
|
|
265
|
+
children: ReactNode;
|
|
266
|
+
/** Called on press. The menu then closes, unless `closeOnSelect` is `false`. */
|
|
267
|
+
onSelect?: () => void;
|
|
268
|
+
/** Default `true`. */
|
|
269
|
+
closeOnSelect?: boolean;
|
|
270
|
+
disabled?: boolean;
|
|
271
|
+
className?: string;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
function MenuRow({
|
|
275
|
+
part,
|
|
276
|
+
itemRole,
|
|
277
|
+
checked,
|
|
278
|
+
color,
|
|
279
|
+
paddingLeft,
|
|
280
|
+
leading,
|
|
281
|
+
children,
|
|
282
|
+
onSelect,
|
|
283
|
+
closeOnSelect = true,
|
|
284
|
+
disabled,
|
|
285
|
+
className,
|
|
286
|
+
accessibilityState,
|
|
287
|
+
...props
|
|
288
|
+
}: MenuItemBaseProps & {
|
|
289
|
+
part: string;
|
|
290
|
+
itemRole: "menuitem" | "radio";
|
|
291
|
+
checked?: boolean;
|
|
292
|
+
color: string;
|
|
293
|
+
paddingLeft: number;
|
|
294
|
+
leading: ReactNode;
|
|
295
|
+
}) {
|
|
296
|
+
const { setOpen } = useMenu(part);
|
|
297
|
+
const scheme = useScheme();
|
|
298
|
+
const metrics = itemMetrics(useLargerText());
|
|
299
|
+
return (
|
|
300
|
+
<Pressable
|
|
301
|
+
accessibilityRole={itemRole}
|
|
302
|
+
{...props}
|
|
303
|
+
disabled={disabled}
|
|
304
|
+
accessibilityState={{ ...accessibilityState, disabled: Boolean(disabled), ...(checked === undefined ? null : { checked }) }}
|
|
305
|
+
onPress={() => {
|
|
306
|
+
onSelect?.();
|
|
307
|
+
if (closeOnSelect) setOpen(false);
|
|
308
|
+
}}
|
|
309
|
+
// No `className` on this Pressable: NativeWind drops a function `style`
|
|
310
|
+
// when a `className` is set, and the row's layout lives in the style.
|
|
311
|
+
style={({ pressed }) => ({
|
|
312
|
+
flexDirection: "row",
|
|
313
|
+
alignItems: "center",
|
|
314
|
+
minHeight: metrics.minHeight,
|
|
315
|
+
gap: DROPDOWN_MENU.itemGap,
|
|
316
|
+
paddingLeft,
|
|
317
|
+
paddingRight: DROPDOWN_MENU.itemPaddingX,
|
|
318
|
+
paddingVertical: DROPDOWN_MENU.itemPaddingY,
|
|
319
|
+
borderRadius: DROPDOWN_MENU.itemRadius,
|
|
320
|
+
backgroundColor: pressed && !disabled ? palette.default[scheme] : "transparent",
|
|
321
|
+
opacity: disabled ? 0.5 : 1,
|
|
322
|
+
})}
|
|
323
|
+
>
|
|
324
|
+
{leading}
|
|
325
|
+
{/* A caller `className` lands here, off the Pressable (see the style note above). */}
|
|
326
|
+
<View className={cn("min-w-0 flex-1 flex-row items-center", className)}>
|
|
327
|
+
{typeof children === "string" ? (
|
|
328
|
+
<Text className="flex-1 font-instrument-sans" style={[metrics.text, { color }]}>
|
|
329
|
+
{children}
|
|
330
|
+
</Text>
|
|
331
|
+
) : (
|
|
332
|
+
children
|
|
333
|
+
)}
|
|
334
|
+
</View>
|
|
335
|
+
</Pressable>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export type DropdownMenuItemProps = MenuItemBaseProps & {
|
|
340
|
+
/** Leading icon (a Tabler icon), drawn at 16 in the item colour. */
|
|
341
|
+
icon?: MenuIcon;
|
|
342
|
+
/** The web `text-destructive` item: label and icon in the danger colour. */
|
|
343
|
+
destructive?: boolean;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/** One action row (`menuitem`): an optional icon and the label. */
|
|
347
|
+
export function DropdownMenuItem({ icon: Icon, destructive, ...props }: DropdownMenuItemProps) {
|
|
348
|
+
const scheme = useScheme();
|
|
349
|
+
const metrics = itemMetrics(useLargerText());
|
|
350
|
+
const color = destructive ? palette.danger[scheme] : palette.overlayForeground[scheme];
|
|
351
|
+
return (
|
|
352
|
+
<MenuRow
|
|
353
|
+
part="DropdownMenuItem"
|
|
354
|
+
itemRole="menuitem"
|
|
355
|
+
color={color}
|
|
356
|
+
paddingLeft={DROPDOWN_MENU.itemPaddingX}
|
|
357
|
+
leading={Icon ? <Icon size={metrics.icon} color={color} /> : null}
|
|
358
|
+
{...props}
|
|
359
|
+
/>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export type DropdownMenuRadioItemProps = MenuItemBaseProps & {
|
|
364
|
+
/** Shows the check and reports `checked`. */
|
|
365
|
+
checked: boolean;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* One choice of a set (`radio`, reporting `checked`): the label inset `pl-8`
|
|
370
|
+
* with a 16 check at `left-2.5` when `checked`.
|
|
371
|
+
*/
|
|
372
|
+
export function DropdownMenuRadioItem({ checked, ...props }: DropdownMenuRadioItemProps) {
|
|
373
|
+
const scheme = useScheme();
|
|
374
|
+
const metrics = itemMetrics(useLargerText());
|
|
375
|
+
const color = palette.overlayForeground[scheme];
|
|
376
|
+
return (
|
|
377
|
+
<MenuRow
|
|
378
|
+
part="DropdownMenuRadioItem"
|
|
379
|
+
itemRole="radio"
|
|
380
|
+
checked={checked}
|
|
381
|
+
color={color}
|
|
382
|
+
paddingLeft={DROPDOWN_MENU.indicatorInset + (metrics.icon - 16)}
|
|
383
|
+
leading={
|
|
384
|
+
checked ? (
|
|
385
|
+
<View
|
|
386
|
+
accessible={false}
|
|
387
|
+
style={{ position: "absolute", left: DROPDOWN_MENU.indicatorLeft, top: 0, bottom: 0, justifyContent: "center" }}
|
|
388
|
+
>
|
|
389
|
+
<IconCheck size={metrics.icon} color={color} />
|
|
390
|
+
</View>
|
|
391
|
+
) : null
|
|
392
|
+
}
|
|
393
|
+
{...props}
|
|
394
|
+
/>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export type DropdownMenuLabelProps = { children: ReactNode; className?: string };
|
|
399
|
+
|
|
400
|
+
/** A group heading: the web `text-xs font-semibold uppercase tracking-widest text-muted-foreground`, `px-2.5 py-1.5`. */
|
|
401
|
+
export function DropdownMenuLabel({ children, className }: DropdownMenuLabelProps) {
|
|
402
|
+
const scheme = useScheme();
|
|
403
|
+
const text = sizeStyle(useLargerText() ? "text-base" : "text-xs");
|
|
404
|
+
return (
|
|
405
|
+
<Text
|
|
406
|
+
accessibilityRole="header"
|
|
407
|
+
className={cn("font-instrument-sans-semibold uppercase", className)}
|
|
408
|
+
style={[
|
|
409
|
+
text,
|
|
410
|
+
typeStyle("semibold"),
|
|
411
|
+
{
|
|
412
|
+
letterSpacing: text.fontSize / 10,
|
|
413
|
+
color: tokens.color.muted[scheme],
|
|
414
|
+
paddingHorizontal: DROPDOWN_MENU.itemPaddingX,
|
|
415
|
+
paddingVertical: DROPDOWN_MENU.labelPaddingY,
|
|
416
|
+
},
|
|
417
|
+
]}
|
|
418
|
+
>
|
|
419
|
+
{children}
|
|
420
|
+
</Text>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** A 1-high `separator` line across the menu, `-mx-1.5 my-1`. */
|
|
425
|
+
export function DropdownMenuSeparator() {
|
|
426
|
+
const scheme = useScheme();
|
|
427
|
+
return (
|
|
428
|
+
<View
|
|
429
|
+
accessible={false}
|
|
430
|
+
importantForAccessibility="no"
|
|
431
|
+
style={{
|
|
432
|
+
height: 1,
|
|
433
|
+
marginHorizontal: -DROPDOWN_MENU.padding,
|
|
434
|
+
marginVertical: DROPDOWN_MENU.separatorMargin,
|
|
435
|
+
backgroundColor: tokens.color.separator[scheme],
|
|
436
|
+
}}
|
|
437
|
+
/>
|
|
438
|
+
);
|
|
439
|
+
}
|
package/src/FloatingFAB.tsx
CHANGED
|
@@ -44,8 +44,11 @@ export function FloatingFAB({
|
|
|
44
44
|
accessibilityRole="button"
|
|
45
45
|
accessibilityLabel={label}
|
|
46
46
|
onPress={onPress}
|
|
47
|
-
className
|
|
47
|
+
// Layout in the function style and no `className`: NativeWind drops a
|
|
48
|
+
// function `style` next to a `className`.
|
|
48
49
|
style={({ pressed }) => ({
|
|
50
|
+
alignItems: "center",
|
|
51
|
+
justifyContent: "center",
|
|
49
52
|
width: size,
|
|
50
53
|
height: size,
|
|
51
54
|
backgroundColor: accentColor,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Text, View } from "react-native";
|
|
2
|
+
import { Badge } from "../Badge";
|
|
3
|
+
import { sizeStyle, textStyle } from "../controlStyles";
|
|
4
|
+
import { palette, withAlpha } from "../palette";
|
|
5
|
+
import { useLargerText } from "../theme";
|
|
6
|
+
import { tokens } from "../tokens";
|
|
7
|
+
import { useScheme } from "../useScheme";
|
|
8
|
+
import { MONO_FONT, chatTextSize } from "./chat-text";
|
|
9
|
+
import { CodeBlock } from "./code-block";
|
|
10
|
+
|
|
11
|
+
/** The step fields the detail reads: the web `ActivityStep` subset. */
|
|
12
|
+
export type ActivityStepDetailStep = {
|
|
13
|
+
type: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
/** Bash command, fuller than `detail`. */
|
|
16
|
+
command?: string;
|
|
17
|
+
output?: { text: string; exitCode?: number; truncated?: boolean };
|
|
18
|
+
edits?: ReadonlyArray<{ oldText: string; newText: string }>;
|
|
19
|
+
/** A written file's preview. */
|
|
20
|
+
contentPreview?: string;
|
|
21
|
+
/** The transcript copy dropped the output, edits or preview; the full step is still loading. */
|
|
22
|
+
hasHiddenDetail?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ActivityStepDetailProps = {
|
|
26
|
+
step: ActivityStepDetailStep;
|
|
27
|
+
/** Copy button on the transcript and preview blocks (native-ui adds no clipboard). */
|
|
28
|
+
onCopy?: (text: string) => void | Promise<void>;
|
|
29
|
+
/** Default "Loading output...". */
|
|
30
|
+
loadingLabel?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Web `bg-emerald-500/10` and `text-emerald-700` (dark `text-emerald-300`). */
|
|
34
|
+
const EMERALD = { fill: "#10b981", light: "#047857", dark: "#6ee7b7" } as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A tool step's body, as the web `ActivityStepDetail`: exit code and
|
|
38
|
+
* "Truncated" badges, the shell transcript (`$ command`, a blank line, then
|
|
39
|
+
* the output) in a `CodeBlock`, edits as a red before block over a green after
|
|
40
|
+
* block, and a written file's preview. While `hasHiddenDetail` is set and
|
|
41
|
+
* nothing has loaded, "Loading output...".
|
|
42
|
+
*/
|
|
43
|
+
export function ActivityStepDetail({ step, onCopy, loadingLabel = "Loading output..." }: ActivityStepDetailProps) {
|
|
44
|
+
const scheme = useScheme();
|
|
45
|
+
const largerText = useLargerText();
|
|
46
|
+
const caption = chatTextSize("caption", largerText);
|
|
47
|
+
const command = step.command ?? (step.type === "bash" ? step.detail : undefined);
|
|
48
|
+
const transcript = [command ? `$ ${command}` : null, step.output?.text ?? null]
|
|
49
|
+
.filter((part): part is string => Boolean(part))
|
|
50
|
+
.join("\n\n");
|
|
51
|
+
const awaitingDetail = step.hasHiddenDetail === true && !step.output && !step.edits?.length && !step.contentPreview;
|
|
52
|
+
const exitCode = step.output?.exitCode;
|
|
53
|
+
const mono = [sizeStyle(caption), { fontFamily: MONO_FONT }];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<View style={{ gap: 8 }}>
|
|
57
|
+
{awaitingDetail ? (
|
|
58
|
+
<Text className="font-instrument-sans" style={[sizeStyle(caption), textStyle("muted", scheme)]}>
|
|
59
|
+
{loadingLabel}
|
|
60
|
+
</Text>
|
|
61
|
+
) : null}
|
|
62
|
+
{exitCode !== undefined || step.output?.truncated ? (
|
|
63
|
+
<View className="flex-row flex-wrap" style={{ gap: 6 }}>
|
|
64
|
+
{exitCode !== undefined ? (
|
|
65
|
+
<Badge variant={exitCode === 0 ? "secondary" : "destructive"}>{`Exit code ${exitCode}`}</Badge>
|
|
66
|
+
) : null}
|
|
67
|
+
{step.output?.truncated ? <Badge variant="outline">Truncated</Badge> : null}
|
|
68
|
+
</View>
|
|
69
|
+
) : null}
|
|
70
|
+
{transcript ? <CodeBlock code={transcript} language="shell" highlight={false} onCopy={onCopy} /> : null}
|
|
71
|
+
{(step.edits ?? []).map((edit, index) => (
|
|
72
|
+
<View
|
|
73
|
+
key={index}
|
|
74
|
+
style={{ borderRadius: 6, borderWidth: 1, borderColor: tokens.color.border[scheme], overflow: "hidden" }}
|
|
75
|
+
>
|
|
76
|
+
<Text
|
|
77
|
+
selectable
|
|
78
|
+
style={[mono, { padding: 8, color: palette.danger[scheme], backgroundColor: withAlpha(palette.danger[scheme], 0.1) }]}
|
|
79
|
+
>
|
|
80
|
+
{edit.oldText}
|
|
81
|
+
</Text>
|
|
82
|
+
<Text
|
|
83
|
+
selectable
|
|
84
|
+
style={[mono, { padding: 8, color: EMERALD[scheme], backgroundColor: withAlpha(EMERALD.fill, 0.1) }]}
|
|
85
|
+
>
|
|
86
|
+
{edit.newText}
|
|
87
|
+
</Text>
|
|
88
|
+
</View>
|
|
89
|
+
))}
|
|
90
|
+
{step.contentPreview ? (
|
|
91
|
+
<CodeBlock code={step.contentPreview} language="text" highlight={false} onCopy={onCopy} />
|
|
92
|
+
) : null}
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
}
|