@vvedantb/native-ui 0.13.0 → 0.17.1

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 (111) hide show
  1. package/README.md +42 -21
  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 +23 -13
  40. package/src/ai/conversation.tsx +394 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +55 -4
  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/subagent-cards.tsx +84 -14
  49. package/src/ai/tool-approval-card.tsx +139 -8
  50. package/src/ai/trace.tsx +54 -17
  51. package/src/ai/unified-diff.tsx +182 -0
  52. package/src/auth/GoogleSignInButton.tsx +6 -3
  53. package/src/auth/SignInScreen.tsx +11 -2
  54. package/src/brand/ProviderIcon.tsx +136 -0
  55. package/src/brand/index.ts +2 -0
  56. package/src/configureTheme.ts +39 -11
  57. package/src/controlStyles.ts +46 -30
  58. package/src/entries/gesture-handler.ts +4 -0
  59. package/src/entries/keyboard-controller.ts +4 -0
  60. package/src/entries/markdown.ts +1 -1
  61. package/src/entries/primitives.ts +24 -3
  62. package/src/entries/theme.ts +2 -1
  63. package/src/entries/video.ts +4 -0
  64. package/src/entries/webview.ts +6 -0
  65. package/src/examples/AiChatGallery.tsx +70 -0
  66. package/src/floating.ts +25 -4
  67. package/src/haptics.ts +30 -4
  68. package/src/highlight.ts +302 -1
  69. package/src/index.ts +27 -5
  70. package/src/loadingGrid.ts +75 -0
  71. package/src/markdown/Markdown.tsx +234 -32
  72. package/src/markdown/MarkdownTable.tsx +188 -0
  73. package/src/markdown/tableText.ts +58 -0
  74. package/src/marketing/UpgradeToProCard.tsx +4 -3
  75. package/src/media/ImageGallery.tsx +224 -0
  76. package/src/media/MediaCard.tsx +100 -0
  77. package/src/media/imageGrid.ts +51 -0
  78. package/src/motion.ts +33 -0
  79. package/src/pagerSwipe.ts +102 -0
  80. package/src/palette.ts +8 -1
  81. package/src/primitives/button.tsx +4 -1
  82. package/src/primitives/keyboard-controller.tsx +106 -0
  83. package/src/primitives/keyboard-parts.tsx +104 -0
  84. package/src/primitives/overlay.tsx +133 -45
  85. package/src/primitives/pager-gesture.tsx +131 -0
  86. package/src/primitives/sheet-drag.tsx +305 -0
  87. package/src/primitives/sheet-gesture.tsx +181 -0
  88. package/src/primitives/swipe-row-gesture.tsx +290 -0
  89. package/src/primitives/toast-drag-gesture.tsx +92 -0
  90. package/src/primitives/web-click-guard.ts +50 -0
  91. package/src/relativeTime.ts +94 -0
  92. package/src/router/RouterStackPageHeader.tsx +2 -2
  93. package/src/settings/SettingsEmptyState.tsx +20 -10
  94. package/src/settings/SettingsField.tsx +11 -5
  95. package/src/settings/SettingsSection.tsx +8 -5
  96. package/src/settings/SettingsToggleRow.tsx +55 -22
  97. package/src/settings/relaxedSizeStyle.ts +11 -0
  98. package/src/sheetDrag.ts +67 -0
  99. package/src/swipeRow.ts +169 -0
  100. package/src/tabBarRoutes.ts +18 -0
  101. package/src/tabIndicator.ts +71 -0
  102. package/src/tailwind-preset.cjs +13 -6
  103. package/src/tailwind-preset.d.cts +5 -0
  104. package/src/theme.tsx +6 -2
  105. package/src/toastDrag.ts +58 -0
  106. package/src/typography.ts +20 -3
  107. package/src/useScheme.ts +10 -1
  108. package/src/video/InlineVideo.tsx +91 -0
  109. package/src/webview/HtmlView.tsx +91 -0
  110. package/src/webview/HtmlView.web.tsx +21 -0
  111. package/src/webview/htmlView.ts +45 -0
@@ -1,15 +1,17 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
  import { Pressable, ScrollView, Text, View, useWindowDimensions, type ViewStyle } from "react-native";
