@vvedantb/native-ui 0.8.0 → 0.9.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,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
+ }
@@ -44,8 +44,11 @@ export function FloatingFAB({
44
44
  accessibilityRole="button"
45
45
  accessibilityLabel={label}
46
46
  onPress={onPress}
47
- className="items-center justify-center"
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
+ }