@mrmeg/expo-ui 0.27.1 → 0.28.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 (140) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/LLM_USAGE.md +233 -35
  3. package/README.md +504 -103
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +29 -1
  11. package/dist/components/BottomSheet.js +107 -30
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +60 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +87 -16
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +5 -2
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +73 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +20 -30
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +71 -26
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/index.d.ts +46 -44
  71. package/dist/components/index.js +2 -0
  72. package/dist/components/keyboardDismiss.js +12 -4
  73. package/dist/components/nativeTextField.android.js +13 -9
  74. package/dist/constants/colors.d.ts +40 -5
  75. package/dist/constants/colors.js +26 -8
  76. package/dist/constants/fonts.d.ts +41 -2
  77. package/dist/constants/fonts.js +46 -7
  78. package/dist/constants/index.d.ts +5 -4
  79. package/dist/constants/index.js +1 -0
  80. package/dist/constants/interaction.d.ts +18 -0
  81. package/dist/constants/interaction.js +18 -0
  82. package/dist/constants/spacing.d.ts +2 -1
  83. package/dist/constants/spacing.js +2 -1
  84. package/dist/design-system.json +154 -2
  85. package/dist/hooks/index.d.ts +12 -7
  86. package/dist/hooks/index.js +5 -0
  87. package/dist/hooks/useDimensions.d.ts +13 -8
  88. package/dist/hooks/useDimensions.js +123 -48
  89. package/dist/hooks/useFocusVisible.d.ts +30 -0
  90. package/dist/hooks/useFocusVisible.js +45 -0
  91. package/dist/hooks/useFontStyle.d.ts +2 -2
  92. package/dist/hooks/useFontStyle.js +3 -2
  93. package/dist/hooks/useHydrated.d.ts +13 -0
  94. package/dist/hooks/useHydrated.js +19 -0
  95. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  96. package/dist/hooks/useNotificationOffset.js +34 -0
  97. package/dist/hooks/useResources.d.ts +21 -20
  98. package/dist/hooks/useResources.js +72 -68
  99. package/dist/hooks/useScalePress.d.ts +8 -5
  100. package/dist/hooks/useScalePress.js +16 -9
  101. package/dist/hooks/useShape.d.ts +24 -0
  102. package/dist/hooks/useShape.js +24 -0
  103. package/dist/hooks/useStaggeredEntrance.js +9 -7
  104. package/dist/hooks/useTheme.d.ts +3 -3
  105. package/dist/hooks/useTheme.js +177 -66
  106. package/dist/hooks/useWindowInsets.d.ts +13 -0
  107. package/dist/hooks/useWindowInsets.js +23 -0
  108. package/dist/index.d.ts +5 -5
  109. package/dist/lib/composeRefs.d.ts +8 -0
  110. package/dist/lib/composeRefs.js +20 -0
  111. package/dist/lib/haptics.d.ts +12 -0
  112. package/dist/lib/haptics.js +21 -0
  113. package/dist/lib/index.d.ts +6 -5
  114. package/dist/lib/index.js +1 -0
  115. package/dist/lib/interFonts.d.ts +15 -0
  116. package/dist/lib/interFonts.js +1 -0
  117. package/dist/lib/interFonts.native.d.ts +3 -0
  118. package/dist/lib/interFonts.native.js +20 -0
  119. package/dist/lib/popoverPlacement.d.ts +36 -0
  120. package/dist/lib/popoverPlacement.js +29 -0
  121. package/dist/lib/themedStyles.d.ts +1 -1
  122. package/dist/lib/useAnimatedValue.d.ts +15 -0
  123. package/dist/lib/useAnimatedValue.js +19 -0
  124. package/dist/lib/webHitTarget.d.ts +40 -0
  125. package/dist/lib/webHitTarget.js +37 -0
  126. package/dist/state/feedbackStore.d.ts +24 -0
  127. package/dist/state/feedbackStore.js +16 -0
  128. package/dist/state/globalUIStore.d.ts +27 -0
  129. package/dist/state/globalUIStore.js +23 -0
  130. package/dist/state/index.d.ts +7 -5
  131. package/dist/state/index.js +2 -0
  132. package/dist/state/initialScheme.d.ts +27 -0
  133. package/dist/state/initialScheme.js +29 -0
  134. package/dist/state/notify.d.ts +1 -1
  135. package/dist/state/themeColorScope.d.ts +1 -1
  136. package/dist/state/themeStore.d.ts +69 -16
  137. package/dist/state/themeStore.js +65 -38
  138. package/llms-full.md +56 -8
  139. package/llms.txt +10 -1
  140. package/package.json +69 -2
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Platform } from "react-native";
2
3
  import { useTheme } from "../hooks/useTheme.js";
