@vvedantb/native-ui 0.12.1 → 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.
- package/README.md +82 -19
- package/package.json +36 -3
- package/src/BottomSheetContainer.tsx +2 -2
- package/src/BulkActionBar.tsx +240 -105
- package/src/Button.tsx +11 -7
- package/src/ConfirmDialog.tsx +25 -35
- package/src/Dialog.tsx +47 -35
- package/src/DividedList.tsx +11 -7
- package/src/DropdownMenu.tsx +22 -8
- package/src/FloatingSurface.tsx +5 -2
- package/src/FloatingTabBar.tsx +13 -22
- package/src/HeaderIconButton.tsx +4 -3
- package/src/Input.tsx +2 -2
- package/src/LoadingState.tsx +132 -0
- package/src/PageTitle.tsx +9 -2
- package/src/Pager.tsx +62 -0
- package/src/PasswordInput.tsx +1 -1
- package/src/PillTabs.tsx +144 -34
- package/src/Progress.tsx +9 -7
- package/src/Select.tsx +4 -4
- package/src/Sheet.tsx +29 -22
- package/src/SheetRow.tsx +27 -9
- package/src/Skeleton.tsx +91 -9
- package/src/SurfaceRow.tsx +111 -0
- package/src/SwipeableRow.tsx +43 -0
- package/src/Switch.tsx +6 -4
- package/src/Tabs.tsx +68 -13
- package/src/Toast.tsx +107 -65
- package/src/ai/activity-step-detail.tsx +1 -3
- package/src/ai/activity-step-label.ts +981 -0
- package/src/ai/activity-step-types.ts +107 -0
- package/src/ai/activity-tasks-utils.ts +199 -0
- package/src/ai/activity-timeline.ts +45 -0
- package/src/ai/attachments.tsx +3 -1
- package/src/ai/card-button.tsx +49 -0
- package/src/ai/chat-text.ts +13 -7
- package/src/ai/chat-thread.tsx +34 -27
- package/src/ai/choice-question.tsx +154 -35
- package/src/ai/code-block.tsx +189 -49
- package/src/ai/collapsible.tsx +19 -13
- package/src/ai/conversation.tsx +390 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +52 -1
- package/src/ai/message-action-sheet.tsx +168 -0
- package/src/ai/message-quote.tsx +98 -0
- package/src/ai/message.tsx +226 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +308 -73
- package/src/ai/reply-quote-chip.tsx +90 -0
- package/src/ai/shimmer.tsx +4 -6
- package/src/ai/subagent-cards.tsx +209 -0
- package/src/ai/tool-approval-card.tsx +162 -0
- package/src/ai/trace.tsx +54 -17
- package/src/ai/unified-diff.tsx +182 -0
- package/src/auth/GoogleSignInButton.tsx +6 -3
- package/src/auth/SignInScreen.tsx +11 -2
- package/src/brand/ProviderIcon.tsx +136 -0
- package/src/brand/index.ts +2 -0
- package/src/configureTheme.ts +39 -11
- package/src/controlStyles.ts +46 -30
- package/src/entries/gesture-handler.ts +4 -0
- package/src/entries/keyboard-controller.ts +4 -0
- package/src/entries/markdown.ts +1 -1
- package/src/entries/primitives.ts +24 -3
- package/src/entries/theme.ts +2 -1
- package/src/entries/video.ts +4 -0
- package/src/entries/webview.ts +6 -0
- package/src/floating.ts +25 -4
- package/src/haptics.ts +30 -4
- package/src/highlight.ts +302 -1
- package/src/index.ts +27 -5
- package/src/loadingGrid.ts +75 -0
- package/src/markdown/Markdown.tsx +234 -32
- package/src/markdown/MarkdownTable.tsx +188 -0
- package/src/markdown/tableText.ts +58 -0
- package/src/marketing/UpgradeToProCard.tsx +4 -3
- package/src/media/ImageGallery.tsx +224 -0
- package/src/media/MediaCard.tsx +100 -0
- package/src/media/imageGrid.ts +51 -0
- package/src/motion.ts +33 -0
- package/src/pagerSwipe.ts +102 -0
- package/src/palette.ts +8 -1
- package/src/primitives/button.tsx +4 -1
- package/src/primitives/keyboard-controller.tsx +106 -0
- package/src/primitives/keyboard-parts.tsx +104 -0
- package/src/primitives/overlay.tsx +133 -45
- package/src/primitives/pager-gesture.tsx +131 -0
- package/src/primitives/sheet-drag.tsx +305 -0
- package/src/primitives/sheet-gesture.tsx +181 -0
- package/src/primitives/swipe-row-gesture.tsx +290 -0
- package/src/primitives/toast-drag-gesture.tsx +92 -0
- package/src/primitives/web-click-guard.ts +50 -0
- package/src/relativeTime.ts +94 -0
- package/src/router/RouterStackPageHeader.tsx +2 -2
- package/src/settings/SettingsEmptyState.tsx +20 -10
- package/src/settings/SettingsField.tsx +11 -5
- package/src/settings/SettingsSection.tsx +8 -5
- package/src/settings/SettingsToggleRow.tsx +55 -22
- package/src/settings/relaxedSizeStyle.ts +11 -0
- package/src/sheetDrag.ts +67 -0
- package/src/swipeRow.ts +169 -0
- package/src/tabBarRoutes.ts +18 -0
- package/src/tabIndicator.ts +71 -0
- package/src/tailwind-preset.cjs +13 -6
- package/src/tailwind-preset.d.cts +5 -0
- package/src/theme.tsx +6 -2
- package/src/toastDrag.ts +58 -0
- package/src/typography.ts +20 -3
- package/src/useScheme.ts +10 -1
- package/src/video/InlineVideo.tsx +91 -0
- package/src/webview/HtmlView.tsx +91 -0
- package/src/webview/HtmlView.web.tsx +21 -0
- package/src/webview/htmlView.ts +45 -0
package/src/BulkActionBar.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
62
|
-
|
|
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
|
|
138
|
+
const largerText = useLargerText();
|
|
92
139
|
const insets = useSafeAreaInsets();
|
|
93
140
|
const { width } = useWindowDimensions();
|
|
94
141
|
if (!isSelecting) return null;
|
|
95
|
-
const
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
+
{primaryActions.map((action) => (
|
|
245
|
+
<BarButton key={action.key} action={action} disabled={!hasSelection} labelled={labelled} docked={docked} />
|
|
246
|
+
))}
|
|
158
247
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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 }) =>
|
|
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
|
-
/**
|
|
269
|
-
|
|
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
|
-
{
|
|
419
|
+
{labelled ? (
|
|
285
420
|
<Text className="font-instrument-sans-medium" style={[metrics.text, typeStyle("medium"), { color }]}>
|
|
286
|
-
{
|
|
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` (`
|
|
66
|
-
// h-
|
|
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-
|
|
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-
|
|
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
|
|
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:
|
|
94
|
-
larger: { small:
|
|
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`:
|
package/src/ConfirmDialog.tsx
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
AlertDialogHeader,
|
|
10
10
|
AlertDialogTitle,
|
|
11
11
|
} from "./AlertDialog";
|
|
12
|
-
import {
|
|
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"`.
|
|
37
|
-
* `
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
}
|