3
- import { FadeInUp, FadeOutDown } from "react-native-reanimated";
3
+ import { FadeInDown, FadeOutDown } from "react-native-reanimated";
4
4
  import { useSafeAreaInsets } from "react-native-safe-area-context";
5
5
  import { IconDots, IconX, type IconProps } from "@tabler/icons-react-native";
6
6
  import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./DropdownMenu";
7
7
  import { FloatingSurface } from "./FloatingSurface";
8
+ import { FLOATING_PILL_RADIUS, tabBarHeight, tabBarSlotStyle } from "./floating";
8
9
  import { MIN_TOUCH_TARGET, sizeStyle, touchSlop, typeStyle } from "./controlStyles";
9
10
  import { palette, withAlpha } from "./palette";
10
11
  import { useLargerText } from "./theme";
11
12
  import { tokens } from "./tokens";
12
13
  import { useScheme } from "./useScheme";
14
+ import { EASE_OUT } from "./motion";
13
15
  import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
14
16
 
15
17
  /**
@@ -20,7 +22,10 @@ export interface BulkBarAction {
20
22
  key: string;
21
23
  /** Full label: the More menu item and the accessible name. */
22
24
  label: string;
23
- /** Shorter label shown in the bar on wide screens (defaults to `label`). */
25
+ /**
26
+ * Shorter label shown in the bar on wide screens, and under the icon with
27
+ * `placement="tabBar"` (defaults to `label`).
28
+ */
24
29
  shortLabel?: string;
25
30
  /** A Tabler icon, drawn at 17 in the button colour. */
26
31
  icon: ComponentType<IconProps>;
@@ -50,21 +55,59 @@ export interface BulkActionBarProps {
50
55
  /**
51
56
  * Space under the bar, in points, before its 12 gap. Default: the bottom
52
57
  * safe-area inset. Above a `FloatingTabBar`, pass
53
- * `tabBarReserve(largerText, insets.bottom)`.
58
+ * `tabBarReserve(largerText, insets.bottom)`. Not used with
59
+ * `placement="tabBar"`.
54
60
  */
55
61
  bottomOffset?: number;
62
+ /**
63
+ * Native only: keep Cancel selection (X) and its divider outside the
64
+ * sideways scroll, at the bar's right end, so it shows even when the bar
65
+ * is wider than the screen. The rest of the bar still scrolls. Default
66
+ * `false`: the X scrolls with the actions, as the web.
67
+ */
68
+ pinCancel?: boolean;
69
+ /**
70
+ * Native only. `"float"` (default): the web bar, floating 12 above
71
+ * `bottomOffset`. `"tabBar"`: the phone contextual bar, in the slot and
72
+ * shape of `FloatingTabBar` (hide the tab bar while selecting, with
73
+ * `tabBarStyle: { display: "none" }` on the screen). It shows up and
74
+ * leaves at once, so the two bars swap with no gap. Each action is its
75
+ * icon over its `shortLabel` (12, or 14 with larger text) in a box 48 high
76
+ * (56 with larger text) that shares out the spare width. There is no count
77
+ * chip: the screen header shows the count, as Android's contextual action
78
+ * bar, and no dividers, as the tab bar. Cancel selection (X) is pinned, as
79
+ * with `pinCancel`.
80
+ */
81
+ placement?: BulkBarPlacement;
56
82
  }
57
83
 
84
+ export type BulkBarPlacement = "float" | "tabBar";
85
+
58
86
  /** The web `sm` breakpoint: below it, bar buttons are icon-only and "selected" is hidden. */
59
87
  export const BULK_BAR_LABEL_MIN_WIDTH = 640;
