@microbit/ui 0.1.0-alpha.3 → 0.1.0-alpha.30

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 (114) hide show
  1. package/LICENSE.md +40 -0
  2. package/README.md +256 -26
  3. package/lang/ui.ca.json +43 -3
  4. package/lang/ui.cy.json +62 -0
  5. package/lang/ui.de.json +62 -0
  6. package/lang/ui.en-us.json +40 -0
  7. package/lang/ui.en.json +40 -0
  8. package/lang/ui.es-es.json +43 -3
  9. package/lang/ui.fr.json +44 -4
  10. package/lang/ui.ga-ie.json +62 -0
  11. package/lang/ui.it.json +62 -0
  12. package/lang/ui.ja.json +44 -4
  13. package/lang/ui.ko.json +44 -4
  14. package/lang/ui.lo.json +62 -0
  15. package/lang/ui.lol.json +40 -0
  16. package/lang/ui.nl.json +44 -4
  17. package/lang/ui.pl.json +44 -4
  18. package/lang/ui.pt-br.json +44 -4
  19. package/lang/ui.vi.json +62 -0
  20. package/lang/ui.zh-cn.json +62 -0
  21. package/lang/ui.zh-tw.json +44 -4
  22. package/package.json +19 -10
  23. package/postcss-legacy-safari.cjs +96 -0
  24. package/reset.css +35 -0
  25. package/src/Avatar.recipe.ts +166 -0
  26. package/src/Avatar.tsx +278 -0
  27. package/src/Breadcrumb.recipe.ts +39 -0
  28. package/src/Breadcrumb.tsx +114 -0
  29. package/src/Button.recipe.ts +88 -50
  30. package/src/Button.tsx +64 -26
  31. package/src/ButtonGroup.tsx +37 -15
  32. package/src/Card.recipe.ts +1 -2
  33. package/src/Card.tsx +2 -1
  34. package/src/Checkbox.recipe.ts +49 -14
  35. package/src/Checkbox.tsx +111 -32
  36. package/src/CheckboxGroup.tsx +70 -0
  37. package/src/CloseButton.tsx +3 -3
  38. package/src/CloseIcon.tsx +6 -4
  39. package/src/Code.tsx +20 -0
  40. package/src/Collapse.tsx +179 -0
  41. package/src/ComboBox.tsx +246 -0
  42. package/src/Divider.tsx +72 -8
  43. package/src/Drawer.recipe.ts +4 -5
  44. package/src/Drawer.tsx +3 -4
  45. package/src/ExternalLink.tsx +43 -0
  46. package/src/Fade.tsx +62 -0
  47. package/src/Field.recipe.ts +114 -0
  48. package/src/Field.tsx +187 -0
  49. package/src/GridList.recipe.ts +57 -0
  50. package/src/GridList.tsx +81 -0
  51. package/src/Heading.recipe.ts +20 -1
  52. package/src/Heading.tsx +3 -3
  53. package/src/Icon.tsx +23 -7
  54. package/src/IconButton.tsx +7 -12
  55. package/src/Image.tsx +1 -1
  56. package/src/Input.recipe.ts +41 -28
  57. package/src/Input.tsx +23 -7
  58. package/src/InputGroup.tsx +21 -8
  59. package/src/Kbd.tsx +26 -0
  60. package/src/Link.tsx +3 -3
  61. package/src/LinkBox.tsx +2 -3
  62. package/src/LinkButton.tsx +81 -0
  63. package/src/List.tsx +8 -6
  64. package/src/ListBox.recipe.ts +43 -0
  65. package/src/ListBox.tsx +88 -0
  66. package/src/Menu.recipe.ts +51 -17
  67. package/src/Menu.tsx +117 -2
  68. package/src/Modal.recipe.ts +14 -9
  69. package/src/Modal.tsx +137 -24
  70. package/src/MoreMenuButton.tsx +63 -0
  71. package/src/NativeSelect.tsx +39 -12
  72. package/src/NativeSelectField.tsx +84 -0
  73. package/src/NumberField.recipe.ts +108 -0
  74. package/src/NumberField.tsx +138 -0
  75. package/src/PopoverArrow.tsx +19 -5
  76. package/src/ProgressBar.tsx +3 -5
  77. package/src/Radio.recipe.ts +108 -0
  78. package/src/Radio.tsx +62 -0
  79. package/src/RadioGroup.tsx +68 -0
  80. package/src/Select.recipe.ts +211 -0
  81. package/src/Select.tsx +194 -0
  82. package/src/SharedUIProvider.tsx +51 -7
  83. package/src/Skeleton.tsx +146 -0
  84. package/src/Slide.tsx +2 -2
  85. package/src/Slider.recipe.ts +24 -15
  86. package/src/Slider.tsx +65 -5
  87. package/src/Spinner.tsx +11 -9
  88. package/src/Svg.tsx +2 -3
  89. package/src/Switch.recipe.ts +58 -13
  90. package/src/Switch.tsx +39 -7
  91. package/src/Text.recipe.ts +26 -0
  92. package/src/Text.tsx +6 -2
  93. package/src/TextField.tsx +54 -40
  94. package/src/Toast.recipe.ts +49 -12
  95. package/src/Toast.tsx +151 -46
  96. package/src/Tooltip.recipe.ts +32 -0
  97. package/src/Tooltip.tsx +51 -28
  98. package/src/TooltipButton.tsx +229 -0
  99. package/src/UnmountCallback.tsx +2 -2
  100. package/src/VisuallyHidden.tsx +1 -1
  101. package/src/base-preset.ts +336 -84
  102. package/src/{chakra-tokens.ts → base-tokens.ts} +7 -7
  103. package/src/button-icon.ts +22 -0
  104. package/src/data-attrs.ts +16 -0
  105. package/src/dense-preset.ts +107 -0
  106. package/src/hooks/useBreakpointValue.ts +4 -4
  107. package/src/hooks/useClipboard.ts +63 -0
  108. package/src/hooks/useDisclosure.ts +31 -0
  109. package/src/hooks/useMediaQuery.ts +27 -0
  110. package/src/hooks/usePrevious.ts +15 -0
  111. package/src/index.ts +29 -3
  112. package/src/rac-locale.ts +33 -0
  113. package/src/system.ts +22 -1
  114. package/src/TextField.recipe.ts +0 -54
