@vvedantb/native-ui 0.13.0 → 0.17.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 (108) hide show
  1. package/README.md +40 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/chat-text.ts +13 -7
  36. package/src/ai/chat-thread.tsx +34 -27
  37. package/src/ai/choice-question.tsx +154 -35
  38. package/src/ai/code-block.tsx +189 -49
  39. package/src/ai/collapsible.tsx +19 -13
  40. package/src/ai/conversation.tsx +390 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +38 -1
  43. package/src/ai/message-action-sheet.tsx +4 -1
  44. package/src/ai/message.tsx +60 -31
  45. package/src/ai/prompt-input-menu.tsx +197 -0
  46. package/src/ai/prompt-input.tsx +301 -75
  47. package/src/ai/shimmer.tsx +4 -6
  48. package/src/ai/trace.tsx +54 -17
  49. package/src/ai/unified-diff.tsx +182 -0
  50. package/src/auth/GoogleSignInButton.tsx +6 -3
  51. package/src/auth/SignInScreen.tsx +11 -2
  52. package/src/brand/ProviderIcon.tsx +136 -0
  53. package/src/brand/index.ts +2 -0
  54. package/src/configureTheme.ts +39 -11
  55. package/src/controlStyles.ts +46 -30
  56. package/src/entries/gesture-handler.ts +4 -0
  57. package/src/entries/keyboard-controller.ts +4 -0
  58. package/src/entries/markdown.ts +1 -1
  59. package/src/entries/primitives.ts +24 -3
  60. package/src/entries/theme.ts +2 -1
  61. package/src/entries/video.ts +4 -0
  62. package/src/entries/webview.ts +6 -0
  63. package/src/floating.ts +25 -4
  64. package/src/haptics.ts +30 -4
  65. package/src/highlight.ts +302 -1
  66. package/src/index.ts +27 -5
  67. package/src/loadingGrid.ts +75 -0
  68. package/src/markdown/Markdown.tsx +234 -32
  69. package/src/markdown/MarkdownTable.tsx +188 -0
  70. package/src/markdown/tableText.ts +58 -0
  71. package/src/marketing/UpgradeToProCard.tsx +4 -3
  72. package/src/media/ImageGallery.tsx +224 -0
  73. package/src/media/MediaCard.tsx +100 -0
  74. package/src/media/imageGrid.ts +51 -0
  75. package/src/motion.ts +33 -0
  76. package/src/pagerSwipe.ts +102 -0
  77. package/src/palette.ts +8 -1
  78. package/src/primitives/button.tsx +4 -1
  79. package/src/primitives/keyboard-controller.tsx +106 -0
  80. package/src/primitives/keyboard-parts.tsx +104 -0
  81. package/src/primitives/overlay.tsx +133 -45
  82. package/src/primitives/pager-gesture.tsx +131 -0
  83. package/src/primitives/sheet-drag.tsx +305 -0
  84. package/src/primitives/sheet-gesture.tsx +181 -0
  85. package/src/primitives/swipe-row-gesture.tsx +290 -0
  86. package/src/primitives/toast-drag-gesture.tsx +92 -0
  87. package/src/primitives/web-click-guard.ts +50 -0
  88. package/src/relativeTime.ts +94 -0
  89. package/src/router/RouterStackPageHeader.tsx +2 -2
  90. package/src/settings/SettingsEmptyState.tsx +20 -10
  91. package/src/settings/SettingsField.tsx +11 -5
  92. package/src/settings/SettingsSection.tsx +8 -5
  93. package/src/settings/SettingsToggleRow.tsx +55 -22
  94. package/src/settings/relaxedSizeStyle.ts +11 -0
  95. package/src/sheetDrag.ts +67 -0
  96. package/src/swipeRow.ts +169 -0
  97. package/src/tabBarRoutes.ts +18 -0
  98. package/src/tabIndicator.ts +71 -0
  99. package/src/tailwind-preset.cjs +13 -6
  100. package/src/tailwind-preset.d.cts +5 -0
  101. package/src/theme.tsx +6 -2
  102. package/src/toastDrag.ts +58 -0
  103. package/src/typography.ts +20 -3
  104. package/src/useScheme.ts +10 -1
  105. package/src/video/InlineVideo.tsx +91 -0
  106. package/src/webview/HtmlView.tsx +91 -0
  107. package/src/webview/HtmlView.web.tsx +21 -0
  108. package/src/webview/htmlView.ts +45 -0