60
88
 
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) {
89
+ /**
90
+ * Web `h-9` buttons with a 17 icon and `text-sm`; with larger text 48 high, a
91
+ * 22 icon and `text-lg`. In the tab bar slot: 48 high with a 20 icon over a
92
+ * `text-xs` label; with larger text 56, a 24 icon and `text-sm`. `caption` is
93
+ * the count chip, or the label under a docked icon.
94
+ */
95
+ function barMetrics(largerText: boolean, docked: boolean) {
96
+ if (docked) {
97
+ return largerText
98
+ ? { height: 56, icon: 24, text: sizeStyle("text-lg"), caption: sizeStyle("text-sm") }
99
+ : { height: 48, icon: 20, text: sizeStyle("text-sm"), caption: sizeStyle("text-xs") };
100
+ }
63
101
  return largerText
64
102
  ? { height: 48, icon: 22, text: sizeStyle("text-lg"), caption: sizeStyle("text-base") }
65
103
  : { height: 36, icon: 17, text: sizeStyle("text-sm"), caption: sizeStyle("text-xs") };
66
104
  }
67
105
 
106
+ // Docked at the bottom: it rises into place and sinks back out, the exit 20%
107
+ // quicker.
108
+ const BAR_ENTER = FadeInDown.duration(150).easing(EASE_OUT);
109
+ const BAR_EXIT = FadeOutDown.duration(120).easing(EASE_OUT);
110
+
68
111
  /**
69
112
  * The floating selection bar, as the web `BulkActionBar`: a pill-ended glass
70
113
  * bar (`FloatingSurface`, radius 16, `px-2.5 py-2`, `gap-1`) centred at the
@@ -72,7 +115,9 @@ function barMetrics(largerText: boolean) {
72
115
  * count chip, Select all (n) / Clear, a divider, the primary actions, a More
73
116
  * menu, the destructive action, and Cancel selection (X). Actions are off with
74
117
  * 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.
118
+ * last in a full-size container; it scrolls sideways when it does not fit
119
+ * (with `pinCancel`, all but the X scrolls). `placement="tabBar"` docks it in
120
+ * the tab bar's slot instead, with labelled 48 high actions.
76
121
  */
77
122
  export function BulkActionBar({
78
123
  isSelecting,
@@ -86,119 +131,163 @@ export function BulkActionBar({
86
131
  destructiveAction,
87
132
  barKey,
88
133
  bottomOffset,
134
+ pinCancel = false,
135
+ placement = "float",
89
136
  }: BulkActionBarProps) {
90
137
  const scheme = useScheme();
91
- const metrics = barMetrics(useLargerText());
138
+ const largerText = useLargerText();
92
139
  const insets = useSafeAreaInsets();
93
140
  const { width } = useWindowDimensions();
94
141
  if (!isSelecting) return null;
95
- const wide = width >= BULK_BAR_LABEL_MIN_WIDTH;
142
+ const docked = placement === "tabBar";
143
+ const metrics = barMetrics(largerText, docked);
144
+ // Docked has no X to pin: the header holds it.
145
+ const pinned = pinCancel && !docked;
146
+ // Docked, every action shows its label (under the icon).
147
+ const labelled = docked || width >= BULK_BAR_LABEL_MIN_WIDTH;
96
148
  const hasSelection = selectedCount > 0;
97
149
  const allSelected = totalCount > 0 && selectedCount >= totalCount;
98
150
  const muted = tokens.color.muted[scheme];
151
+ // Docked, the screen's header carries the close X (Android's contextual
152
+ // action bar), so the bar has none: one exit, and room for every label.
153
+ const cancel = docked ? null : (
154
+ <>
155
+ <BarDivider />
156
+ <BarPressable height={metrics.height} paddingX={0} docked={docked} label="Cancel selection" onPress={onExitSelect}>
157
+ <IconX size={metrics.icon} color={muted} />
158
+ </BarPressable>
159
+ </>
160
+ );
99
161
 
100
162
  return (
101
163
  <NativeOnlyAnimatedView
102
164
  testID={barKey}
103
165
  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
- }}
166
+ // Docked, the bar swaps with the tab bar at once.
167
+ entering={docked ? undefined : BAR_ENTER}
168
+ exiting={docked ? undefined : BAR_EXIT}
169
+ style={
170
+ docked
171
+ ? tabBarSlotStyle(insets)
172
+ : {
173
+ position: "absolute",
174
+ left: 0,
175
+ right: 0,
176
+ bottom: 12 + (bottomOffset ?? insets.bottom),
177
+ alignItems: "center",
178
+ paddingHorizontal: 16,
179
+ }
180
+ }
114
181
  >
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 }}
182
+ <FloatingSurface
183
+ radius={docked ? FLOATING_PILL_RADIUS : tokens.radius.lg}
184
+ style={docked ? undefined : { maxWidth: width - 32 }}
185
+ >
186
+ {/* Docked: as high as the tab bar it stands in for. */}
187
+ <View style={{ flexDirection: "row", alignItems: "center", height: docked ? tabBarHeight(largerText) : undefined }}>
188
+ <ScrollView
189
+ horizontal
190
+ bounces={false}
191
+ showsHorizontalScrollIndicator={false}
192
+ // Pinned: the scroll gives up width first, and the X part keeps the right padding.
193
+ style={pinned || docked ? { flexShrink: 1 } : undefined}
194
+ contentContainerStyle={{
195
+ // Docked, the actions grow to fill the bar, and it scrolls only when they do not fit.
196
+ ...(docked ? { flexGrow: 1 } : null),
197
+ flexDirection: "row",
198
+ alignItems: "center",
199
+ gap: docked ? 0 : 4,
200
+ paddingLeft: docked ? 8 : 10,
201
+ paddingRight: docked ? 8 : pinned ? 0 : 10,
202
+ paddingVertical: docked ? 0 : 8,
203
+ }}
126
204
  >
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] }]}
205
+ {docked ? null : (
206
+ <View
207
+ accessible
208
+ accessibilityLabel={`${selectedCount} selected`}
209
+ style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingLeft: 4, paddingRight: 2 }}
138
210
  >
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>
211
+ <View
212
+ style={{
213
+ borderRadius: tokens.radius.md,
214
+ paddingHorizontal: 8,
215
+ paddingVertical: 2,
216
+ backgroundColor: tokens.color.surfaceSecondary[scheme],
217
+ }}
218
+ >
219
+ <Text
220
+ className="font-instrument-sans-semibold"
221
+ style={[metrics.caption, typeStyle("semibold"), { fontVariant: ["tabular-nums"], color: tokens.color.foreground[scheme] }]}
222
+ >
223
+ {selectedCount}
224
+ </Text>
225
+ </View>
226
+ {labelled ? (
227
+ <Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color: muted }]}>
228
+ selected
229
+ </Text>
230
+ ) : null}
231
+ </View>
232
+ )}
233
+
234
+ {totalCount > 0 ? (
235
+ <BarPressable height={metrics.height} paddingX={8} docked={docked} onPress={allSelected ? onClearSelection : onSelectAll}>
236
+ <Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color: muted }]}>
237
+ {allSelected ? "Clear" : `Select all (${totalCount})`}
238
+ </Text>
239
+ </BarPressable>
146
240
  ) : null}
