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) {