react-native-terra-ui 0.11.17 → 0.12.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 (88) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/lib/module/components/accordion/Accordion.js +109 -0
  3. package/lib/module/components/accordion/Accordion.js.map +1 -0
  4. package/lib/module/components/accordion/context.js +34 -0
  5. package/lib/module/components/accordion/context.js.map +1 -0
  6. package/lib/module/components/accordion/hooks/use-accordion-core.js +46 -0
  7. package/lib/module/components/accordion/hooks/use-accordion-core.js.map +1 -0
  8. package/lib/module/components/accordion/index.js +5 -0
  9. package/lib/module/components/accordion/index.js.map +1 -0
  10. package/lib/module/components/accordion/parts/AccordionContent.js +98 -0
  11. package/lib/module/components/accordion/parts/AccordionContent.js.map +1 -0
  12. package/lib/module/components/accordion/parts/AccordionIndicator.js +60 -0
  13. package/lib/module/components/accordion/parts/AccordionIndicator.js.map +1 -0
  14. package/lib/module/components/accordion/parts/AccordionItem.js +53 -0
  15. package/lib/module/components/accordion/parts/AccordionItem.js.map +1 -0
  16. package/lib/module/components/accordion/parts/AccordionTrigger.js +85 -0
  17. package/lib/module/components/accordion/parts/AccordionTrigger.js.map +1 -0
  18. package/lib/module/components/accordion/types.js +4 -0
  19. package/lib/module/components/accordion/types.js.map +1 -0
  20. package/lib/module/components/accordion/utils.js +15 -0
  21. package/lib/module/components/accordion/utils.js.map +1 -0
  22. package/lib/module/components/badge/Badge.js +219 -64
  23. package/lib/module/components/badge/Badge.js.map +1 -1
  24. package/lib/module/components/badge/WithBadge.js +94 -0
  25. package/lib/module/components/badge/WithBadge.js.map +1 -0
  26. package/lib/module/components/badge/index.js +1 -0
  27. package/lib/module/components/badge/index.js.map +1 -1
  28. package/lib/module/components/badge/utils.js +3 -3
  29. package/lib/module/components/header/parts/HeaderBar.js +34 -7
  30. package/lib/module/components/header/parts/HeaderBar.js.map +1 -1
  31. package/lib/module/components/index.js +1 -0
  32. package/lib/module/components/index.js.map +1 -1
  33. package/lib/typescript/src/components/accordion/Accordion.d.ts +33 -0
  34. package/lib/typescript/src/components/accordion/Accordion.d.ts.map +1 -0
  35. package/lib/typescript/src/components/accordion/context.d.ts +25 -0
  36. package/lib/typescript/src/components/accordion/context.d.ts.map +1 -0
  37. package/lib/typescript/src/components/accordion/hooks/use-accordion-core.d.ts +11 -0
  38. package/lib/typescript/src/components/accordion/hooks/use-accordion-core.d.ts.map +1 -0
  39. package/lib/typescript/src/components/accordion/index.d.ts +8 -0
  40. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -0
  41. package/lib/typescript/src/components/accordion/parts/AccordionContent.d.ts +8 -0
  42. package/lib/typescript/src/components/accordion/parts/AccordionContent.d.ts.map +1 -0
  43. package/lib/typescript/src/components/accordion/parts/AccordionIndicator.d.ts +16 -0
  44. package/lib/typescript/src/components/accordion/parts/AccordionIndicator.d.ts.map +1 -0
  45. package/lib/typescript/src/components/accordion/parts/AccordionItem.d.ts +16 -0
  46. package/lib/typescript/src/components/accordion/parts/AccordionItem.d.ts.map +1 -0
  47. package/lib/typescript/src/components/accordion/parts/AccordionTrigger.d.ts +11 -0
  48. package/lib/typescript/src/components/accordion/parts/AccordionTrigger.d.ts.map +1 -0
  49. package/lib/typescript/src/components/accordion/types.d.ts +65 -0
  50. package/lib/typescript/src/components/accordion/types.d.ts.map +1 -0
  51. package/lib/typescript/src/components/accordion/utils.d.ts +8 -0
  52. package/lib/typescript/src/components/accordion/utils.d.ts.map +1 -0
  53. package/lib/typescript/src/components/badge/Badge.d.ts +13 -9
  54. package/lib/typescript/src/components/badge/Badge.d.ts.map +1 -1
  55. package/lib/typescript/src/components/badge/WithBadge.d.ts +18 -0
  56. package/lib/typescript/src/components/badge/WithBadge.d.ts.map +1 -0
  57. package/lib/typescript/src/components/badge/index.d.ts +2 -1
  58. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  59. package/lib/typescript/src/components/badge/types.d.ts +35 -12
  60. package/lib/typescript/src/components/badge/types.d.ts.map +1 -1
  61. package/lib/typescript/src/components/badge/utils.d.ts +3 -3
  62. package/lib/typescript/src/components/badge/utils.d.ts.map +1 -1
  63. package/lib/typescript/src/components/header/parts/HeaderBar.d.ts.map +1 -1
  64. package/lib/typescript/src/components/index.d.ts +1 -0
  65. package/lib/typescript/src/components/index.d.ts.map +1 -1
  66. package/package.json +1 -1
  67. package/src/components/accordion/Accordion.tsx +116 -0
  68. package/src/components/accordion/context.ts +45 -0
  69. package/src/components/accordion/hooks/use-accordion-core.ts +51 -0
  70. package/src/components/accordion/index.ts +14 -0
  71. package/src/components/accordion/parts/AccordionContent.tsx +96 -0
  72. package/src/components/accordion/parts/AccordionIndicator.tsx +67 -0
  73. package/src/components/accordion/parts/AccordionItem.tsx +55 -0
  74. package/src/components/accordion/parts/AccordionTrigger.tsx +88 -0
  75. package/src/components/accordion/types.ts +71 -0
  76. package/src/components/accordion/utils.ts +12 -0
  77. package/src/components/badge/Badge.tsx +177 -59
  78. package/src/components/badge/WithBadge.tsx +79 -0
  79. package/src/components/badge/index.ts +2 -1
  80. package/src/components/badge/types.ts +36 -12
  81. package/src/components/badge/utils.ts +3 -3
  82. package/src/components/header/parts/HeaderBar.tsx +41 -8
  83. package/src/components/index.ts +1 -0
  84. package/lib/module/components/badge/parts/BadgeIndicator.js +0 -212
  85. package/lib/module/components/badge/parts/BadgeIndicator.js.map +0 -1
  86. package/lib/typescript/src/components/badge/parts/BadgeIndicator.d.ts +0 -19
  87. package/lib/typescript/src/components/badge/parts/BadgeIndicator.d.ts.map +0 -1
  88. package/src/components/badge/parts/BadgeIndicator.tsx +0 -178