3
- import { ICONS } from "./iconRegistry.generated";
4
+ import { ICONS } from "./iconRegistry.generated.js";
4
5
  /**
5
6
  * Resolve an icon color against the active theme.
6
7
  *
@@ -18,6 +19,33 @@ function resolveIconColor(color, themeColors) {
18
19
  }
19
20
  return color;
20
21
  }
22
+ /**
23
+ * On web the SVG root is a DOM element and React forwards every prop to it,
24
+ * so the RN-only accessibility props (`accessible`,
25
+ * `importantForAccessibility`, `accessibilityElementsHidden`) surface as
26
+ * "unknown prop" warnings on every page. Web gets ARIA only; native keeps
27
+ * the RN props.
28
+ *
29
+ * Only a labelled icon is exposed. An icon without a label used to render as
30
+ * an unnamed `role="img"` (web) or an `accessible` element with nothing to
31
+ * say (native) — dozens per screen, each a WCAG 1.1.1 failure — so no label
32
+ * now means decorative.
33
+ */
34
+ function iconAccessibilityProps(decorative, label) {
35
+ const hidden = decorative || label === undefined;
36
+ if (Platform.OS === "web") {
37
+ return hidden ? { "aria-hidden": true } : { role: "img", "aria-label": label };
38
+ }
39
+ if (hidden) {
40
+ return {
41
+ accessible: false,
42
+ importantForAccessibility: "no-hide-descendants",
43
+ accessibilityElementsHidden: true,
44
+ "aria-hidden": true,
45
+ };
46
+ }
47
+ return { accessible: true, accessibilityLabel: label };
48
+ }
21
49
  /**
22
50
  * Universal Icon Component
23
51
  * Renders `lucide-react-native` SVG icons with theme integration and style
@@ -41,18 +69,11 @@ function warnUnknownIconName(name) {
41
69
  "Add it to icon-names.json and run `bun run ui:icons`, or pass a Lucide component through `component`.");
42
70
  }
43
71
  export function Icon(props) {
44
- const { size = 24, color, style, decorative = false } = props;
72
+ const { size = 24, color, style, decorative = false, accessibilityLabel } = props;
45
73
  const { theme } = useTheme();
46
74
  const iconColor = resolveIconColor(color, theme.colors);
47
75
  const CustomComponent = "component" in props ? props.component : undefined;
48
- const accessibilityProps = decorative
49
- ? {
50
- accessible: false,
51
- importantForAccessibility: "no-hide-descendants",
52
- accessibilityElementsHidden: true,
53
- "aria-hidden": true,
54
- }
55
- : { accessible: true };
76
+ const accessibilityProps = iconAccessibilityProps(decorative, accessibilityLabel);
56
77
  // `style` stays a text style on the public prop for source compatibility
57
78
  // with the font-icon era; SVG roots take a view style, and every layout
58
79
  // property callers actually pass (margins, transforms) belongs to both.
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef, useState, useCallback } from "react";
3
3
  import { View, TextInput as RNTextInput, Pressable, StyleSheet, Platform, } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
+ import { useShape } from "../hooks/useShape.js";
5
6
  import { spacing } from "../constants/spacing.js";
6
7
  import { StyledText } from "./StyledText.js";
7
8
  import { createThemedStyles } from "../lib/themedStyles.js";
@@ -85,6 +86,8 @@ function OTPCell({ index, total, char, isActive, hasError, disabled, theme, onPr
85
86
  // pass every frame. The 1↔2px and color changes read as instant for OTP
86
87
  // cells, so compute both during render.
87
88
  const borderWidth = isActive && !hasError ? 2 : 1;
89
+ // Host-app `setShape({ input })`: cells share the TextInput radius.
90
+ const inputShape = useShape("input");
88
91
  const borderColor = hasError
89
92
  ? theme.colors.destructive
90
93
  : isActive
@@ -94,7 +97,7 @@ function OTPCell({ index, total, char, isActive, hasError, disabled, theme, onPr
94
97
  {
95
98
  width: CELL_WIDTH,
96
99
  height: CELL_HEIGHT,
97
- borderRadius: spacing.radiusMd,
100
+ borderRadius: inputShape?.borderRadius ?? spacing.radiusMd,
98
101
  borderWidth,
99
102
  justifyContent: "center",
100
103
  alignItems: "center",
@@ -132,5 +135,5 @@ const createStyles = (theme) => StyleSheet.create({
132
135
  marginTop: spacing.xs,
133
136
  },
134
137
  });
135
- const themedStyles = createThemedStyles(createStyles);
138
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);
136
139
  export { InputOTP };
@@ -1,14 +1,56 @@
1
1
  import React from "react";
2
2
  import { StyleProp, ViewStyle } from "react-native";
3
- import { type TextProps } from "./StyledText";
4
- import { type IconName, type ThemeColorName } from "./Icon";
3
+ import { type TextProps } from "./StyledText.js";
4
+ import { type IconName, type ThemeColorName } from "./Icon.js";
5
+ /**
6
+ * What a row tells the controls inside it: the title's text when it is a plain
7
+ * string (the accessible name on every platform), and — once ids can be
8
+ * trusted — the id its `ItemTitle` renders under (`nativeID`), so a composed
9
+ * title can still be linked with `aria-labelledby`.
10
+ *
11
+ * `titleId` is `undefined` during SSR and the hydration pass on web: `useId()`
12
+ * values do not survive Expo Router's streamed hydration (see `useHydrated`),
13
+ * so ids only appear in the first client render after it.
14
+ */
15
+ export interface ItemLabel {
16
+ titleId: string | undefined;
17
+ title: string | undefined;
18
+ }
19
+ /**
20
+ * The row label a control inside an `Item` can borrow, or `null` outside a
21
+ * row. `Switch`, `Checkbox` and `Toggle` use it when they have no label of
22
+ * their own: `aria-labelledby={titleId}` on web, `accessibilityLabel={title}`
23
+ * on native. Apps composing their own controls can do the same.
24
+ */
25
+ export declare function useItemLabel(): ItemLabel | null;
26
+ type OwnLabelProps = {
27
+ accessibilityLabel?: string;
28
+ "aria-label"?: string;
29
+ "aria-labelledby"?: string;
30
+ accessibilityLabelledBy?: string | string[];
31
+ };
32
+ /**
33
+ * Accessibility props a control inside an `Item` should spread when it has no
34
+ * label of its own — its own props always win, so this returns `{}` whenever
35
+ * any label prop is set, or outside a row. A plain-text title becomes the
36
+ * control's `accessibilityLabel` (`aria-label` on web) on every platform, in
37
+ * the server HTML too. A composed title has no text to copy, so the control
38
+ * points at the title's id instead — `aria-labelledby` on web,
39
+ * `accessibilityLabelledBy` on Android — once that id exists (after hydration
40
+ * on web; see `ItemLabel`).
41
+ */
42
+ export declare function useItemControlLabel(own: OwnLabelProps): OwnLabelProps;
5
43
  export interface ItemProps {
6
44
  children?: React.ReactNode;
7
45
  /** Makes the row pressable with scale feedback (mirrors Card's pattern). */
8
46
  onPress?: () => void;
9
47
  /** Disables press handling when `onPress` is set. */
10
48
  disabled?: boolean;
11
- /** Renders a hairline divider below the row, inset past the media slot. */
49
+ /**
50
+ * Renders a hairline divider below the row, starting under the title (past
51
+ * the `ItemMedia` slot when there is one). Inside an `ItemGroup` the group
52
+ * sets this for every row but the last; pass `false` to drop one row's line.
53
+ */
12
54
  separator?: boolean;
13
55
  /** Custom style override for the row. */
14
56
  style?: StyleProp<ViewStyle>;
@@ -20,7 +62,9 @@ export interface ItemProps {
20
62
  * `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
21
63
  * and an optional pressable scale interaction. Compose
22
64
  * with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
23
- * `ItemActions`.
65
+ * `ItemActions`. Stack rows in an `ItemGroup`, which draws the separators;
66
+ * the row's own padding is the screen's only horizontal inset, so don't wrap
67
+ * rows in a padded or bordered container.
24
68
  *
25
69
  * @example
26
70
  * ```tsx
@@ -37,6 +81,60 @@ export interface ItemProps {
37
81
  * ```
38
82
  */
39
83
  export declare function Item({ children, onPress, disabled, separator, style }: ItemProps): React.JSX.Element;
84
+ export interface ItemGroupProps {
85
+ /**
86
+ * Section label above the rows: an uppercase eyebrow in the muted color,
87
+ * announced as a header.
88
+ */
89
+ title?: string;
90
+ /** Supporting copy under the title. */
91
+ description?: string;
92
+ /** Helper text under the rows, e.g. what the settings above it do. */
93
+ footer?: string;
94
+ /**
95
+ * The rows: `Item`s, or components that render one. Each direct child is
96
+ * one row; the group draws the hairline under every row but the last.
97
+ */
98
+ children?: React.ReactNode;
99
+ /** Style override for the group container (outer margins, width). */
100
+ style?: StyleProp<ViewStyle>;
101
+ /** Test id for the group container. */
102
+ testID?: string;
103
+ }
104
+ /**
105
+ * ItemGroup
106
+ *
107
+ * A flat grouped list: an optional eyebrow title and description, full-width
108
+ * `Item` rows separated by inset hairlines, and optional footer text. No
109
+ * border, radius, shadow, or fill: the header and footer sit on the row
110
+ * padding (`spacing.rowPaddingX`), so the group needs no horizontal padding
111
+ * from its parent. Space groups apart with `spacing.sectionSpacing`.
112
+ *
113
+ * @example
114
+ * ```tsx
115
+ * <ItemGroup title="Account" footer="Signed in as jane@example.com">
116
+ * <Item onPress={openProfile}>
117
+ * <ItemMedia icon="user" />
118
+ * <ItemContent>
119
+ * <ItemTitle>Edit profile</ItemTitle>
120
+ * </ItemContent>
121
+ * <ItemActions>
122
+ * <Icon name="chevron-right" size={18} color="mutedForeground" />
123
+ * </ItemActions>
124
+ * </Item>
125
+ * <Item>
126
+ * <ItemMedia icon="bell" />
127
+ * <ItemContent>
128
+ * <ItemTitle>Notifications</ItemTitle>
129
+ * </ItemContent>
130
+ * <ItemActions>
131
+ * <Switch checked={enabled} onCheckedChange={setEnabled} />
132
+ * </ItemActions>
133
+ * </Item>
134
+ * </ItemGroup>
135
+ * ```
136
+ */
137
+ export declare function ItemGroup({ title, description, footer, children, style, testID }: ItemGroupProps): React.JSX.Element;
40
138
  export interface ItemMediaProps {
41
139
  /** Square size of the media slot in pixels. @default 40 */
42
140
  size?: number;
@@ -92,3 +190,4 @@ export interface ItemActionsProps {
92
190
  * icon, a `Switch`, value text, etc.).
93
191
  */
94
192
  export declare function ItemActions({ children, style }: ItemActionsProps): React.JSX.Element;
193
+ export {};
@@ -1,14 +1,93 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { Children, createContext, isValidElement, use, useId } from "react";
2
3
  import { View, Pressable, StyleSheet, Platform, Animated, } from "react-native";
3
- import { StyledText, CaptionText } from "./StyledText.js";
4
+ import { StyledText, CaptionText, EyebrowText } from "./StyledText.js";
5
+ import { useHydrated } from "../hooks/useHydrated.js";
4
6
  import { Icon } from "./Icon.js";
5
7
  import { useTheme } from "../hooks/useTheme.js";
6
8
  import { useScalePress } from "../hooks/useScalePress.js";
9
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
7
10
  import { spacing } from "../constants/spacing.js";
8
- // Default ItemMedia footprint (see ItemMedia's `size` prop) — used to inset
9
- // the optional separator past the media slot without needing a context.
11
+ import { interaction } from "../constants/interaction.js";
12
+ // Default ItemMedia footprint (see ItemMedia's `size` prop).
10
13
  const DEFAULT_MEDIA_SIZE = 40;
11
- const SEPARATOR_INSET = spacing.rowPaddingX + DEFAULT_MEDIA_SIZE + spacing.rowGap;
14
+ const ItemGroupRowContext = createContext(null);
15
+ const SEPARATED_ROW = { separator: true };
16
+ const LAST_ROW = { separator: false };
17
+ const ItemLabelContext = createContext(null);
18
+ /** True inside `ItemContent`, where the row's title lives. */
19
+ const ItemContentContext = createContext(false);
20
+ /**
21
+ * The row's title, read synchronously from its element tree: the first
22
+ * `ItemTitle` directly inside a direct `ItemContent`. `found` is false when
23
+ * the row has no such title (a media-only or custom row), so controls in it
24
+ * borrow nothing. `text` is the title when its children are strings (or
25
+ * numbers); a composed title (`<ItemTitle>{name} <Badge/></ItemTitle>`) gives
26
+ * `undefined` — web still links by id, native then needs an explicit label.
27
+ */
28
+ function scanTitle(children) {
29
+ for (const child of Children.toArray(children)) {
30
+ if (!isValidElement(child))
31
+ continue;
32
+ if (child.type === ItemContent) {
33
+ for (const inner of Children.toArray(child.props.children)) {
34
+ if (isValidElement(inner) && inner.type === ItemTitle) {
35
+ const parts = Children.toArray(inner.props.children);
36
+ const plain = parts.length > 0 && parts.every((part) => typeof part === "string" || typeof part === "number");
37
+ return { found: true, text: plain ? parts.join("") : undefined };
38
+ }
39
+ }
40
+ }
41
+ }
42
+ return { found: false, text: undefined };
43
+ }
44
+ /**
45
+ * The row label a control inside an `Item` can borrow, or `null` outside a
46
+ * row. `Switch`, `Checkbox` and `Toggle` use it when they have no label of
47
+ * their own: `aria-labelledby={titleId}` on web, `accessibilityLabel={title}`
48
+ * on native. Apps composing their own controls can do the same.
49
+ */
50
+ export function useItemLabel() {
51
+ return use(ItemLabelContext);
52
+ }
53
+ /**
54
+ * Accessibility props a control inside an `Item` should spread when it has no
55
+ * label of its own — its own props always win, so this returns `{}` whenever
56
+ * any label prop is set, or outside a row. A plain-text title becomes the
57
+ * control's `accessibilityLabel` (`aria-label` on web) on every platform, in
58
+ * the server HTML too. A composed title has no text to copy, so the control
59
+ * points at the title's id instead — `aria-labelledby` on web,
60
+ * `accessibilityLabelledBy` on Android — once that id exists (after hydration
61
+ * on web; see `ItemLabel`).
62
+ */
63
+ export function useItemControlLabel(own) {
64
+ const label = use(ItemLabelContext);
65
+ if (!label ||
66
+ own.accessibilityLabel !== undefined ||
67
+ own["aria-label"] !== undefined ||
68
+ own["aria-labelledby"] !== undefined ||
69
+ own.accessibilityLabelledBy !== undefined) {
70
+ return {};
71
+ }
72
+ if (label.title !== undefined)
73
+ return { accessibilityLabel: label.title };
74
+ if (label.titleId === undefined)
75
+ return {};
76
+ return Platform.OS === "web" ? { "aria-labelledby": label.titleId } : { accessibilityLabelledBy: label.titleId };
77
+ }
78
+ /**
79
+ * Where a row's hairline starts: under the title when the row leads with an
80
+ * `ItemMedia` (row padding + media + gap, so a custom `size` lines up too),
81
+ * otherwise at the row padding.
82
+ */
83
+ function separatorInset(children) {
84
+ for (const child of Children.toArray(children)) {
85
+ if (isValidElement(child) && child.type === ItemMedia) {
86
+ return spacing.rowPaddingX + (child.props.size ?? DEFAULT_MEDIA_SIZE) + spacing.rowGap;
87
+ }
88
+ }
89
+ return spacing.rowPaddingX;
90
+ }
12
91
  /**
13
92
  * Item
14
93
  *
@@ -16,7 +95,9 @@ const SEPARATOR_INSET = spacing.rowPaddingX + DEFAULT_MEDIA_SIZE + spacing.rowGa
16
95
  * `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
17
96
  * and an optional pressable scale interaction. Compose
18
97
  * with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
19
- * `ItemActions`.
98
+ * `ItemActions`. Stack rows in an `ItemGroup`, which draws the separators;
99
+ * the row's own padding is the screen's only horizontal inset, so don't wrap
100
+ * rows in a padded or bordered container.
20
101
  *
21
102
  * @example
22
103
  * ```tsx
@@ -32,20 +113,78 @@ const SEPARATOR_INSET = spacing.rowPaddingX + DEFAULT_MEDIA_SIZE + spacing.rowGa
32
113
  * </Item>
33
114
  * ```
34
115
  */
35
- export function Item({ children, onPress, disabled, separator = false, style }) {
36
- const { theme } = useTheme();
116
+ export function Item({ children, onPress, disabled, separator, style }) {
117
+ const { theme, getFocusRingStyle } = useTheme();
118
+ const groupRow = use(ItemGroupRowContext);
119
+ const showSeparator = separator ?? groupRow?.separator ?? false;
120
+ const reactId = useId();
121
+ const hydrated = useHydrated();
122
+ const { found: hasTitle, text: title } = scanTitle(children);
123
+ // Ids only after hydration on web (see ItemLabel); native has no hydration.
124
+ const titleId = Platform.OS !== "web" || hydrated ? reactId : undefined;
125
+ const label = React.useMemo(() => (hasTitle ? { titleId, title } : null), [hasTitle, titleId, title]);
37
126
  const { animatedStyle, pressHandlers } = useScalePress({
38
127
  disabled: !onPress || !!disabled,
39
128
  scaleTo: 0.98,
40
- haptic: false,
41
129
  });
42
- const row = (_jsx(View, { style: [styles.row, style], children: children }));
43
- const content = separator ? (_jsxs(View, { children: [row, _jsx(View, { style: [styles.separator, { backgroundColor: theme.colors.border }] })] })) : (row);
130
+ const focus = useFocusVisible();
131
+ const row = (_jsx(View, { style: [styles.row, style], children: _jsx(ItemLabelContext.Provider, { value: label, children: children }) }));
132
+ const content = showSeparator ? (_jsxs(View, { children: [row, _jsx(View, { style: [
133
+ styles.separator,
134
+ { marginLeft: separatorInset(children), backgroundColor: theme.colors.border },
135
+ ] })] })) : (row);
44
136
  if (onPress) {
45
- return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityState: { disabled: !!disabled }, ...pressHandlers, style: Platform.OS === "web" ? { cursor: "pointer" } : undefined, children: _jsx(Animated.View, { style: animatedStyle, children: content }) }));
137
+ return (_jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityState: { disabled: !!disabled }, ...pressHandlers, onFocus: focus.onFocus, onBlur: focus.onBlur, style: ({ pressed }) => [
138
+ { borderRadius: spacing.radiusSm },
139
+ Platform.OS === "web" && { cursor: "pointer", outlineStyle: "none" },
140
+ pressed && { opacity: interaction.pressedOpacity },
141
+ focus.focused && !disabled && getFocusRingStyle(),
142
+ ], children: _jsx(Animated.View, { style: animatedStyle, children: content }) }));
46
143
  }
