mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.
@@ -58,6 +58,7 @@ export const iconNames = [
|
||||
"key",
|
||||
"language",
|
||||
"left_panel_close",
|
||||
"left_panel_open",
|
||||
"light_mode",
|
||||
"link",
|
||||
"link_off",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user