@microbit/ui 0.0.0-gray.ramp.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +53 -0
- package/README.md +222 -0
- package/lang/ui.ca.json +22 -0
- package/lang/ui.cy.json +22 -0
- package/lang/ui.de.json +22 -0
- package/lang/ui.en-us.json +22 -0
- package/lang/ui.en.json +22 -0
- package/lang/ui.es-es.json +22 -0
- package/lang/ui.fr.json +22 -0
- package/lang/ui.ga-ie.json +22 -0
- package/lang/ui.it.json +22 -0
- package/lang/ui.ja.json +22 -0
- package/lang/ui.ko.json +22 -0
- package/lang/ui.lol.json +22 -0
- package/lang/ui.nl.json +22 -0
- package/lang/ui.pl.json +22 -0
- package/lang/ui.pt-br.json +22 -0
- package/lang/ui.zh-cn.json +22 -0
- package/lang/ui.zh-tw.json +22 -0
- package/package.json +66 -0
- package/postcss-legacy-safari.cjs +96 -0
- package/reset.css +35 -0
- package/src/Avatar.recipe.ts +168 -0
- package/src/Avatar.tsx +276 -0
- package/src/Button.recipe.ts +192 -0
- package/src/Button.tsx +69 -0
- package/src/ButtonGroup.tsx +70 -0
- package/src/Card.recipe.ts +49 -0
- package/src/Card.tsx +56 -0
- package/src/Checkbox.recipe.ts +111 -0
- package/src/Checkbox.tsx +106 -0
- package/src/CloseButton.tsx +87 -0
- package/src/CloseIcon.tsx +40 -0
- package/src/Code.tsx +20 -0
- package/src/Collapse.tsx +180 -0
- package/src/ComboBox.tsx +192 -0
- package/src/Divider.tsx +85 -0
- package/src/Drawer.recipe.ts +98 -0
- package/src/Drawer.tsx +138 -0
- package/src/Fade.tsx +48 -0
- package/src/GridList.recipe.ts +58 -0
- package/src/GridList.tsx +81 -0
- package/src/Heading.recipe.ts +52 -0
- package/src/Heading.tsx +14 -0
- package/src/Icon.tsx +75 -0
- package/src/IconButton.tsx +44 -0
- package/src/Image.tsx +11 -0
- package/src/Input.recipe.ts +75 -0
- package/src/Input.tsx +44 -0
- package/src/InputGroup.tsx +62 -0
- package/src/Kbd.tsx +26 -0
- package/src/Link.tsx +23 -0
- package/src/LinkBox.tsx +88 -0
- package/src/LinkButton.tsx +80 -0
- package/src/List.tsx +31 -0
- package/src/ListBox.recipe.ts +43 -0
- package/src/ListBox.tsx +88 -0
- package/src/Menu.recipe.ts +124 -0
- package/src/Menu.tsx +291 -0
- package/src/Modal.recipe.ts +163 -0
- package/src/Modal.tsx +377 -0
- package/src/NativeSelect.tsx +94 -0
- package/src/NumberField.recipe.ts +67 -0
- package/src/NumberField.tsx +86 -0
- package/src/PopoverArrow.tsx +67 -0
- package/src/ProgressBar.tsx +61 -0
- package/src/Radio.recipe.ts +112 -0
- package/src/Radio.tsx +89 -0
- package/src/Select.recipe.ts +210 -0
- package/src/Select.tsx +153 -0
- package/src/SharedUIProvider.tsx +55 -0
- package/src/Skeleton.tsx +146 -0
- package/src/Slide.tsx +54 -0
- package/src/Slider.recipe.ts +102 -0
- package/src/Slider.tsx +163 -0
- package/src/Spinner.tsx +74 -0
- package/src/Svg.tsx +23 -0
- package/src/Switch.recipe.ts +107 -0
- package/src/Switch.tsx +62 -0
- package/src/Text.recipe.ts +29 -0
- package/src/Text.tsx +16 -0
- package/src/TextField.recipe.ts +54 -0
- package/src/TextField.tsx +91 -0
- package/src/Toast.recipe.ts +98 -0
- package/src/Toast.tsx +173 -0
- package/src/Tooltip.recipe.ts +37 -0
- package/src/Tooltip.tsx +76 -0
- package/src/UnmountCallback.tsx +18 -0
- package/src/VisuallyHidden.tsx +14 -0
- package/src/base-preset.ts +371 -0
- package/src/base-tokens.ts +1017 -0
- package/src/button-icon.ts +23 -0
- package/src/data-attrs.ts +16 -0
- package/src/dense-preset.ts +108 -0
- package/src/hooks/useBreakpointValue.ts +63 -0
- package/src/hooks/useClipboard.ts +64 -0
- package/src/hooks/useDisclosure.ts +32 -0
- package/src/hooks/useMediaQuery.ts +28 -0
- package/src/hooks/usePrevious.ts +18 -0
- package/src/index.ts +63 -0
- package/src/messages.ts +17 -0
- package/src/system.ts +45 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"ui.close-action": {
|
|
3
|
+
"defaultMessage": "關閉",
|
|
4
|
+
"description": "Close button text or label"
|
|
5
|
+
},
|
|
6
|
+
"ui.toast-status-error": {
|
|
7
|
+
"defaultMessage": "Error",
|
|
8
|
+
"description": "Announced by screen readers before an error notification"
|
|
9
|
+
},
|
|
10
|
+
"ui.toast-status-info": {
|
|
11
|
+
"defaultMessage": "Information",
|
|
12
|
+
"description": "Announced by screen readers before an informational notification"
|
|
13
|
+
},
|
|
14
|
+
"ui.toast-status-success": {
|
|
15
|
+
"defaultMessage": "Success",
|
|
16
|
+
"description": "Announced by screen readers before a success notification"
|
|
17
|
+
},
|
|
18
|
+
"ui.toast-status-warning": {
|
|
19
|
+
"defaultMessage": "Warning",
|
|
20
|
+
"description": "Announced by screen readers before a warning notification"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@microbit/ui",
|
|
3
|
+
"version": "0.0.0-gray.ramp.90",
|
|
4
|
+
"description": "micro:bit design-system primitives: react-aria-components + Panda CSS with a design language ported from Chakra UI v2. Ships as source; see README for the consumption setup.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./base-preset": "./src/base-preset.ts",
|
|
10
|
+
"./base-tokens": "./src/base-tokens.ts",
|
|
11
|
+
"./dense-preset": "./src/dense-preset.ts",
|
|
12
|
+
"./messages": "./src/messages.ts",
|
|
13
|
+
"./postcss-legacy-safari": "./postcss-legacy-safari.cjs",
|
|
14
|
+
"./reset.css": "./reset.css",
|
|
15
|
+
"./lang/*": "./lang/*"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"lang",
|
|
20
|
+
"postcss-legacy-safari.cjs",
|
|
21
|
+
"reset.css",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE.md"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"scripts": {
|
|
27
|
+
"panda": "panda codegen",
|
|
28
|
+
"typecheck": "npm run panda && tsc --noEmit",
|
|
29
|
+
"pretest": "npm run panda",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"prestorybook": "npm run panda",
|
|
32
|
+
"storybook": "storybook dev -p 6006 --no-open",
|
|
33
|
+
"prebuild-storybook": "npm run panda",
|
|
34
|
+
"build-storybook": "storybook build"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@pandacss/dev": "^1.11.4",
|
|
38
|
+
"react": "^18.3.1",
|
|
39
|
+
"react-aria-components": "^1.19.0",
|
|
40
|
+
"react-icons": "^4.12.0 || ^5.0.0",
|
|
41
|
+
"react-intl": "^6.6.8 || ^7.0.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@pandacss/dev": "^1.11.4",
|
|
45
|
+
"@storybook/react-vite": "^10.5.2",
|
|
46
|
+
"@testing-library/react": "^16.3.2",
|
|
47
|
+
"@testing-library/user-event": "^14.6.1",
|
|
48
|
+
"@types/node": "^26.1.1",
|
|
49
|
+
"@types/react": "^18.3.3",
|
|
50
|
+
"@types/react-dom": "^18.3.0",
|
|
51
|
+
"@vitejs/plugin-react": "^4.5.2",
|
|
52
|
+
"jsdom": "^29.1.1",
|
|
53
|
+
"react": "^18.3.1",
|
|
54
|
+
"react-aria-components": "^1.19.0",
|
|
55
|
+
"react-dom": "^18.3.1",
|
|
56
|
+
"react-icons": "^4.12.0",
|
|
57
|
+
"react-intl": "^6.6.8",
|
|
58
|
+
"storybook": "^10.5.2",
|
|
59
|
+
"typescript": "^5.4.2",
|
|
60
|
+
"vite": "^6.3.5",
|
|
61
|
+
"vitest": "^4.1.10"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"tag": "gray-ramp"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*
|
|
6
|
+
* TEMPORARY compatibility shim for consuming apps that still support Safari
|
|
7
|
+
* below 15. Delete this file (and the app-side wiring — see the README) once
|
|
8
|
+
* every consuming app has raised its support floor past the affected browsers.
|
|
9
|
+
*
|
|
10
|
+
* The bug: Safari 14.x silently drops logical *shorthands* whose value
|
|
11
|
+
* contains var() — `padding-inline: var(--spacing-2)` applies nothing, even
|
|
12
|
+
* though `padding-inline: 10px` (literal) and `padding-inline-start:
|
|
13
|
+
* var(--spacing-2)` (longhand) both work. Panda emits these shorthands for its
|
|
14
|
+
* px/py/mx/my utilities, so the bug removes most token-based spacing.
|
|
15
|
+
*
|
|
16
|
+
* The fix: rewrite the inline/block logical shorthands into their -start/-end
|
|
17
|
+
* longhands. Kept logical (not physical left/right) so RTL still flips.
|
|
18
|
+
*
|
|
19
|
+
* This is one of TWO legacy concerns for the same era of browsers; the other
|
|
20
|
+
* is @layer, which those browsers drop wholesale. Apps also run
|
|
21
|
+
* @csstools/postcss-cascade-layers (de-layering) and pin build.cssTarget — see
|
|
22
|
+
* the "Legacy browser support" section of the README. All of it is expected to
|
|
23
|
+
* be removed together when the floor rises.
|
|
24
|
+
*
|
|
25
|
+
* Exposed as a PostCSS plugin factory:
|
|
26
|
+
* const { expandLogicalShorthands } = require("@microbit/ui/postcss-legacy-safari");
|
|
27
|
+
* module.exports = { plugins: [expandLogicalShorthands(), ...] };
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// Logical shorthands whose value is `<start> <end>` (one value applies to
|
|
31
|
+
// both). NOT included: border-inline / border-block — those are compound
|
|
32
|
+
// (`width style color`) and duplicate the whole value to each side rather than
|
|
33
|
+
// splitting, so they would need different handling. Add them explicitly if a
|
|
34
|
+
// consuming app ever emits them.
|
|
35
|
+
const LOGICAL_SHORTHANDS = {
|
|
36
|
+
"padding-inline": ["padding-inline-start", "padding-inline-end"],
|
|
37
|
+
"padding-block": ["padding-block-start", "padding-block-end"],
|
|
38
|
+
"margin-inline": ["margin-inline-start", "margin-inline-end"],
|
|
39
|
+
"margin-block": ["margin-block-start", "margin-block-end"],
|
|
40
|
+
"inset-inline": ["inset-inline-start", "inset-inline-end"],
|
|
41
|
+
"inset-block": ["inset-block-start", "inset-block-end"],
|
|
42
|
+
"scroll-margin-inline": [
|
|
43
|
+
"scroll-margin-inline-start",
|
|
44
|
+
"scroll-margin-inline-end",
|
|
45
|
+
],
|
|
46
|
+
"scroll-margin-block": [
|
|
47
|
+
"scroll-margin-block-start",
|
|
48
|
+
"scroll-margin-block-end",
|
|
49
|
+
],
|
|
50
|
+
"scroll-padding-inline": [
|
|
51
|
+
"scroll-padding-inline-start",
|
|
52
|
+
"scroll-padding-inline-end",
|
|
53
|
+
],
|
|
54
|
+
"scroll-padding-block": [
|
|
55
|
+
"scroll-padding-block-start",
|
|
56
|
+
"scroll-padding-block-end",
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Split a value on top-level whitespace, ignoring spaces inside parens so
|
|
61
|
+
// var() fallbacks stay intact. One value applies to both sides; two map to
|
|
62
|
+
// start then end (per the CSS shorthand rules).
|
|
63
|
+
const splitTopLevel = (value) => {
|
|
64
|
+
const parts = [];
|
|
65
|
+
let depth = 0;
|
|
66
|
+
let current = "";
|
|
67
|
+
for (const ch of value) {
|
|
68
|
+
if (ch === "(") depth++;
|
|
69
|
+
else if (ch === ")") depth--;
|
|
70
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
71
|
+
if (current.trim()) parts.push(current.trim());
|
|
72
|
+
current = "";
|
|
73
|
+
} else {
|
|
74
|
+
current += ch;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (current.trim()) parts.push(current.trim());
|
|
78
|
+
return parts;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const expandLogicalShorthands = () => ({
|
|
82
|
+
postcssPlugin: "microbit-ui-expand-logical-shorthands",
|
|
83
|
+
Declaration(decl) {
|
|
84
|
+
const longhands = LOGICAL_SHORTHANDS[decl.prop.toLowerCase()];
|
|
85
|
+
if (!longhands) return;
|
|
86
|
+
const parts = splitTopLevel(decl.value);
|
|
87
|
+
if (parts.length === 0) return;
|
|
88
|
+
const [start, end = start] = parts;
|
|
89
|
+
decl.cloneBefore({ prop: longhands[0], value: start });
|
|
90
|
+
decl.cloneBefore({ prop: longhands[1], value: end });
|
|
91
|
+
decl.remove();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
expandLogicalShorthands.postcss = true;
|
|
95
|
+
|
|
96
|
+
module.exports = { expandLogicalShorthands };
|
package/reset.css
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (c) 2026, Micro:bit Educational Foundation and contributors
|
|
3
|
+
*
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*
|
|
6
|
+
* Chakra-reset parity that must live in the `reset` cascade layer: import
|
|
7
|
+
* from the app's layers.css with
|
|
8
|
+
*
|
|
9
|
+
* @import "@microbit/ui/reset.css" layer(reset);
|
|
10
|
+
*
|
|
11
|
+
* Deliberately not in the preset's globalCss: globalCss emits into the
|
|
12
|
+
* `base` layer, and the legacy-Safari cascade-layer flattening
|
|
13
|
+
* specificity-boosts base+ rules above CSS it can't see (runtime-injected
|
|
14
|
+
* styles such as CodeMirror themes, and other per-file-processed app CSS).
|
|
15
|
+
* A reset must stay at the bottom of the cascade in production exactly as
|
|
16
|
+
* it is in dev — see playbook gotcha #28.
|
|
17
|
+
*/
|
|
18
|
+
*,
|
|
19
|
+
*::before,
|
|
20
|
+
*::after {
|
|
21
|
+
border-color: var(--colors-gray-200);
|
|
22
|
+
word-wrap: break-word;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Panda's preflight (base layer) beats the rule above with
|
|
27
|
+
* `border-color: var(--global-color-border, currentcolor)`. The currentcolor
|
|
28
|
+
* fallback poisons `border-color: inherit` chains (inherit copies the
|
|
29
|
+
* currentcolor KEYWORD, which then resolves against the child's own `color`
|
|
30
|
+
* — e.g. white on the checkbox/radio control). Defining the hook keeps the
|
|
31
|
+
* default border colour a real colour, as Chakra's global reset did.
|
|
32
|
+
*/
|
|
33
|
+
:root {
|
|
34
|
+
--global-color-border: var(--colors-gray-200);
|
|
35
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
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 — Chakra's avatar: a circle showing an image, the
|
|
10
|
+
* initials of a name, or a generic person glyph, optionally with a badge
|
|
11
|
+
* pinned to one corner.
|
|
12
|
+
*
|
|
13
|
+
* The background and text colour come from `var(--avatar-bg)` and
|
|
14
|
+
* `var(--avatar-color)` rather than being flat values, because the component
|
|
15
|
+
* derives them from the name (see Avatar.tsx) and writes them as inline custom
|
|
16
|
+
* properties — exactly as Chakra did. Two reasons, both about letting a call
|
|
17
|
+
* site win with a plain `css={{ bg: …, color: … }}`: an inline *property*
|
|
18
|
+
* would beat any class, where an inline *variable* only feeds this
|
|
19
|
+
* declaration; and both must stay single-class selectors, since a state
|
|
20
|
+
* selector like `&[data-light-bg]` outranks the call site's utility class on
|
|
21
|
+
* specificity wherever cascade layers aren't in play — which is every app
|
|
22
|
+
* still coexisting with Chakra (playbook gotcha #40).
|
|
23
|
+
*
|
|
24
|
+
* Sizes are Chakra's, with its `calc(size / 2.5)` font size resolved per size
|
|
25
|
+
* so an app preset can restate either independently (classroom's avatars are
|
|
26
|
+
* a grade larger than Chakra's).
|
|
27
|
+
*
|
|
28
|
+
* Registered in the base preset (base-preset.ts), which also has the
|
|
29
|
+
* `staticCss` entry that keeps the runtime-prop variants generated.
|
|
30
|
+
*/
|
|
31
|
+
export const avatar = defineSlotRecipe({
|
|
32
|
+
className: "avatar",
|
|
33
|
+
slots: ["root", "label", "image", "badge"],
|
|
34
|
+
base: {
|
|
35
|
+
root: {
|
|
36
|
+
display: "inline-flex",
|
|
37
|
+
alignItems: "center",
|
|
38
|
+
justifyContent: "center",
|
|
39
|
+
flexShrink: 0,
|
|
40
|
+
position: "relative",
|
|
41
|
+
verticalAlign: "top",
|
|
42
|
+
textAlign: "center",
|
|
43
|
+
textTransform: "uppercase",
|
|
44
|
+
fontWeight: "medium",
|
|
45
|
+
borderRadius: "full",
|
|
46
|
+
// Chakra's no-name defaults; the name-derived pair arrives inline.
|
|
47
|
+
background: "var(--avatar-bg, token(colors.gray.400))",
|
|
48
|
+
color: "var(--avatar-color, token(colors.white))",
|
|
49
|
+
borderColor: "white",
|
|
50
|
+
},
|
|
51
|
+
label: {
|
|
52
|
+
lineHeight: "1",
|
|
53
|
+
},
|
|
54
|
+
image: {
|
|
55
|
+
width: "100%",
|
|
56
|
+
height: "100%",
|
|
57
|
+
objectFit: "cover",
|
|
58
|
+
borderRadius: "inherit",
|
|
59
|
+
},
|
|
60
|
+
badge: {
|
|
61
|
+
position: "absolute",
|
|
62
|
+
display: "flex",
|
|
63
|
+
alignItems: "center",
|
|
64
|
+
justifyContent: "center",
|
|
65
|
+
borderRadius: "full",
|
|
66
|
+
// em-relative, so a badge keeps its proportions at every avatar size.
|
|
67
|
+
borderWidth: "0.2em",
|
|
68
|
+
borderStyle: "solid",
|
|
69
|
+
borderColor: "white",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
variants: {
|
|
73
|
+
// Chakra's scale: the container size, and Chakra's `calc(size / 2.5)`
|
|
74
|
+
// font size kept as a calc over the same token so both track a preset
|
|
75
|
+
// that rescales `sizes` (the dense preset does, by 0.88).
|
|
76
|
+
//
|
|
77
|
+
// The font size lands on the root *and* the label, as Chakra's did
|
|
78
|
+
// (through one variable). They are separate declarations so an app can
|
|
79
|
+
// move one without the other: the root's is the em basis for a badge,
|
|
80
|
+
// the label's is how big the initials are, and the two are not always
|
|
81
|
+
// the same wish.
|
|
82
|
+
size: {
|
|
83
|
+
"2xs": {
|
|
84
|
+
root: {
|
|
85
|
+
width: "4",
|
|
86
|
+
height: "4",
|
|
87
|
+
fontSize: "calc(token(sizes.4) / 2.5)",
|
|
88
|
+
},
|
|
89
|
+
label: { fontSize: "calc(token(sizes.4) / 2.5)" },
|
|
90
|
+
},
|
|
91
|
+
xs: {
|
|
92
|
+
root: {
|
|
93
|
+
width: "6",
|
|
94
|
+
height: "6",
|
|
95
|
+
fontSize: "calc(token(sizes.6) / 2.5)",
|
|
96
|
+
},
|
|
97
|
+
label: { fontSize: "calc(token(sizes.6) / 2.5)" },
|
|
98
|
+
},
|
|
99
|
+
sm: {
|
|
100
|
+
root: {
|
|
101
|
+
width: "8",
|
|
102
|
+
height: "8",
|
|
103
|
+
fontSize: "calc(token(sizes.8) / 2.5)",
|
|
104
|
+
},
|
|
105
|
+
label: { fontSize: "calc(token(sizes.8) / 2.5)" },
|
|
106
|
+
},
|
|
107
|
+
md: {
|
|
108
|
+
root: {
|
|
109
|
+
width: "12",
|
|
110
|
+
height: "12",
|
|
111
|
+
fontSize: "calc(token(sizes.12) / 2.5)",
|
|
112
|
+
},
|
|
113
|
+
label: { fontSize: "calc(token(sizes.12) / 2.5)" },
|
|
114
|
+
},
|
|
115
|
+
lg: {
|
|
116
|
+
root: {
|
|
117
|
+
width: "16",
|
|
118
|
+
height: "16",
|
|
119
|
+
fontSize: "calc(token(sizes.16) / 2.5)",
|
|
120
|
+
},
|
|
121
|
+
label: { fontSize: "calc(token(sizes.16) / 2.5)" },
|
|
122
|
+
},
|
|
123
|
+
xl: {
|
|
124
|
+
root: {
|
|
125
|
+
width: "24",
|
|
126
|
+
height: "24",
|
|
127
|
+
fontSize: "calc(token(sizes.24) / 2.5)",
|
|
128
|
+
},
|
|
129
|
+
label: { fontSize: "calc(token(sizes.24) / 2.5)" },
|
|
130
|
+
},
|
|
131
|
+
"2xl": {
|
|
132
|
+
root: {
|
|
133
|
+
width: "32",
|
|
134
|
+
height: "32",
|
|
135
|
+
fontSize: "calc(token(sizes.32) / 2.5)",
|
|
136
|
+
},
|
|
137
|
+
label: { fontSize: "calc(token(sizes.32) / 2.5)" },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
/** Which corner the badge sits in. Chakra's placements, same offsets. */
|
|
141
|
+
placement: {
|
|
142
|
+
"top-start": {
|
|
143
|
+
badge: {
|
|
144
|
+
top: "0",
|
|
145
|
+
insetStart: "0",
|
|
146
|
+
transform: "translate(-25%, -25%)",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
"top-end": {
|
|
150
|
+
badge: { top: "0", insetEnd: "0", transform: "translate(25%, -25%)" },
|
|
151
|
+
},
|
|
152
|
+
"bottom-start": {
|
|
153
|
+
badge: {
|
|
154
|
+
bottom: "0",
|
|
155
|
+
insetStart: "0",
|
|
156
|
+
transform: "translate(-25%, 25%)",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
"bottom-end": {
|
|
160
|
+
badge: { bottom: "0", insetEnd: "0", transform: "translate(25%, 25%)" },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
defaultVariants: {
|
|
165
|
+
size: "md",
|
|
166
|
+
placement: "bottom-end",
|
|
167
|
+
},
|
|
168
|
+
});
|
package/src/Avatar.tsx
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
* Chakra's `randomColor({ string })`, reproduced exactly: a djb2-style hash of
|
|
24
|
+
* the name, its low three bytes read as a colour. Not random despite the name
|
|
25
|
+
* — the same name is always the same colour, which is the point, and
|
|
26
|
+
* reproducing the hash means avatars keep the colours they had under Chakra.
|
|
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
|
+
* Chakra's contrast rule for the generated background: perceived brightness
|
|
44
|
+
* (the 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
|
+
* Chakra's `initials`: first letter of the first and last words. Prefixed
|
|
56
|
+
* because it is exported from the package root, where a bare `initials`
|
|
57
|
+
* would be a broad name to 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
|
+
* Chakra's generic person glyph, the fallback when there is no name. Chakra
|
|
70
|
+
* hardcoded it white; here it inherits `currentColor`, which is the same white
|
|
71
|
+
* on the no-name grey background and stays visible if a call site recolours.
|
|
72
|
+
*/
|
|
73
|
+
export const GenericAvatarIcon = (props: SVGProps<SVGSVGElement>) => (
|
|
74
|
+
<svg viewBox="0 0 128 128" width="100%" height="100%" {...props}>
|
|
75
|
+
<path
|
|
76
|
+
fill="currentColor"
|
|
77
|
+
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"
|
|
78
|
+
/>
|
|
79
|
+
<path
|
|
80
|
+
fill="currentColor"
|
|
81
|
+
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"
|
|
82
|
+
/>
|
|
83
|
+
</svg>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
type ImageStatus = "pending" | "loading" | "loaded" | "failed";
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Chakra's `useImage`: load the photo out of band and report how it went, so
|
|
90
|
+
* the avatar can show the initials or the icon meanwhile and keep showing
|
|
91
|
+
* them if it never arrives.
|
|
92
|
+
*
|
|
93
|
+
* The <img> element is only mounted once this says "loaded", which is what
|
|
94
|
+
* keeps a broken URL from leaving the browser's broken-image glyph inside the
|
|
95
|
+
* circle — the failure mode a fallback exists to prevent.
|
|
96
|
+
*/
|
|
97
|
+
const useImageStatus = (src?: string, srcSet?: string): ImageStatus => {
|
|
98
|
+
const [status, setStatus] = useState<ImageStatus>(
|
|
99
|
+
src ? "loading" : "pending",
|
|
100
|
+
);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!src) {
|
|
103
|
+
setStatus("pending");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// A new src starts again: without this the avatar would keep showing the
|
|
107
|
+
// previous person's photo, or stay stuck on a fallback it has outgrown.
|
|
108
|
+
setStatus("loading");
|
|
109
|
+
const img = new Image();
|
|
110
|
+
let current = true;
|
|
111
|
+
img.onload = () => {
|
|
112
|
+
if (current) {
|
|
113
|
+
setStatus("loaded");
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
img.onerror = () => {
|
|
117
|
+
if (current) {
|
|
118
|
+
setStatus("failed");
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
// srcSet before src, so the browser has the candidates to choose from
|
|
122
|
+
// when the load starts.
|
|
123
|
+
if (srcSet) {
|
|
124
|
+
img.srcset = srcSet;
|
|
125
|
+
}
|
|
126
|
+
img.src = src;
|
|
127
|
+
return () => {
|
|
128
|
+
current = false;
|
|
129
|
+
img.onload = null;
|
|
130
|
+
img.onerror = null;
|
|
131
|
+
};
|
|
132
|
+
}, [src, srcSet]);
|
|
133
|
+
return status;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export interface AvatarProps
|
|
137
|
+
extends Omit<HTMLAttributes<HTMLSpanElement>, "color" | "children">,
|
|
138
|
+
Pick<AvatarVariantProps, "size"> {
|
|
139
|
+
/**
|
|
140
|
+
* The person. Shown as initials, and hashed into the background colour, so
|
|
141
|
+
* two people are unlikely to share one.
|
|
142
|
+
*/
|
|
143
|
+
name?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Photo. The initials (or the icon) show until it has loaded, and go on
|
|
146
|
+
* showing if it fails — the avatar never renders a broken image.
|
|
147
|
+
*/
|
|
148
|
+
src?: string;
|
|
149
|
+
srcSet?: string;
|
|
150
|
+
/** Shown when there is no name. Defaults to Chakra's person glyph. */
|
|
151
|
+
icon?: ReactNode;
|
|
152
|
+
/** Accessible name for the icon fallback. Chakra's default was " avatar". */
|
|
153
|
+
iconLabel?: string;
|
|
154
|
+
/** Override how a name becomes initials. */
|
|
155
|
+
getInitials?: (name: string) => string;
|
|
156
|
+
/** Chakra's `showBorder`: a 2px ring in the avatar's border colour. */
|
|
157
|
+
showBorder?: boolean;
|
|
158
|
+
/** An `AvatarBadge`. */
|
|
159
|
+
children?: ReactNode;
|
|
160
|
+
/** Per-instance style overrides, merged after the recipe. */
|
|
161
|
+
css?: SystemStyleObject;
|
|
162
|
+
className?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Avatar — Chakra's <Avatar>: a circular identity marker showing a photo, the
|
|
167
|
+
* initials of a name, or a generic glyph, in a colour derived from the name.
|
|
168
|
+
*
|
|
169
|
+
* Decorative in most designs — pass `aria-hidden` where the name is already
|
|
170
|
+
* beside it, as Chakra's call sites did.
|
|
171
|
+
*/
|
|
172
|
+
export const Avatar = ({
|
|
173
|
+
name,
|
|
174
|
+
src,
|
|
175
|
+
srcSet,
|
|
176
|
+
icon,
|
|
177
|
+
iconLabel = " avatar",
|
|
178
|
+
getInitials = avatarInitials,
|
|
179
|
+
showBorder,
|
|
180
|
+
size,
|
|
181
|
+
children,
|
|
182
|
+
css: cssProp,
|
|
183
|
+
className,
|
|
184
|
+
style,
|
|
185
|
+
...rest
|
|
186
|
+
}: AvatarProps) => {
|
|
187
|
+
const status = useImageStatus(src, srcSet);
|
|
188
|
+
const isLoaded = status === "loaded";
|
|
189
|
+
const slots = avatar({ size });
|
|
190
|
+
// Only while the image isn't showing, matching Chakra's `:not([data-loaded])`.
|
|
191
|
+
const bg = name && !isLoaded ? colorFromName(name) : undefined;
|
|
192
|
+
return (
|
|
193
|
+
<span
|
|
194
|
+
{...rest}
|
|
195
|
+
data-loaded={isLoaded || undefined}
|
|
196
|
+
className={cx(
|
|
197
|
+
slots.root,
|
|
198
|
+
showBorder ? css({ borderWidth: "2px" }) : undefined,
|
|
199
|
+
cssProp ? css(cssProp) : undefined,
|
|
200
|
+
className,
|
|
201
|
+
)}
|
|
202
|
+
style={
|
|
203
|
+
bg
|
|
204
|
+
? ({
|
|
205
|
+
...style,
|
|
206
|
+
"--avatar-bg": bg,
|
|
207
|
+
// Chakra's contrast rule, as a variable rather than a state
|
|
208
|
+
// selector so a call site's `css` colour still wins (see the
|
|
209
|
+
// recipe).
|
|
210
|
+
"--avatar-color": isLight(bg)
|
|
211
|
+
? token("colors.gray.800")
|
|
212
|
+
: token("colors.white"),
|
|
213
|
+
} as CSSProperties)
|
|
214
|
+
: style
|
|
215
|
+
}
|
|
216
|
+
>
|
|
217
|
+
{isLoaded ? (
|
|
218
|
+
<img
|
|
219
|
+
src={src}
|
|
220
|
+
srcSet={srcSet}
|
|
221
|
+
alt={name ?? iconLabel}
|
|
222
|
+
className={slots.image}
|
|
223
|
+
/>
|
|
224
|
+
) : name ? (
|
|
225
|
+
<span role="img" aria-label={name} className={slots.label}>
|
|
226
|
+
{getInitials(name)}
|
|
227
|
+
</span>
|
|
228
|
+
) : // The icon is labelled in place rather than wrapped, as Chakra did:
|
|
229
|
+
// a wrapper would make it an inline child with a line box of its own,
|
|
230
|
+
// where directly in the flex container it is a flex item and centres
|
|
231
|
+
// exactly.
|
|
232
|
+
isValidElement(icon) ? (
|
|
233
|
+
cloneElement(icon as ReactElement<Record<string, unknown>>, {
|
|
234
|
+
role: "img",
|
|
235
|
+
"aria-label": iconLabel,
|
|
236
|
+
})
|
|
237
|
+
) : (
|
|
238
|
+
icon ?? <GenericAvatarIcon role="img" aria-label={iconLabel} />
|
|
239
|
+
)}
|
|
240
|
+
{children}
|
|
241
|
+
</span>
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export interface AvatarBadgeProps
|
|
246
|
+
extends Omit<HTMLAttributes<HTMLDivElement>, "color">,
|
|
247
|
+
Pick<AvatarVariantProps, "placement"> {
|
|
248
|
+
children?: ReactNode;
|
|
249
|
+
/**
|
|
250
|
+
* Per-instance style overrides. The badge has no size of its own — Chakra's
|
|
251
|
+
* didn't either, so call sites set one (`boxSize: "1.5em"` scales with the
|
|
252
|
+
* avatar).
|
|
253
|
+
*/
|
|
254
|
+
css?: SystemStyleObject;
|
|
255
|
+
className?: string;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** AvatarBadge — a status dot pinned to a corner of its `Avatar`. */
|
|
259
|
+
export const AvatarBadge = ({
|
|
260
|
+
placement,
|
|
261
|
+
children,
|
|
262
|
+
css: cssProp,
|
|
263
|
+
className,
|
|
264
|
+
...rest
|
|
265
|
+
}: AvatarBadgeProps) => (
|
|
266
|
+
<div
|
|
267
|
+
{...rest}
|
|
268
|
+
className={cx(
|
|
269
|
+
avatar({ placement }).badge,
|
|
270
|
+
cssProp ? css(cssProp) : undefined,
|
|
271
|
+
className,
|
|
272
|
+
)}
|
|
273
|
+
>
|
|
274
|
+
{children}
|
|
275
|
+
</div>
|
|
276
|
+
);
|