47
144
  return content;
48
145
  }
146
+ /**
147
+ * ItemGroup
148
+ *
149
+ * A flat grouped list: an optional eyebrow title and description, full-width
150
+ * `Item` rows separated by inset hairlines, and optional footer text. No
151
+ * border, radius, shadow, or fill: the header and footer sit on the row
152
+ * padding (`spacing.rowPaddingX`), so the group needs no horizontal padding
153
+ * from its parent. Space groups apart with `spacing.sectionSpacing`.
154
+ *
155
+ * @example
156
+ * ```tsx
157
+ * <ItemGroup title="Account" footer="Signed in as jane@example.com">
158
+ * <Item onPress={openProfile}>
159
+ * <ItemMedia icon="user" />
160
+ * <ItemContent>
161
+ * <ItemTitle>Edit profile</ItemTitle>
162
+ * </ItemContent>
163
+ * <ItemActions>
164
+ * <Icon name="chevron-right" size={18} color="mutedForeground" />
165
+ * </ItemActions>
166
+ * </Item>
167
+ * <Item>
168
+ * <ItemMedia icon="bell" />
169
+ * <ItemContent>
170
+ * <ItemTitle>Notifications</ItemTitle>
171
+ * </ItemContent>
172
+ * <ItemActions>
173
+ * <Switch checked={enabled} onCheckedChange={setEnabled} />
174
+ * </ItemActions>
175
+ * </Item>
176
+ * </ItemGroup>
177
+ * ```
178
+ */
179
+ export function ItemGroup({ title, description, footer, children, style, testID }) {
180
+ const { theme } = useTheme();
181
+ const rows = Children.toArray(children);
182
+ // react-native-web renders an unlevelled header as h1; level the title as h2
183
+ // there. Native has no heading levels and takes the role alone. Read at render
184
+ // time (not module load) so tests can flip `Platform.OS`.
185
+ const headingLevel = Platform.OS === "web" ? { "aria-level": 2 } : undefined;
186
+ return (_jsxs(View, { style: style, testID: testID, children: [(!!title || !!description) && (_jsxs(View, { style: styles.groupHeader, children: [!!title && (_jsx(EyebrowText, { accessibilityRole: "header", ...headingLevel, style: { color: theme.colors.mutedForeground }, children: title })), !!description && (_jsx(CaptionText, { style: { color: theme.colors.textDim }, children: description }))] })), rows.map((row, index) => (_jsx(ItemGroupRowContext.Provider, { value: index < rows.length - 1 ? SEPARATED_ROW : LAST_ROW, children: row }, isValidElement(row) && row.key != null ? row.key : index))), !!footer && (_jsx(View, { style: styles.groupFooter, children: _jsx(CaptionText, { style: { color: theme.colors.mutedForeground }, children: footer }) }))] }));
187
+ }
49
188
  /**
50
189
  * ItemMedia
51
190
  *
@@ -73,7 +212,7 @@ export function ItemMedia({ size = DEFAULT_MEDIA_SIZE, icon, iconColor = "mutedF
73
212
  * content). Sits between `ItemMedia` and `ItemActions`.
74
213
  */
