@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.
- package/README.md +42 -21
- 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 +23 -13
- package/src/ai/conversation.tsx +394 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +55 -4
- 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/subagent-cards.tsx +84 -14
- package/src/ai/tool-approval-card.tsx +139 -8
- 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/examples/AiChatGallery.tsx +70 -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/PillTabs.tsx
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Pressable } from "react-native";
|
|
2
|
+
import Animated, {
|
|
3
|
+
scrollTo,
|
|
4
|
+
useAnimatedReaction,
|
|
5
|
+
useAnimatedRef,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
type SharedValue,
|
|
8
|
+
} from "react-native-reanimated";
|
|
2
9
|
import { cn } from "./cn";
|
|
10
|
+
import { selectionHaptic } from "./haptics";
|
|
3
11
|
import {
|
|
4
12
|
MIN_TOUCH_TARGET,
|
|
5
13
|
tabColors,
|
|
@@ -7,59 +15,161 @@ import {
|
|
|
7
15
|
touchSlop,
|
|
8
16
|
typeStyle,
|
|
9
17
|
} from "./controlStyles";
|
|
18
|
+
import { centredTabScroll } from "./pagerSwipe";
|
|
19
|
+
import { useTabIndicator, useTabLabelStyle } from "./tabIndicator";
|
|
10
20
|
import { useLargerText } from "./theme";
|
|
11
21
|
import { useScheme } from "./useScheme";
|
|
12
22
|
|
|
13
23
|
/**
|
|
14
|
-
* A row of pill tabs from an options array
|
|
15
|
-
*
|
|
24
|
+
* A row of pill tabs from an options array, the web pills look tuned for a
|
|
25
|
+
* phone: 36 high pills (`px-3.5`, a `text-sm` medium label) with 4 slop above
|
|
26
|
+
* and below, so each is a 44 target. The row is one line that scrolls
|
|
27
|
+
* sideways when the pills do not fit (larger system text), and never wraps.
|
|
28
|
+
* The active pill scrolls into view, centred, each time it changes.
|
|
29
|
+
*
|
|
30
|
+
* With `progress` (a `Pager`'s), the selected fill is one indicator that
|
|
31
|
+
* slides with that value, and each label blends to the selected colour with
|
|
32
|
+
* it, so fill, label and the centred scroll follow the pages under a swipe;
|
|
33
|
+
* a press still changes `value` at once.
|
|
16
34
|
*/
|
|
17
35
|
export function PillTabs<T extends string>({
|
|
18
36
|
options,
|
|
19
37
|
value,
|
|
20
38
|
onChange,
|
|
39
|
+
progress,
|
|
21
40
|
}: {
|
|
22
41
|
options: ReadonlyArray<{ id: T; label: string; testID?: string }>;
|
|
23
42
|
value: T;
|
|
24
43
|
onChange: (id: T) => void;
|
|
44
|
+
/** Where the linked `Pager` is, in pages: option 0 is page 0. */
|
|
45
|
+
progress?: SharedValue<number>;
|
|
25
46
|
}) {
|
|
26
47
|
const largerText = useLargerText();
|
|
27
48
|
const scheme = useScheme();
|
|
28
49
|
const trigger = tabTriggerSize("pills", "default", largerText);
|
|
50
|
+
const slop = touchSlop(MIN_TOUCH_TARGET, trigger.minHeight);
|
|
51
|
+
const indicator = useTabIndicator(progress, options.length);
|
|
52
|
+
const { boxes } = indicator;
|
|
53
|
+
const selectedIndex = Math.max(0, options.findIndex((tab) => tab.id === value));
|
|
54
|
+
const scroll = useAnimatedRef<Animated.ScrollView>();
|
|
55
|
+
const viewport = useSharedValue(0);
|
|
56
|
+
// Keeps the active pill centred: with `progress` it follows the pages frame
|
|
57
|
+
// by frame (a swipe, or a press's slide), else it moves with `value`.
|
|
58
|
+
useAnimatedReaction(
|
|
59
|
+
() => (progress ? progress.get() : selectedIndex),
|
|
60
|
+
(at) => {
|
|
61
|
+
if (boxes.length === 0 || viewport.get() <= 0) return;
|
|
62
|
+
scrollTo(scroll, centredTabScroll(boxes, at, viewport.get()), 0, false);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
29
66
|
return (
|
|
30
|
-
<
|
|
67
|
+
<Animated.ScrollView
|
|
68
|
+
ref={scroll}
|
|
69
|
+
horizontal
|
|
31
70
|
accessibilityRole="tablist"
|
|
32
|
-
|
|
71
|
+
showsHorizontalScrollIndicator={false}
|
|
72
|
+
// Android fades the pills out at an edge that scrolls.
|
|
73
|
+
fadingEdgeLength={16}
|
|
74
|
+
onLayout={(event) => viewport.set(event.nativeEvent.layout.width)}
|
|
75
|
+
// The slop above and below lies inside the row, where touches land; the
|
|
76
|
+
// negative margins keep the row's place in the layout.
|
|
77
|
+
style={{ flexGrow: 0, marginVertical: -slop.top }}
|
|
78
|
+
// The web pills' `gap-1.5`, as a style: the animated scroll view drops `contentContainerClassName`.
|
|
79
|
+
contentContainerStyle={{ flexDirection: "row", gap: 6, paddingVertical: slop.top }}
|
|
33
80
|
>
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
|
|
81
|
+
{indicator.measured ? (
|
|
82
|
+
// The selected fill, absolutely placed with no children: animating its
|
|
83
|
+
// width keeps the pill's radius, and lays out nothing else.
|
|
84
|
+
<Animated.View
|
|
85
|
+
pointerEvents="none"
|
|
86
|
+
style={[
|
|
87
|
+
{ position: "absolute", left: 0, borderRadius: 9999 },
|
|
88
|
+
{ backgroundColor: tabColors("pills", true, scheme).backgroundColor },
|
|
89
|
+
indicator.style,
|
|
90
|
+
]}
|
|
91
|
+
/>
|
|
92
|
+
) : null}
|
|
93
|
+
{options.map((tab, index) => (
|
|
94
|
+
<PillTab
|
|
95
|
+
key={tab.id}
|
|
96
|
+
label={tab.label}
|
|
97
|
+
testID={tab.testID}
|
|
98
|
+
index={index}
|
|
99
|
+
selected={value === tab.id}
|
|
100
|
+
// Until the indicator is measured, the selected pill paints its own fill.
|
|
101
|
+
filled={!indicator.measured}
|
|
102
|
+
progress={progress}
|
|
103
|
+
trigger={trigger}
|
|
104
|
+
hitSlop={slop}
|
|
105
|
+
onLayout={indicator.onLayout(index)}
|
|
106
|
+
onPress={() => onChange(tab.id)}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
</Animated.ScrollView>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
37
112
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
113
|
+
function PillTab({
|
|
114
|
+
label,
|
|
115
|
+
testID,
|
|
116
|
+
index,
|
|
117
|
+
selected,
|
|
118
|
+
filled,
|
|
119
|
+
progress,
|
|
120
|
+
trigger,
|
|
121
|
+
hitSlop,
|
|
122
|
+
onLayout,
|
|
123
|
+
onPress,
|
|
124
|
+
}: {
|
|
125
|
+
label: string;
|
|
126
|
+
testID?: string;
|
|
127
|
+
index: number;
|
|
128
|
+
selected: boolean;
|
|
129
|
+
filled: boolean;
|
|
130
|
+
progress: SharedValue<number> | undefined;
|
|
131
|
+
trigger: ReturnType<typeof tabTriggerSize>;
|
|
132
|
+
hitSlop: ReturnType<typeof touchSlop>;
|
|
133
|
+
onLayout: ReturnType<ReturnType<typeof useTabIndicator>["onLayout"]>;
|
|
134
|
+
onPress: () => void;
|
|
135
|
+
}) {
|
|
136
|
+
const scheme = useScheme();
|
|
137
|
+
const colors = tabColors("pills", selected, scheme);
|
|
138
|
+
const labelStyle = useTabLabelStyle(
|
|
139
|
+
progress,
|
|
140
|
+
index,
|
|
141
|
+
{ selected: tabColors("pills", true, scheme).color, muted: tabColors("pills", false, scheme).color },
|
|
142
|
+
colors.color,
|
|
143
|
+
);
|
|
144
|
+
return (
|
|
145
|
+
<Pressable
|
|
146
|
+
testID={testID}
|
|
147
|
+
accessibilityLabel={label}
|
|
148
|
+
onLayout={onLayout}
|
|
149
|
+
onPress={() => {
|
|
150
|
+
// A segmented choice ticking to a new value, as `TabsTrigger`.
|
|
151
|
+
if (!selected) selectionHaptic();
|
|
152
|
+
onPress();
|
|
153
|
+
}}
|
|
154
|
+
accessibilityRole="tab"
|
|
155
|
+
accessibilityState={{ selected }}
|
|
156
|
+
hitSlop={hitSlop}
|
|
157
|
+
className={cn(
|
|
158
|
+
"justify-center rounded-full border border-white/40 active:opacity-70 dark:border-white/10",
|
|
159
|
+
trigger.className,
|
|
160
|
+
)}
|
|
161
|
+
style={{
|
|
162
|
+
backgroundColor: filled ? colors.backgroundColor : "transparent",
|
|
163
|
+
minHeight: trigger.minHeight,
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
<Animated.Text
|
|
167
|
+
numberOfLines={1}
|
|
168
|
+
className={cn("text-center font-instrument-sans-medium", trigger.textClassName)}
|
|
169
|
+
style={[typeStyle("medium"), labelStyle]}
|
|
170
|
+
>
|
|
171
|
+
{label}
|
|
172
|
+
</Animated.Text>
|
|
173
|
+
</Pressable>
|
|
64
174
|
);
|
|
65
175
|
}
|
package/src/Progress.tsx
CHANGED
|
@@ -38,16 +38,18 @@ export function Progress({
|
|
|
38
38
|
const width = useSharedValue(percent);
|
|
39
39
|
|
|
40
40
|
useEffect(() => {
|
|
41
|
-
width.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
width.set(
|
|
42
|
+
reduceMotion
|
|
43
|
+
? percent
|
|
44
|
+
: withTiming(percent, {
|
|
45
|
+
duration: 300,
|
|
46
|
+
easing: Easing.bezier(0.22, 1, 0.36, 1),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
47
49
|
}, [percent, reduceMotion, width]);
|
|
48
50
|
|
|
49
51
|
const indicatorStyle = useAnimatedStyle(() => ({
|
|
50
|
-
width: `${width.
|
|
52
|
+
width: `${width.get()}%` as const,
|
|
51
53
|
}));
|
|
52
54
|
|
|
53
55
|
return (
|
package/src/Select.tsx
CHANGED
|
@@ -107,12 +107,12 @@ function Select<TValue = string | number | null>({
|
|
|
107
107
|
accessibilityRole="combobox"
|
|
108
108
|
accessibilityLabel={accessibilityLabel}
|
|
109
109
|
accessibilityState={{ disabled: Boolean(disabled) }}
|
|
110
|
-
//
|
|
111
|
-
hitSlop={touchSlop(MIN_TOUCH_TARGET, largerText ? 64 :
|
|
110
|
+
// 44 high on screen, as `Input` (the web h-10 is a desktop 40): a whole touch target.
|
|
111
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET, largerText ? 64 : 44)}
|
|
112
112
|
className={cn(
|
|
113
|
-
// The web trigger
|
|
113
|
+
// The web trigger px-3.5 with 16 px type (its phone size), 44 high. A
|
|
114
114
|
// minimum height, so a large Dynamic Type size does not clip the value.
|
|
115
|
-
"h-auto min-h-
|
|
115
|
+
"h-auto min-h-11 w-full border px-3.5 py-0 shadow-none",
|
|
116
116
|
largerText && "min-h-16 px-4",
|
|
117
117
|
triggerClassName,
|
|
118
118
|
className,
|
package/src/Sheet.tsx
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { View, type ViewProps } from "react-native";
|
|
2
|
-
import { SlideInDown } from "react-native-reanimated";
|
|
3
2
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
3
|
import { FloatingSurface } from "./FloatingSurface";
|
|
5
4
|
import { cn } from "./cn";
|
|
@@ -7,7 +6,7 @@ import { withAlpha } from "./palette";
|
|
|
7
6
|
import { tokens } from "./tokens";
|
|
8
7
|
import { useScheme } from "./useScheme";
|
|
9
8
|
import { DialogBody, DialogDescription, DialogTitle } from "./Dialog";
|
|
10
|
-
import {
|
|
9
|
+
import { SheetDragView } from "./primitives/sheet-drag";
|
|
11
10
|
import {
|
|
12
11
|
OverlayClose,
|
|
13
12
|
OverlayModal,
|
|
@@ -23,7 +22,7 @@ export type SheetProps = OverlayRootProps;
|
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
24
|
* Bottom sheet in an RN `Modal`, with no `@gorhom/bottom-sheet`. Same open
|
|
26
|
-
* state API as `Dialog`.
|
|
25
|
+
* state API as `Dialog`.
|
|
27
26
|
*/
|
|
28
27
|
export function Sheet(props: SheetProps) {
|
|
29
28
|
return <OverlayRoot {...props} />;
|
|
@@ -43,40 +42,48 @@ export type SheetContentProps = ViewProps & {
|
|
|
43
42
|
dismissible?: boolean;
|
|
44
43
|
/** Grab handle above the content. Default `true`. */
|
|
45
44
|
showHandle?: boolean;
|
|
45
|
+
/** Called once the sheet is on screen: focus its field here, as `DialogContent`'s `onShow`. */
|
|
46
|
+
onShow?: () => void;
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* Panel pinned to the bottom edge: opaque surface, top corners at the card
|
|
50
|
-
* radius, safe-area bottom padding
|
|
51
|
-
*
|
|
51
|
+
* radius, safe-area bottom padding. It slides up on open and back down on
|
|
52
|
+
* close, with the backdrop fading to match (`OverlayModal`). Drag it down to
|
|
53
|
+
* close (when `dismissible`): it follows the finger, the backdrop fades, and
|
|
54
|
+
* a flick or a drag past half carries it out while a short slow one springs
|
|
55
|
+
* back (`SheetDragView`). Its bottom corners sit below the
|
|
56
|
+
* screen edge, so only the top corners show. Taller than the room (a short
|
|
57
|
+
* window, or the keyboard up), it shrinks to fit under the top inset, so a
|
|
58
|
+
* `SheetBody` or other scroll view inside scrolls and the title stays on screen.
|
|
52
59
|
*/
|
|
53
60
|
export function SheetContent({
|
|
54
61
|
children,
|
|
55
62
|
className,
|
|
56
63
|
dismissible = true,
|
|
57
64
|
showHandle = true,
|
|
65
|
+
onShow,
|
|
58
66
|
style,
|
|
59
67
|
...props
|
|
60
68
|
}: SheetContentProps) {
|
|
61
69
|
const insets = useSafeAreaInsets();
|
|
62
70
|
return (
|
|
63
|
-
<OverlayModal part="SheetContent" placement="bottom" dismissible={dismissible}>
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
</NativeOnlyAnimatedView>
|
|
71
|
+
<OverlayModal part="SheetContent" placement="bottom" dismissible={dismissible} onShow={onShow}>
|
|
72
|
+
<View style={{ marginBottom: -SHEET_RADIUS, flexShrink: 1 }}>
|
|
73
|
+
<SheetDragView enabled={dismissible} style={{ flexShrink: 1 }}>
|
|
74
|
+
<FloatingSurface blur={false} radius={SHEET_RADIUS} style={{ flexShrink: 1 }}>
|
|
75
|
+
<View
|
|
76
|
+
accessibilityViewIsModal
|
|
77
|
+
className={cn("gap-4 px-5 pt-3", className)}
|
|
78
|
+
{...props}
|
|
79
|
+
style={[{ paddingBottom: insets.bottom + 20 + SHEET_RADIUS, flexShrink: 1 }, style]}
|
|
80
|
+
>
|
|
81
|
+
{showHandle ? <SheetHandle /> : null}
|
|
82
|
+
{children}
|
|
83
|
+
</View>
|
|
84
|
+
</FloatingSurface>
|
|
85
|
+
</SheetDragView>
|
|
86
|
+
</View>
|
|
80
87
|
</OverlayModal>
|
|
81
88
|
);
|
|
82
89
|
}
|
package/src/SheetRow.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ComponentType } from "react";
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
2
|
import { Pressable, Text, View } from "react-native";
|
|
3
3
|
import { IconCheck, type IconProps } from "@tabler/icons-react-native";
|
|
4
4
|
import { cn } from "./cn";
|
|
@@ -11,13 +11,24 @@ import { useScheme } from "./useScheme";
|
|
|
11
11
|
export type SheetRowProps = {
|
|
12
12
|
/** A Tabler icon at the start. */
|
|
13
13
|
icon?: ComponentType<IconProps>;
|
|
14
|
+
/** At the start in place of `icon`: a mark that is not a Tabler icon, such as `ProviderIcon`. */
|
|
15
|
+
leading?: ReactNode;
|
|
14
16
|
title: string;
|
|
15
17
|
/** Muted line under the title, also the accessibility hint. */
|
|
16
18
|
detail?: string;
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Makes the row one choice of a picker: `true` draws a check at the end.
|
|
21
|
+
* Set (`true` or `false`) on every row of the picker, so assistive tech
|
|
22
|
+
* reads each as a `radio` (a `checkbox` with `multiple`) with `checked`.
|
|
23
|
+
* Leave it out on an action row.
|
|
24
|
+
*/
|
|
18
25
|
active?: boolean;
|
|
26
|
+
/** A picker where several rows can be on at once (or a single on/off row): `checkbox`, not `radio`. */
|
|
27
|
+
multiple?: boolean;
|
|
19
28
|
/** A destructive action: title and icon in `palette.danger`. */
|
|
20
29
|
danger?: boolean;
|
|
30
|
+
/** The web `text-warning` item (such as Archive): title and icon in `palette.warning`. */
|
|
31
|
+
warning?: boolean;
|
|
21
32
|
disabled?: boolean;
|
|
22
33
|
onPress?: () => void;
|
|
23
34
|
testID?: string;
|
|
@@ -25,35 +36,42 @@ export type SheetRowProps = {
|
|
|
25
36
|
|
|
26
37
|
/**
|
|
27
38
|
* One action in a `Sheet` or bottom sheet, a touch size of the web menu item:
|
|
28
|
-
* icon, title and optional detail, at least 56 high (64 with larger text) with
|
|
39
|
+
* icon (or another `leading` mark), title and optional detail, at least 56 high (64 with larger text) with
|
|
29
40
|
* the `ListRow` padding (`px-4 py-3`). While pressed it takes the web menu
|
|
30
41
|
* item highlight (`bg-default`). `danger` draws the title and icon in the
|
|
31
|
-
* danger colour; `active`
|
|
42
|
+
* danger colour, `warning` in the warning colour; `active` makes it a choice with a check; `disabled` dims it to 50%, as the web
|
|
32
43
|
* item, and ignores presses.
|
|
33
44
|
*/
|
|
34
45
|
export function SheetRow({
|
|
35
46
|
icon: Icon,
|
|
47
|
+
leading,
|
|
36
48
|
title,
|
|
37
49
|
detail,
|
|
38
|
-
active
|
|
50
|
+
active,
|
|
51
|
+
multiple = false,
|
|
39
52
|
danger = false,
|
|
53
|
+
warning = false,
|
|
40
54
|
disabled = false,
|
|
41
55
|
onPress,
|
|
42
56
|
testID,
|
|
43
57
|
}: SheetRowProps) {
|
|
44
58
|
const scheme = useScheme();
|
|
45
59
|
const largerText = useLargerText();
|
|
46
|
-
const color = danger
|
|
60
|
+
const color = danger
|
|
61
|
+
? palette.danger[scheme]
|
|
62
|
+
: warning
|
|
63
|
+
? palette.warning[scheme]
|
|
64
|
+
: tokens.color.foreground[scheme];
|
|
47
65
|
const titleSize = largerText ? "text-xl" : "text-base";
|
|
48
66
|
const detailSize = largerText ? "text-lg" : "text-sm";
|
|
49
67
|
const iconSize = largerText ? 26 : 22;
|
|
50
68
|
return (
|
|
51
69
|
<Pressable
|
|
52
70
|
testID={testID}
|
|
53
|
-
accessibilityRole="button"
|
|
71
|
+
accessibilityRole={active === undefined ? "button" : multiple ? "checkbox" : "radio"}
|
|
54
72
|
accessibilityLabel={title}
|
|
55
73
|
accessibilityHint={detail}
|
|
56
|
-
accessibilityState={{ disabled,
|
|
74
|
+
accessibilityState={active === undefined ? { disabled } : { disabled, checked: active }}
|
|
57
75
|
disabled={disabled}
|
|
58
76
|
onPress={onPress}
|
|
59
77
|
className={cn(
|
|
@@ -66,7 +84,7 @@ export function SheetRow({
|
|
|
66
84
|
backgroundColor: pressed && !disabled ? palette.default[scheme] : "transparent",
|
|
67
85
|
})}
|
|
68
86
|
>
|
|
69
|
-
{Icon ? <Icon size={iconSize} color={color} /> : null}
|
|
87
|
+
{leading ?? (Icon ? <Icon size={iconSize} color={color} /> : null)}
|
|
70
88
|
<View className="min-w-0 flex-1">
|
|
71
89
|
<Text
|
|
72
90
|
className={cn("font-instrument-sans-medium", titleSize)}
|
package/src/Skeleton.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Fragment, type ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View, type ViewProps } from "react-native";
|
|
2
3
|
import Animated, {
|
|
3
4
|
cancelAnimation,
|
|
4
5
|
useAnimatedStyle,
|
|
@@ -9,10 +10,16 @@ import Animated, {
|
|
|
9
10
|
import { useEffect } from "react";
|
|
10
11
|
import { cn } from "./cn";
|
|
11
12
|
import { cardStyle } from "./controlStyles";
|
|
13
|
+
import { ListDivider } from "./DividedList";
|
|
12
14
|
import { useReduceMotion } from "./useReduceMotion";
|
|
13
15
|
import { useScheme } from "./useScheme";
|
|
14
16
|
|
|
15
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Pulsing placeholder block. Holds still with Reduce Motion. Size it with
|
|
19
|
+
* `className` (for example `h-4 w-32`). The classes sit on a plain `View`:
|
|
20
|
+
* NativeWind does not style a Reanimated `Animated.View`, so only the pulse
|
|
21
|
+
* (opacity) is animated, on a fill inside.
|
|
22
|
+
*/
|
|
16
23
|
export function Skeleton({
|
|
17
24
|
className,
|
|
18
25
|
style,
|
|
@@ -23,25 +30,29 @@ export function Skeleton({
|
|
|
23
30
|
|
|
24
31
|
useEffect(() => {
|
|
25
32
|
if (reduceMotion) {
|
|
26
|
-
opacity.
|
|
33
|
+
opacity.set(0.7);
|
|
27
34
|
return;
|
|
28
35
|
}
|
|
29
|
-
opacity.
|
|
36
|
+
opacity.set(withRepeat(withTiming(1, { duration: 800 }), -1, true));
|
|
30
37
|
return () => cancelAnimation(opacity);
|
|
31
38
|
}, [opacity, reduceMotion]);
|
|
32
39
|
|
|
33
40
|
const animatedStyle = useAnimatedStyle(() => ({
|
|
34
|
-
opacity: opacity.
|
|
41
|
+
opacity: opacity.get(),
|
|
35
42
|
}));
|
|
36
43
|
|
|
37
44
|
return (
|
|
38
|
-
<
|
|
45
|
+
<View
|
|
39
46
|
accessibilityElementsHidden
|
|
40
47
|
importantForAccessibility="no-hide-descendants"
|
|
41
48
|
{...props}
|
|
42
|
-
style={
|
|
43
|
-
className={cn("
|
|
44
|
-
|
|
49
|
+
style={style}
|
|
50
|
+
className={cn("overflow-hidden rounded-lg", className)}
|
|
51
|
+
>
|
|
52
|
+
<Animated.View style={[StyleSheet.absoluteFill, animatedStyle]}>
|
|
53
|
+
<View className="flex-1 bg-neutral-200 dark:bg-neutral-700" />
|
|
54
|
+
</Animated.View>
|
|
55
|
+
</View>
|
|
45
56
|
);
|
|
46
57
|
}
|
|
47
58
|
|
|
@@ -55,3 +66,74 @@ export function CardSkeleton() {
|
|
|
55
66
|
</View>
|
|
56
67
|
);
|
|
57
68
|
}
|
|
69
|
+
|
|
70
|
+
/** Title widths, row by row, so a list of placeholders does not read as one block. */
|
|
71
|
+
const TITLE_WIDTHS: readonly `${number}%`[] = ["72%", "54%", "84%", "62%", "46%", "78%"];
|
|
72
|
+
|
|
73
|
+
export type SkeletonRowProps = {
|
|
74
|
+
/** The row's place in its list: the title width varies with it. */
|
|
75
|
+
index?: number;
|
|
76
|
+
/** A round mark before the text, this wide (a status dot, an avatar). */
|
|
77
|
+
leading?: number;
|
|
78
|
+
/** A title only, or a title over a shorter detail line. */
|
|
79
|
+
lines?: 1 | 2;
|
|
80
|
+
/** A short block at the end, where the real row shows its age. Default on. */
|
|
81
|
+
trailing?: boolean;
|
|
82
|
+
/** The real row's height, so nothing moves when it loads. Default 56. */
|
|
83
|
+
minHeight?: number;
|
|
84
|
+
className?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A list row's placeholder, shaped like the row it stands for: a round mark,
|
|
89
|
+
* a title (and a detail line), and a short trailing block, on the `px-4` row
|
|
90
|
+
* padding. Pulses as `Skeleton`; holds still with Reduce Motion.
|
|
91
|
+
*/
|
|
92
|
+
export function SkeletonRow({ index = 0, leading, lines = 1, trailing = true, minHeight = 56, className }: SkeletonRowProps) {
|
|
93
|
+
return (
|
|
94
|
+
<View className={cn("flex-row items-center gap-3 px-4", className)} style={{ minHeight }}>
|
|
95
|
+
{leading ? <Skeleton style={{ width: leading, height: leading, borderRadius: leading / 2 }} /> : null}
|
|
96
|
+
<View className="min-w-0 flex-1 gap-2">
|
|
97
|
+
<Skeleton className="h-3.5" style={{ width: TITLE_WIDTHS[index % TITLE_WIDTHS.length] }} />
|
|
98
|
+
{lines === 2 ? <Skeleton className="h-3 w-1/3" /> : null}
|
|
99
|
+
</View>
|
|
100
|
+
{trailing ? <Skeleton className="h-3 w-8" /> : null}
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type SkeletonListProps = {
|
|
106
|
+
/** What is loading, for assistive tech ("Loading sessions"). */
|
|
107
|
+
label: string;
|
|
108
|
+
/** Rows to draw. Default 6. */
|
|
109
|
+
count?: number;
|
|
110
|
+
/** A `ListDivider` between rows, as a `DividedList`. Default on. */
|
|
111
|
+
divided?: boolean;
|
|
112
|
+
/** One placeholder row, such as a `SkeletonRow` with this `index`. */
|
|
113
|
+
renderRow: (index: number) => ReactNode;
|
|
114
|
+
className?: string;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Placeholder rows where a list is loading, in place of a centred spinner:
|
|
119
|
+
* the screen keeps the shape it will have. Screen readers hear one busy
|
|
120
|
+
* element named `label`.
|
|
121
|
+
*/
|
|
122
|
+
export function SkeletonList({ label, count = 6, divided = true, renderRow, className }: SkeletonListProps) {
|
|
123
|
+
return (
|
|
124
|
+
<View
|
|
125
|
+
accessible
|
|
126
|
+
accessibilityRole="progressbar"
|
|
127
|
+
accessibilityLabel={label}
|
|
128
|
+
accessibilityState={{ busy: true }}
|
|
129
|
+
className={cn("min-h-0 w-full flex-1 overflow-hidden", className)}
|
|
130
|
+
>
|
|
131
|
+
{Array.from({ length: count }, (_, index) => (
|
|
132
|
+
<Fragment key={index}>
|
|
133
|
+
{divided && index > 0 ? <ListDivider /> : null}
|
|
134
|
+
{renderRow(index)}
|
|
135
|
+
</Fragment>
|
|
136
|
+
))}
|
|
137
|
+
</View>
|
|
138
|
+
);
|
|
139
|
+
}
|