@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.
Files changed (113) hide show
  1. package/README.md +82 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/card-button.tsx +49 -0
  36. package/src/ai/chat-text.ts +13 -7
  37. package/src/ai/chat-thread.tsx +34 -27
  38. package/src/ai/choice-question.tsx +154 -35
  39. package/src/ai/code-block.tsx +189 -49
  40. package/src/ai/collapsible.tsx +19 -13
  41. package/src/ai/conversation.tsx +390 -57
  42. package/src/ai/icon-button.tsx +41 -6
  43. package/src/ai/index.ts +52 -1
  44. package/src/ai/message-action-sheet.tsx +168 -0
  45. package/src/ai/message-quote.tsx +98 -0
  46. package/src/ai/message.tsx +226 -31
  47. package/src/ai/prompt-input-menu.tsx +197 -0
  48. package/src/ai/prompt-input.tsx +308 -73
  49. package/src/ai/reply-quote-chip.tsx +90 -0
  50. package/src/ai/shimmer.tsx +4 -6
  51. package/src/ai/subagent-cards.tsx +209 -0
  52. package/src/ai/tool-approval-card.tsx +162 -0
  53. package/src/ai/trace.tsx +54 -17
  54. package/src/ai/unified-diff.tsx +182 -0
  55. package/src/auth/GoogleSignInButton.tsx +6 -3
  56. package/src/auth/SignInScreen.tsx +11 -2
  57. package/src/brand/ProviderIcon.tsx +136 -0
  58. package/src/brand/index.ts +2 -0
  59. package/src/configureTheme.ts +39 -11
  60. package/src/controlStyles.ts +46 -30
  61. package/src/entries/gesture-handler.ts +4 -0
  62. package/src/entries/keyboard-controller.ts +4 -0
  63. package/src/entries/markdown.ts +1 -1
  64. package/src/entries/primitives.ts +24 -3
  65. package/src/entries/theme.ts +2 -1
  66. package/src/entries/video.ts +4 -0
  67. package/src/entries/webview.ts +6 -0
  68. package/src/floating.ts +25 -4
  69. package/src/haptics.ts +30 -4
  70. package/src/highlight.ts +302 -1
  71. package/src/index.ts +27 -5
  72. package/src/loadingGrid.ts +75 -0
  73. package/src/markdown/Markdown.tsx +234 -32
  74. package/src/markdown/MarkdownTable.tsx +188 -0
  75. package/src/markdown/tableText.ts +58 -0
  76. package/src/marketing/UpgradeToProCard.tsx +4 -3
  77. package/src/media/ImageGallery.tsx +224 -0
  78. package/src/media/MediaCard.tsx +100 -0
  79. package/src/media/imageGrid.ts +51 -0
  80. package/src/motion.ts +33 -0
  81. package/src/pagerSwipe.ts +102 -0
  82. package/src/palette.ts +8 -1
  83. package/src/primitives/button.tsx +4 -1
  84. package/src/primitives/keyboard-controller.tsx +106 -0
  85. package/src/primitives/keyboard-parts.tsx +104 -0
  86. package/src/primitives/overlay.tsx +133 -45
  87. package/src/primitives/pager-gesture.tsx +131 -0
  88. package/src/primitives/sheet-drag.tsx +305 -0
  89. package/src/primitives/sheet-gesture.tsx +181 -0
  90. package/src/primitives/swipe-row-gesture.tsx +290 -0
  91. package/src/primitives/toast-drag-gesture.tsx +92 -0
  92. package/src/primitives/web-click-guard.ts +50 -0
  93. package/src/relativeTime.ts +94 -0
  94. package/src/router/RouterStackPageHeader.tsx +2 -2
  95. package/src/settings/SettingsEmptyState.tsx +20 -10
  96. package/src/settings/SettingsField.tsx +11 -5
  97. package/src/settings/SettingsSection.tsx +8 -5
  98. package/src/settings/SettingsToggleRow.tsx +55 -22
  99. package/src/settings/relaxedSizeStyle.ts +11 -0
  100. package/src/sheetDrag.ts +67 -0
  101. package/src/swipeRow.ts +169 -0
  102. package/src/tabBarRoutes.ts +18 -0
  103. package/src/tabIndicator.ts +71 -0
  104. package/src/tailwind-preset.cjs +13 -6
  105. package/src/tailwind-preset.d.cts +5 -0
  106. package/src/theme.tsx +6 -2
  107. package/src/toastDrag.ts +58 -0
  108. package/src/typography.ts +20 -3
  109. package/src/useScheme.ts +10 -1
  110. package/src/video/InlineVideo.tsx +91 -0
  111. package/src/webview/HtmlView.tsx +91 -0
  112. package/src/webview/HtmlView.web.tsx +21 -0
  113. package/src/webview/htmlView.ts +45 -0