75
214
  export function ItemContent({ children, style }) {
76
- return _jsx(View, { style: [styles.content, style], children: children });
215
+ return (_jsx(View, { style: [styles.content, style], children: _jsx(ItemContentContext.Provider, { value: true, children: children }) }));
77
216
  }
78
217
  /**
79
218
  * ItemTitle
@@ -81,7 +220,13 @@ export function ItemContent({ children, style }) {
81
220
  * `Item` row title — label weight (medium) at body size.
82
221
  */
83
222
  export function ItemTitle({ children, style, ...props }) {
84
- return (_jsx(StyledText, { size: "body", fontWeight: "medium", ...props, style: style, children: children }));
223
+ // The row's title (the one inside ItemContent) carries the id the row hands
224
+ // to its controls; titles used elsewhere (ItemMedia initials, a value in
225
+ // ItemActions) render without it.
226
+ const label = use(ItemLabelContext);
227
+ const inContent = use(ItemContentContext);
228
+ const nativeID = inContent ? label?.titleId : undefined;
229
+ return (_jsx(StyledText, { size: "body", fontWeight: "medium", nativeID: nativeID, ...props, style: style, children: children }));
85
230
  }
86
231
  /**
87
232
  * ItemDescription
@@ -101,7 +246,7 @@ export function ItemDescription({ children, style, ...props }) {
101
246
  export function ItemActions({ children, style }) {
102
247
  return _jsx(View, { style: [styles.actions, style], children: children });
103
248
  }
104
- const styles = StyleSheet.create({
249
+ const styles = /*#__PURE__*/ StyleSheet.create({
105
250
  row: {
106
251
  flexDirection: "row",
107
252
  alignItems: "center",
@@ -122,6 +267,14 @@ const styles = StyleSheet.create({
122
267
  },
123
268
  separator: {
124
269
  height: StyleSheet.hairlineWidth,
125
- marginLeft: SEPARATOR_INSET,
270
+ },
271
+ groupHeader: {
272
+ paddingHorizontal: spacing.rowPaddingX,
273
+ paddingBottom: spacing.xs,
274
+ gap: spacing.xxs,
275
+ },
276
+ groupFooter: {
277
+ paddingHorizontal: spacing.rowPaddingX,
278
+ paddingTop: spacing.xs,
126
279
  },
127
280
  });
@@ -18,5 +18,5 @@ export declare function useKeyboardAvoidance(): boolean;
18
18
  * so screens can avoid the soft keyboard with `automaticOffset`; web renders a
19
19
  * plain `View`.
20
20
  */
21
- export declare function KeyboardAvoidingView({ children, style, behavior, automaticOffset, contentContainerStyle, keyboardVerticalOffset, ...props }: KeyboardAvoidingViewProps): React.JSX.Element;
21
+ export declare function KeyboardAvoidingView({ children, style, behavior: behaviorProp, automaticOffset, contentContainerStyle, keyboardVerticalOffset, ...props }: KeyboardAvoidingViewProps): React.JSX.Element;
22
22
  export {};
@@ -14,7 +14,10 @@ export function useKeyboardAvoidance() {
14
14
  * so screens can avoid the soft keyboard with `automaticOffset`; web renders a
15
15
  * plain `View`.
16
16
  */
17
- export function KeyboardAvoidingView({ children, style, behavior = Platform.OS === "ios" ? "padding" : "height", automaticOffset = true, contentContainerStyle, keyboardVerticalOffset, ...props }) {
17
+ export function KeyboardAvoidingView({ children, style, behavior: behaviorProp, automaticOffset = true, contentContainerStyle, keyboardVerticalOffset, ...props }) {
18
+ // Resolved in the body rather than as a default parameter: the React
19
+ // Compiler can't reorder a computed default, and skipped the component.
20
+ const behavior = behaviorProp === undefined ? (Platform.OS === "ios" ? "padding" : "height") : behaviorProp;
18
21
  if (Platform.OS === "web") {
19
22
  return (_jsx(KeyboardAvoidanceContext.Provider, { value: true, children: _jsx(View, { style: style, ...props, children: children }) }));
20
23
  }
@@ -4,6 +4,7 @@ import * as LabelPrimitive from "@rn-primitives/label";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { useFontStyle } from "../hooks/useFontStyle.js";
6
6
  import { spacing } from "../constants/spacing.js";
7
+ import { interaction } from "../constants/interaction.js";
7
8
  import { StyledText } from "./StyledText.js";
8
9
  import { createThemedStyles } from "../lib/themedStyles.js";
9
10
  const SIZE_CONFIGS = {
@@ -85,7 +86,7 @@ const createStyles = (theme) => StyleSheet.create({
85
86
  color: theme.colors.destructive,
86
87
  },
87
88
  disabledLabel: {
88
- opacity: 0.6,
89
+ opacity: interaction.disabledOpacity,
89
90
  },
90
91
  });
91
- const themedStyles = createThemedStyles(createStyles);
92
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);
@@ -57,7 +57,7 @@ export function MaxWidthContainer({ children, maxWidth, preset = "2xl", style, c
57
57
  style,
58
58
  ], children: children }));
59
59
  }
60
- const styles = StyleSheet.create({
60
+ const styles = /*#__PURE__*/ StyleSheet.create({
61
61
  container: {
62
62
  flex: 1,
63
63
  },
@@ -1,19 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, use, useEffect, useEffectEvent, useRef } from "react";
2
+ import { useCallback, useEffect, useEffectEvent, useMemo, useRef } from "react";
3
3
  import { Animated, Easing, StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
4
- import { SafeAreaInsetsContext } from "react-native-safe-area-context";
4
+ import { useStore } from "zustand";
5
5
  import { Icon } from "./Icon.js";
6
6
  import { useTheme, withAlpha } from "../hooks/useTheme.js";
7
7
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
8
+ import { useWindowInsets } from "../hooks/useWindowInsets.js";
8
9
  import { spacing } from "../constants/spacing.js";
9
10
  import { durations } from "../constants/motion.js";
10
11
  import { StyledText } from "./StyledText.js";
11
12
  import { palette } from "../constants/colors.js";
12
13
  import { translateText } from "../lib/i18n.js";
13
14
  import { createThemedStyles } from "../lib/themedStyles.js";
14
- import { globalUIStore } from "../state/globalUIStore.js";
15
- const timingIn = { duration: durations.fast, easing: Easing.out(Easing.quad), useNativeDriver: true };
16
- const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad), useNativeDriver: true };
15
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
16
+ import { shouldUseNativeDriver } from "../lib/animations.js";
17
+ import { globalUIStore, selectNotificationOffset } from "../state/globalUIStore.js";
18
+ const timingIn = { duration: durations.fast, easing: Easing.out(Easing.quad), useNativeDriver: shouldUseNativeDriver };
19
+ const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad), useNativeDriver: shouldUseNativeDriver };
20
+ /**
21
+ * Run an action's handler, then dismiss, even when the handler throws. A
22
+ * module-level function because the React Compiler can't compile a
23
+ * `try`/`finally` and would otherwise skip the whole component.
24
+ */
25
+ function runThenDismiss(run, dismiss) {
26
+ try {
27
+ run();
28
+ }
29
+ finally {
30
+ dismiss();
31
+ }
32
+ }
17
33
  /**
18
34
  * Notification
19
35
  *
@@ -35,22 +51,27 @@ const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad),
35
51
  export const Notification = () => {
36
52
  const { theme, getShadowStyle } = useTheme();
37
53
  const reduceMotion = useReducedMotion();
38
- const insets = use(SafeAreaInsetsContext);
39
- const { alert, hide } = globalUIStore();
54
+ // Window insets, not the provider context alone: a toast host above the
55
+ // app's SafeAreaProvider (or inside a Modal) used to fall back to 20 pt and
56
+ // sit under the Dynamic Island.
57
+ const insets = useWindowInsets();
58
+ // Read the store through zustand's `useStore`, not by calling
59
+ // `globalUIStore()` directly: the React Compiler recognizes hooks by their
60
+ // `use` prefix, so it treated that call as a plain function, cached its
61
+ // result, and skipped the hook on the next render (React error #311).
62
+ const { alert, hide } = useStore(globalUIStore);
63
+ // Bars a layout registered with `useNotificationOffset` (a tab bar, a
64
+ // floating header), measured from the window edge like the insets. Select
65
+ // the stable record and derive the maxima here: a selector that built a new
66
+ // object per call would never satisfy `useSyncExternalStore`'s snapshot check.
67
+ const notificationOffsets = useStore(globalUIStore, (state) => state.notificationOffsets);
68
+ const offset = useMemo(() => selectNotificationOffset({ notificationOffsets }), [notificationOffsets]);
40
69
  const styles = themedStyles(theme);
41
70
  const position = alert?.position ?? "top";
42
71
  const isBottom = position === "bottom";
43
72
  // Just opacity + translateY — no scale (scale = bouncy feel)
44
- const opacityRef = useRef(null);
45
- if (opacityRef.current === null) {
46
- opacityRef.current = new Animated.Value(0);
47
- }
48
- const opacity = opacityRef.current;
49
- const translateYRef = useRef(null);
50
- if (translateYRef.current === null) {
51
- translateYRef.current = new Animated.Value(0);
52
- }
53
- const translateY = translateYRef.current;
73
+ const opacity = useAnimatedValue(0);
74
+ const translateY = useAnimatedValue(0);
54
75
  const wasVisibleRef = useRef(false);
55
76
  const timerRef = useRef(null);
56
77
  const hideNotification = useCallback(() => {
@@ -88,12 +109,7 @@ export const Notification = () => {
88
109
  const action = alert?.action;
89
110
  if (!action)
90
111
  return;
91
- try {
92
- action.onPress();
93
- }
94
- finally {
95
- animateOut();
96
- }
112
+ runThenDismiss(() => action.onPress(), animateOut);
97
113
  }, [alert?.action, animateOut]);
98
114
  // The auto-dismiss timer only needs the latest animateOut; wrapping it in an
99
115
  // Effect Event keeps it out of the deps so the effect doesn't re-run (and
@@ -150,8 +166,10 @@ export const Notification = () => {
150
166
  opacity,
151
167
  transform: [{ translateY }],
152
168
  };
153
- const topPosition = insets?.top ? insets.top : 20;
154
- const bottomPosition = insets?.bottom ? insets.bottom : 20;
169
+ // 8 pt of air past the inset or a registered bar, whichever reaches further;
170
+ // without either, 20 pt from the edge (as before).
171
+ const topPosition = Math.max(insets.top, spacing.smd, offset.top) + spacing.sm;
172
+ const bottomPosition = Math.max(insets.bottom, spacing.smd, offset.bottom) + spacing.sm;
155
173
  const getIconProps = () => {
156
174
  switch (alert?.type) {
157
175
  case "error":
@@ -208,7 +226,7 @@ export const Notification = () => {
208
226
  if (!alert?.show) {
209
227
  return null;
210
228
  }
211
- return (_jsx(Animated.View, { accessibilityLiveRegion: "polite", accessibilityRole: "alert", style: [
229
+ return (_jsx(Animated.View, { testID: "ui-notification", accessibilityLiveRegion: "polite", accessibilityRole: "alert", style: [
212
230
  styles.container,
213
231
  isBottom
214
232
  ? { bottom: bottomPosition }
@@ -302,4 +320,4 @@ const createStyles = (theme) => StyleSheet.create({
302
320
  ...(Platform.OS === "web" && { cursor: "pointer" }),
303
321
  },
304
322
  });
305
- const themedStyles = createThemedStyles(createStyles);
323
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);