147
- </View>
148
241
 
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}
242
+ {docked ? null : <BarDivider />}
156
243
 
157
- <BarDivider />
244
+ {primaryActions.map((action) => (
245
+ <BarButton key={action.key} action={action} disabled={!hasSelection} labelled={labelled} docked={docked} />
246
+ ))}
158
247
 
159
- {primaryActions.map((action) => (
160
- <BarButton key={action.key} action={action} disabled={!hasSelection} wide={wide} />
161
- ))}
248
+ {moreActions && moreActions.length > 0 ? (
249
+ <DropdownMenu>
250
+ <DropdownMenuTrigger
251
+ accessibilityLabel="More actions"
252
+ disabled={!hasSelection}
253
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, metrics.height)}
254
+ style={({ pressed }) => barButtonStyle(metrics.height, 0, pressed, !hasSelection, false, docked, scheme)}
255
+ >
256
+ <IconDots size={metrics.icon} color={muted} />
257
+ </DropdownMenuTrigger>
258
+ <DropdownMenuContent align="end" sideOffset={8}>
259
+ {moreActions.map((action) => (
260
+ <DropdownMenuItem
261
+ key={action.key}
262
+ icon={action.icon}
263
+ destructive={action.destructive}
264
+ disabled={!hasSelection || Boolean(action.disabled)}
265
+ onSelect={action.onClick}
266
+ >
267
+ {action.label}
268
+ </DropdownMenuItem>
269
+ ))}
270
+ </DropdownMenuContent>
271
+ </DropdownMenu>
272
+ ) : null}
162
273
 
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}
274
+ {destructiveAction ? (
275
+ <>
276
+ {docked ? null : <BarDivider />}
277
+ <BarButton action={destructiveAction} disabled={!hasSelection} labelled={labelled} docked={docked} />
278
+ </>
279
+ ) : null}
188
280
 
