@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.
- package/README.md +40 -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/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 +38 -1
- package/src/ai/message-action-sheet.tsx +4 -1
- package/src/ai/message.tsx +60 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +301 -75
- package/src/ai/shimmer.tsx +4 -6
- 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/Dialog.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
ScrollView,
|
|
3
2
|
Text,
|
|
4
3
|
View,
|
|
5
4
|
useWindowDimensions,
|
|
@@ -7,7 +6,6 @@ import {
|
|
|
7
6
|
type TextProps,
|
|
8
7
|
type ViewProps,
|
|
9
8
|
} from "react-native";
|
|
10
|
-
import { SlideInDown } from "react-native-reanimated";
|
|
11
9
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
12
10
|
import { IconX } from "@tabler/icons-react-native";
|
|
13
11
|
import { FloatingSurface } from "./FloatingSurface";
|
|
@@ -18,7 +16,7 @@ import { palette } from "./palette";
|
|
|
18
16
|
import { tokens } from "./tokens";
|
|
19
17
|
import { useLargerText } from "./theme";
|
|
20
18
|
import { useScheme } from "./useScheme";
|
|
21
|
-
import {
|
|
19
|
+
import { SheetDragView, SheetScrollView } from "./primitives/sheet-drag";
|
|
22
20
|
import {
|
|
23
21
|
OverlayClose,
|
|
24
22
|
OverlayModal,
|
|
@@ -57,6 +55,13 @@ export type DialogContentProps = ViewProps & {
|
|
|
57
55
|
* at every size.
|
|
58
56
|
*/
|
|
59
57
|
viewportAware?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Called once the dialog is on screen. Focus its field here (`ref.focus()`)
|
|
60
|
+
* rather than with `autoFocus`: in a `Modal`, Android may not raise the
|
|
61
|
+
* keyboard for a field focused at mount, and the web focus trap moves focus
|
|
62
|
+
* to the first control (the backdrop) after it.
|
|
63
|
+
*/
|
|
64
|
+
onShow?: () => void;
|
|
60
65
|
};
|
|
61
66
|
|
|
62
67
|
/** Web `rounded-t-2xl` (16) on the compact sheet. */
|
|
@@ -64,8 +69,9 @@ const SHEET_RADIUS = tokens.radius.lg;
|
|
|
64
69
|
|
|
65
70
|
/** The centred panel: at most 480 wide. */
|
|
66
71
|
const PANEL = { width: "100%", maxWidth: 480, alignSelf: "center" } as const;
|
|
67
|
-
/** The compact sheet: its bottom corners sit below the screen edge
|
|
68
|
-
const SHEET = { marginBottom: -SHEET_RADIUS };
|
|
72
|
+
/** The compact sheet: its bottom corners sit below the screen edge, and it shrinks to the room, as `SheetContent`. */
|
|
73
|
+
const SHEET = { marginBottom: -SHEET_RADIUS, flexShrink: 1 };
|
|
74
|
+
const SHRINK = { flexShrink: 1 };
|
|
69
75
|
|
|
70
76
|
const CENTRED: DialogLayout = { compact: false, stacked: false };
|
|
71
77
|
const COMPACT: DialogLayout = { compact: true, stacked: false };
|
|
@@ -79,8 +85,11 @@ const STACKED: DialogLayout = { compact: true, stacked: true };
|
|
|
79
85
|
* In a compact window (the web `COMPACT_OVERLAY_QUERY`, from the window size)
|
|
80
86
|
* it is the web phone bottom sheet: full width, top corners 16, a handle,
|
|
81
87
|
* `pt-3 px-6`, the bottom safe-area inset (at least 24) below, and it slides
|
|
82
|
-
* up unless Reduce Motion is on.
|
|
83
|
-
* as `SheetContent`
|
|
88
|
+
* up on open and down on close unless Reduce Motion is on. Drag it down to
|
|
89
|
+
* close, as `SheetContent`
|
|
90
|
+
* (when `dismissible`). Its bottom corners sit below the screen edge,
|
|
91
|
+
* as `SheetContent`'s do. `DialogFooter` stacks its buttons there. Taller than
|
|
92
|
+
* the room, it shrinks to fit, so a `DialogBody` inside scrolls.
|
|
84
93
|
*/
|
|
85
94
|
export function DialogContent({
|
|
86
95
|
children,
|
|
@@ -88,6 +97,7 @@ export function DialogContent({
|
|
|
88
97
|
hideCloseButton = false,
|
|
89
98
|
dismissible = true,
|
|
90
99
|
viewportAware = true,
|
|
100
|
+
onShow,
|
|
91
101
|
style,
|
|
92
102
|
...props
|
|
93
103
|
}: DialogContentProps) {
|
|
@@ -103,27 +113,27 @@ export function DialogContent({
|
|
|
103
113
|
part="DialogContent"
|
|
104
114
|
placement={compact ? "bottom" : "center"}
|
|
105
115
|
dismissible={dismissible}
|
|
116
|
+
onShow={onShow}
|
|
106
117
|
>
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
{
|
|
123
|
-
</
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
</NativeOnlyAnimatedView>
|
|
118
|
+
<View style={compact ? SHEET : PANEL}>
|
|
119
|
+
<SheetDragView enabled={compact && dismissible} style={compact ? SHRINK : undefined}>
|
|
120
|
+
<FloatingSurface blur={false} radius={compact ? SHEET_RADIUS : tokens.radius.card} style={compact ? SHRINK : undefined}>
|
|
121
|
+
<View
|
|
122
|
+
accessibilityViewIsModal
|
|
123
|
+
className={cn(compact ? "gap-4 px-6 pt-3" : "gap-4 p-6", className)}
|
|
124
|
+
{...props}
|
|
125
|
+
style={[
|
|
126
|
+
compact ? { paddingBottom: Math.max(24, insets.bottom) + SHEET_RADIUS, flexShrink: 1 } : null,
|
|
127
|
+
style,
|
|
128
|
+
]}
|
|
129
|
+
>
|
|
130
|
+
{compact ? <DialogHandle /> : null}
|
|
131
|
+
{children}
|
|
132
|
+
</View>
|
|
133
|
+
{hideCloseButton ? null : <DialogCloseButton />}
|
|
134
|
+
</FloatingSurface>
|
|
135
|
+
</SheetDragView>
|
|
136
|
+
</View>
|
|
127
137
|
</OverlayModal>
|
|
128
138
|
</DialogLayoutContext.Provider>
|
|
129
139
|
);
|
|
@@ -131,7 +141,7 @@ export function DialogContent({
|
|
|
131
141
|
|
|
132
142
|
/**
|
|
133
143
|
* The web compact handle: an `h-1 w-12` pill in `surface-tertiary`, in a
|
|
134
|
-
* 44-high row.
|
|
144
|
+
* 44-high row. The whole sheet drags (`SheetDragView`), the handle with it.
|
|
135
145
|
*/
|
|
136
146
|
function DialogHandle() {
|
|
137
147
|
const scheme = useScheme();
|
|
@@ -209,17 +219,19 @@ export function DialogDescription({ className, style, ...props }: TextPartProps)
|
|
|
209
219
|
);
|
|
210
220
|
}
|
|
211
221
|
|
|
212
|
-
/**
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Scrolling body for long content. Height is capped at `maxHeight` (default
|
|
224
|
+
* 60% of the window). In a bottom sheet (`SheetScrollView`) dragging down
|
|
225
|
+
* scrolls it back first and moves the sheet only from the top, and it holds
|
|
226
|
+
* still while the sheet follows the finger.
|
|
227
|
+
*/
|
|
228
|
+
export function DialogBody({ maxHeight, style, ...props }: ScrollViewProps & { maxHeight?: number }) {
|
|
218
229
|
const { height } = useWindowDimensions();
|
|
219
230
|
return (
|
|
220
|
-
<
|
|
231
|
+
<SheetScrollView
|
|
221
232
|
bounces={false}
|
|
222
233
|
keyboardShouldPersistTaps="handled"
|
|
234
|
+
scrollEventThrottle={16}
|
|
223
235
|
style={[{ maxHeight: maxHeight ?? height * 0.6 }, style]}
|
|
224
236
|
{...props}
|
|
225
237
|
/>
|
package/src/DividedList.tsx
CHANGED
|
@@ -17,21 +17,25 @@ export type DividedListProps = ViewProps & {
|
|
|
17
17
|
* no divider is left hanging.
|
|
18
18
|
*/
|
|
19
19
|
export function DividedList({ children, inset = 0, className, style, ...props }: DividedListProps) {
|
|
20
|
-
const scheme = useScheme();
|
|
21
20
|
const rows = Children.toArray(children).filter(isValidElement);
|
|
22
21
|
return (
|
|
23
22
|
<View className={className} style={style} {...props}>
|
|
24
23
|
{rows.map((row, index) => (
|
|
25
24
|
<Fragment key={row.key ?? index}>
|
|
26
|
-
{index > 0 ?
|
|
27
|
-
<View
|
|
28
|
-
accessible={false}
|
|
29
|
-
style={{ height: 1, marginLeft: inset, backgroundColor: tokens.color.border[scheme] }}
|
|
30
|
-
/>
|
|
31
|
-
) : null}
|
|
25
|
+
{index > 0 ? <ListDivider inset={inset} /> : null}
|
|
32
26
|
{row}
|
|
33
27
|
</Fragment>
|
|
34
28
|
))}
|
|
35
29
|
</View>
|
|
36
30
|
);
|
|
37
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The hairline `DividedList` draws between two rows (1 high, `border`
|
|
35
|
+
* colour), on its own: the `ItemSeparatorComponent` of a `FlatList` or
|
|
36
|
+
* `SectionList` whose rows should look like a `DividedList`.
|
|
37
|
+
*/
|
|
38
|
+
export function ListDivider({ inset = 0 }: { inset?: number }) {
|
|
39
|
+
const scheme = useScheme();
|
|
40
|
+
return <View accessible={false} style={{ height: 1, marginLeft: inset, backgroundColor: tokens.color.border[scheme] }} />;
|
|
41
|
+
}
|
package/src/DropdownMenu.tsx
CHANGED
|
@@ -16,7 +16,8 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
|
16
16
|
import { IconCheck, type IconProps } from "@tabler/icons-react-native";
|
|
17
17
|
import { FloatingSurface } from "./FloatingSurface";
|
|
18
18
|
import { cn } from "./cn";
|
|
19
|
-
import {
|
|
19
|
+
import { sizeStyle, typeStyle } from "./controlStyles";
|
|
20
|
+
import { selectionHaptic } from "./haptics";
|
|
20
21
|
import { palette } from "./palette";
|
|
21
22
|
import { useLargerText } from "./theme";
|
|
22
23
|
import { tokens } from "./tokens";
|
|
@@ -29,7 +30,8 @@ import { useControllableState } from "./primitives/use-controllable-state";
|
|
|
29
30
|
* p-1.5 rounded-lg`, `sideOffset` 4 and `collisionPadding` 8; items `px-2.5
|
|
30
31
|
* py-2 rounded-menu-item` (8) with a 16 icon `gap-2`; radio items `pl-8` with
|
|
31
32
|
* the check at `left-2.5`; the label `px-2.5 py-1.5`; the separator `-mx-1.5
|
|
32
|
-
* my-1 h-px`. Items are at least
|
|
33
|
+
* my-1 h-px`. Items are at least 48 high, a phone menu row (the web
|
|
34
|
+
* `max-sm:min-h-10` is 40).
|
|
33
35
|
*/
|
|
34
36
|
export const DROPDOWN_MENU = {
|
|
35
37
|
minWidth: 192,
|
|
@@ -37,6 +39,7 @@ export const DROPDOWN_MENU = {
|
|
|
37
39
|
radius: tokens.radius.lg,
|
|
38
40
|
sideOffset: 4,
|
|
39
41
|
edge: 8,
|
|
42
|
+
itemHeight: 48,
|
|
40
43
|
itemRadius: 8,
|
|
41
44
|
itemPaddingX: 10,
|
|
42
45
|
itemPaddingY: 8,
|
|
@@ -206,9 +209,11 @@ export function DropdownMenuContent({
|
|
|
206
209
|
const window = useWindowDimensions();
|
|
207
210
|
const insets = useSafeAreaInsets();
|
|
208
211
|
const reduceMotion = useReduceMotion();
|
|
209
|
-
|
|
212
|
+
// `null` until the content is measured: only then is it known whether the
|
|
213
|
+
// menu fits below the trigger or flips above, so it stays hidden till then.
|
|
214
|
+
const [contentHeight, setContentHeight] = useState<number | null>(null);
|
|
210
215
|
const close = () => setOpen(false);
|
|
211
|
-
const position = dropdownMenuPosition({ trigger, window, insets, align, sideOffset, contentHeight });
|
|
216
|
+
const position = dropdownMenuPosition({ trigger, window, insets, align, sideOffset, contentHeight: contentHeight ?? 0 });
|
|
212
217
|
return (
|
|
213
218
|
<Modal
|
|
214
219
|
visible={open}
|
|
@@ -237,6 +242,7 @@ export function DropdownMenuContent({
|
|
|
237
242
|
...position,
|
|
238
243
|
minWidth: DROPDOWN_MENU.minWidth,
|
|
239
244
|
maxWidth: window.width - insets.left - insets.right - 2 * DROPDOWN_MENU.edge,
|
|
245
|
+
opacity: contentHeight === null ? 0 : 1,
|
|
240
246
|
}}
|
|
241
247
|
>
|
|
242
248
|
<FloatingSurface blur={false} radius={DROPDOWN_MENU.radius} style={{ maxHeight: position.maxHeight }}>
|
|
@@ -257,11 +263,11 @@ export function DropdownMenuContent({
|
|
|
257
263
|
);
|
|
258
264
|
}
|
|
259
265
|
|
|
260
|
-
/** Item type and icon size: the web `text-sm` with a 16 icon, or `text-lg` with a 22 icon and 56 rows with larger text. */
|
|
266
|
+
/** Item type and icon size: the web `text-sm` with a 16 icon in 48 rows, or `text-lg` with a 22 icon and 56 rows with larger text. */
|
|
261
267
|
function itemMetrics(largerText: boolean) {
|
|
262
268
|
return largerText
|
|
263
269
|
? { text: sizeStyle("text-lg"), icon: 22, minHeight: 56 }
|
|
264
|
-
: { text: sizeStyle("text-sm"), icon: 16, minHeight:
|
|
270
|
+
: { text: sizeStyle("text-sm"), icon: 16, minHeight: DROPDOWN_MENU.itemHeight };
|
|
265
271
|
}
|
|
266
272
|
|
|
267
273
|
type MenuIcon = ComponentType<IconProps>;
|
|
@@ -308,6 +314,8 @@ function MenuRow({
|
|
|
308
314
|
disabled={disabled}
|
|
309
315
|
accessibilityState={{ ...accessibilityState, disabled: Boolean(disabled), ...(checked === undefined ? null : { checked }) }}
|
|
310
316
|
onPress={() => {
|
|
317
|
+
// A new pick from a set ticks; a plain action and the current choice do not.
|
|
318
|
+
if (checked === false) selectionHaptic();
|
|
311
319
|
onSelect?.();
|
|
312
320
|
if (closeOnSelect) setOpen(false);
|
|
313
321
|
}}
|
|
@@ -346,13 +354,19 @@ export type DropdownMenuItemProps = MenuItemBaseProps & {
|
|
|
346
354
|
icon?: MenuIcon;
|
|
347
355
|
/** The web `text-destructive` item: label and icon in the danger colour. */
|
|
348
356
|
destructive?: boolean;
|
|
357
|
+
/** The web `text-warning` item (such as Archive): label and icon in the warning colour. */
|
|
358
|
+
warning?: boolean;
|
|
349
359
|
};
|
|
350
360
|
|
|
351
361
|
/** One action row (`menuitem`): an optional icon and the label. */
|
|
352
|
-
export function DropdownMenuItem({ icon: Icon, destructive, ...props }: DropdownMenuItemProps) {
|
|
362
|
+
export function DropdownMenuItem({ icon: Icon, destructive, warning, ...props }: DropdownMenuItemProps) {
|
|
353
363
|
const scheme = useScheme();
|
|
354
364
|
const metrics = itemMetrics(useLargerText());
|
|
355
|
-
const color = destructive
|
|
365
|
+
const color = destructive
|
|
366
|
+
? palette.danger[scheme]
|
|
367
|
+
: warning
|
|
368
|
+
? palette.warning[scheme]
|
|
369
|
+
: palette.overlayForeground[scheme];
|
|
356
370
|
return (
|
|
357
371
|
<MenuRow
|
|
358
372
|
part="DropdownMenuItem"
|
package/src/FloatingSurface.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
|
2
|
-
import { useColorScheme } from "nativewind";
|
|
3
2
|
import { glassMode, loadBlur } from "./blur";
|
|
4
3
|
import { cn } from "./cn";
|
|
5
4
|
import { hasDefiniteHeight } from "./definiteSize";
|
|
@@ -12,6 +11,7 @@ import {
|
|
|
12
11
|
} from "./floating";
|
|
13
12
|
import { useGlassBlurTarget } from "./glassBlur";
|
|
14
13
|
import { useReduceTransparency } from "./useReduceTransparency";
|
|
14
|
+
import { useScheme } from "./useScheme";
|
|
15
15
|
|
|
16
16
|
type FloatingSurfaceProps = ViewProps & {
|
|
17
17
|
className?: string;
|
|
@@ -29,7 +29,7 @@ export function FloatingSurface({
|
|
|
29
29
|
elevated = true,
|
|
30
30
|
...props
|
|
31
31
|
}: FloatingSurfaceProps) {
|
|
32
|
-
const
|
|
32
|
+
const colorScheme = useScheme();
|
|
33
33
|
const reduceTransparency = useReduceTransparency();
|
|
34
34
|
const blurTarget = useGlassBlurTarget();
|
|
35
35
|
const BlurView = loadBlur()?.BlurView;
|
|
@@ -127,6 +127,9 @@ export function GlassSheetBackground({
|
|
|
127
127
|
const styles = StyleSheet.create({
|
|
128
128
|
clip: {
|
|
129
129
|
overflow: "hidden",
|
|
130
|
+
// A surface held shorter than its content (a sheet in a short window)
|
|
131
|
+
// shrinks the clip with it, so a scroll view inside can scroll.
|
|
132
|
+
flexShrink: 1,
|
|
130
133
|
},
|
|
131
134
|
fill: {
|
|
132
135
|
flex: 1,
|
package/src/FloatingTabBar.tsx
CHANGED
|
@@ -10,16 +10,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
|
10
10
|
import { floatingTabPillColors, tabColors, typeStyle } from "./controlStyles";
|
|
11
11
|
import { selectionHaptic } from "./haptics";
|
|
12
12
|
import { FloatingSurface } from "./FloatingSurface";
|
|
13
|
-
import { isTabBarRouteVisible, type FloatingTabOptions } from "./tabBarRoutes";
|
|
13
|
+
import { isTabBarHidden, isTabBarRouteVisible, type FloatingTabOptions } from "./tabBarRoutes";
|
|
14
14
|
import { useLargerText } from "./theme";
|
|
15
15
|
import { useReduceMotion } from "./useReduceMotion";
|
|
16
16
|
import { useScheme } from "./useScheme";
|
|
17
|
-
import {
|
|
18
|
-
FLOATING_INSET,
|
|
19
|
-
FLOATING_PILL_RADIUS,
|
|
20
|
-
FLOATING_TAB_BAR_HEIGHT,
|
|
21
|
-
FLOATING_TAB_BAR_HEIGHT_LARGE,
|
|
22
|
-
} from "./floating";
|
|
17
|
+
import { FLOATING_PILL_RADIUS, tabBarHeight, tabBarSlotStyle } from "./floating";
|
|
23
18
|
|
|
24
19
|
/**
|
|
25
20
|
* Space between the bar edge and the tabs, on every side of the active pill,
|
|
@@ -58,7 +53,8 @@ export type FloatingTabBarProps = {
|
|
|
58
53
|
* `tabBarActiveTintColor` and `tabBarInactiveTintColor` override the label
|
|
59
54
|
* and icon colours. Hide a tab with `options={{ href: null }}` on its
|
|
60
55
|
* `Tabs.Screen`: the bar draws no tab for it (`isTabBarRouteVisible`), and
|
|
61
|
-
* the other tabs keep their focus and navigation.
|
|
56
|
+
* the other tabs keep their focus and navigation. The focused route hides the
|
|
57
|
+
* whole bar with `tabBarStyle: { display: "none" }` (`isTabBarHidden`).
|
|
62
58
|
*/
|
|
63
59
|
export function FloatingTabBar({
|
|
64
60
|
state,
|
|
@@ -68,27 +64,22 @@ export function FloatingTabBar({
|
|
|
68
64
|
const insets = useSafeAreaInsets();
|
|
69
65
|
const largerText = useLargerText();
|
|
70
66
|
const scheme = useScheme();
|
|
71
|
-
const height = largerText
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
const height = tabBarHeight(largerText);
|
|
68
|
+
const focusedRoute = state.routes[state.index];
|
|
69
|
+
if (focusedRoute && isTabBarHidden(descriptors[focusedRoute.key]?.options)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
74
72
|
// The pill counts only the tabs drawn. No pill while a hidden route is focused.
|
|
75
73
|
const visible = state.routes.filter((route) =>
|
|
76
74
|
isTabBarRouteVisible(descriptors[route.key]?.options),
|
|
77
75
|
);
|
|
78
|
-
const active = visible.findIndex(
|
|
79
|
-
(route) => route.key === state.routes[state.index]?.key,
|
|
80
|
-
);
|
|
76
|
+
const active = visible.findIndex((route) => route.key === focusedRoute?.key);
|
|
81
77
|
|
|
82
78
|
return (
|
|
83
79
|
<View
|
|
84
80
|
pointerEvents="box-none"
|
|
85
81
|
// Inside the home indicator and, in landscape, the notch.
|
|
86
|
-
style={
|
|
87
|
-
position: "absolute",
|
|
88
|
-
left: insets.left + FLOATING_INSET,
|
|
89
|
-
right: insets.right + FLOATING_INSET,
|
|
90
|
-
bottom: FLOATING_INSET + Math.max(insets.bottom, 8),
|
|
91
|
-
}}
|
|
82
|
+
style={tabBarSlotStyle(insets)}
|
|
92
83
|
>
|
|
93
84
|
<FloatingSurface radius={FLOATING_PILL_RADIUS}>
|
|
94
85
|
{active >= 0 ? (
|
|
@@ -184,11 +175,11 @@ function ActiveTabPill({ index, count }: { index: number; count: number }) {
|
|
|
184
175
|
const offset = useSharedValue(left);
|
|
185
176
|
|
|
186
177
|
useEffect(() => {
|
|
187
|
-
offset.
|
|
178
|
+
offset.set(reduceMotion ? left : withTiming(left, PILL_SLIDE));
|
|
188
179
|
}, [left, reduceMotion, offset]);
|
|
189
180
|
|
|
190
181
|
const slide = useAnimatedStyle(() => ({
|
|
191
|
-
left: `${offset.
|
|
182
|
+
left: `${offset.get()}%` as const,
|
|
192
183
|
}));
|
|
193
184
|
|
|
194
185
|
return (
|
package/src/HeaderIconButton.tsx
CHANGED
|
@@ -6,13 +6,14 @@ import { useScheme } from "./useScheme";
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Fill and border of `HeaderIconButton`: the surface with a white hairline
|
|
9
|
-
* (40% in light,
|
|
10
|
-
*
|
|
9
|
+
* (40%) in light and, in dark, the glass `tokens.color.hairline` of the
|
|
10
|
+
* floating tab bar, so `configureTheme` sets both. Styles, not `bg-surface`,
|
|
11
|
+
* which an app theme that nests `dark` under `surface` keeps white in dark mode.
|
|
11
12
|
*/
|
|
12
13
|
export function headerIconButtonColors(scheme: "light" | "dark") {
|
|
13
14
|
return {
|
|
14
15
|
backgroundColor: tokens.color.surface[scheme],
|
|
15
|
-
borderColor:
|
|
16
|
+
borderColor: scheme === "dark" ? tokens.color.hairline.dark : withAlpha(tokens.color.white, 0.4),
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
|
package/src/Input.tsx
CHANGED
|
@@ -21,8 +21,8 @@ const Input = React.forwardRef<
|
|
|
21
21
|
ref={ref}
|
|
22
22
|
placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
|
|
23
23
|
className={cn(
|
|
24
|
-
// The web field
|
|
25
|
-
"min-h-
|
|
24
|
+
// The web field px-3.5 py-2 with 16 px type (its phone size), 44 high (the web h-10 is a desktop 40).
|
|
25
|
+
"min-h-11 border px-3.5 py-2 text-base font-instrument-sans",
|
|
26
26
|
largerText && "min-h-16 px-4 py-4 text-xl font-instrument-sans-medium",
|
|
27
27
|
className,
|
|
28
28
|
)}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import Animated, {
|
|
3
|
+
useAnimatedStyle,
|
|
4
|
+
useFrameCallback,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
type SharedValue,
|
|
7
|
+
} from "react-native-reanimated";
|
|
8
|
+
import {
|
|
9
|
+
DRIVE_DELAYS,
|
|
10
|
+
DRIVE_SIZES,
|
|
11
|
+
DRIVE_STILL_FRAME_MS,
|
|
12
|
+
driveCellOpacity,
|
|
13
|
+
type LoadingStateSize,
|
|
14
|
+
} from "./loadingGrid";
|
|
15
|
+
import { tokens } from "./tokens";
|
|
16
|
+
import { useReduceMotion } from "./useReduceMotion";
|
|
17
|
+
import { useScheme } from "./useScheme";
|
|
18
|
+
|
|
19
|
+
export type { LoadingStateSize } from "./loadingGrid";
|
|
20
|
+
|
|
21
|
+
export type LoadingStateProps = {
|
|
22
|
+
/** What is running, for assistive tech. Default `"Working"`, as the web. */
|
|
23
|
+
label?: string;
|
|
24
|
+
/** `sm` is the web session-row grid (3 pt cells, 11 pt). Default `"default"` (4 pt cells, 15 pt). */
|
|
25
|
+
size?: LoadingStateSize;
|
|
26
|
+
/** Cell colour. Default the muted text colour. Native has no `currentColor`. */
|
|
27
|
+
color?: string;
|
|
28
|
+
/**
|
|
29
|
+
* One colour per column, left to right, as the web `LoadingState` `colors`
|
|
30
|
+
* (Eva's brand purple-to-blue sweep). Wins over `color`.
|
|
31
|
+
*/
|
|
32
|
+
colors?: readonly [string, string, string];
|
|
33
|
+
/** Hold a still frame, for example while the screen is not focused. Default `false`. */
|
|
34
|
+
paused?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The web `LoadingState` with `variant="Drive"` and `iconOnly`: a 3x3 pixel
|
|
39
|
+
* grid whose chevron wavefront drives right. Same cells, delays, 650 ms
|
|
40
|
+
* cycle and keyframes as the web (see `loadingGrid.ts`). One frame clock per
|
|
41
|
+
* grid on the UI thread, started on mount as the web WAAPI pulse is. With
|
|
42
|
+
* Reduce Motion, or `paused`, it holds a still chevron frame and runs no
|
|
43
|
+
* clock. The labelled form (label and elapsed timer) and the `Dots` and
|
|
44
|
+
* `Orbit` variants are not ported.
|
|
45
|
+
*/
|
|
46
|
+
export function LoadingState({ label = "Working", size = "default", color, colors, paused = false }: LoadingStateProps) {
|
|
47
|
+
const scheme = useScheme();
|
|
48
|
+
const reduceMotion = useReduceMotion();
|
|
49
|
+
const single = color ?? tokens.color.muted[scheme];
|
|
50
|
+
const fill: Fills = colors ?? [single, single, single];
|
|
51
|
+
const still = reduceMotion || paused;
|
|
52
|
+
return (
|
|
53
|
+
<View
|
|
54
|
+
accessible
|
|
55
|
+
accessibilityRole="progressbar"
|
|
56
|
+
accessibilityLabel={label}
|
|
57
|
+
accessibilityState={{ busy: true }}
|
|
58
|
+
style={{ alignSelf: "flex-start" }}
|
|
59
|
+
>
|
|
60
|
+
{still ? (
|
|
61
|
+
<DriveGrid size={size} fill={fill} elapsed={undefined} />
|
|
62
|
+
) : (
|
|
63
|
+
<RunningDriveGrid size={size} fill={fill} />
|
|
64
|
+
)}
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Column colours, left to right. */
|
|
70
|
+
type Fills = readonly [string, string, string];
|
|
71
|
+
|
|
72
|
+
/** Owns the clock, so unmounting it (Reduce Motion, `paused`) stops the frame callback. */
|
|
73
|
+
function RunningDriveGrid({ size, fill }: { size: LoadingStateSize; fill: Fills }) {
|
|
74
|
+
const elapsed = useSharedValue(0);
|
|
75
|
+
useFrameCallback((frame) => {
|
|
76
|
+
"worklet";
|
|
77
|
+
elapsed.set(frame.timeSinceFirstFrame);
|
|
78
|
+
}, true);
|
|
79
|
+
return <DriveGrid size={size} fill={fill} elapsed={elapsed} />;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The nine cells. No `elapsed`: the still frame. */
|
|
83
|
+
function DriveGrid({
|
|
84
|
+
size,
|
|
85
|
+
fill,
|
|
86
|
+
elapsed,
|
|
87
|
+
}: {
|
|
88
|
+
size: LoadingStateSize;
|
|
89
|
+
fill: Fills;
|
|
90
|
+
elapsed: SharedValue<number> | undefined;
|
|
91
|
+
}) {
|
|
92
|
+
const { gap } = DRIVE_SIZES[size];
|
|
93
|
+
// Three explicit rows, not a wrapping row: a fractional gap could wrap early.
|
|
94
|
+
return (
|
|
95
|
+
<View style={{ gap }}>
|
|
96
|
+
{[0, 1, 2].map((row) => (
|
|
97
|
+
<View key={row} style={{ flexDirection: "row", gap }}>
|
|
98
|
+
{DRIVE_DELAYS.slice(row * 3, row * 3 + 3).map((delay, column) =>
|
|
99
|
+
elapsed ? (
|
|
100
|
+
<DriveCell key={column} size={size} fill={fill[column] ?? fill[0]} delay={delay} elapsed={elapsed} />
|
|
101
|
+
) : (
|
|
102
|
+
<View
|
|
103
|
+
key={column}
|
|
104
|
+
style={[cellStyle(size, fill[column] ?? fill[0]), { opacity: driveCellOpacity(DRIVE_STILL_FRAME_MS, delay) }]}
|
|
105
|
+
/>
|
|
106
|
+
),
|
|
107
|
+
)}
|
|
108
|
+
</View>
|
|
109
|
+
))}
|
|
110
|
+
</View>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function DriveCell({
|
|
115
|
+
size,
|
|
116
|
+
fill,
|
|
117
|
+
delay,
|
|
118
|
+
elapsed,
|
|
119
|
+
}: {
|
|
120
|
+
size: LoadingStateSize;
|
|
121
|
+
fill: string;
|
|
122
|
+
delay: number;
|
|
123
|
+
elapsed: SharedValue<number>;
|
|
124
|
+
}) {
|
|
125
|
+
const pulse = useAnimatedStyle(() => ({ opacity: driveCellOpacity(elapsed.get(), delay) }));
|
|
126
|
+
return <Animated.View style={[cellStyle(size, fill), pulse]} />;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cellStyle(size: LoadingStateSize, fill: string) {
|
|
130
|
+
const { cell, radius } = DRIVE_SIZES[size];
|
|
131
|
+
return { width: cell, height: cell, borderRadius: radius, backgroundColor: fill };
|
|
132
|
+
}
|
package/src/PageTitle.tsx
CHANGED
|
@@ -35,6 +35,11 @@ export function PageTitle({ className, style, ...props }: PageTitleProps) {
|
|
|
35
35
|
export type PageHeaderProps = Omit<ViewProps, "children"> & {
|
|
36
36
|
/** The page title, drawn with `PageTitle`. */
|
|
37
37
|
title: string;
|
|
38
|
+
/**
|
|
39
|
+
* Content before the title, such as a close `HeaderIconButton` while the
|
|
40
|
+
* page is in select mode (Android's contextual action bar). It keeps its width.
|
|
41
|
+
*/
|
|
42
|
+
leading?: ReactNode;
|
|
38
43
|
/**
|
|
39
44
|
* Content at the end of the row, such as an "Update all" `Button` (pass it
|
|
40
45
|
* `fullWidth={false}`). It keeps its width; a long title is cut off instead.
|
|
@@ -52,13 +57,14 @@ export type PageHeaderProps = Omit<ViewProps, "children"> & {
|
|
|
52
57
|
};
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
|
-
* A screen's title row: `
|
|
56
|
-
* the end, with the `ScreenContainer` side padding (`px-4`). The row is at
|
|
60
|
+
* A screen's title row: an optional `leading` part, `PageTitle` and an
|
|
61
|
+
* optional `action` at the end, with the `ScreenContainer` side padding (`px-4`). The row is at
|
|
57
62
|
* least `PAGE_HEADER_HEIGHT` (56) high below the top inset, so tab roots with
|
|
58
63
|
* and without an action put their titles at the same height.
|
|
59
64
|
*/
|
|
60
65
|
export function PageHeader({
|
|
61
66
|
title,
|
|
67
|
+
leading,
|
|
62
68
|
action,
|
|
63
69
|
safeTop = true,
|
|
64
70
|
className,
|
|
@@ -74,6 +80,7 @@ export function PageHeader({
|
|
|
74
80
|
style={[{ paddingTop: top, minHeight: top + PAGE_HEADER_HEIGHT }, style]}
|
|
75
81
|
{...props}
|
|
76
82
|
>
|
|
83
|
+
{leading ? <View className="shrink-0">{leading}</View> : null}
|
|
77
84
|
<PageTitle className={cn("min-w-0 flex-1", titleClassName)}>{title}</PageTitle>
|
|
78
85
|
{action ? <View className="shrink-0">{action}</View> : null}
|
|
79
86
|
</View>
|
package/src/Pager.tsx
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import type { SharedValue } from "react-native-reanimated";
|
|
4
|
+
|
|
5
|
+
export type PagerProps = {
|
|
6
|
+
/** The page shown, from 0. A change from outside (a tab press, a link) slides there in 220 ms, through the pages between (at once with Reduce Motion, and without the swipe). */
|
|
7
|
+
index: number;
|
|
8
|
+
count: number;
|
|
9
|
+
/** A swipe settled on another page. Set `index` to it. */
|
|
10
|
+
onIndexChange: (index: number) => void;
|
|
11
|
+
/**
|
|
12
|
+
* Where the pages are, in pages (1.5 is halfway between the second and the
|
|
13
|
+
* third). Create it with `useSharedValue(index)` and pass the same value to
|
|
14
|
+
* `PillTabs` (`progress`), so its indicator moves with the pages.
|
|
15
|
+
*/
|
|
16
|
+
progress: SharedValue<number>;
|
|
17
|
+
/** Draws one page. A page is drawn once visited or once a swipe could reach it, and then stays, so it keeps its state. */
|
|
18
|
+
renderPage: (index: number) => ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The Gesture Handler pager, from `@vvedantb/native-ui/gesture-handler`
|
|
23
|
+
* (`GestureHandlerProvider`). A context, so only that entry imports
|
|
24
|
+
* `react-native-gesture-handler`.
|
|
25
|
+
*/
|
|
26
|
+
export const PagerGestureContext = createContext<ComponentType<PagerProps> | null>(null);
|
|
27
|
+
|
|
28
|
+
/** Set inside a swiped `Pager`: a `SwipeableRow` there leaves the directions it has no actions for to the pager. */
|
|
29
|
+
export const InsidePagerContext = createContext(false);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Side-by-side pages a horizontal swipe moves between, such as the sections
|
|
33
|
+
* under a row of `PillTabs`. The pages follow the finger 1:1 and, on release,
|
|
34
|
+
* spring on at the finger's speed to the nearest page (a flick turns one
|
|
35
|
+
* page); past the first and last page they rubber-band. The linked
|
|
36
|
+
* `progress` moves the `PillTabs` indicator with them. Only a clearly
|
|
37
|
+
* sideways drag takes the pages: a vertical drag scrolls the page, and a
|
|
38
|
+
* `SwipeableRow` under the finger takes its own swipe first (it lets a drag
|
|
39
|
+
* toward a side it has no actions for through to the pager). A settled page
|
|
40
|
+
* fires a selection haptic and `onIndexChange`. With Reduce Motion on the
|
|
41
|
+
* pages still follow the finger, and the release settles at once.
|
|
42
|
+
*
|
|
43
|
+
* The swipe needs `GestureHandlerProvider`; without it only the current page
|
|
44
|
+
* is drawn, with no swipe.
|
|
45
|
+
*/
|
|
46
|
+
export function Pager(props: PagerProps) {
|
|
47
|
+
const GesturePager = useContext(PagerGestureContext);
|
|
48
|
+
if (GesturePager) return <GesturePager {...props} />;
|
|
49
|
+
return <StaticPager {...props} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function StaticPager({ index, progress, renderPage }: PagerProps) {
|
|
53
|
+
// The linked indicator follows a page set from outside. An effect: a shared value is never written in render.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
progress.set(index);
|
|
56
|
+
}, [index, progress]);
|
|
57
|
+
return (
|
|
58
|
+
<View key={index} style={{ flex: 1 }}>
|
|
59
|
+
{renderPage(index)}
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/PasswordInput.tsx
CHANGED
|
@@ -54,7 +54,7 @@ const PasswordInput = React.forwardRef<
|
|
|
54
54
|
// The type comes from these classes only (no style `fontSize`), so it
|
|
55
55
|
// cannot diverge from NativeWind's `text-base` / `text-xl`.
|
|
56
56
|
className={cn(
|
|
57
|
-
"min-h-
|
|
57
|
+
"min-h-11 border px-3.5 py-2 text-base font-instrument-sans",
|
|
58
58
|
largerText && "min-h-16 px-4 py-4 text-xl font-instrument-sans-medium",
|
|
59
59
|
className,
|
|
60
60
|
)}
|