diff --git a/packages/ui/src/components/icon.test.tsx b/packages/ui/src/components/icon.test.tsx
index 84e092a63..56d212a9b 100644
--- a/packages/ui/src/components/icon.test.tsx
+++ b/packages/ui/src/components/icon.test.tsx
@@ -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();
+ it("draws the glyph from data-icon, so its name is neither text nor an accessible name", () => {
+ const { container } = render(
+ ,
+ );
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", () => {
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 3f84857a7..0102ffbd3 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -21,8 +21,8 @@ type IconProps = Omit, "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}
-
+ />
);
}
diff --git a/packages/ui/src/hooks/use-breakpoint.test.tsx b/packages/ui/src/hooks/use-breakpoint.test.tsx
new file mode 100644
index 000000000..6f721a626
--- /dev/null
+++ b/packages/ui/src/hooks/use-breakpoint.test.tsx
@@ -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");
+ });
+});
diff --git a/packages/ui/src/hooks/use-breakpoint.tsx b/packages/ui/src/hooks/use-breakpoint.tsx
new file mode 100644
index 000000000..64a066650
--- /dev/null
+++ b/packages/ui/src/hooks/use-breakpoint.tsx
@@ -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");
+}
diff --git a/packages/ui/src/icons/material-symbols-rounded.json b/packages/ui/src/icons/material-symbols-rounded.json
index 75ddabf03..a889face9 100644
--- a/packages/ui/src/icons/material-symbols-rounded.json
+++ b/packages/ui/src/icons/material-symbols-rounded.json
@@ -53,6 +53,7 @@
"key",
"language",
"left_panel_close",
+ "left_panel_open",
"light_mode",
"link",
"link_off",
diff --git a/packages/ui/src/icons/material-symbols-rounded.woff2 b/packages/ui/src/icons/material-symbols-rounded.woff2
index 214a66945..e8520faba 100644
Binary files a/packages/ui/src/icons/material-symbols-rounded.woff2 and b/packages/ui/src/icons/material-symbols-rounded.woff2 differ
diff --git a/packages/ui/src/icons/names.ts b/packages/ui/src/icons/names.ts
index f83b5ff46..686bedc4e 100644
--- a/packages/ui/src/icons/names.ts
+++ b/packages/ui/src/icons/names.ts
@@ -58,6 +58,7 @@ export const iconNames = [
"key",
"language",
"left_panel_close",
+ "left_panel_open",
"light_mode",
"link",
"link_off",
diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css
index b546eb697..366a63ed4 100644
--- a/packages/ui/src/styles/globals.css
+++ b/packages/ui/src/styles/globals.css
@@ -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) {