@@ -0,0 +1,166 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { defineSlotRecipe } from "@pandacss/dev";
7
+
8
+ /**
9
+ * Avatar slot recipe — a circle showing an image, the initials of a name, or
10
+ * a generic person glyph, optionally with a badge pinned to one corner.
11
+ *
12
+ * The background and text colour come from `var(--avatar-bg)` and
13
+ * `var(--avatar-color)` rather than being flat values, because the component
14
+ * derives them from the name (see Avatar.tsx) and writes them as inline custom
15
+ * properties. Two reasons, both about letting a call site win with a plain
16
+ * `css={{ bg: …, color: … }}`: an inline *property* would beat any class,
17
+ * where an inline *variable* only feeds this declaration; and both must stay
18
+ * single-class selectors, since a state selector like `&[data-light-bg]`
19
+ * would outrank the call site's utility class on specificity.
20
+ *
21
+ * The `calc(size / 2.5)` font size is resolved per size so an app preset can
22
+ * restate either independently (classroom's avatars are a grade larger).
23
+ *
24
+ * Registered in the base preset (base-preset.ts), which also has the
25
+ * `staticCss` entry that keeps the runtime-prop variants generated.
26
+ */
27
+ export const avatar = defineSlotRecipe({
28
+ className: "avatar",
29
+ slots: ["root", "label", "image", "badge"],
30
+ base: {
31
+ root: {
32
+ display: "inline-flex",
33
+ alignItems: "center",
34
+ justifyContent: "center",
35
+ flexShrink: 0,
36
+ position: "relative",
37
+ verticalAlign: "top",
38
+ textAlign: "center",
39
+ textTransform: "uppercase",
40
+ fontWeight: "medium",
41
+ borderRadius: "full",
42
+ // The no-name defaults; the name-derived pair arrives inline.
43
+ // gray.350 is the decorative-fill stop — 400+ are reserved for
44
+ // accessible outlines and text (see the ramp in base-preset.ts).
45
+ background: "var(--avatar-bg, token(colors.gray.350))",
46
+ color: "var(--avatar-color, token(colors.white))",
47
+ borderColor: "white",
48
+ },
49
+ label: {
50
+ lineHeight: "1",
51
+ },
52
+ image: {
53
+ width: "100%",
54
+ height: "100%",
55
+ objectFit: "cover",
56
+ borderRadius: "inherit",
57
+ },
58
+ badge: {
59
+ position: "absolute",
60
+ display: "flex",
61
+ alignItems: "center",
62
+ justifyContent: "center",
63
+ borderRadius: "full",
64
+ // em-relative, so a badge keeps its proportions at every avatar size.
65
+ borderWidth: "0.2em",
66
+ borderStyle: "solid",
67
+ borderColor: "white",
68
+ },
69
+ },
70
+ variants: {
71
+ // The container size, and the `calc(size / 2.5)` font size kept as a
72
+ // calc over the same token so both track a preset that rescales `sizes`
73
+ // (the dense preset does, by 0.88).
74
+ //
75
+ // The font size lands on the root *and* the label. They are separate
76
+ // declarations so an app can
77
+ // move one without the other: the root's is the em basis for a badge,
78
+ // the label's is how big the initials are, and the two are not always
79
+ // the same wish.
80
+ size: {
81
+ "2xs": {
82
+ root: {
83
+ width: "4",
84
+ height: "4",
85
+ fontSize: "calc(token(sizes.4) / 2.5)",
86
+ },
87
+ label: { fontSize: "calc(token(sizes.4) / 2.5)" },
88
+ },
89
+ xs: {
90
+ root: {
91
+ width: "6",
92
+ height: "6",
93
+ fontSize: "calc(token(sizes.6) / 2.5)",
94
+ },
95
+ label: { fontSize: "calc(token(sizes.6) / 2.5)" },
96
+ },
97
+ sm: {
98
+ root: {
99
+ width: "8",
100
+ height: "8",
101
+ fontSize: "calc(token(sizes.8) / 2.5)",
102
+ },
103
+ label: { fontSize: "calc(token(sizes.8) / 2.5)" },
104
+ },
105
+ md: {
106
+ root: {
107
+ width: "12",
108
+ height: "12",
109
+ fontSize: "calc(token(sizes.12) / 2.5)",
110
+ },
111
+ label: { fontSize: "calc(token(sizes.12) / 2.5)" },
112
+ },
113
+ lg: {
114
+ root: {
115
+ width: "16",
116
+ height: "16",
117
+ fontSize: "calc(token(sizes.16) / 2.5)",
118
+ },
119
+ label: { fontSize: "calc(token(sizes.16) / 2.5)" },
120
+ },
121
+ xl: {
122
+ root: {
123
+ width: "24",
124
+ height: "24",
125
+ fontSize: "calc(token(sizes.24) / 2.5)",
126
+ },
127
+ label: { fontSize: "calc(token(sizes.24) / 2.5)" },
128
+ },
129
+ "2xl": {
130
+ root: {
131
+ width: "32",
132
+ height: "32",
133
+ fontSize: "calc(token(sizes.32) / 2.5)",
134
+ },
135
+ label: { fontSize: "calc(token(sizes.32) / 2.5)" },
136
+ },
137
+ },
138
+ /** Which corner the badge sits in. */
139
+ placement: {
140
+ "top-start": {
141
+ badge: {
142
+ top: "0",
143
+ insetStart: "0",
144
+ transform: "translate(-25%, -25%)",
145
+ },
146
+ },
147
+ "top-end": {
148
+ badge: { top: "0", insetEnd: "0", transform: "translate(25%, -25%)" },
149
+ },
150
+ "bottom-start": {
151
+ badge: {
152
+ bottom: "0",
153
+ insetStart: "0",
154
+ transform: "translate(-25%, 25%)",
155
+ },
156
+ },
157
+ "bottom-end": {
158
+ badge: { bottom: "0", insetEnd: "0", transform: "translate(25%, 25%)" },
159
+ },
160
+ },
161
+ },
162
+ defaultVariants: {
163
+ size: "md",
164
+ placement: "bottom-end",
165
+ },
166
+ });
package/src/Avatar.tsx ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import {
7
+ cloneElement,
8
+ CSSProperties,
9
+ HTMLAttributes,
10
+ isValidElement,
11
+ ReactElement,
12
+ ReactNode,
13
+ SVGProps,
14
+ useEffect,
15
+ useState,
16
+ } from "react";
17
+ import { css, cx } from "styled-system/css";
18
+ import { avatar, AvatarVariantProps } from "styled-system/recipes";
19
+ import { token } from "styled-system/tokens";
20
+ import { SystemStyleObject } from "styled-system/types";
21
+
22
+ /**
23
+ * A djb2-style hash of the name, its low three bytes read as a colour. The
24
+ * same name is always the same colour, and the exact hash is a compatibility
25
+ * contract: apps' existing avatar rosters keep the colours they have always
26
+ * had, so don't change it.
27
+ */
28
+ const colorFromName = (name: string): string => {
29
+ let hash = 0;
30
+ for (let i = 0; i < name.length; i += 1) {
31
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
32
+ hash = hash & hash;
33
+ }
34
+ let color = "#";
35
+ for (let j = 0; j < 3; j += 1) {
36
+ const value = (hash >> (j * 8)) & 255;
37
+ color += `00${value.toString(16)}`.slice(-2);
38
+ }
39
+ return color;
40
+ };
41
+
42
+ /**
43
+ * Contrast rule for the generated background: perceived brightness (the
44
+ * classic 299/587/114 weighting) below 128 counts as dark, and dark
45
+ * backgrounds take white text.
46
+ */
47
+ const isLight = (hex: string): boolean => {
48
+ const r = parseInt(hex.slice(1, 3), 16);
49
+ const g = parseInt(hex.slice(3, 5), 16);
50
+ const b = parseInt(hex.slice(5, 7), 16);
51
+ return (r * 299 + g * 587 + b * 114) / 1000 >= 128;
52
+ };
53
+
54
+ /**
55
+ * First letter of the first and last words. Prefixed because it is exported
56
+ * from the package root, where a bare `initials` would be a broad name to
57
+ * claim.
58
+ */
59
+ export const avatarInitials = (name: string): string => {
60
+ const names = name.trim().split(" ");
61
+ const firstName = names[0] ?? "";
62
+ const lastName = names.length > 1 ? names[names.length - 1] : "";
63
+ return firstName && lastName
64
+ ? `${firstName.charAt(0)}${lastName.charAt(0)}`
65
+ : firstName.charAt(0);
66
+ };
67
+
68
+ /**
69
+ * The generic person glyph, the fallback when there is no name. It inherits
70
+ * `currentColor` — white on the no-name grey background — so it stays visible
71
+ * if a call site recolours.
72
+ *
73
+ * The paths are Chakra UI's avatar glyph, inlined for visual parity with the
74
+ * apps' original look (see the notice in LICENSE.md).
75
+ */
76
+ export const GenericAvatarIcon = (props: SVGProps<SVGSVGElement>) => (
77
+ <svg viewBox="0 0 128 128" width="100%" height="100%" {...props}>
78
+ <path
79
+ fill="currentColor"
80
+ d="M103,102.1388 C93.094,111.92 79.3504,118 64.1638,118 C48.8056,118 34.9294,111.768 25,101.7892 L25,95.2 C25,86.8096 31.981,80 40.6,80 L87.4,80 C96.019,80 103,86.8096 103,95.2 L103,102.1388 Z"
81
+ />
82
+ <path
83
+ fill="currentColor"
84
+ d="M63.9961647,24 C51.2938136,24 41,34.2938136 41,46.9961647 C41,59.7061864 51.2938136,70 63.9961647,70 C76.6985159,70 87,59.7061864 87,46.9961647 C87,34.2938136 76.6985159,24 63.9961647,24"
85
+ />
86
+ </svg>
87
+ );
88
+
89
+ type ImageStatus = "pending" | "loading" | "loaded" | "failed";
90
+
91
+ /**
92
+ * Load the photo out of band and report how it went, so the avatar can show
93
+ * the initials or the icon meanwhile and keep showing them if it never
94
+ * arrives.
95
+ *
96
+ * The <img> element is only mounted once this says "loaded", which is what
97
+ * keeps a broken URL from leaving the browser's broken-image glyph inside the
98
+ * circle — the failure mode a fallback exists to prevent.
99
+ */
100
+ const useImageStatus = (src?: string, srcSet?: string): ImageStatus => {
101
+ const [status, setStatus] = useState<ImageStatus>(
102
+ src ? "loading" : "pending",
103
+ );
104
+ useEffect(() => {
105
+ if (!src) {
106
+ setStatus("pending");
107
+ return;
108
+ }
109
+ // A new src starts again: without this the avatar would keep showing the
110
+ // previous person's photo, or stay stuck on a fallback it has outgrown.
111
+ setStatus("loading");
112
+ const img = new Image();
113
+ let current = true;
114
+ img.onload = () => {
115
+ if (current) {
116
+ setStatus("loaded");
117
+ }
118
+ };
119
+ img.onerror = () => {
120
+ if (current) {
121
+ setStatus("failed");
122
+ }
123
+ };
124
+ // srcSet before src, so the browser has the candidates to choose from
125
+ // when the load starts.
126
+ if (srcSet) {
127
+ img.srcset = srcSet;
128
+ }
129
+ img.src = src;
130
+ return () => {
131
+ current = false;
132
+ img.onload = null;
133
+ img.onerror = null;
134
+ };
135
+ }, [src, srcSet]);
136
+ return status;
137
+ };
138
+
139
+ export interface AvatarProps
140
+ extends Omit<HTMLAttributes<HTMLSpanElement>, "color" | "children">,
141
+ Pick<AvatarVariantProps, "size"> {
142
+ /**
143
+ * The person. Shown as initials, and hashed into the background colour, so
144
+ * two people are unlikely to share one.
145
+ */
146
+ name?: string;
147
+ /**
148
+ * Photo. The initials (or the icon) show until it has loaded, and go on
149
+ * showing if it fails — the avatar never renders a broken image.
150
+ */
151
+ src?: string;
152
+ srcSet?: string;
153
+ /** Shown when there is no name. Defaults to the generic person glyph. */
154
+ icon?: ReactNode;
155
+ /** Accessible name for the icon fallback. Defaults to " avatar". */
156
+ iconLabel?: string;
157
+ /** Override how a name becomes initials. */
158
+ getInitials?: (name: string) => string;
159
+ /** A 2px ring in the avatar's border colour. */
160
+ showBorder?: boolean;
161
+ /** An `AvatarBadge`. */
162
+ children?: ReactNode;
163
+ /** Per-instance style overrides, merged after the recipe. */
164
+ css?: SystemStyleObject;
165
+ className?: string;
166
+ }
167
+
168
+ /**
169
+ * Avatar — a circular identity marker showing a photo, the initials of a
170
+ * name, or a generic glyph, in a colour derived from the name.
171
+ *
172
+ * Decorative in most designs — pass `aria-hidden` where the name is already
173
+ * beside it.
174
+ */
175
+ export const Avatar = ({
176
+ name,
177
+ src,
178
+ srcSet,
179
+ icon,
180
+ iconLabel = " avatar",
181
+ getInitials = avatarInitials,
182
+ showBorder,
183
+ size,
184
+ children,
185
+ css: cssProp,
186
+ className,
187
+ style,
188
+ ...rest
189
+ }: AvatarProps) => {
190
+ const status = useImageStatus(src, srcSet);
191
+ const isLoaded = status === "loaded";
192
+ const slots = avatar({ size });
193
+ // Only while the image isn't showing.
194
+ const bg = name && !isLoaded ? colorFromName(name) : undefined;
195
+ return (
196
+ <span
197
+ {...rest}
198
+ data-loaded={isLoaded || undefined}
199
+ className={cx(
200
+ slots.root,
201
+ showBorder ? css({ borderWidth: "2px" }) : undefined,
202
+ cssProp ? css(cssProp) : undefined,
203
+ className,
204
+ )}
205
+ style={
206
+ bg
207
+ ? ({
208
+ ...style,
209
+ "--avatar-bg": bg,
210
+ // The contrast rule, as a variable rather than a state
211
+ // selector so a call site's `css` colour still wins (see the
212
+ // recipe).
213
+ "--avatar-color": isLight(bg)
214
+ ? token("colors.gray.800")
215
+ : token("colors.white"),
216
+ } as CSSProperties)
217
+ : style
218
+ }
219
+ >
220
+ {isLoaded ? (
221
+ <img
222
+ src={src}
223
+ srcSet={srcSet}
224
+ alt={name ?? iconLabel}
225
+ className={slots.image}
226
+ />
227
+ ) : name ? (
228
+ <span role="img" aria-label={name} className={slots.label}>
229
+ {getInitials(name)}
230
+ </span>
231
+ ) : // The icon is labelled in place rather than wrapped: a wrapper
232
+ // would make it an inline child with a line box of its own, where
233
+ // directly in the flex container it is a flex item and centres
234
+ // exactly.
235
+ isValidElement(icon) ? (
236
+ cloneElement(icon as ReactElement<Record<string, unknown>>, {
237
+ role: "img",
238
+ "aria-label": iconLabel,
239
+ })
240
+ ) : (
241
+ icon ?? <GenericAvatarIcon role="img" aria-label={iconLabel} />
242
+ )}
243
+ {children}
244
+ </span>
245
+ );
246
+ };
247
+
248
+ export interface AvatarBadgeProps
249
+ extends Omit<HTMLAttributes<HTMLDivElement>, "color">,
250
+ Pick<AvatarVariantProps, "placement"> {
251
+ children?: ReactNode;
252
+ /**
253
+ * Per-instance style overrides. The badge has no size of its own — call
254
+ * sites set one (`boxSize: "1.5em"` scales with the avatar).
255
+ */
256
+ css?: SystemStyleObject;
257
+ className?: string;
258
+ }
259
+
260
+ /** AvatarBadge — a status dot pinned to a corner of its `Avatar`. */
261
+ export const AvatarBadge = ({
262
+ placement,
263
+ children,
264
+ css: cssProp,
265
+ className,
266
+ ...rest
267
+ }: AvatarBadgeProps) => (
268
+ <div
269
+ {...rest}
270
+ className={cx(
271
+ avatar({ placement }).badge,
272
+ cssProp ? css(cssProp) : undefined,
273
+ className,
274
+ )}
275
+ >
276
+ {children}
277
+ </div>
278
+ );
@@ -0,0 +1,39 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { defineSlotRecipe } from "@pandacss/dev";
7
+
8
+ /**
9
+ * Breadcrumb slot recipe — a flex list with a separator between items. The
10
+ * links themselves are the shared `Link`, so there is no link slot; the
11
+ * current page renders as a plain span.
12
+ *
13
+ * Registered in the base preset (base-preset.ts). No variants, so it needs
14
+ * no `staticCss` entry.
15
+ */
16
+ export const breadcrumb = defineSlotRecipe({
17
+ className: "breadcrumb",
18
+ slots: ["root", "list", "item", "separator"],
19
+ base: {
20
+ root: {},
21
+ list: {
22
+ display: "flex",
23
+ alignItems: "center",
24
+ listStyle: "none",
25
+ margin: 0,
26
+ padding: 0,
27
+ },
28
+ item: {
29
+ display: "inline-flex",
30
+ alignItems: "center",
31
+ // The separator renders inside every item (no children introspection);
32
+ // the last item's simply doesn't show.
33
+ "&:last-of-type [data-separator]": { display: "none" },
34
+ },
35
+ separator: {
36
+ mx: "2",
37
+ },
38
+ },
39
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * (c) 2026, Micro:bit Educational Foundation and contributors
3
+ *
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ import { createContext, ReactNode, useContext } from "react";
7
+ import { useIntl } from "react-intl";
8
+ import { css, cx } from "styled-system/css";
9
+ import { styled, type HTMLStyledProps } from "styled-system/jsx";
10
+ import { breadcrumb } from "styled-system/recipes";
11
+ import { SystemStyleObject } from "styled-system/types";
12
+ import { Link } from "./Link";
13
+ import { uiMessage } from "./messages";
14
+
15
+ // The separator is declared once on the Breadcrumb and rendered by every
16
+ // item; the current-page flag hops from BreadcrumbItem to the BreadcrumbLink
17
+ // inside it.
18
+ const SeparatorContext = createContext<ReactNode>("/");
19
+ const CurrentPageContext = createContext(false);
20
+
21
+ export interface BreadcrumbProps {
22
+ /** Between items; an element or string. Default "/". */
23
+ separator?: ReactNode;
24
+ /** Per-instance style overrides for the nav (e.g. fontSize). */
25
+ css?: SystemStyleObject;
26
+ className?: string;
27
+ /** BreadcrumbItems. */
28
+ children: ReactNode;
29
+ }
30
+
31
+ /**
32
+ * Breadcrumb — a navigation trail: nav > ol > li with a separator between
33
+ * items and the current page as plain text with `aria-current="page"`.
34
+ */
35
+ export const Breadcrumb = ({
36
+ separator = "/",
37
+ css: cssProp,
38
+ className,
39
+ children,
40
+ }: BreadcrumbProps) => {
41
+ const intl = useIntl();
42
+ const slots = breadcrumb();
43
+ return (
44
+ <nav
45
+ aria-label={intl.formatMessage(uiMessage("ui.breadcrumb"))}
46
+ className={cx(slots.root, cssProp ? css(cssProp) : undefined, className)}
47
+ >
48
+ <SeparatorContext.Provider value={separator}>
49
+ <ol className={slots.list}>{children}</ol>
50
+ </SeparatorContext.Provider>
51
+ </nav>
52
+ );
53
+ };
54
+
55
+ export interface BreadcrumbItemProps {
56
+ /**
57
+ * Marks this item as the current page: its BreadcrumbLink renders as a
58
+ * plain span with `aria-current="page"` rather than a link.
59
+ */
60
+ isCurrentPage?: boolean;
61
+ css?: SystemStyleObject;
62
+ className?: string;
63
+ children: ReactNode;
64
+ }
65
+
66
+ export const BreadcrumbItem = ({
67
+ isCurrentPage = false,
68
+ css: cssProp,
69
+ className,
70
+ children,
71
+ }: BreadcrumbItemProps) => {
72
+ const separator = useContext(SeparatorContext);
73
+ const slots = breadcrumb();
74
+ return (
75
+ <li
76
+ className={cx(slots.item, cssProp ? css(cssProp) : undefined, className)}
77
+ >
78
+ <CurrentPageContext.Provider value={isCurrentPage}>
79
+ {children}
80
+ </CurrentPageContext.Provider>
81
+ <span
82
+ data-separator
83
+ role="presentation"
84
+ aria-hidden
85
+ className={slots.separator}
86
+ >
87
+ {separator}
88
+ </span>
89
+ </li>
90
+ );
91
+ };
92
+
93
+ export type BreadcrumbLinkProps = HTMLStyledProps<"a">;
94
+
95
+ // The current page's text: same element shape as the link (so call-site
96
+ // style props keep working) minus the interactivity.
97
+ const CurrentPageText = styled("span");
98
+
99
+ /**
100
+ * The trail's link: the shared `Link`, or a plain span with
101
+ * `aria-current="page"` inside an item marked `isCurrentPage`.
102
+ */
103
+ export const BreadcrumbLink = (props: BreadcrumbLinkProps) => {
104
+ const isCurrentPage = useContext(CurrentPageContext);
105
+ if (isCurrentPage) {
106
+ const { href: _href, children, ...rest } = props;
107
+ return (
108
+ <CurrentPageText aria-current="page" {...rest}>
109
+ {children}
110
+ </CurrentPageText>
111
+ );
112
+ }
113
+ return <Link {...props} />;
114
+ };