@@ -133,23 +133,26 @@ const TEXT_SIZE = {
133
133
 
134
134
  export type TextSize = keyof typeof TEXT_SIZE;
135
135
 
136
- /** Tailwind `leading-tight` and `leading-snug`, as multiples of the size. */
137
- const LEADING = { tight: 1.25, snug: 1.375 } as const;
136
+ /** Tailwind `leading-tight`, `leading-snug` and `leading-relaxed`, as multiples of the size. */
137
+ const LEADING = { tight: 1.25, snug: 1.375, relaxed: 1.625 } as const;
138
+
139
+ export type TextLeading = keyof typeof LEADING;
138
140
 
139
141
  /**
140
142
  * Font size and line height of a `text-*` class as a style, set beside the
141
143
  * class. On Expo web at 390 wide (0.3.6 gallery), `Button` labels with
142
144
  * `text-sm` measured 16, so the class alone does not always reach the text.
143
- * `"tight"` and `"snug"` are the Tailwind `leading-tight` (1.25 × the size)
144
- * and `leading-snug` (1.375 ×).
145
+ * `"tight"`, `"snug"` and `"relaxed"` are the Tailwind `leading-tight` (1.25 ×
146
+ * the size), `leading-snug` (1.375 ×) and `leading-relaxed` (1.625 ×, the web
147
+ * `.markdown` body and the settings descriptions).
145
148
  */
146
- export function sizeStyle(size: TextSize, leading?: "tight" | "snug") {
149
+ export function sizeStyle(size: TextSize, leading?: TextLeading) {
147
150
  const { fontSize, lineHeight } = TEXT_SIZE[size];
148
151
  return { fontSize, lineHeight: leading ? fontSize * LEADING[leading] : lineHeight };
149
152
  }
150
153
 
151
- /** Tailwind `tracking-tight`, in em. */
152
- const TRACKING = { tight: -0.025 } as const;
154
+ /** Tailwind `tracking-tight` and `tracking-wide`, in em. */
155
+ const TRACKING = { tight: -0.025, wide: 0.025 } as const;
153
156
 
154
157
  /**
155
158
  * Letter spacing of a `tracking-*` class at `fontSize`, as a style in points.
@@ -313,23 +316,26 @@ export function tabColors(variant: TabsVariant, selected: boolean, scheme: Schem
313
316
  export type TabsSize = "default" | "sm";
314
317
 
315
318
  /**
316
- * Trigger box and type for `TabsTrigger` and `PillTabs`. Without larger text
317
- * these are the web sizes: `pills` and `segmented` are the web `.t-tab` (30
318
- * high, `px-3 py-1`, `text-sm`), `soft` the web soft trigger (`px-4 py-2
319
- * text-sm`) and `sm` the web `sm` list (`px-2.5 py-1 text-xs`). With larger
320
- * text they are the 0.3.4 sizes. `minHeight` is a minimum, so a large Dynamic
321
- * Type size grows the trigger instead of clipping its label.
319
+ * Trigger box and type for `TabsTrigger` and `PillTabs`. Phone-tuned from the
320
+ * web look: `pills` and `segmented` are the web `.t-tab` (`py-1`, `text-sm`)
321
+ * grown to 36 high with `px-3.5`, so the 4 slop above and below makes 44;
322
+ * `sm` is the web `sm` list (`px-2.5 py-1 text-xs`) with the label kept at 14
323
+ * (`text-sm`, `px-3`) and also 36 high; `soft` is the web soft trigger
324
+ * (`px-4 py-2 text-sm`). With larger text they are the 0.3.4 sizes.
325
+ * `minHeight` is a minimum, so a large Dynamic Type size grows the trigger
326
+ * instead of clipping its label. `letterSpacing` is the web type-scale
327
+ * tracking of the label (`tokens/src/css/type-scale.css`): `text-sm` is 0em.
322
328
  */
323
329
  export function tabTriggerSize(variant: TabsVariant, size: TabsSize, largerText: boolean) {
324
330
  if (largerText) {
325
331
  return size === "sm"
326
- ? { minHeight: 48, className: "px-4 py-2", textClassName: "text-lg" }
327
- : { minHeight: 56, className: "px-4 py-3", textClassName: "text-xl" };
332
+ ? { minHeight: 48, className: "px-4 py-2", textClassName: "text-lg", letterSpacing: 0 }
333
+ : { minHeight: 56, className: "px-4 py-3", textClassName: "text-xl", letterSpacing: 0 };
328
334
  }
329
- if (size === "sm") return { minHeight: 30, className: "px-2.5 py-1", textClassName: "text-xs" };
335
+ if (size === "sm") return { minHeight: 36, className: "px-3 py-1", textClassName: "text-sm", letterSpacing: 0 };
330
336
  return variant === "soft"
331
- ? { minHeight: 36, className: "px-4 py-2", textClassName: "text-sm" }
332
- : { minHeight: 30, className: "px-3 py-1", textClassName: "text-sm" };
337
+ ? { minHeight: 36, className: "px-4 py-2", textClassName: "text-sm", letterSpacing: 0 }
338
+ : { minHeight: 36, className: "px-3.5 py-1", textClassName: "text-sm", letterSpacing: 0 };
333
339
  }
334
340
 
335
341
  /** The segmented track, web `--tabs-bar-bg` (`--default`). */
@@ -341,14 +347,14 @@ export function tabTrackStyle(scheme: Scheme) {
341
347
  * Fill and label of the `FloatingTabBar` active pill. On the web the raised
342
348
  * `--segment` pill sits on a muted `--default` track. The floating bar is
343
349
  * already the raised surface (`tokens.color.fill`, near-white in light), so in
344
- * light the pill takes the muted track fill (`palette.default`), as a white
345
- * `--segment` pill would not show on it. In dark it keeps `--segment`
346
- * (`palette.segment`), lighter than the dark glass. The label is the
350
+ * light the pill takes the muted track fill (`--default`), as a white
351
+ * `--segment` pill would not show on it. In dark it keeps the kit `--segment`,
352
+ * lighter than the dark glass. Both are `palette.tabBarPill`. The label is the
347
353
  * segmented `--tabs-text-active`, as `tabColors("segmented", true, scheme)`.
348
354
  */
349
355
  export function floatingTabPillColors(scheme: Scheme): TabColors {
350
356
  return {
351
- backgroundColor: scheme === "light" ? palette.default.light : palette.segment.dark,
357
+ backgroundColor: palette.tabBarPill[scheme],
352
358
  color: tabColors("segmented", true, scheme).color,
353
359
  };
354
360
  }
@@ -372,16 +378,26 @@ export function checkboxColors(checked: boolean, scheme: Scheme) {
372
378
  };
373
379
  }
374
380
 
381
+ export type SwitchSize = "sm" | "md";
382
+
375
383
  /**
376
- * Track and thumb size classes, and the track size in points for the hit slop.
377
- * Without larger text it is the web `Switch`: a `h-5 w-9` track (20 × 36,
378
- * with a 1 border) and a `size-4` thumb that moves `translate-x-4`. With larger
379
- * text it is the 0.3.4 size. `justify-center` on the track centres the thumb.
384
+ * Track and thumb size classes, the thumb offset off and on, and the track
385
+ * size in points for the hit slop. `justify-center` on the track centres the
386
+ * thumb. Without larger text:
387
+ * - `sm` is the web `Switch`: a `h-5 w-9` track (20 × 36, with a 1 border)
388
+ * and a `size-4` thumb that moves `translate-x-4`.
389
+ * - `md` is the larger app switch some web apps set through `UiConfig.Switch`:
390
+ * a `h-6 w-10` track (24 × 40, with a 1 border) and a `size-5` thumb that
391
+ * sits at `translate-x-0.5` and moves to `translate-x-[18px]`.
392
+ * With larger text both are the 0.3.4 size.
380
393
  */
381
- export function switchSize(largerText: boolean) {
382
- return largerText
383
- ? { track: "h-8 w-14", thumb: "h-7 w-7", on: "translate-x-6", width: 56, height: 32 }
384
- : { track: "h-5 w-9", thumb: "h-4 w-4", on: "translate-x-4", width: 36, height: 20 };
394
+ export function switchSize(largerText: boolean, size: SwitchSize = "sm") {
395
+ if (largerText) {
396
+ return { track: "h-8 w-14", thumb: "h-7 w-7", off: "translate-x-0", on: "translate-x-6", width: 56, height: 32 };
397
+ }
398
+ return size === "md"
399
+ ? { track: "h-6 w-10", thumb: "h-5 w-5", off: "translate-x-0.5", on: "translate-x-[18px]", width: 40, height: 24 }
400
+ : { track: "h-5 w-9", thumb: "h-4 w-4", off: "translate-x-0", on: "translate-x-4", width: 36, height: 20 };
385
401
  }
386
402
 
387
403
  /**
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/gesture-handler`. Imports `react-native-gesture-handler`
2
+ // (an optional peer): the only entry that does, so an app without it bundles
3
+ // the `PanResponder` sheets as before.
4
+ export { GestureHandlerProvider } from "../primitives/sheet-gesture";
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/keyboard-controller`. Imports
2
+ // `react-native-keyboard-controller` (an optional peer): the only entry that
3
+ // does, so an app without it keeps React Native's `KeyboardAvoidingView`.
4
+ export { KeyboardControllerProvider } from "../primitives/keyboard-controller";
@@ -2,4 +2,4 @@
2
2
  // `.markdown` block. Not on the root entry: it imports the optional peer
3
3
  // `react-native-marked`, and Metro does not tree-shake.
4
4
  export { Markdown, markdownStyles, inlineCodeStyle, MARKDOWN_SCALE, MARKDOWN_LIST } from "../markdown/Markdown";
5
- export type { MarkdownProps } from "../markdown/Markdown";
5
+ export type { MarkdownHeadings, MarkdownProps } from "../markdown/Markdown";
@@ -42,16 +42,24 @@ export type { IconButtonProps, IconButtonSize, IconButtonVariant } from "../Icon
42
42
  export { CaptureButton } from "../CaptureButton";
43
43
  export type { CaptureButtonProps } from "../CaptureButton";
44
44
  export { Separator } from "../Separator";
45
+ export { KeyboardAvoidingView } from "../primitives/keyboard-parts";
46
+ export type { KeyboardAvoidingViewProps } from "../primitives/keyboard-parts";
45
47
  export type { SeparatorProps } from "../Separator";
46
48
  export { Progress } from "../Progress";
47
49
  export type { ProgressProps } from "../Progress";
48
50
  export { Spinner, CenteredSpinner } from "../Spinner";
49
51
  export type { SpinnerProps } from "../Spinner";
52
+ export { LoadingState } from "../LoadingState";
53
+ export type { LoadingStateProps, LoadingStateSize } from "../LoadingState";
50
54
  export { Avatar, AvatarImage, AvatarFallback } from "../Avatar";
51
55
  export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from "../Avatar";
52
56
  export { initials } from "../initials";
57
+ export { formatCompactRelativeTime } from "../relativeTime";
58
+ export type { CompactRelativeTimeOptions } from "../relativeTime";
53
59
  export { Chip } from "../Chip";
54
60
  export { PillTabs } from "../PillTabs";
61
+ export { Pager } from "../Pager";
62
+ export type { PagerProps } from "../Pager";
55
63
  export { Tabs, TabsList, TabsTrigger, TabsContent } from "../Tabs";
56
64
  export type {
57
65
  TabsProps,
@@ -61,9 +69,21 @@ export type {
61
69
  TabsContentProps,
62
70
  } from "../Tabs";
63
71
  export { ListRow } from "../ListRow";
64
- export { DividedList } from "../DividedList";
72
+ export { DividedList, ListDivider } from "../DividedList";
65
73
  export type { DividedListProps } from "../DividedList";
66
74
  export type { ListRowProps } from "../ListRow";
75
+ export { SwipeableRow } from "../SwipeableRow";
76
+ export type { SwipeableRowProps } from "../SwipeableRow";
77
+ export { closeSwipeRows, swipeActionsAccessibility } from "../swipeRow";
78
+ export type { SwipeAction } from "../swipeRow";
79
+ export { ImageGallery, ImageViewer } from "../media/ImageGallery";
80
+ export type { ImageGalleryProps, ImageViewerProps } from "../media/ImageGallery";
81
+ export { MediaCard } from "../media/MediaCard";
82
+ export type { MediaCardProps } from "../media/MediaCard";
83
+ export { imageAspect, imageGridTiles, imageViewerPage, MEDIA } from "../media/imageGrid";
84
+ export type { GalleryImage, ImageGridTile } from "../media/imageGrid";
85
+ export { SurfaceRow, SURFACE_ROW } from "../SurfaceRow";
86
+ export type { SurfaceRowProps } from "../SurfaceRow";
67
87
  export { HeaderIconButton } from "../HeaderIconButton";
68
88
  export { ThemeToggle } from "../ThemeToggle";
69
89
  export type { ThemeToggleProps } from "../ThemeToggle";
@@ -76,7 +96,8 @@ export {
76
96
  CardFooter,
77
97
  } from "../Card";
78
98
  export type { CardProps } from "../Card";
79
- export { Skeleton, CardSkeleton } from "../Skeleton";
99
+ export { Skeleton, CardSkeleton, SkeletonList, SkeletonRow } from "../Skeleton";
100
+ export type { SkeletonListProps, SkeletonRowProps } from "../Skeleton";
80
101
  export { EmptyState } from "../EmptyState";
81
102
  export type { EmptyStateVariant } from "../EmptyState";
82
103
  export {
@@ -105,7 +126,7 @@ export {
105
126
  export type { AlertDialogProps, AlertDialogContentProps } from "../AlertDialog";
106
127
  export { ConfirmDialog } from "../ConfirmDialog";
107
128
  export { BulkActionBar, BULK_BAR_LABEL_MIN_WIDTH } from "../BulkActionBar";
108
- export type { BulkActionBarProps, BulkBarAction } from "../BulkActionBar";
129
+ export type { BulkActionBarProps, BulkBarAction, BulkBarPlacement } from "../BulkActionBar";
109
130
  export {
110
131
  DropdownMenu,
111
132
  DropdownMenuTrigger,
@@ -13,5 +13,6 @@ export { palette, withAlpha } from "../palette";
13
13
  export type { Palette } from "../palette";
14
14
  export { configureTheme } from "../configureTheme";
15
15
  export type { ThemeConfig, ThemeColors, ThemePalette } from "../configureTheme";
16
- export { fontClass } from "../typography";
16
+ export { fontClass, fontFamily } from "../typography";
17
+ export type { ThemeFonts } from "../typography";
17
18
  export { PINK_50, PINK_100, PINK_600, PINK_700, NEUTRAL_100, NEUTRAL_500, NEUTRAL_900, Colors } from "../colors";
@@ -0,0 +1,4 @@
1
+ // `@vvedantb/native-ui/video`. Imports `expo-video` (an optional peer): the
2
+ // only entry that does, so an app without it bundles no video player.
3
+ export { InlineVideo, INLINE_VIDEO_ASPECT } from "../video/InlineVideo";
4
+ export type { InlineVideoProps } from "../video/InlineVideo";
@@ -0,0 +1,6 @@
1
+ // `@vvedantb/native-ui/webview`. Imports `react-native-webview` (an optional
2
+ // peer): the only entry that does, so an app without it bundles no WebView.
3
+ export { HtmlView } from "../webview/HtmlView";
4
+ export type { HtmlViewProps } from "../webview/HtmlView";
5
+ export { HTML_VIEW_HEIGHT_SCRIPT, htmlViewNavigation, htmlViewPostScript, parseHtmlViewHeight } from "../webview/htmlView";
6
+ export type { HtmlViewNavigation } from "../webview/htmlView";
package/src/floating.ts CHANGED
@@ -48,9 +48,30 @@ export const floatingFill = tokens.color.fill;
48
48
  /** The `tokens.color.surface` pair itself, so `configureTheme` reaches it. */
49
49
  export const floatingSolidFill = tokens.color.surface;
50
50
 
51
+ /**
52
+ * Where `FloatingTabBar` sits: `FLOATING_INSET` in from the screen sides (and,
53
+ * in landscape, the notch) and above the home indicator. `BulkActionBar` with
54
+ * `placement="tabBar"` takes the same slot while the tab bar hides.
55
+ */
56
+ export function tabBarSlotStyle(insets: { left: number; right: number; bottom: number }): {
57
+ position: "absolute";
58
+ left: number;
59
+ right: number;
60
+ bottom: number;
61
+ } {
62
+ return {
63
+ position: "absolute",
64
+ left: insets.left + FLOATING_INSET,
65
+ right: insets.right + FLOATING_INSET,
66
+ bottom: FLOATING_INSET + Math.max(insets.bottom, 8),
67
+ };
68
+ }
69
+
70
+ /** The height of `FloatingTabBar`, and of `BulkActionBar` in its slot. */
71
+ export function tabBarHeight(largerText: boolean) {
72
+ return largerText ? FLOATING_TAB_BAR_HEIGHT_LARGE : FLOATING_TAB_BAR_HEIGHT;
73
+ }
74
+
51
75
  export function tabBarReserve(largerText: boolean, bottomInset: number) {
52
- const bar = largerText
53
- ? FLOATING_TAB_BAR_HEIGHT_LARGE
54
- : FLOATING_TAB_BAR_HEIGHT;
55
- return bar + FLOATING_INSET + Math.max(bottomInset, 8) + 28;
76
+ return tabBarHeight(largerText) + FLOATING_INSET + Math.max(bottomInset, 8) + 28;
56
77
  }
package/src/haptics.ts CHANGED
@@ -21,9 +21,35 @@ export function loadHaptics(): ExpoHapticsModule | null {
21
21
  return expoHaptics;
22
22
  }
23
23
 
24
+ function native(): ExpoHapticsModule | null {
25
+ return Platform.OS === "web" ? null : loadHaptics();
26
+ }
27
+
28
+ /** A value ticks past a step: a tab, a segment, a checkbox. */
24
29
  export function selectionHaptic() {
25
- if (Platform.OS === "web") {
26
- return;
27
- }
28
- void loadHaptics()?.selectionAsync();
30
+ void native()?.selectionAsync();
31
+ }
32
+
33
+ /**
34
+ * A physical moment: `"light"` when something snaps home (a dragged sheet
35
+ * springing back), `"medium"` when a destructive action fires. Fire it in the
36
+ * same frame as the visual, once per user action.
37
+ */
38
+ export function impactHaptic(weight: "light" | "medium") {
39
+ const haptics = native();
40
+ if (!haptics) return;
41
+ void haptics.impactAsync(
42
+ weight === "light" ? haptics.ImpactFeedbackStyle.Light : haptics.ImpactFeedbackStyle.Medium,
43
+ );
44
+ }
45
+
46
+ /** An operation the user started succeeded or failed (the success and error toasts). */
47
+ export function notificationHaptic(outcome: "success" | "error") {
48
+ const haptics = native();
49
+ if (!haptics) return;
50
+ void haptics.notificationAsync(
51
+ outcome === "success"
52
+ ? haptics.NotificationFeedbackType.Success
53
+ : haptics.NotificationFeedbackType.Error,
54
+ );
29
55
  }
package/src/highlight.ts CHANGED
@@ -111,7 +111,308 @@ const PRISM_ROLE: Record<string, CodeColorRole> = {
111
111
  * "punctuation"]`) is plain.
112
112
  */
113
113
  export function codeTokenColor(types: readonly string[], scheme: "light" | "dark"): string {
114
+ return GITHUB_CODE_COLORS[scheme][prismRole(types)];
115
+ }
116
+
117
+ function prismRole(types: readonly string[]): CodeColorRole {
114
118
  let role: CodeColorRole = "plain";
115
119
  for (const type of types) role = PRISM_ROLE[type] ?? role;
116
- return GITHUB_CODE_COLORS[scheme][role];
120
+ return role;
121
+ }
122
+
123
+ /** One coloured run of code. */
124
+ export type CodeToken = { content: string; role: CodeColorRole };
125
+
126
+ /** A Prism token as `prism-react-renderer` hands it over: its types, outermost first. */
127
+ export type PrismToken = { types: readonly string[]; content: string; empty?: boolean };
128
+
129
+ /** Languages `scriptTokens` refines: the JavaScript family, as Shiki's grammars. */
130
+ export const SCRIPT_LANGUAGES = new Set(["js", "jsx", "javascript", "mjs", "cjs", "ts", "tsx", "typescript", "mts", "cts"]);
131
+
132
+ /** Languages `shellTokens` colours. `prism-react-renderer` bundles no shell grammar. */
133
+ export const SHELL_LANGUAGES = new Set(["bash", "sh", "shell", "zsh", "shellscript", "console"]);
134
+
135
+ /** Keywords Shiki colours as constants (`constant.language`, `variable.language`, a `void` type). */
136
+ const SCRIPT_CONSTANT_KEYWORDS = new Set(["null", "undefined", "this", "super", "void"]);
137
+
138
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
139
+
140
+ /** A token's role before context: what Prism's types say, with the Shiki differences that need no context. */
141
+ function scriptBaseRole({ types, content }: PrismToken): CodeColorRole {
142
+ // Inside `${…}` Shiki keeps the punctuation in the string colour and the code in its own.
143
+ const interpolation = types.lastIndexOf("interpolation");
144
+ if (interpolation >= 0) {
145
+ const inner = types.slice(interpolation + 1);
146
+ return inner.includes("punctuation") || inner.includes("interpolation-punctuation") ? "string" : prismRole(inner);
147
+ }
148
+ // A regex character class, brackets and escapes too.
149
+ if (types.includes("char-class")) return "constant";
150
+ const role = prismRole(types);
151
+ if (role === "keyword" && SCRIPT_CONSTANT_KEYWORDS.has(content)) return "constant";
152
+ if (types.includes("builtin") && content === "console") return "plain";
153
+ if (types.includes("function") && content === "constructor") return "keyword";
154
+ return role;
155
+ }
156
+
157
+ type Piece = CodeToken & { line: number; types: readonly string[] };
158
+
159
+ /** Splits a token so escapes in strings and each word of plain text get a role of their own. */
160
+ function splitPiece(piece: Piece): Piece[] {
161
+ const parts =
162
+ piece.role === "string" && !piece.types.includes("regex")
163
+ ? piece.content.split(/(\\.)/)
164
+ : piece.role === "plain"
165
+ ? piece.content.split(/([A-Za-z_$][\w$]*)/)
166
+ : [piece.content];
167
+ return parts
168
+ .filter((part) => part !== "")
169
+ .map((part) => ({ ...piece, content: part, role: piece.role === "string" && /^\\./.test(part) ? "constant" : piece.role }));
170
+ }
171
+
172
+ const isWord = (piece: Piece | undefined, word: string) => piece?.content === word;
173
+
174
+ /**
175
+ * Prism tokens for the JavaScript family, recoloured where Prism and the
176
+ * web's Shiki grammars disagree, so the phone draws the web colours:
177
+ * `null`, `undefined` and `this` as constants; escapes in strings; a regex
178
+ * character class; `${…}` punctuation in the string colour; a `const` name
179
+ * as a constant; parameters in the variable colour; type and class names
180
+ * (a capitalised word not followed by `.`) as functions; ALL_CAPS words as
181
+ * constants. Returns lines of runs, adjacent runs of one role merged.
182
+ */
183
+ export function scriptTokens(lines: readonly (readonly PrismToken[])[]): CodeToken[][] {
184
+ const pieces = lines.flatMap((tokens, line) =>
185
+ tokens.filter((token) => !token.empty && token.content !== "").flatMap((token) =>
186
+ splitPiece({ content: token.content, types: token.types, role: scriptBaseRole(token), line }),
187
+ ),
188
+ );
189
+ // Indices of the pieces that are not whitespace.
190
+ const sig = pieces.flatMap((piece, index) => (piece.content.trim() === "" ? [] : [index]));
191
+ const at = (position: number) => {
192
+ const index = sig[position];
193
+ return index === undefined ? undefined : pieces[index];
194
+ };
195
+ const isIdentifier = (piece: Piece | undefined): piece is Piece =>
196
+ piece !== undefined && piece.role === "plain" && IDENTIFIER.test(piece.content);
197
+ const closing = (open: number) => {
198
+ let depth = 0;
199
+ for (let position = open; position < sig.length; position += 1) {
200
+ const content = at(position)?.content;
201
+ if (content === "(") depth += 1;
202
+ else if (content === ")") {
203
+ depth -= 1;
204
+ if (depth === 0) return position;
205
+ }
206
+ }
207
+ return undefined;
208
+ };
209
+ // An arrow function's parameters, or a declared function's or method's.
210
+ const isParameterList = (open: number, close: number) => {
211
+ const before = at(open - 1);
212
+ if (isWord(before, "function") || isWord(before, "constructor")) return true;
213
+ if (before?.role === "function" && isWord(at(open - 2), "function")) return true;
214
+ let depth = 0;
215
+ for (let position = close + 1; position < Math.min(sig.length, close + 40); position += 1) {
216
+ const content = at(position)?.content;
217
+ if (position === close + 1 && content !== ":") return content === "=>" || (content === "{" && before?.role === "function");
218
+ if (content === "(" || content === "<" || content === "[") depth += 1;
219
+ else if (content === ")" || content === ">" || content === "]") depth -= 1;
220
+ else if (depth === 0 && content === "=>") return true;
221
+ else if (depth === 0 && content === "{") return before?.role === "function";
222
+ else if (depth === 0 && (content === ";" || content === "=")) return false;
223
+ }
224
+ return false;
225
+ };
226
+ for (let position = 0; position < sig.length; position += 1) {
227
+ if (!isWord(at(position), "(")) continue;
228
+ const close = closing(position);
229
+ if (close === undefined || !isParameterList(position, close)) continue;
230
+ let depth = 0;
231
+ let expectName = true;
232
+ for (let inner = position + 1; inner < close; inner += 1) {
233
+ const piece = at(inner);
234
+ const content = piece?.content;
235
+ if (content === "(" || content === "[" || content === "{" || content === "<") depth += 1;
236
+ else if (content === ")" || content === "]" || content === "}" || content === ">") depth -= 1;
237
+ else if (depth === 0 && content === ",") expectName = true;
238
+ else if (depth === 0 && (content === ":" || content === "=" || content === "?")) expectName = false;
239
+ else if (depth === 0 && expectName && isIdentifier(piece)) {
240
+ piece.role = "variable";
241
+ expectName = false;
242
+ }
243
+ }
244
+ }
245
+ let inImport = false;
246
+ let constPattern = 0;
247
+ // `Map<string, number>`: Shiki draws type-argument brackets plain, Prism as operators.
248
+ let generic = 0;
249
+ for (let position = 0; position < sig.length; position += 1) {
250
+ const piece = at(position);
251
+ if (!piece) continue;
252
+ const previous = at(position - 1);
253
+ if (piece.role === "keyword" && piece.content === "import") inImport = true;
254
+ if (piece.role === "keyword" && piece.content === "from") inImport = false;
255
+ // `const { a, b } = …`: every name in the pattern.
256
+ if (isWord(previous, "const") && (piece.content === "{" || piece.content === "[")) constPattern = 1;
257
+ else if (constPattern > 0 && (piece.content === "{" || piece.content === "[")) constPattern += 1;
258
+ else if (constPattern > 0 && (piece.content === "}" || piece.content === "]")) constPattern -= 1;
259
+ if (piece.role === "keyword" && piece.content === "<" && pieces[(sig[position] ?? 0) - 1]?.role === "function") {
260
+ piece.role = "plain";
261
+ generic += 1;
262
+ } else if (generic > 0 && piece.role === "keyword" && piece.content === ">") {
263
+ piece.role = "plain";
264
+ generic -= 1;
265
+ }
266
+ if (!isIdentifier(piece) || inImport) continue;
267
+ const next = at(position + 1);
268
+ if ((previous?.role === "keyword" && previous.content === "const") || constPattern > 0) piece.role = "constant";
269
+ else if (isWord(next, "=>")) piece.role = "variable";
270
+ else if (/^[A-Z][A-Z0-9_]+$/.test(piece.content)) piece.role = "constant";
271
+ else if (/^[A-Z]/.test(piece.content) && /[a-z]/.test(piece.content) && !isWord(next, ".")) piece.role = "function";
272
+ }
273
+ return mergeLines(pieces, lines.length);
274
+ }
275
+
276
+ function mergeLines(pieces: ReadonlyArray<CodeToken & { line: number }>, count: number): CodeToken[][] {
277
+ const out: CodeToken[][] = Array.from({ length: count }, () => []);
278
+ for (const { content, role, line } of pieces) {
279
+ const runs = out[line];
280
+ if (!runs) continue;
281
+ const last = runs.at(-1);
282
+ if (last && last.role === role) last.content += content;
283
+ else runs.push({ content, role });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /** Shell words Shiki colours as keywords. */
289
+ const SHELL_KEYWORDS = new Set([
290
+ "if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac", "in",
291
+ "function", "select", "break", "continue", "return", "export", "local", "readonly", "declare",
292
+ ]);
293
+
294
+ /** After these a new command starts. */
295
+ const SHELL_COMMAND_KEYWORDS = new Set(["then", "else", "do", "if", "while", "until", "elif"]);
296
+
297
+ /** Shell builtins Shiki colours as constants (`support.function.builtin`). */
298
+ const SHELL_BUILTINS = new Set([
299
+ "echo", "cd", "printf", "source", "set", "unset", "exit", "pwd", "read", "eval", "exec", "shift",
300
+ "trap", "wait", "type", "alias", "unalias", "true", "false", "test", "ulimit", "umask", "kill",
301
+ ]);
302
+
303
+ /**
304
+ * Shell code in the roles of the web's Shiki `bash` grammar, for `bash`, `sh`,
305
+ * `shell` and `zsh` blocks: a command name as a function, its arguments as
306
+ * strings, `-x` and `--flag` options and numbers as constants, builtins
307
+ * (`echo`, `cd`) as constants, keywords, `|`, `||` and redirects as
308
+ * keywords, `$VAR` plain (also inside a double-quoted string), comments.
309
+ * `prism-react-renderer` bundles no shell grammar, so this needs no peer.
310
+ */
311
+ export function shellTokens(code: string): CodeToken[][] {
312
+ return code.split("\n").map((source) => {
313
+ const runs: CodeToken[] = [];
314
+ const push = (content: string, role: CodeColorRole) => {
315
+ if (content === "") return;
316
+ const last = runs.at(-1);
317
+ if (last && last.role === role) last.content += content;
318
+ else runs.push({ content, role });
319
+ };
320
+ let index = 0;
321
+ let command = true;
322
+ // `for NAME in`: the name is plain, then `in` is a keyword.
323
+ let forState: "none" | "name" | "in" = "none";
324
+ // Inside `[ … ]` Shiki draws test operators (`-f`) as keywords and words plain.
325
+ let inTest = false;
326
+ while (index < source.length) {
327
+ const rest = source.slice(index);
328
+ const space = /^\s+/.exec(rest);
329
+ if (space) {
330
+ push(space[0], "plain");
331
+ index += space[0].length;
332
+ continue;
333
+ }
334
+ if (rest.startsWith("#")) {
335
+ push(rest, "comment");
336
+ break;
337
+ }
338
+ if (rest.startsWith('"')) {
339
+ // Double quotes: the string colour, with `$VAR` and `${VAR}` names plain.
340
+ const end = rest.slice(1).search(/(?<!\\)"/);
341
+ const text = end < 0 ? rest : rest.slice(0, end + 2);
342
+ for (const part of text.split(/(\$\{[^}]*\}|\$[A-Za-z_]\w*)/)) {
343
+ const braced = /^\$\{([^}]*)\}$/.exec(part);
344
+ if (braced) {
345
+ push("${", "string");
346
+ push(braced[1] ?? "", "plain");
347
+ push("}", "string");
348
+ } else push(part, /^\$[A-Za-z_]/.test(part) ? "plain" : "string");
349
+ }
350
+ index += text.length;
351
+ command = false;
352
+ continue;
353
+ }
354
+ if (rest.startsWith("'")) {
355
+ const end = rest.indexOf("'", 1);
356
+ const text = end < 0 ? rest : rest.slice(0, end + 1);
357
+ push(text, "string");
358
+ index += text.length;
359
+ command = false;
360
+ continue;
361
+ }
362
+ const operator = /^(?:\d?>>?&?\d?|<|\|\||\||&&|;|&|\$\(|\(|\)|`)/.exec(rest);
363
+ if (operator) {
364
+ const text = operator[0];
365
+ // Shiki: `|`, `||` and redirects in the keyword colour, `&&` and `;` plain.
366
+ push(text, text === "&&" || text === ";" || text === "$(" || text === "(" || text === ")" || text === "`" ? "plain" : "keyword");
367
+ index += text.length;
368
+ command = text !== ")" && !/^\d?>/.test(text) && text !== "<";
369
+ continue;
370
+ }
371
+ const variable = /^\$(?:\{[^}]*\}|[A-Za-z_]\w*|[0-9@#?$!*-])/.exec(rest);
372
+ if (variable) {
373
+ push(variable[0], "plain");
374
+ index += variable[0].length;
375
+ command = false;
376
+ continue;
377
+ }
378
+ const word = /^[^\s|&;<>()$"'`]+/.exec(rest)?.[0] ?? rest.charAt(0);
379
+ index += word.length;
380
+ const assignment = /^([A-Za-z_]\w*)=(.*)$/.exec(word);
381
+ if (SHELL_KEYWORDS.has(word) && (command || (word === "in" && forState === "in"))) {
382
+ push(word, "keyword");
383
+ command = SHELL_COMMAND_KEYWORDS.has(word);
384
+ forState = word === "for" ? "name" : "none";
385
+ continue;
386
+ }
387
+ // A test's brackets are plain, and what follows them is not a command.
388
+ if (word === "[" || word === "[[" || word === "]" || word === "]]") {
389
+ push(word, "plain");
390
+ command = false;
391
+ inTest = word.startsWith("[");
392
+ continue;
393
+ }
394
+ if (inTest) {
395
+ push(word, /^-[a-z]+$/.test(word) ? "keyword" : "plain");
396
+ continue;
397
+ }
398
+ if (assignment && (command || runs.at(-2)?.content === "export")) {
399
+ push(assignment[1] ?? "", "plain");
400
+ push("=", "keyword");
401
+ push(assignment[2] ?? "", "plain");
402
+ continue;
403
+ }
404
+ if (forState === "name") {
405
+ push(word, "plain");
406
+ forState = "in";
407
+ continue;
408
+ }
409
+ if (command) {
410
+ push(word, SHELL_BUILTINS.has(word) ? "constant" : "function");
411
+ command = false;
412
+ continue;
413
+ }
414
+ push(word, /^-/.test(word) || /^\d+$/.test(word) ? "constant" : "string");
415
+ }
416
+ return runs;
417
+ });
117
418
  }