@@ -1,78 +1,196 @@
1
- import { type ComponentRef, forwardRef } from "react";
2
- import { View } from "react-native";
1
+ import { Text as RNText, View } from "react-native";
3
2
 
4
- import { StyleSheet } from "react-native-unistyles";
3
+ import { StyleSheet, useUnistyles } from "react-native-unistyles";
5
4
 
6
- import { BadgeIndicator } from "./parts/BadgeIndicator";
7
- import type { BadgeProps } from "./types";
8
- import { getAnchorPosition } from "./utils";
5
+ import type { ColorToken, RoleTextVariant } from "#theme/types";
6
+ import { resolveThemeColor } from "#utils/resolve-theme-color";
7
+ import { FONT_WEIGHT_VALUE, SYSTEM_FONT } from "#utils/typography";
8
+ import type { BadgeProps, BadgeSize } from "./types";
9
+ import { getCustomBadgeColors, hasLabel } from "./utils";
10
+
11
+ const SCHEME_NAMES = new Set(["primary", "secondary", "success", "warning", "danger"]);
12
+
13
+ type BadgeScheme = "primary" | "secondary" | "success" | "warning" | "danger";
14
+
15
+ /** Label foreground per scheme — mirrors the spec's token table. */
16
+ const FG_TOKEN: Record<BadgeScheme, ColorToken> = {
17
+ primary: "accent.solid.fg",
18
+ secondary: "neutral.solid.fg",
19
+ success: "success.solid.fg",
20
+ warning: "warning.solid.fg",
21
+ danger: "danger.solid.fg",
22
+ };
23
+
24
+ const LABEL_VARIANT: Record<BadgeSize, RoleTextVariant> = {
25
+ sm: "label-sm",
26
+ md: "label-md",
27
+ lg: "label-lg",
28
+ };
9
29
 