package/src/PillTabs.tsx CHANGED
@@ -1,5 +1,13 @@
1
- import { View, Text, Pressable } from "react-native";
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: the `pills` `TabsList` look and
15
- * size (30 high, `text-sm` medium), as wide as its pills, at the start.
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
- <View
67
+ <Animated.ScrollView
68
+ ref={scroll}
69
+ horizontal
31
70
  accessibilityRole="tablist"
32
- className="max-w-full flex-row flex-wrap gap-1.5 self-start"
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
- {options.map((tab) => {
35
- const isSelected = value === tab.id;
36
- const colors = tabColors("pills", isSelected, scheme);
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
- return (
39
- <Pressable
40
- key={tab.id}
41
- testID={tab.testID}
42
- accessibilityLabel={tab.label}
43
- onPress={() => onChange(tab.id)}
44
- accessibilityRole="tab"
45
- accessibilityState={{ selected: isSelected }}
46
- hitSlop={touchSlop(MIN_TOUCH_TARGET, trigger.minHeight)}
47
- className={cn(
48
- "shrink justify-center rounded-full border border-white/40 active:opacity-70 dark:border-white/10",
49
- trigger.className,
50
- )}
51
- style={{ backgroundColor: colors.backgroundColor, minHeight: trigger.minHeight }}
52
- >
53
- <Text
54
- numberOfLines={1}
55
- className={cn("text-center font-instrument-sans-medium", trigger.textClassName)}
56
- style={[{ color: colors.color }, typeStyle("medium")]}
57
- >
58
- {tab.label}
59
- </Text>
60
- </Pressable>
61
- );
62
- })}
63
- </View>
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.value = reduceMotion
42
- ? percent
43
- : withTiming(percent, {
44
- duration: 300,
45
- easing: Easing.bezier(0.22, 1, 0.36, 1),
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.value}%` as const,
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
- // 40 high on screen, as on the web; a 44 target to touch.
111
- hitSlop={touchSlop(MIN_TOUCH_TARGET, largerText ? 64 : 40)}
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: h-10 px-3.5, 16 px type (its phone size). A
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-10 w-full border px-3.5 py-0 shadow-none",
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 { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
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`. Use `BottomSheetContainer` for drag-to-dismiss.
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, slides in. Its bottom corners sit below the
51
- * screen edge, so only the top corners show.
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
- <NativeOnlyAnimatedView
65
- entering={SlideInDown.duration(240)}
66
- style={{ marginBottom: -SHEET_RADIUS }}
67
- >
68
- <FloatingSurface blur={false} radius={SHEET_RADIUS}>
69
- <View
70
- accessibilityViewIsModal
71
- className={cn("gap-4 px-5 pt-3", className)}
72
- {...props}
73
- style={[{ paddingBottom: insets.bottom + 20 + SHEET_RADIUS }, style]}
74
- >
75
- {showHandle ? <SheetHandle /> : null}
76
- {children}
77
- </View>
78
- </FloatingSurface>
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
- /** The current choice: a check at the end, and `selected` for assistive tech. */
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` adds a check; `disabled` dims it to 50%, as the web
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 = false,
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 ? palette.danger[scheme] : tokens.color.foreground[scheme];
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, selected: active }}
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 { View, type ViewProps } from "react-native";
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
- /** Pulsing placeholder block. Holds still with Reduce Motion. Size it with `className` (for example `h-4 w-32`). */
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.value = 0.7;
33
+ opacity.set(0.7);
27
34
  return;
28
35
  }
29
- opacity.value = withRepeat(withTiming(1, { duration: 800 }), -1, true);
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.value,
41
+ opacity: opacity.get(),
35
42
  }));
36
43
 
37
44
  return (
38
- <Animated.View
45
+ <View
39
46
  accessibilityElementsHidden
40
47
  importantForAccessibility="no-hide-descendants"
41
48
  {...props}
42
- style={[animatedStyle, style]}
43
- className={cn("bg-neutral-200 dark:bg-neutral-700 rounded-lg", className)}
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
+ }