gbs-add-block 1.2.19 → 2.0.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.
- package/README.md +4 -4
- package/index.cjs +25 -9
- package/package.json +1 -1
- package/source/beta-components/accordion/README.md +45 -0
- package/source/beta-components/accordion/__tests__/core.test.ts +58 -0
- package/source/beta-components/accordion/core/index.ts +2 -0
- package/source/beta-components/accordion/core/open.ts +56 -0
- package/source/beta-components/accordion/core/types.ts +17 -0
- package/source/beta-components/accordion/index.ts +8 -0
- package/source/beta-components/accordion/react/Accordion.tsx +133 -0
- package/source/beta-components/accordion/react/AccordionItem.tsx +120 -0
- package/source/beta-components/accordion/react/context.ts +20 -0
- package/source/beta-components/accordion/react/props.ts +13 -0
- package/source/beta-components/accordion/styles.css +208 -0
- package/source/beta-components/alert/README.md +39 -0
- package/source/beta-components/alert/__tests__/core.test.ts +17 -0
- package/source/beta-components/alert/core/index.ts +2 -0
- package/source/beta-components/alert/core/live.ts +25 -0
- package/source/beta-components/alert/core/types.ts +7 -0
- package/source/beta-components/alert/index.ts +5 -0
- package/source/beta-components/alert/react/Alert.tsx +113 -0
- package/source/beta-components/alert/react/icons.tsx +21 -0
- package/source/beta-components/alert/react/locale.ts +5 -0
- package/source/beta-components/alert/react/props.ts +11 -0
- package/source/beta-components/alert/styles.css +155 -0
- package/source/beta-components/avatar/__tests__/core.test.ts +88 -0
- package/source/beta-components/avatar/core/identity.ts +103 -0
- package/source/beta-components/avatar/core/index.ts +2 -0
- package/source/beta-components/avatar/core/types.ts +12 -0
- package/source/beta-components/avatar/index.ts +7 -0
- package/source/beta-components/avatar/react/Avatar.tsx +118 -0
- package/source/beta-components/avatar/react/AvatarGroup.tsx +74 -0
- package/source/beta-components/avatar/react/locale.ts +11 -0
- package/source/beta-components/avatar/react/props.ts +6 -0
- package/source/beta-components/avatar/styles.css +227 -0
- package/source/beta-components/badge/README.md +37 -0
- package/source/beta-components/badge/__tests__/core.test.ts +46 -0
- package/source/beta-components/badge/core/count.ts +37 -0
- package/source/beta-components/badge/core/index.ts +2 -0
- package/source/beta-components/badge/core/types.ts +11 -0
- package/source/beta-components/badge/index.ts +7 -0
- package/source/beta-components/badge/react/Badge.tsx +89 -0
- package/source/beta-components/badge/react/Tag.tsx +82 -0
- package/source/beta-components/badge/react/locale.ts +5 -0
- package/source/beta-components/badge/react/props.ts +6 -0
- package/source/beta-components/badge/styles.css +204 -0
- package/source/beta-components/progress/README.md +38 -0
- package/source/beta-components/progress/__tests__/core.test.ts +56 -0
- package/source/beta-components/progress/core/index.ts +2 -0
- package/source/beta-components/progress/core/progress.ts +40 -0
- package/source/beta-components/progress/core/types.ts +23 -0
- package/source/beta-components/progress/index.ts +7 -0
- package/source/beta-components/progress/react/CircularProgress.tsx +106 -0
- package/source/beta-components/progress/react/Progress.tsx +113 -0
- package/source/beta-components/progress/react/locale.ts +7 -0
- package/source/beta-components/progress/react/props.ts +6 -0
- package/source/beta-components/progress/styles.css +202 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { colorIndex, initials, splitGroup } from "../core/identity";
|
|
3
|
+
|
|
4
|
+
describe("initials", () => {
|
|
5
|
+
it("takes the first and last part of a name", () => {
|
|
6
|
+
expect(initials("Ada Lovelace")).toBe("AL");
|
|
7
|
+
expect(initials("Ada Byron King Lovelace")).toBe("AL");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("takes one letter from a single name, not two", () => {
|
|
11
|
+
// "MA" out of "Madonna" reads as a mistake.
|
|
12
|
+
expect(initials("Madonna")).toBe("M");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("splits on the punctuation that joins parts of a name", () => {
|
|
16
|
+
expect(initials("jean-luc picard")).toBe("JP");
|
|
17
|
+
expect(initials("ada.lovelace")).toBe("AL");
|
|
18
|
+
expect(initials("ada_lovelace")).toBe("AL");
|
|
19
|
+
expect(initials("Ada Lovelace")).toBe("AL");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("reads an email as the part before the at sign", () => {
|
|
23
|
+
// Nothing in "example.com" is this person's name.
|
|
24
|
+
expect(initials("ada@example.com")).toBe("A");
|
|
25
|
+
expect(initials("ada.lovelace@example.com")).toBe("AL");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("keeps a whole character, not half a surrogate pair", () => {
|
|
29
|
+
expect(initials("\u{1F680} Launch")).toBe("\u{1F680}L");
|
|
30
|
+
expect(Array.from(initials("\u{1F680}")).length).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("leaves scripts without letter case alone", () => {
|
|
34
|
+
// Uppercasing means nothing here, and can change the character.
|
|
35
|
+
expect(initials("山田 太郎")).toBe("山太");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not turn one letter into two", () => {
|
|
39
|
+
// Uppercase ß is SS, which would be two letters in a one-letter slot.
|
|
40
|
+
expect(initials("ßrian")).toBe("ß");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("has nothing for nothing", () => {
|
|
44
|
+
expect(initials("")).toBe("");
|
|
45
|
+
expect(initials(" ")).toBe("");
|
|
46
|
+
expect(initials("@example.com")).toBe("");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("colorIndex", () => {
|
|
51
|
+
it("gives the same person the same colour every time", () => {
|
|
52
|
+
expect(colorIndex("Ada Lovelace", 8)).toBe(colorIndex("Ada Lovelace", 8));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("stays inside the palette", () => {
|
|
56
|
+
for (const name of ["", "a", "Ada Lovelace", "\u{1F680}", "x".repeat(500)]) {
|
|
57
|
+
const index = colorIndex(name, 8);
|
|
58
|
+
expect(index).toBeGreaterThanOrEqual(0);
|
|
59
|
+
expect(index).toBeLessThan(8);
|
|
60
|
+
}
|
|
61
|
+
expect(colorIndex("Ada", 0)).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("spreads names across the palette rather than piling them up", () => {
|
|
65
|
+
const names = ["Ada", "Grace", "Alan", "Katherine", "Edsger", "Barbara", "Tim", "Linus"];
|
|
66
|
+
const used = new Set(names.map((name) => colorIndex(name, 8)));
|
|
67
|
+
expect(used.size).toBeGreaterThanOrEqual(5);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("splitGroup", () => {
|
|
72
|
+
const people = ["a", "b", "c", "d", "e"];
|
|
73
|
+
|
|
74
|
+
it("shows everyone when they fit", () => {
|
|
75
|
+
expect(splitGroup(people, 5)).toEqual({ shown: people, overflow: 0 });
|
|
76
|
+
expect(splitGroup(people, 9).overflow).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("counts the overflow bubble as one of the slots", () => {
|
|
80
|
+
// Four slots: three faces and a "+2", so the group stays four wide.
|
|
81
|
+
expect(splitGroup(people, 4)).toEqual({ shown: ["a", "b", "c"], overflow: 2 });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("handles a group with no room for faces", () => {
|
|
85
|
+
expect(splitGroup(people, 1)).toEqual({ shown: [], overflow: 5 });
|
|
86
|
+
expect(splitGroup(people, 0)).toEqual({ shown: [], overflow: 5 });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a name into the two things an avatar needs when there is no picture:
|
|
3
|
+
* a couple of letters, and a colour that is always the same for that person.
|
|
4
|
+
*
|
|
5
|
+
* Both are harder than they look, and both are wrong in a way that is hard to
|
|
6
|
+
* notice from an English-speaking desk — which is why they are here, pure and
|
|
7
|
+
* under test, rather than inline in the component.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Names are split on whitespace — `\s` covers the no-break space a copied name
|
|
12
|
+
* often carries — and on the punctuation that joins parts of one.
|
|
13
|
+
*/
|
|
14
|
+
const SEPARATORS = /[\s._\-–—/\\|,]+/u;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Letters from a name: two for a name with parts, one otherwise.
|
|
18
|
+
*
|
|
19
|
+
* `"Ada Lovelace"` gives `AL`, `"ada"` gives `A`, `"ada@example.com"` gives
|
|
20
|
+
* `A` — an email is not a name, so only the part before the `@` is looked at
|
|
21
|
+
* and never the domain.
|
|
22
|
+
*
|
|
23
|
+
* Two deliberate decisions:
|
|
24
|
+
*
|
|
25
|
+
* Characters are taken with `Intl.Segmenter` where it exists, so a name
|
|
26
|
+
* beginning with an emoji, a surrogate pair or a letter carrying a combining
|
|
27
|
+
* mark yields that whole character rather than half of it — `slice(0, 1)` on a
|
|
28
|
+
* string is a byte operation wearing a costume.
|
|
29
|
+
*
|
|
30
|
+
* Scripts without letter case are left alone: uppercasing is meaningless for
|
|
31
|
+
* CJK and can change the character. A Japanese name gives its first character,
|
|
32
|
+
* not a mangled one.
|
|
33
|
+
*/
|
|
34
|
+
export function initials(name: string, max = 2): string {
|
|
35
|
+
const source = name.trim().split("@")[0] ?? "";
|
|
36
|
+
if (source === "") return "";
|
|
37
|
+
|
|
38
|
+
const parts = source.split(SEPARATORS).filter(Boolean);
|
|
39
|
+
if (parts.length === 0) return "";
|
|
40
|
+
|
|
41
|
+
// One part is a single name or a handle: one letter is enough, and taking
|
|
42
|
+
// two from "Madonna" gives "MA", which looks like a mistake.
|
|
43
|
+
const chosen = parts.length === 1 ? [parts[0]] : [parts[0], parts[parts.length - 1]];
|
|
44
|
+
|
|
45
|
+
return chosen
|
|
46
|
+
.slice(0, max)
|
|
47
|
+
.map((part) => firstCharacter(part))
|
|
48
|
+
.join("");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function firstCharacter(part: string): string {
|
|
52
|
+
const character = segmentFirst(part);
|
|
53
|
+
// `toLocaleUpperCase` on a script with no case returns it unchanged, which is
|
|
54
|
+
// the behaviour we want; it is the two-character mappings (ß → SS) that have
|
|
55
|
+
// to be cut back to one.
|
|
56
|
+
const upper = character.toLocaleUpperCase();
|
|
57
|
+
return upper.length > character.length ? character : upper;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let segmenter: Intl.Segmenter | null | undefined;
|
|
61
|
+
|
|
62
|
+
function segmentFirst(text: string): string {
|
|
63
|
+
if (segmenter === undefined) {
|
|
64
|
+
segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter() : null;
|
|
65
|
+
}
|
|
66
|
+
if (segmenter) {
|
|
67
|
+
for (const { segment } of segmenter.segment(text)) return segment;
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
// Without Segmenter, code points at least keep surrogate pairs whole.
|
|
71
|
+
return Array.from(text)[0] ?? "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A stable index into a palette, from any string.
|
|
76
|
+
*
|
|
77
|
+
* The same person keeps the same colour on every page and in every session,
|
|
78
|
+
* without anything being stored, because the hash is a pure function of their
|
|
79
|
+
* name. FNV-1a: small, well spread for short strings, and not a security
|
|
80
|
+
* decision — nothing here is a secret.
|
|
81
|
+
*/
|
|
82
|
+
export function colorIndex(seed: string, count: number): number {
|
|
83
|
+
if (count <= 0) return 0;
|
|
84
|
+
let hash = 0x811c9dc5;
|
|
85
|
+
for (let index = 0; index < seed.length; index++) {
|
|
86
|
+
hash ^= seed.charCodeAt(index);
|
|
87
|
+
hash = Math.imul(hash, 0x01000193);
|
|
88
|
+
}
|
|
89
|
+
return Math.abs(hash) % count;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* How an avatar group splits: the faces shown, and how many are left over.
|
|
94
|
+
*
|
|
95
|
+
* The overflow counter takes one of the slots, so showing "+2" out of five
|
|
96
|
+
* faces with `max: 3` means two faces and a counter, not three and a counter.
|
|
97
|
+
*/
|
|
98
|
+
export function splitGroup<T>(items: readonly T[], max: number): { shown: T[]; overflow: number } {
|
|
99
|
+
if (max <= 0) return { shown: [], overflow: items.length };
|
|
100
|
+
if (items.length <= max) return { shown: [...items], overflow: 0 };
|
|
101
|
+
const shown = items.slice(0, max - 1);
|
|
102
|
+
return { shown, overflow: items.length - shown.length };
|
|
103
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
|
|
2
|
+
|
|
3
|
+
export type AvatarShape = "circle" | "square";
|
|
4
|
+
|
|
5
|
+
/** Who is on the other side of a presence dot. */
|
|
6
|
+
export type AvatarStatus = "online" | "away" | "busy" | "offline";
|
|
7
|
+
|
|
8
|
+
export interface AvatarLocaleText {
|
|
9
|
+
/** The overflow bubble in a group. */
|
|
10
|
+
more(count: number): string;
|
|
11
|
+
status: Record<AvatarStatus, string>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { Avatar } from "./react/Avatar";
|
|
2
|
+
export type { AvatarProps } from "./react/Avatar";
|
|
3
|
+
export { AvatarGroup } from "./react/AvatarGroup";
|
|
4
|
+
export type { AvatarGroupProps } from "./react/AvatarGroup";
|
|
5
|
+
export { defaultAvatarText } from "./react/locale";
|
|
6
|
+
export type { AvatarGroupSlot, AvatarSlot } from "./react/props";
|
|
7
|
+
export * from "./core";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type HTMLAttributes,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
type Ref,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { colorIndex, initials as toInitials } from "../core/identity";
|
|
12
|
+
import type { AvatarLocaleText, AvatarShape, AvatarSize, AvatarStatus } from "../core/types";
|
|
13
|
+
import { defaultAvatarText } from "./locale";
|
|
14
|
+
import { cx, type AvatarSlot } from "./props";
|
|
15
|
+
|
|
16
|
+
/** Hues spread around the wheel; the fill and text are mixed from one of these. */
|
|
17
|
+
const PALETTE = 8;
|
|
18
|
+
|
|
19
|
+
export interface AvatarProps extends Omit<HTMLAttributes<HTMLSpanElement>, "children"> {
|
|
20
|
+
/**
|
|
21
|
+
* The person or thing. Used for the alternative text, for the initials and
|
|
22
|
+
* for the colour, so the same person is always the same colour.
|
|
23
|
+
*/
|
|
24
|
+
name?: string;
|
|
25
|
+
src?: string;
|
|
26
|
+
/** Overrides the text derived from `name`. */
|
|
27
|
+
initials?: string;
|
|
28
|
+
/** An icon or anything else to show instead of initials. */
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
/** Default `md` (32px). */
|
|
31
|
+
size?: AvatarSize;
|
|
32
|
+
/** Default `circle`. */
|
|
33
|
+
shape?: AvatarShape;
|
|
34
|
+
/** A presence dot in the corner. */
|
|
35
|
+
status?: AvatarStatus;
|
|
36
|
+
/** Marks the avatar as decoration, for a name printed next to it. */
|
|
37
|
+
decorative?: boolean;
|
|
38
|
+
className?: string;
|
|
39
|
+
classNames?: Partial<Record<AvatarSlot, string>>;
|
|
40
|
+
style?: CSSProperties;
|
|
41
|
+
localeText?: Partial<AvatarLocaleText>;
|
|
42
|
+
ref?: Ref<HTMLSpanElement>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A person in a small square or circle: their picture, or the letters of their
|
|
47
|
+
* name on a colour derived from it.
|
|
48
|
+
*
|
|
49
|
+
* An image that fails to load falls back to the initials rather than leaving a
|
|
50
|
+
* broken frame — which happens more than it should with avatar URLs that have
|
|
51
|
+
* expired or come from a third party.
|
|
52
|
+
*/
|
|
53
|
+
export function Avatar(props: AvatarProps) {
|
|
54
|
+
const {
|
|
55
|
+
ref,
|
|
56
|
+
name = "",
|
|
57
|
+
src,
|
|
58
|
+
initials: initialsProp,
|
|
59
|
+
children,
|
|
60
|
+
size = "md",
|
|
61
|
+
shape = "circle",
|
|
62
|
+
status,
|
|
63
|
+
decorative = false,
|
|
64
|
+
className,
|
|
65
|
+
classNames,
|
|
66
|
+
style,
|
|
67
|
+
localeText,
|
|
68
|
+
...rest
|
|
69
|
+
} = props;
|
|
70
|
+
|
|
71
|
+
const text = useMemo(() => ({ ...defaultAvatarText, ...localeText }), [localeText]);
|
|
72
|
+
const [failed, setFailed] = useState(false);
|
|
73
|
+
const letters = initialsProp ?? toInitials(name);
|
|
74
|
+
const showImage = Boolean(src) && !failed;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<span
|
|
78
|
+
{...rest}
|
|
79
|
+
ref={ref}
|
|
80
|
+
className={cx("av-root", classNames?.root, className)}
|
|
81
|
+
style={{ ...style, "--av-hue-index": colorIndex(name || letters, PALETTE) } as CSSProperties}
|
|
82
|
+
data-size={size}
|
|
83
|
+
data-shape={shape}
|
|
84
|
+
data-status={status}
|
|
85
|
+
// Decoration when the name is printed beside it; otherwise the name is
|
|
86
|
+
// the alternative text, and there is no point repeating it.
|
|
87
|
+
role={decorative ? "presentation" : "img"}
|
|
88
|
+
aria-label={decorative ? undefined : name || undefined}
|
|
89
|
+
aria-hidden={decorative && !name ? "true" : undefined}
|
|
90
|
+
>
|
|
91
|
+
{showImage ? (
|
|
92
|
+
<img
|
|
93
|
+
className={cx("av-image", classNames?.image)}
|
|
94
|
+
src={src}
|
|
95
|
+
alt=""
|
|
96
|
+
loading="lazy"
|
|
97
|
+
decoding="async"
|
|
98
|
+
onError={() => setFailed(true)}
|
|
99
|
+
/>
|
|
100
|
+
) : (
|
|
101
|
+
<span className={cx("av-fallback", classNames?.fallback)} aria-hidden="true">
|
|
102
|
+
{children ?? letters}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
|
|
106
|
+
{status && (
|
|
107
|
+
<span
|
|
108
|
+
className={cx("av-status", classNames?.status)}
|
|
109
|
+
data-status={status}
|
|
110
|
+
// The dot is a fact about the person, so it is named rather than
|
|
111
|
+
// hidden — colour on its own tells a colour-blind user nothing.
|
|
112
|
+
role="img"
|
|
113
|
+
aria-label={text.status[status]}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
</span>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Children, useMemo, type CSSProperties, type ReactNode, type Ref } from "react";
|
|
4
|
+
import { splitGroup } from "../core/identity";
|
|
5
|
+
import type { AvatarLocaleText, AvatarShape, AvatarSize } from "../core/types";
|
|
6
|
+
import { defaultAvatarText } from "./locale";
|
|
7
|
+
import { cx, type AvatarGroupSlot } from "./props";
|
|
8
|
+
|
|
9
|
+
export interface AvatarGroupProps {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** How many slots there are, the overflow bubble included. Default 4. */
|
|
12
|
+
max?: number;
|
|
13
|
+
/** Applied to the group; each Avatar keeps its own if it sets one. */
|
|
14
|
+
size?: AvatarSize;
|
|
15
|
+
shape?: AvatarShape;
|
|
16
|
+
/** Names the set, e.g. "Assigned to". A list of faces with no name says nothing. */
|
|
17
|
+
label?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
classNames?: Partial<Record<AvatarGroupSlot, string>>;
|
|
20
|
+
style?: CSSProperties;
|
|
21
|
+
localeText?: Partial<AvatarLocaleText>;
|
|
22
|
+
ref?: Ref<HTMLDivElement>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Overlapping avatars with a "+3" at the end.
|
|
27
|
+
*
|
|
28
|
+
* The overflow bubble takes one of the `max` slots, so five people with
|
|
29
|
+
* `max={4}` show three faces and a "+2" — the alternative, four faces and a
|
|
30
|
+
* "+2", is five slots wide and quietly breaks a layout built for four.
|
|
31
|
+
*/
|
|
32
|
+
export function AvatarGroup(props: AvatarGroupProps) {
|
|
33
|
+
const {
|
|
34
|
+
ref,
|
|
35
|
+
children,
|
|
36
|
+
max = 4,
|
|
37
|
+
size,
|
|
38
|
+
shape,
|
|
39
|
+
label,
|
|
40
|
+
className,
|
|
41
|
+
classNames,
|
|
42
|
+
style,
|
|
43
|
+
localeText,
|
|
44
|
+
} = props;
|
|
45
|
+
|
|
46
|
+
const text = useMemo(() => ({ ...defaultAvatarText, ...localeText }), [localeText]);
|
|
47
|
+
const items = Children.toArray(children);
|
|
48
|
+
const { shown, overflow } = splitGroup(items, max);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
ref={ref}
|
|
53
|
+
className={cx("av-group", classNames?.root, className)}
|
|
54
|
+
style={style}
|
|
55
|
+
data-size={size}
|
|
56
|
+
data-shape={shape}
|
|
57
|
+
role="group"
|
|
58
|
+
aria-label={label}
|
|
59
|
+
>
|
|
60
|
+
{shown}
|
|
61
|
+
{overflow > 0 && (
|
|
62
|
+
<span
|
|
63
|
+
className={cx("av-overflow", classNames?.overflow)}
|
|
64
|
+
data-size={size}
|
|
65
|
+
data-shape={shape}
|
|
66
|
+
role="img"
|
|
67
|
+
aria-label={text.more(overflow)}
|
|
68
|
+
>
|
|
69
|
+
+{overflow}
|
|
70
|
+
</span>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Avatar and AvatarGroup styles.
|
|
3
|
+
*
|
|
4
|
+
* The fallback colour comes from --av-hue-index, which the component sets from
|
|
5
|
+
* a hash of the name: the same person is the same colour everywhere, and the
|
|
6
|
+
* palette is generated rather than listed, so it follows a themed hue. Tokens
|
|
7
|
+
* read the shared --gbs-* variables when they are set on an ancestor (set them
|
|
8
|
+
* on :root to theme every component at once). Rules live in the `components`
|
|
9
|
+
* layer, so utility classes passed through `className` / `classNames` override
|
|
10
|
+
* them.
|
|
11
|
+
*/
|
|
12
|
+
@layer theme, base, components, utilities;
|
|
13
|
+
|
|
14
|
+
@layer components {
|
|
15
|
+
.av-root,
|
|
16
|
+
.av-group,
|
|
17
|
+
.av-overflow {
|
|
18
|
+
--av-fg: var(--gbs-fg, var(--dg-fg, light-dark(#18181b, #f4f4f5)));
|
|
19
|
+
--av-muted: var(--gbs-muted, var(--dg-muted, light-dark(#71717a, #a1a1aa)));
|
|
20
|
+
--av-bg: var(--gbs-bg, var(--dg-bg, light-dark(#ffffff, #0b0b0e)));
|
|
21
|
+
--av-subtle: var(--gbs-subtle, light-dark(#f4f4f5, #1c1c20));
|
|
22
|
+
--av-border: var(--gbs-border, var(--dg-border, light-dark(#e4e4e7, #27272a)));
|
|
23
|
+
--av-success: var(--gbs-success, light-dark(#15803d, #4ade80));
|
|
24
|
+
--av-warning: var(--gbs-warning, light-dark(#b45309, #fbbf24));
|
|
25
|
+
--av-danger: var(--gbs-danger, var(--dg-danger, light-dark(#dc2626, #f87171)));
|
|
26
|
+
--av-radius: var(--gbs-radius, var(--dg-radius, 8px));
|
|
27
|
+
--av-size: 32px;
|
|
28
|
+
--av-font-size: 13px;
|
|
29
|
+
--av-hue-index: 0;
|
|
30
|
+
/* Eight hues, 45° apart, starting at the blue end. */
|
|
31
|
+
--av-hue: calc(210 + var(--av-hue-index) * 45);
|
|
32
|
+
|
|
33
|
+
color-scheme: inherit;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
:where(.dark, [data-theme="dark"]) :is(.av-root, .av-group, .av-overflow) {
|
|
37
|
+
color-scheme: dark;
|
|
38
|
+
}
|
|
39
|
+
:where(.light, [data-theme="light"]) :is(.av-root, .av-group, .av-overflow) {
|
|
40
|
+
color-scheme: light;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
:where(.av-root, .av-group) *,
|
|
44
|
+
:where(.av-root, .av-group) *::before,
|
|
45
|
+
:where(.av-root, .av-group) *::after {
|
|
46
|
+
box-sizing: border-box;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.av-root {
|
|
50
|
+
position: relative;
|
|
51
|
+
display: inline-flex;
|
|
52
|
+
flex: none;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
width: var(--av-size);
|
|
56
|
+
height: var(--av-size);
|
|
57
|
+
border-radius: 50%;
|
|
58
|
+
background: var(--av-subtle);
|
|
59
|
+
font-size: var(--av-font-size);
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
line-height: 1;
|
|
62
|
+
overflow: hidden;
|
|
63
|
+
user-select: none;
|
|
64
|
+
vertical-align: middle;
|
|
65
|
+
}
|
|
66
|
+
.av-root[data-shape="square"] {
|
|
67
|
+
border-radius: var(--av-radius);
|
|
68
|
+
overflow: visible;
|
|
69
|
+
}
|
|
70
|
+
.av-root[data-shape="square"] .av-image,
|
|
71
|
+
.av-root[data-shape="square"] .av-fallback {
|
|
72
|
+
border-radius: var(--av-radius);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.av-root[data-size="xs"] {
|
|
76
|
+
--av-size: 20px;
|
|
77
|
+
--av-font-size: 10px;
|
|
78
|
+
}
|
|
79
|
+
.av-root[data-size="sm"] {
|
|
80
|
+
--av-size: 24px;
|
|
81
|
+
--av-font-size: 11px;
|
|
82
|
+
}
|
|
83
|
+
.av-root[data-size="lg"] {
|
|
84
|
+
--av-size: 40px;
|
|
85
|
+
--av-font-size: 15px;
|
|
86
|
+
}
|
|
87
|
+
.av-root[data-size="xl"] {
|
|
88
|
+
--av-size: 56px;
|
|
89
|
+
--av-font-size: 20px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.av-image {
|
|
93
|
+
width: 100%;
|
|
94
|
+
height: 100%;
|
|
95
|
+
object-fit: cover;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* `oklch` keeps every hue at the same lightness, so no one person's initials
|
|
99
|
+
come out harder to read than another's — which is what a list of hand-picked
|
|
100
|
+
hex pairs always ends up doing. */
|
|
101
|
+
.av-fallback {
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
justify-content: center;
|
|
105
|
+
width: 100%;
|
|
106
|
+
height: 100%;
|
|
107
|
+
background: oklch(from light-dark(oklch(0.93 0.05 0), oklch(0.32 0.06 0)) l c var(--av-hue));
|
|
108
|
+
color: oklch(from light-dark(oklch(0.42 0.13 0), oklch(0.88 0.1 0)) l c var(--av-hue));
|
|
109
|
+
text-transform: uppercase;
|
|
110
|
+
}
|
|
111
|
+
.av-fallback > svg {
|
|
112
|
+
width: 60%;
|
|
113
|
+
height: 60%;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.av-status {
|
|
117
|
+
position: absolute;
|
|
118
|
+
right: 0;
|
|
119
|
+
bottom: 0;
|
|
120
|
+
width: 30%;
|
|
121
|
+
height: 30%;
|
|
122
|
+
min-width: 8px;
|
|
123
|
+
min-height: 8px;
|
|
124
|
+
border: 2px solid var(--av-bg);
|
|
125
|
+
border-radius: 50%;
|
|
126
|
+
background: var(--av-muted);
|
|
127
|
+
}
|
|
128
|
+
.av-root[data-shape="square"] .av-status {
|
|
129
|
+
right: -2px;
|
|
130
|
+
bottom: -2px;
|
|
131
|
+
}
|
|
132
|
+
.av-status[data-status="online"] {
|
|
133
|
+
background: var(--av-success);
|
|
134
|
+
}
|
|
135
|
+
.av-status[data-status="away"] {
|
|
136
|
+
background: var(--av-warning);
|
|
137
|
+
}
|
|
138
|
+
.av-status[data-status="busy"] {
|
|
139
|
+
background: var(--av-danger);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ---------------------------------------------------------------- group */
|
|
143
|
+
|
|
144
|
+
.av-group {
|
|
145
|
+
display: inline-flex;
|
|
146
|
+
align-items: center;
|
|
147
|
+
vertical-align: middle;
|
|
148
|
+
}
|
|
149
|
+
/* Each face overlaps the one before and is ringed in the page colour, so the
|
|
150
|
+
stack reads as separate people rather than one smear. */
|
|
151
|
+
.av-group > * {
|
|
152
|
+
margin-inline-start: calc(var(--av-size) / -4);
|
|
153
|
+
box-shadow: 0 0 0 2px var(--av-bg);
|
|
154
|
+
}
|
|
155
|
+
.av-group > *:first-child {
|
|
156
|
+
margin-inline-start: 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.av-overflow {
|
|
160
|
+
position: relative;
|
|
161
|
+
display: inline-flex;
|
|
162
|
+
flex: none;
|
|
163
|
+
align-items: center;
|
|
164
|
+
justify-content: center;
|
|
165
|
+
width: var(--av-size);
|
|
166
|
+
height: var(--av-size);
|
|
167
|
+
border-radius: 50%;
|
|
168
|
+
background: var(--av-subtle);
|
|
169
|
+
/* Muted grey clears 4.5:1 on the page background but only reaches 4.39:1
|
|
170
|
+
on this one, so the count moves a step towards the text colour. */
|
|
171
|
+
color: color-mix(in oklab, var(--av-muted) 65%, var(--av-fg));
|
|
172
|
+
font-size: calc(var(--av-font-size) - 1px);
|
|
173
|
+
font-weight: 600;
|
|
174
|
+
font-variant-numeric: tabular-nums;
|
|
175
|
+
line-height: 1;
|
|
176
|
+
}
|
|
177
|
+
.av-overflow[data-shape="square"] {
|
|
178
|
+
border-radius: var(--av-radius);
|
|
179
|
+
}
|
|
180
|
+
.av-group[data-size="xs"],
|
|
181
|
+
.av-overflow[data-size="xs"] {
|
|
182
|
+
--av-size: 20px;
|
|
183
|
+
--av-font-size: 10px;
|
|
184
|
+
}
|
|
185
|
+
.av-group[data-size="sm"],
|
|
186
|
+
.av-overflow[data-size="sm"] {
|
|
187
|
+
--av-size: 24px;
|
|
188
|
+
--av-font-size: 11px;
|
|
189
|
+
}
|
|
190
|
+
.av-group[data-size="lg"],
|
|
191
|
+
.av-overflow[data-size="lg"] {
|
|
192
|
+
--av-size: 40px;
|
|
193
|
+
--av-font-size: 15px;
|
|
194
|
+
}
|
|
195
|
+
.av-group[data-size="xl"],
|
|
196
|
+
.av-overflow[data-size="xl"] {
|
|
197
|
+
--av-size: 56px;
|
|
198
|
+
--av-font-size: 20px;
|
|
199
|
+
}
|
|
200
|
+
/* The group's size reaches the avatars inside it, unless one sets its own. */
|
|
201
|
+
.av-group[data-size="xs"] .av-root:not([data-size]) {
|
|
202
|
+
--av-size: 20px;
|
|
203
|
+
--av-font-size: 10px;
|
|
204
|
+
}
|
|
205
|
+
.av-group[data-size="sm"] .av-root:not([data-size]) {
|
|
206
|
+
--av-size: 24px;
|
|
207
|
+
--av-font-size: 11px;
|
|
208
|
+
}
|
|
209
|
+
.av-group[data-size="lg"] .av-root:not([data-size]) {
|
|
210
|
+
--av-size: 40px;
|
|
211
|
+
--av-font-size: 15px;
|
|
212
|
+
}
|
|
213
|
+
.av-group[data-size="xl"] .av-root:not([data-size]) {
|
|
214
|
+
--av-size: 56px;
|
|
215
|
+
--av-font-size: 20px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
@media (forced-colors: active) {
|
|
219
|
+
.av-root,
|
|
220
|
+
.av-overflow {
|
|
221
|
+
border: 1px solid CanvasText;
|
|
222
|
+
}
|
|
223
|
+
.av-status {
|
|
224
|
+
border-color: Canvas;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Badge and Tag
|
|
2
|
+
|
|
3
|
+
Short labels for React 19, styled to match the rest of the library. A **Badge**
|
|
4
|
+
is static text — a status, a category, a count. A **Tag** is a chip the user can
|
|
5
|
+
take off. No runtime dependencies besides React.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { Badge, Tag } from "@/components/badge";
|
|
9
|
+
import "@/components/badge/styles.css";
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
<Badge variant="success">Active</Badge>
|
|
14
|
+
<Badge variant="danger" appearance="solid" count={128} /> {/* 99+ */}
|
|
15
|
+
<Badge variant="warning" dot>Degraded</Badge>
|
|
16
|
+
|
|
17
|
+
<Tag onRemove={() => remove("berlin")}>Berlin</Tag>
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
**Badge:** `children` or `count`, `variant` (`neutral` `accent` `success`
|
|
21
|
+
`warning` `danger` `info`), `appearance` (`soft` `solid` `outline`), `size`
|
|
22
|
+
(`sm` `md`), `max`, `showZero`, `formatValue`, `dot`, `icon`, `classNames`
|
|
23
|
+
(`root` `dot` `icon` `label`), `ref`, and every `<span>` attribute.
|
|
24
|
+
|
|
25
|
+
**Tag:** the same variants, plus `onRemove`, `label` (names the remove button
|
|
26
|
+
when the children are not a plain string), `disabled`, `classNames` (`root`
|
|
27
|
+
`icon` `label` `remove`), `localeText`.
|
|
28
|
+
|
|
29
|
+
A badge with `count` renders nothing at all when the count is zero, unless
|
|
30
|
+
`showZero` says otherwise: an empty counter is noise on the page and a spurious
|
|
31
|
+
announcement in a screen reader.
|
|
32
|
+
|
|
33
|
+
The remove button carries the tag's own text in its name — "Remove Berlin" —
|
|
34
|
+
because a row of identical "Remove" buttons cannot be told apart by anyone
|
|
35
|
+
listening to it.
|
|
36
|
+
|
|
37
|
+
`formatCount` and `showCount` are exported from the framework-free `core`.
|