10
30
  /**
11
- * Overlays a count or status indicator on one corner of the element it wraps.
31
+ * A dot, or a pill carrying a count or short label.
12
32
  *
13
33
  * ```tsx
14
- * <Badge placement="top-right" color="danger" label={3}>
15
- * <Toolbar.Button icon="bell" accessibilityLabel="Notifications" />
16
- * </Badge>
34
+ * <Text>Appearing</Text>
35
+ * <Badge color="secondary" size="sm" label={56} />
17
36
  * ```
18
37
  *
19
- * The child renders untouched — `Badge` contributes the positioning context and
20
- * the overlay, nothing else. A round child usually wants a negative `offset`,
21
- * since the corner being anchored to is the bounding box's, not the circle's.
38
+ * Stands on its own, inline in a row beside the text it counts. To pin one to
39
+ * the corner of an icon or button, wrap that element in `WithBadge`, which
40
+ * renders this and owns only the position.
41
+ *
42
+ * `useUnistyles` is called unconditionally, which is what subscribes this
43
+ * component to theme changes. The background below comes from a variant, which
44
+ * unistyles applies straight to the native node on a theme switch; without a
45
+ * re-render React would go on believing the node holds the previous theme's
46
+ * value. `Chip` documents the same hazard on its root.
22
47
  */
