feat(ui): add a breakpoint hook, panel icons and CSS-drawn icon glyphs

Icons now draw their ligature from data-icon in a pseudo-element, so icon
names stay out of copied text, find-in-page and text queries. useBreakpoint
reports the design system's mobile, tablet, desktop and wide ranges.
This commit is contained in:
Amruth Pillai
2026-09-28 17:17:54 +02:00
parent 90d7d0a19b
commit a887a72d77
8 changed files with 107 additions and 9 deletions
+10 -4
View File
@@ -5,12 +5,18 @@ import { iconNames } from "../icons/names";
import { Icon } from "./icon";
describe("Icon", () => {
it("is hidden from assistive tech and translation, so ligature text never becomes a name", () => {
const { container } = render(<Icon name="search" />);
it("draws the glyph from data-icon, so its name is neither text nor an accessible name", () => {
const { container } = render(
<button type="button">
<Icon name="search" />
Search
</button>,
);
const icon = container.querySelector('[data-slot="icon"]');
expect(icon).toHaveAttribute("aria-hidden", "true");
expect(icon).toHaveAttribute("translate", "no");
expect(icon).toHaveTextContent("search");
expect(icon).toHaveAttribute("data-icon", "search");
expect(icon).toBeEmptyDOMElement();
expect(container.querySelector("button")).toHaveTextContent(/^Search$/);
});
it("uses the filled glyph only when asked", () => {
+3 -5
View File
@@ -21,8 +21,8 @@ type IconProps = Omit<React.ComponentProps<"span">, "children"> & {
};
/**
* A Material Symbols Rounded glyph (weight 300). Icons are decorative: the control they sit in
* carries the accessible name, so the ligature text is hidden from assistive tech and translation.
* A Material Symbols Rounded glyph (weight 300), drawn from `data-icon` by CSS so the name never becomes text.
* Icons are decorative: the control they sit in carries the accessible name.
*/
function Icon({ name, filled = false, size = 20, className, style, ...props }: IconProps) {
return (
@@ -38,9 +38,7 @@ function Icon({ name, filled = false, size = 20, className, style, ...props }: I
...style,
}}
{...props}
>
{name}
</span>
/>
);
}
@@ -0,0 +1,54 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useBreakpoint } from "./use-breakpoint";
let width = 1440;
const listeners = new Set<() => void>();
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => {
const min = Number(/min-width: (\d+)px/.exec(query)?.[1] ?? 0);
return {
get matches() {
return width >= min;
},
media: query,
addEventListener: (_: string, listener: () => void) => listeners.add(listener),
removeEventListener: (_: string, listener: () => void) => listeners.delete(listener),
};
}),
});
afterEach(() => {
width = 1440;
});
describe("useBreakpoint", () => {
it.each([
[390, "mobile"],
[639, "mobile"],
[640, "tablet"],
[1023, "tablet"],
[1024, "desktop"],
[1279, "desktop"],
[1280, "wide"],
] as const)("maps %ipx to %s", (nextWidth, expected) => {
width = nextWidth;
const { result } = renderHook(() => useBreakpoint());
expect(result.current).toBe(expected);
});
it("updates when the viewport crosses a breakpoint", () => {
const { result } = renderHook(() => useBreakpoint());
expect(result.current).toBe("wide");
act(() => {
width = 800;
for (const listener of listeners) listener();
});
expect(result.current).toBe("tablet");
});
});
+32
View File
@@ -0,0 +1,32 @@
import { useSyncExternalStore } from "react";
/** Design-system breakpoints: mobile < 640, tablet 640–1023, desktop ≥ 1024, wide ≥ 1280. */
export type Breakpoint = "mobile" | "tablet" | "desktop" | "wide";
const QUERIES = {
tablet: "(min-width: 640px)",
desktop: "(min-width: 1024px)",
wide: "(min-width: 1280px)",
} as const;
function readBreakpoint(): Breakpoint {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "desktop";
if (window.matchMedia(QUERIES.wide).matches) return "wide";
if (window.matchMedia(QUERIES.desktop).matches) return "desktop";
if (window.matchMedia(QUERIES.tablet).matches) return "tablet";
return "mobile";
}
function subscribe(onChange: () => void) {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
const lists = Object.values(QUERIES).map((query) => window.matchMedia(query));
for (const list of lists) list.addEventListener("change", onChange);
return () => {
for (const list of lists) list.removeEventListener("change", onChange);
};
}
export function useBreakpoint(): Breakpoint {
return useSyncExternalStore(subscribe, readBreakpoint, () => "desktop");
}
@@ -53,6 +53,7 @@
"key",
"language",
"left_panel_close",
"left_panel_open",
"light_mode",
"link",
"link_off",
Binary file not shown.
+1
View File
@@ -58,6 +58,7 @@ export const iconNames = [
"key",
"language",
"left_panel_close",
"left_panel_open",
"light_mode",
"link",
"link_off",
+6
View File
@@ -293,6 +293,12 @@
width: 1em;
height: 1em;
overflow: hidden;
/* The glyph is the ligature of the name in data-icon. Drawing it from a pseudo-element keeps the name out
of the text content, so copied labels, find-in-page and text queries see only the label. */
&::before {
content: attr(data-icon);
}
}
@media (prefers-reduced-motion: reduce) {