mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
fix(ui): 44px touch targets and readable descriptions on tinted rows
On coarse pointers, buttons, switches, checkboxes and tabs get an invisible hit area of at least 44x44 centred on them, without changing how they look. A checked switch row's description steps up to ink-2, since ink-3 falls short of 4.5:1 on the accent tint. Adds contrastOnWhite to the colour utilities.
This commit is contained in:
@@ -5,7 +5,7 @@ import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button relative inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap border border-transparent font-medium text-sm transition-[background-color,border-color,color,filter] duration-quick ease-enter disabled:pointer-events-none aria-busy:cursor-progress [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"group/button touch-target relative inline-flex shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap border border-transparent font-medium text-sm transition-[background-color,border-color,color,filter] duration-quick ease-enter disabled:pointer-events-none aria-busy:cursor-progress [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -7,7 +7,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border-[1.5px] border-line-2 bg-raised outline-none transition-[background-color,border-color] duration-quick aria-invalid:border-danger data-disabled:cursor-not-allowed data-checked:border-accent data-indeterminate:border-accent data-checked:bg-accent data-indeterminate:bg-accent data-checked:text-on-accent data-indeterminate:text-on-accent data-disabled:opacity-50",
|
||||
"peer touch-target relative flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border-[1.5px] border-line-2 bg-raised outline-none transition-[background-color,border-color] duration-quick aria-invalid:border-danger data-disabled:cursor-not-allowed data-checked:border-accent data-indeterminate:border-accent data-checked:bg-accent data-indeterminate:bg-accent data-checked:text-on-accent data-indeterminate:text-on-accent data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -28,7 +28,7 @@ function Switch({ className, size = "default", ...props }: SwitchProps) {
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/switch peer inline-flex shrink-0 rounded-full outline-none data-disabled:cursor-not-allowed",
|
||||
"group/switch peer touch-target relative inline-flex shrink-0 rounded-full outline-none data-disabled:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -66,7 +66,11 @@ function SwitchRow({ className, label, description, size = "default", ...props }
|
||||
{label}
|
||||
</span>
|
||||
{description && (
|
||||
<span id={descriptionId} className="text-[13px] text-ink-3 leading-[18px]">
|
||||
// A checked row may sit on the accent tint, where ink-3 falls short of 4.5:1; ink-2 keeps it readable.
|
||||
<span
|
||||
id={descriptionId}
|
||||
className="text-[13px] text-ink-3 leading-[18px] group-data-checked/switch:text-ink-2"
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -70,7 +70,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex min-w-fit items-center justify-center gap-1.5 whitespace-nowrap font-medium transition-colors duration-quick hover:text-ink disabled:pointer-events-none disabled:text-ink-3 aria-disabled:pointer-events-none aria-disabled:text-ink-3 data-active:text-ink group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"touch-target relative inline-flex min-w-fit items-center justify-center gap-1.5 whitespace-nowrap font-medium transition-colors duration-quick hover:text-ink disabled:pointer-events-none disabled:text-ink-3 aria-disabled:pointer-events-none aria-disabled:text-ink-3 data-active:text-ink group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
"group-data-[variant=default]/tabs-list:h-full group-data-[variant=default]/tabs-list:flex-1 group-data-[variant=default]/tabs-list:rounded-sm group-data-[variant=default]/tabs-list:px-3 group-data-[variant=default]/tabs-list:text-[13px]",
|
||||
"group-data-[variant=line]/tabs-list:h-full group-data-[variant=line]/tabs-list:text-sm group-data-[variant=line]/tabs-list:data-active:shadow-[inset_0_-2px_0_var(--ink)]",
|
||||
className,
|
||||
|
||||
@@ -241,6 +241,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Touch screens: a control's hit area grows to at least 44×44, centred on it, without changing how it looks
|
||||
* (README §4.7). The control needs `position: relative`.
|
||||
*/
|
||||
@utility touch-target {
|
||||
@media (pointer: coarse) {
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: max(100%, 44px);
|
||||
height: max(100%, 44px);
|
||||
translate: -50% -50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility material-symbol {
|
||||
font-family: "Material Symbols Rounded", sans-serif;
|
||||
font-weight: 300;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isDarkColor, parseColorString, rgbaStringToHex } from "./color";
|
||||
import { contrastOnWhite, isDarkColor, parseColorString, rgbaStringToHex } from "./color";
|
||||
|
||||
describe("rgbaStringToHex", () => {
|
||||
it("converts opaque rgb to hex", () => {
|
||||
@@ -123,3 +123,17 @@ describe("parseColorString", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("contrastOnWhite", () => {
|
||||
it("measures WCAG contrast against white", () => {
|
||||
expect(contrastOnWhite("rgba(0, 0, 0, 1)")).toBeCloseTo(21, 0);
|
||||
expect(contrastOnWhite("#ffffff")).toBeCloseTo(1, 5);
|
||||
// The sample template blue, just short of 4.5:1 for body text.
|
||||
expect(contrastOnWhite("rgba(0, 132, 209, 1)")).toBeCloseTo(4.02, 1);
|
||||
});
|
||||
|
||||
it("lays transparency over white, and treats unreadable colours as no contrast", () => {
|
||||
expect(contrastOnWhite("rgba(0, 0, 0, 0)")).toBeCloseTo(1, 5);
|
||||
expect(contrastOnWhite("hsl(0 0% 0%)")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,3 +69,19 @@ export function isDarkColor(colorString: string): boolean {
|
||||
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
return luminance < 128;
|
||||
}
|
||||
|
||||
// WCAG 2 relative luminance of an sRGB channel (0–255).
|
||||
const linear = (channel: number) => {
|
||||
const value = channel / 255;
|
||||
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
/** WCAG contrast of a colour against white, with any transparency laid over white first. 1 when it can't be read. */
|
||||
export function contrastOnWhite(colorString: string): number {
|
||||
const color = parseColorString(colorString);
|
||||
if (!color) return 1;
|
||||
const alpha = Math.max(0, Math.min(1, color.a));
|
||||
const [r, g, b] = [color.r, color.g, color.b].map((channel) => linear(channel * alpha + 255 * (1 - alpha)));
|
||||
const luminance = 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0);
|
||||
return 1.05 / (luminance + 0.05);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user