23
- const Badge = forwardRef<ComponentRef<typeof View>, BadgeProps>(function Badge(
24
- {
25
- children,
26
- placement = "top-right",
27
- label,
28
- color = "danger",
29
- size = "md",
30
- offset = 0,
31
- isVisible = true,
32
- fullWidth = false,
33
- accessibilityLabel,
34
- style,
35
- ...rest
36
- },
37
- ref
38
- ) {
39
- styles.useVariants({ fullWidth });
48
+ export const Badge = ({
49
+ label,
50
+ color = "danger",
51
+ size = "md",
52
+ ring = false,
53
+ accessibilityLabel,
54
+ style,
55
+ ...rest
56
+ }: BadgeProps) => {
57
+ const { theme } = useUnistyles();
58
+ // A literal never matches one of the five variant options, so it's cast to
59
+ // satisfy `useVariants`'s literal-union type while still being compared
60
+ // against the real value below.
61
+ styles.useVariants({ color: color as BadgeScheme, size, ring });
62
+
63
+ const custom = SCHEME_NAMES.has(color) ? null : getCustomBadgeColors(color);
64
+ const labelled = hasLabel(label);
65
+ const foreground = custom ? custom.fg : FG_TOKEN[color as BadgeScheme];
66
+
67
+ // An explicit label wins even on a dot; a dot with nothing to announce is
68
+ // decorative and hidden rather than read out as an unlabelled element.
69
+ const announced = accessibilityLabel ?? (labelled ? String(label) : undefined);
70
+ const accessibility = announced
71
+ ? { accessible: true, accessibilityLabel: announced }
72
+ : {
73
+ accessibilityElementsHidden: true,
74
+ importantForAccessibility: "no-hide-descendants" as const,
75
+ "aria-hidden": true,
76
+ };
40
77
 
41
78
  return (
42
- // `style` last so an explicit `alignSelf` beats `fullWidth`.
43
- <View ref={ref} style={[styles.wrapper, style]} {...rest}>
44
- {children}
45
- {isVisible ? (
46
- <View pointerEvents="box-none" style={[styles.anchor, getAnchorPosition(placement, size, offset)]}>
47
- <BadgeIndicator label={label} color={color} size={size} accessibilityLabel={accessibilityLabel} />
48
- </View>
79
+ <View
80
+ style={[
81
+ styles.indicator,
82
+ labelled ? styles.pill : styles.dot,
83
+ custom ? { backgroundColor: custom.bg } : null,
84
+ style,
85
+ ]}
86
+ {...rest}
87
+ {...accessibility}
88
+ >
89
+ {labelled ? (
90
+ <RNText
91
+ style={[styles.label, { color: resolveThemeColor(foreground, theme) }]}
92
+ maxFontSizeMultiplier={theme.typography.variants[LABEL_VARIANT[size]].maxFontSizeMultiplier}
93
+ numberOfLines={1}
94
+ >
95
+ {label}
96
+ </RNText>
49
97
  ) : null}
50
98
  </View>
51
99
  );
52
- });
53
-
54
- export { Badge };
100
+ };
55
101
 
56
102
  // ─── Styles ───────────────────────────────────────────────────────────────────
57
103
  //
58
- // The indicator's insets are computed in `getAnchorPosition`, not declared here:
59
- // `offset` is an open-ended number, so it could never be a variant condition,
60
- // and the position involves no theme tokens at all. That leaves this stylesheet
61
- // with only what genuinely varies declaratively.
62
- const styles = StyleSheet.create(() => ({
63
- wrapper: {
64
- position: "relative",
65
- variants: {
66
- // `alignSelf: 'flex-start'` hugs the child, so the indicator anchors to
67
- // the child's bounds rather than the surrounding container's. A child
68
- // that was stretching needs `fullWidth` to go on doing so.
69
- fullWidth: {
70
- true: { alignSelf: "stretch" },
71
- false: { alignSelf: "flex-start" },
104
+ // `color` is a plain variant rather than a compound one: `Badge` renders solid
105
+ // only, so there is no second paint axis for it to interact with.
106
+ //
107
+ // React Native's box model is border-box, so the ring below eats into the
108
+ // badge rather than growing it — the dot's outer diameter stays exactly what
109
+ // `size` declares, with no compensation arithmetic, ring or not.
110
+ const styles = StyleSheet.create((theme) => {
111
+ const c = theme.color;
112
+ // A custom font encodes its weight in the family name, so only the system
113
+ // font takes a numeric `fontWeight` — the same branch `Chip` makes. Inlined
114
+ // rather than extracted: the unistyles Babel plugin rewrites this factory,
115
+ // and an imported function called from inside it is not in scope at runtime.
116
+ const labelFont = theme.typography.fonts.semibold;
117
+ const isSystemLabelFont = labelFont === SYSTEM_FONT;
118
+
119
+ return {
120
+ indicator: {
121
+ alignItems: "center",
122
+ justifyContent: "center",
123
+ borderRadius: theme.radius.full,
124
+ borderColor: c["surface.canvas"],
125
+ variants: {
126
+ // No styles of its own; it only has to exist for the compound rows
127
+ // below to match on. The ring's width depends on size as well.
128
+ size: { sm: {}, md: {}, lg: {} },
129
+ ring: {
130
+ true: {},
131
+ false: { borderWidth: 0 },
132
+ },
133
+ color: {
134
+ primary: { backgroundColor: c["accent.solid.bg"] },
135
+ secondary: { backgroundColor: c["neutral.solid.bg"] },
136
+ success: { backgroundColor: c["success.solid.bg"] },
137
+ warning: { backgroundColor: c["warning.solid.bg"] },
138
+ danger: { backgroundColor: c["danger.solid.bg"] },
139
+ },
72
140
  },
141
+ compoundVariants: [
142
+ { ring: true, size: "sm", styles: { borderWidth: 1.5 } },
143
+ { ring: true, size: "md", styles: { borderWidth: 2 } },
144
+ { ring: true, size: "lg", styles: { borderWidth: 2 } },
145
+ ],
73
146
  },
74
- },
75
- anchor: {
76
- position: "absolute",
77
- },
78
- }));
147
+ // These diameters are mirrored by `DOT_SIZE` in `./utils.ts`, which the
148
+ // anchor arithmetic reads. The Babel plugin needs them literal here, so a
149
+ // test asserts the two stay equal.
150
+ dot: {
151
+ variants: {
152
+ size: {
153
+ sm: { width: 12, height: 12 },
154
+ md: { width: 16, height: 16 },
155
+ lg: { width: 20, height: 20 },
156
+ },
157
+ },
158
+ },
159
+ pill: {
160
+ variants: {
161
+ size: {
162
+ sm: { height: 18, minWidth: 18, paddingHorizontal: 5 },
163
+ md: { height: 22, minWidth: 22, paddingHorizontal: 6 },
164
+ lg: { height: 26, minWidth: 26, paddingHorizontal: 8 },
165
+ },
166
+ },
167
+ },
168
+ // Spelled out rather than derived from `LABEL_VARIANT`: the unistyles Babel
169
+ // plugin reads this object statically, so computed variant keys are not
170
+ // safe here even though they would resolve correctly under test.
171
+ label: {
172
+ textAlign: "center",
173
+ fontFamily: isSystemLabelFont ? undefined : labelFont,
174
+ fontWeight: isSystemLabelFont ? FONT_WEIGHT_VALUE.semibold : undefined,
175
+ variants: {
176
+ size: {
177
+ sm: {
178
+ fontSize: theme.typography.variants["label-sm"].fontSize,
179
+ lineHeight: theme.typography.variants["label-sm"].lineHeight,
180
+ letterSpacing: theme.typography.variants["label-sm"].letterSpacing,
181
+ },
182
+ md: {
183
+ fontSize: theme.typography.variants["label-md"].fontSize,
184
+ lineHeight: theme.typography.variants["label-md"].lineHeight,
185
+ letterSpacing: theme.typography.variants["label-md"].letterSpacing,
186
+ },
187
+ lg: {
188
+ fontSize: theme.typography.variants["label-lg"].fontSize,
189
+ lineHeight: theme.typography.variants["label-lg"].lineHeight,
190
+ letterSpacing: theme.typography.variants["label-lg"].letterSpacing,
191
+ },
192
+ },
193
+ },
194
+ },
195
+ };
196
+ });
@@ -0,0 +1,79 @@
1
+ import { type ComponentRef, forwardRef } from "react";
2
+ import { View } from "react-native";
3
+
4
+ import { StyleSheet } from "react-native-unistyles";
5
+
6
+ import { Badge } from "./Badge";
7
+ import type { WithBadgeProps } from "./types";
8
+ import { getAnchorPosition } from "./utils";
9
+
10
+ /**
11
+ * Pins a `Badge` to one corner of the element it wraps.
12
+ *
13
+ * ```tsx
14
+ * <WithBadge placement="top-right" color="danger" label={3}>
15
+ * <Toolbar.Button icon="bell" accessibilityLabel="Notifications" />
16
+ * </WithBadge>
17
+ * ```
18
+ *
19
+ * The child renders untouched — `WithBadge` contributes the positioning context
20
+ * and the overlay, nothing else. A round child usually wants a negative
21
+ * `offset`, since the corner being anchored to is the bounding box's, not the
22
+ * circle's. For a badge that sits inline beside text, use `Badge` on its own.
23
+ */
24
+ const WithBadge = forwardRef<ComponentRef<typeof View>, WithBadgeProps>(function WithBadge(
25
+ {
26
+ children,
27
+ placement = "top-right",
28
+ label,
29
+ color = "danger",
30
+ size = "md",
31
+ offset = 0,
32
+ isVisible = true,
33
+ fullWidth = false,
34
+ accessibilityLabel,
35
+ style,
36
+ ...rest
37
+ },
38
+ ref
39
+ ) {
40
+ styles.useVariants({ fullWidth });
41
+
42
+ return (
43
+ // `style` last so an explicit `alignSelf` beats `fullWidth`.
44
+ <View ref={ref} style={[styles.wrapper, style]} {...rest}>
45
+ {children}
46
+ {isVisible ? (
47
+ <View pointerEvents="box-none" style={[styles.anchor, getAnchorPosition(placement, size, offset)]}>
48
+ <Badge label={label} color={color} size={size} ring accessibilityLabel={accessibilityLabel} />
49
+ </View>
50
+ ) : null}
51
+ </View>
52
+ );
53
+ });
54
+
55
+ export { WithBadge };
56
+
57
+ // ─── Styles ───────────────────────────────────────────────────────────────────
58
+ //
59
+ // The badge's insets are computed in `getAnchorPosition`, not declared here:
60
+ // `offset` is an open-ended number, so it could never be a variant condition,
61
+ // and the position involves no theme tokens at all. That leaves this stylesheet
62
+ // with only what genuinely varies declaratively.
63
+ const styles = StyleSheet.create(() => ({
64
+ wrapper: {
65
+ position: "relative",
66
+ variants: {
67
+ // `alignSelf: 'flex-start'` hugs the child, so the indicator anchors to
68
+ // the child's bounds rather than the surrounding container's. A child
69
+ // that was stretching needs `fullWidth` to go on doing so.
70
+ fullWidth: {
71
+ true: { alignSelf: "stretch" },
72
+ false: { alignSelf: "flex-start" },
73
+ },
74
+ },
75
+ },
76
+ anchor: {
77
+ position: "absolute",
78
+ },
79
+ }));
@@ -1,2 +1,3 @@
1
1
  export { Badge } from "./Badge";
2
- export type { BadgeColor, BadgePlacement, BadgeProps, BadgeSize } from "./types";
2
+ export type { BadgeColor, BadgePlacement, BadgeProps, BadgeSize, WithBadgeProps } from "./types";
3
+ export { WithBadge } from "./WithBadge";
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { ViewProps } from "react-native";
3
3
 
4
- /** Corner of the wrapped child the indicator is anchored to. */
4
+ /** Corner of the wrapped child a `WithBadge` anchors its badge to. */
5
5
  export type BadgePlacement = "top-right" | "top-left" | "bottom-right" | "bottom-left";
6
6
 
7
7
  /** Scale of the dot, pill, and label. */
@@ -9,7 +9,7 @@ export type BadgeSize = "sm" | "md" | "lg";
9
9
 
10
10
  /**
11
11
  * Semantic color scheme, or a raw CSS color literal (`#fff`, `rgb(...)`, `hsl(...)`).
12
- * A literal paints the indicator with that exact value and derives a readable
12
+ * A literal paints the badge with that exact value and derives a readable
13
13
  * foreground for the label.
14
14
  */
15
15
  export type BadgeColor =
@@ -21,37 +21,61 @@ export type BadgeColor =
21
21
  // literals last so scheme-name autocomplete still surfaces
22
22
  | (string & {});
23
23
 
24
- export interface BadgeProps extends ViewProps {
25
- /** The element being badged. Rendered untouched; `Badge` only adds the overlay. */
24
+ export interface BadgeProps extends Omit<ViewProps, "children"> {
25
+ /**
26
+ * Text in the badge. Omit it (or pass an empty string) for a dot.
27
+ * `0` renders as a label; a caller hiding a zero count leaves the badge out.
28
+ */
29
+ label?: string | number;
30
+ /** Semantic scheme or raw color literal. Defaults to `'danger'`. */
31
+ color?: BadgeColor;
32
+ /** Scale of the badge. Defaults to `'md'`. */
33
+ size?: BadgeSize;
34
+ /**
35
+ * Draws a `surface.canvas` ring around the badge, so it stays legible over
36
+ * whatever it overlaps. `WithBadge` turns it on. A badge inline beside text
37
+ * overlaps nothing, so it defaults to `false`: on a card the ring would only
38
+ * read as an outline.
39
+ */
40
+ ring?: boolean;
41
+ /**
42
+ * Announced in place of the label. Defaults to the label's own text; an
43
+ * unlabelled dot is hidden from assistive technology.
44
+ */
45
+ accessibilityLabel?: string;
46
+ }
47
+
48
+ export interface WithBadgeProps extends ViewProps {
49
+ /** The element being badged. Rendered untouched; `WithBadge` only adds the overlay. */
26
50
  children: ReactNode;
27
51
  /**
28
- * Corner the indicator sits on. Defaults to `'top-right'`.
52
+ * Corner the badge sits on. Defaults to `'top-right'`.
29
53
  *
30
- * The indicator overhangs the child, so an ancestor that clips — a `Surface`
54
+ * The badge overhangs the child, so an ancestor that clips — a `Surface`
31
55
  * with a radius, or a `Screen` list row — will cut it off. Pad that container
32
- * by the overhang, or lift the `Badge` outside it.
56
+ * by the overhang, or lift the `WithBadge` outside it.
33
57
  */
34
58
  placement?: BadgePlacement;
35
59
  /**
36
- * Text in the indicator. Omit it (or pass an empty string) for a dot.
60
+ * Text in the badge. Omit it (or pass an empty string) for a dot.
37
61
  * `0` renders as a label — use `isVisible` to suppress a zero count.
38
62
  */
39
63
  label?: string | number;
40
64
  /** Semantic scheme or raw color literal. Defaults to `'danger'`. */
41
65
  color?: BadgeColor;
42
- /** Scale of the indicator. Defaults to `'md'`. */
66
+ /** Scale of the badge. Defaults to `'md'`. */
43
67
  size?: BadgeSize;
44
68
  /**
45
- * Nudges the indicator along both axes from the corner `placement` picked.
69
+ * Nudges the badge along both axes from the corner `placement` picked.
46
70
  * Positive moves it away from the child's centre, negative pulls it in.
47
71
  * A number applies to both axes; `{ x, y }` sets them separately.
48
72
  *
49
73
  * The corner it anchors to is the child's **bounding box**, so a round child
50
- * needs a negative offset to bring the indicator onto the visible edge — a
74
+ * needs a negative offset to bring the badge onto the visible edge — a
51
75
  * circle only meets its box at 45°, about `0.21 × width` away. Defaults to `0`.
52
76
  */
53
77
  offset?: number | { x?: number; y?: number };
54
- /** Renders the indicator. The child renders either way. Defaults to `true`. */
78
+ /** Renders the badge. The child renders either way. Defaults to `true`. */
55
79
  isVisible?: boolean;
56
80
  /**
57
81
  * Stretches the wrapper along its parent's cross axis, so a child that
@@ -10,7 +10,7 @@ export interface CustomBadgeColors {
10
10
  fg: string;
11
11
  }
12
12
 
13
- /** The indicator's paint for a raw literal: the literal itself, plus a readable label. */
13
+ /** The badge's paint for a raw literal: the literal itself, plus a readable label. */
14
14
  export function getCustomBadgeColors(literal: string): CustomBadgeColors {
15
15
  return { bg: literal, fg: readableOn(literal) };
16
16
  }
@@ -25,7 +25,7 @@ export function hasLabel(label: string | number | undefined): label is string |
25
25
  }
26
26
 
27
27
  /**
28
- * Outer diameter of the dot per size. The indicator's own stylesheet repeats
28
+ * Outer diameter of the dot per size. The badge's own stylesheet repeats
29
29
  * these as literals — the unistyles Babel plugin reads that object statically,
30
30
  * so it cannot reference this table — and `anchorOffsetsMatchDotRadius` in the
31
31
  * test suite is what keeps the two in step.
@@ -44,7 +44,7 @@ export interface AnchorPosition {
44
44
  }
45
45
 
46
46
  /**
47
- * Absolute insets for the indicator, in pure arithmetic rather than one
47
+ * Absolute insets for a `WithBadge`'s badge, in pure arithmetic rather than one
48
48
  * compound variant per (placement, size) cell: `offset` is an open-ended number
49
49
  * and could never be a variant condition, and nothing here touches the theme.
50
50
  *
@@ -1,5 +1,5 @@
1
1
  import { type ComponentProps, type ReactNode, useState } from "react";
2
- import { Text, View } from "react-native";
2
+ import { type LayoutChangeEvent, Text, View } from "react-native";
3
3
 
4
4
  import Animated from "react-native-reanimated";
5
5
  import { ScrollToTopGuard } from "react-native-screens/experimental";
@@ -38,6 +38,13 @@ export function HeaderBar({
38
38
  }: HeaderBarProps) {
39
39
  const { theme } = useUnistyles();
40
40
  const [windowInsets, setWindowInsets] = useState({ left: 0, right: 0 });
41
+ // Toolbar widths, so a centered title can stay clear of both sides.
42
+ const [leadingWidth, setLeadingWidth] = useState(0);
43
+ const [trailingWidth, setTrailingWidth] = useState(0);
44
+ const onLeadingLayout = (event: LayoutChangeEvent) => setLeadingWidth(event.nativeEvent.layout.width);
45
+ const onTrailingLayout = (event: LayoutChangeEvent) => setTrailingWidth(event.nativeEvent.layout.width);
46
+ const hasLeading = leading != null || dismissAction === "back";
47
+ const hasTrailing = trailing != null || dismissAction === "close";
41
48
  styles.useVariants({ titleAlignment });
42
49
  const dismiss = (
43
50
  <HeaderDismissButton dismissAction={dismissAction} navigation={navigation} onDismiss={onDismiss} />
@@ -62,8 +69,8 @@ export function HeaderBar({
62
69
  <View style={[styles.bar, styles.windowInsets(windowInsets.left, windowInsets.right)]}>
63
70
  <HeaderSurface color={color} animatedStyle={surfaceStyle} />
64
71
  <HeaderWindowInsets onInsetsChange={setWindowInsets} />
65
- {(leading != null || dismissAction === "back") && (
66
- <Toolbar>
72
+ {hasLeading && (
73
+ <Toolbar onLayout={onLeadingLayout}>
67
74
  {dismissAction === "back" && dismiss}
68
75
  {leading}
69
76
  </Toolbar>
@@ -71,7 +78,13 @@ export function HeaderBar({
71
78
  <Animated.View
72
79
  style={[
73
80
  styles.titleContainer,
74
- styles.titleInsets(windowInsets.left, windowInsets.right, titleAlignment),
81
+ styles.titleInsets(
82
+ windowInsets.left,
83
+ windowInsets.right,
84
+ hasLeading ? leadingWidth : 0,
85
+ hasTrailing ? trailingWidth : 0,
86
+ titleAlignment
87
+ ),
75
88
  titleStyle,
76
89
  ]}
77
90
  pointerEvents={isCustomTitle(title) ? "box-none" : "none"}
@@ -80,6 +93,7 @@ export function HeaderBar({
80
93
  ? String(title).trim().length > 0 && (
81
94
  <Text
82
95
  style={styles.title}
96
+ numberOfLines={1}
83
97
  maxFontSizeMultiplier={theme.typography.variants["title-md"].maxFontSizeMultiplier}
84
98
  >
85
99
  {title}
@@ -87,8 +101,8 @@ export function HeaderBar({
87
101
  )
88
102
  : title}
89
103
  </Animated.View>
90
- {(trailing != null || dismissAction === "close") && (
91
- <Toolbar style={styles.trailing}>
104
+ {hasTrailing && (
105
+ <Toolbar style={styles.trailing} onLayout={onTrailingLayout}>
92
106
  {dismissAction === "close" && dismiss}
93
107
  {trailing}
94
108
  </Toolbar>
@@ -103,8 +117,27 @@ const styles = StyleSheet.create((theme, runtime) => ({
103
117
  paddingLeft: Math.max(resolveScreenPadding(theme, runtime.breakpoint).x, left),
104
118
  paddingRight: Math.max(resolveScreenPadding(theme, runtime.breakpoint).x, right),
105
119
  }),
106
- titleInsets: (left: number, right: number, alignment: "center" | "left") =>
107
- alignment === "center" ? { left, right } : {},
120
+ // A centered title spans the region between the window controls and is inset
121
+ // by the same amount from both of its edges, so it stays centered in that
122
+ // region. The inset is whichever side reaches further in — bar padding plus
123
+ // that side's toolbar — so a long title truncates before either toolbar
124
+ // instead of running underneath it. A left-aligned title is in the flex row
125
+ // already and needs none of this.
126
+ titleInsets: (
127
+ left: number,
128
+ right: number,
129
+ leadingWidth: number,
130
+ trailingWidth: number,
131
+ alignment: "center" | "left"
132
+ ) => {
133
+ if (alignment !== "center") return {};
134
+ const padding = resolveScreenPadding(theme, runtime.breakpoint).x;
135
+ const gap = theme.spacing["3"];
136
+ const reachLeft = Math.max(padding, left) + (leadingWidth > 0 ? leadingWidth + gap : 0) - left;
137
+ const reachRight = Math.max(padding, right) + (trailingWidth > 0 ? trailingWidth + gap : 0) - right;
138
+ const inset = Math.max(reachLeft, reachRight);
139
+ return { left: left + inset, right: right + inset };
140
+ },
108
141
  bar: {
109
142
  height: theme.layout.header.height,
110
143
  flexDirection: "row",
@@ -1,3 +1,4 @@
1
+ export * from "./accordion";
1
2
  export * from "./avatar";
2
3
  export * from "./badge";
3
4
  export * from "./box";