189
- {destructiveAction ? (
190
- <>
191
- <BarDivider />
192
- <BarButton action={destructiveAction} disabled={!hasSelection} wide={wide} />
193
- </>
281
+ {pinned ? null : cancel}
282
+ </ScrollView>
283
+ {pinned ? (
284
+ // The scroll's 4 gap before the divider, then the bar's end padding.
285
+ // Docked, as the tab bar: no dividers and no gaps, the boxes' own padding apart.
286
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 4, paddingLeft: docked ? 0 : 4, paddingRight: docked ? 8 : 10 }}>
287
+ {cancel}
288
+ </View>
194
289
  ) : 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>
290
+ </View>
202
291
  </FloatingSurface>
203
292
  </NativeOnlyAnimatedView>
204
293
  );
@@ -207,7 +296,8 @@ export function BulkActionBar({
207
296
  /**
208
297
  * A bar button box: the web `h-9 rounded-lg`, at least 44 wide and slopped to
209
298
  * 44 high, `surface-secondary` while pressed (a 10% danger tint for a
210
- * destructive one), faded to 30% when off.
299
+ * destructive one), faded to 30% when off. Docked: a pill (as the tab bar's
300
+ * active pill), at least 48 wide.
211
301
  */
212
302
  function barButtonStyle(
213
303
  height: number,
@@ -215,18 +305,19 @@ function barButtonStyle(
215
305
  pressed: boolean,
216
306
  disabled: boolean,
217
307
  destructive: boolean,
308
+ docked: boolean,
218
309
  scheme: "light" | "dark",
219
310
  ): ViewStyle {
220
311
  const pressedFill = destructive ? withAlpha(palette.danger[scheme], 0.1) : tokens.color.surfaceSecondary[scheme];
221
312
  return {
222
313
  height,
223
- minWidth: MIN_TOUCH_TARGET,
314
+ minWidth: docked ? 48 : MIN_TOUCH_TARGET,
224
315
  paddingHorizontal: paddingX,
225
316
  flexDirection: "row",
226
317
  alignItems: "center",
227
318
  justifyContent: "center",
228
319
  gap: 6,
229
- borderRadius: tokens.radius.lg,
320
+ borderRadius: docked ? FLOATING_PILL_RADIUS : tokens.radius.lg,
230
321
  backgroundColor: pressed && !disabled ? pressedFill : "transparent",
231
322
  opacity: disabled ? 0.3 : 1,
232
323
  };
@@ -235,6 +326,8 @@ function barButtonStyle(
235
326
  function BarPressable({
236
327
  height,
237
328
  paddingX,
329
+ docked,
330
+ grow = false,
238
331
  label,
239
332
  disabled = false,
240
333
  destructive = false,
@@ -243,6 +336,9 @@ function BarPressable({
243
336
  }: {
244
337
  height: number;
245
338
  paddingX: number;
339
+ docked: boolean;
340
+ /** Take a share of the bar's spare width (a docked action). */
341
+ grow?: boolean;
246
342
  label?: string;
247
343
  disabled?: boolean;
248
344
  destructive?: boolean;
@@ -258,32 +354,71 @@ function BarPressable({
258
354
  disabled={disabled}
259
355
  hitSlop={touchSlop(MIN_TOUCH_TARGET, height)}
260
356
  onPress={onPress}
261
- style={({ pressed }) => barButtonStyle(height, paddingX, pressed, disabled, destructive, scheme)}
357
+ style={({ pressed }) => ({
358
+ ...barButtonStyle(height, paddingX, pressed, disabled, destructive, docked, scheme),
359
+ ...(grow ? { flexGrow: 1 } : null),
360
+ })}
262
361
  >
263
362
  {children}
264
363
  </Pressable>
265
364
  );
266
365
  }
267
366
 
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 }) {
367
+ /**
368
+ * One action: the icon, and on wide screens the short label beside it
369
+ * (`text-sm` medium). Docked, the short label sits under the icon (`caption`).
370
+ */
371
+ function BarButton({
372
+ action,
373
+ disabled,
374
+ labelled,
375
+ docked,
376
+ }: {
377
+ action: BulkBarAction;
378
+ disabled: boolean;
379
+ labelled: boolean;
380
+ docked: boolean;
381
+ }) {
270
382
  const scheme = useScheme();
271
- const metrics = barMetrics(useLargerText());
383
+ const metrics = barMetrics(useLargerText(), docked);
272
384
  const Icon = action.icon;
273
385
  const color = action.destructive ? palette.danger[scheme] : tokens.color.muted[scheme];
386
+ const shortLabel = action.shortLabel ?? action.label;
387
+ if (docked) {
388
+ return (
389
+ <BarPressable
390
+ height={metrics.height}
391
+ paddingX={6}
392
+ docked
393
+ grow
394
+ label={action.label}
395
+ disabled={disabled || Boolean(action.disabled)}
396
+ destructive={action.destructive}
397
+ onPress={action.onClick}
398
+ >
399
+ <View style={{ alignItems: "center", gap: 2 }}>
400
+ <Icon size={metrics.icon} color={color} />
401
+ <Text numberOfLines={1} className="font-instrument-sans-medium" style={[metrics.caption, typeStyle("medium"), { color }]}>
402
+ {shortLabel}
403
+ </Text>
404
+ </View>
405
+ </BarPressable>
406
+ );
407
+ }
274
408
  return (
275
409
  <BarPressable
276
410
  height={metrics.height}
277
411
  paddingX={10}
412
+ docked={false}
278
413
  label={action.label}
279
414
  disabled={disabled || Boolean(action.disabled)}
280
415
  destructive={action.destructive}
281
416
  onPress={action.onClick}
282
417
  >
283
418
  <Icon size={metrics.icon} color={color} />
284
- {wide ? (
419
+ {labelled ? (
285
420
  <Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color }]}>
286
- {action.shortLabel ?? action.label}
421
+ {shortLabel}
287
422
  </Text>
288
423
  ) : null}
289
424
  </BarPressable>
package/src/Button.tsx CHANGED
@@ -62,14 +62,15 @@ const SIZE_ALIAS = {
62
62
  icon: "icon",
63
63
  } as const;
64
64
 
65
- // `regular` is the web `Button` (`sm` h-8 px-3, `default` h-10 px-4, `lg`
66
- // h-11 px-6, `icon` h-10 w-10). `larger` is the larger-text step-up from
65
+ // `regular` is the web `Button` (`default` h-10 px-4, `lg` h-11 px-6, `icon`
66
+ // h-10 w-10), except `sm`: the web h-8 `text-xs` is a desktop size, so here it
67
+ // is 36 high with a 14 label (`text-sm`). `larger` is the larger-text step-up from
67
68
  // 0.3.2. Labelled sizes are minimum heights (`h-auto` drops the primitive's
68
69
  // `h-10`), so a large Dynamic Type size grows the button instead of clipping
69
70
  // the label.
70
71
  const HEIGHT = {
71
72
  regular: {
72
- small: "h-auto min-h-8 px-3",
73
+ small: "h-auto min-h-9 px-3",
73
74
  medium: "h-auto min-h-10 px-4",
74
75
  large: "h-auto min-h-11 px-6",
75
76
  icon: "h-10 w-10 px-0",
@@ -84,14 +85,17 @@ const HEIGHT = {
84
85
 
85
86
  // Each class is also set as a style (`sizeStyle`): web `text-sm` is 14 / 20.
86
87
  const TEXT = {
87
- regular: { small: "text-xs", medium: "text-sm", large: "text-sm", icon: "text-sm" },
88
+ regular: { small: "text-sm", medium: "text-sm", large: "text-sm", icon: "text-sm" },
88
89
  larger: { small: "text-base", medium: "text-xl", large: "text-2xl", icon: "text-base" },
89
90
  } as const;
90
91
 
91
- // The web sizes are under the 44 pt touch target; the slop brings each to 48.
92
+ // The web sizes are under the 44 pt touch target; the slop brings each to 44
93
+ // or more. `small` (32) slops only above and below: small buttons sit 4 apart
94
+ // in a row (Cancel and Delete), and side slop would let one steal the other's
95
+ // taps. The larger sizes are 56 or more and need none.
92
96
  const HIT_SLOP = {
93
- regular: { small: 8, medium: 4, large: 2, icon: 4 },
94
- larger: { small: 8, medium: undefined, large: undefined, icon: 8 },
97
+ regular: { small: { top: 6, bottom: 6, left: 0, right: 0 }, medium: 4, large: 2, icon: 4 },
98
+ larger: { small: undefined, medium: undefined, large: undefined, icon: undefined },
95
99
  };
96
100
 
97
101
  // The web `shape` (from @vvedantb/ui 0.31.0). `pill` is `rounded-full`:
@@ -9,7 +9,8 @@ import {
9
9
  AlertDialogHeader,
10
10
  AlertDialogTitle,
11
11
  } from "./AlertDialog";
12
- import { useOverlay } from "./primitives/overlay";
12
+ import { impactHaptic } from "./haptics";
13
+ import { useControllableState } from "./primitives/use-controllable-state";
13
14
 
14
15
  export type ConfirmDialogProps = {
15
16
  open?: boolean;
@@ -33,8 +34,10 @@ export type ConfirmDialogProps = {
33
34
 
34
35
  /**
35
36
  * Blocking confirm dialog that closes on either button: the web `ConfirmDialog`
36
- * legacy props with `size="default"`. The `sm` / `md` sizes and the composable
37
- * `ConfirmDialog.*` parts are web-only; compose `AlertDialog` parts instead.
37
+ * legacy props with `size="default"`. Every close but Confirm (Cancel, Android
38
+ * back, the VoiceOver escape) calls `onCancel`, as the web `onOpenChange`
39
+ * close does. The `sm` / `md` sizes and the composable `ConfirmDialog.*`
40
+ * parts are web-only; compose `AlertDialog` parts instead.
38
41
  */
39
42
  export function ConfirmDialog({
40
43
  open,
@@ -51,8 +54,15 @@ export function ConfirmDialog({
51
54
  children,
52
55
  testID,
53
56
  }: ConfirmDialogProps) {
57
+ const [isOpen, setOpen] = useControllableState({ value: open, defaultValue: defaultOpen ?? false, onChange: onOpenChange });
54
58
  return (
55
- <AlertDialog open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
59
+ <AlertDialog
60
+ open={isOpen}
61
+ onOpenChange={(next) => {
62
+ if (!next) onCancel?.();
63
+ setOpen(next);
64
+ }}
65
+ >
56
66
  {children}
57
67
  <AlertDialogContent testID={testID}>
58
68
  <AlertDialogHeader>
@@ -62,44 +72,24 @@ export function ConfirmDialog({
62
72
  ) : null}
63
73
  </AlertDialogHeader>
64
74
  <AlertDialogFooter>
65
- <AlertDialogCancel
66
- testID={testID ? `${testID}-cancel` : undefined}
67
- onPress={onCancel}
68
- >
69
- {cancelLabel}
70
- </AlertDialogCancel>
71
- <ConfirmAction
75
+ {/* Closes the dialog, and so calls `onCancel`. */}
76
+ <AlertDialogCancel testID={testID ? `${testID}-cancel` : undefined}>{cancelLabel}</AlertDialogCancel>
77
+ <AlertDialogAction
72
78
  testID={testID ? `${testID}-confirm` : undefined}
73
79
  variant={variant}
74
80
  disabled={confirmDisabled}
75
- onConfirm={onConfirm}
81
+ onPress={() => {
82
+ // A destructive confirm lands with a medium haptic.
83
+ if (variant === "destructive") impactHaptic("medium");
84
+ onConfirm?.();
85
+ // Past the dialog's own close, so a confirm is not a cancel.
86
+ setOpen(false);
87
+ }}
76
88
  >
77
89
  {confirmLabel}
78
- </ConfirmAction>
90
+ </AlertDialogAction>
79
91
  </AlertDialogFooter>
80
92
  </AlertDialogContent>
81
93
  </AlertDialog>
82
94
  );
83
95
  }
84
-
85
- function ConfirmAction({
86
- onConfirm,
87
- ...props
88
- }: {
89
- onConfirm?: () => void;
90
- variant: "default" | "destructive";
91
- disabled?: boolean;
92
- testID?: string;
93
- children: ReactNode;
94
- }) {
95
- const { setOpen } = useOverlay("ConfirmDialog");
96
- return (
97
- <AlertDialogAction
98
- {...props}
99
- onPress={() => {
100
- onConfirm?.();
101
- setOpen(false);
102
- }}
103
- />
104
- );
105
- }