mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: keep switches, selections and pressed buttons visible in Windows contrast themes
This commit is contained in:
@@ -91,4 +91,24 @@
|
||||
:is(.bk, .bk *):focus-visible {
|
||||
outline-color: Highlight !important;
|
||||
}
|
||||
/* Forced colours drop the switch's tint and ring; a border and a solid
|
||||
knob keep on and off apart (as app/accessibility.css does). */
|
||||
[role="switch"].bk {
|
||||
border: 1px solid ButtonText !important;
|
||||
}
|
||||
[role="switch"].bk > span {
|
||||
forced-color-adjust: none;
|
||||
background-color: ButtonText !important;
|
||||
}
|
||||
[role="switch"].bk[aria-checked="true"] {
|
||||
background-color: Highlight !important;
|
||||
border-color: Highlight !important;
|
||||
}
|
||||
[role="switch"].bk[aria-checked="true"] > span {
|
||||
background-color: HighlightText !important;
|
||||
}
|
||||
:is(.bk [aria-pressed="true"], .bk [role="radio"][aria-checked="true"], .bk [role="tab"][aria-selected="true"]) {
|
||||
background-color: Highlight !important;
|
||||
color: HighlightText !important;
|
||||
}
|
||||
}
|
||||
|
||||
+101
-2
@@ -252,12 +252,111 @@ html.dark[data-contrast="more"] {
|
||||
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cpath d='M14 4 h12 M20 4 V36 M14 36 h12' fill='none' stroke='white' stroke-width='7' stroke-linecap='round'/%3E%3Cpath d='M14 4 h12 M20 4 V36 M14 36 h12' fill='none' stroke='black' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E") 20 20, text;
|
||||
}
|
||||
|
||||
/* ---------- Forced colours (Windows contrast themes) ---------- */
|
||||
|
||||
/* ---------- Forced colours (Windows contrast themes) ----------
|
||||
*
|
||||
* Forced colours replace every background with Canvas and drop box-shadows,
|
||||
* so state drawn only as a tint or a ring disappears. Each state gets a
|
||||
* system colour here instead: selected, pressed and current items take
|
||||
* Highlight with HighlightText (and so does everything inside them, whose own
|
||||
* colours would otherwise be forced back to CanvasText on Highlight), today
|
||||
* gets a frame, switches a border and a solid knob, dots CanvasText. A colour
|
||||
* swatch keeps its own colour (forced-color-adjust on the swatch only),
|
||||
* because the colour is what it shows.
|
||||
*/
|
||||
@media (forced-colors: active) {
|
||||
:focus-visible:not([tabindex="-1"]:not(a[href], button, input, select, textarea, summary, [contenteditable="true"], [role="button"], [role="link"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="gridcell"], [role="row"], [role="treeitem"], [role="tab"], [role="radio"], [role="checkbox"], [role="switch"], [role="slider"], [role="spinbutton"], [role="columnheader"], [role="rowheader"])) {
|
||||
outline-color: Highlight !important;
|
||||
}
|
||||
|
||||
:is(
|
||||
[aria-selected="true"]:not([role="gridcell"]:has(> button)),
|
||||
[role="gridcell"][aria-selected="true"] > button,
|
||||
[aria-pressed="true"],
|
||||
[aria-current="page"],
|
||||
[aria-current="true"],
|
||||
[aria-current="step"],
|
||||
[aria-current="location"],
|
||||
[data-selected="true"],
|
||||
[data-active="true"],
|
||||
[role="menuitemradio"][aria-checked="true"],
|
||||
[role="radio"][aria-checked="true"]:not(input)
|
||||
) {
|
||||
/* Not forced, so text inside gets no Canvas backplate; the rules here
|
||||
and below set every colour instead. */
|
||||
forced-color-adjust: none;
|
||||
background-color: Highlight !important;
|
||||
color: HighlightText !important;
|
||||
border-color: HighlightText !important;
|
||||
}
|
||||
:is(
|
||||
[aria-selected="true"]:not([role="gridcell"]:has(> button)),
|
||||
[role="gridcell"][aria-selected="true"] > button,
|
||||
[aria-pressed="true"],
|
||||
[aria-current="page"],
|
||||
[aria-current="true"],
|
||||
[aria-current="step"],
|
||||
[aria-current="location"],
|
||||
[data-selected="true"],
|
||||
[data-active="true"],
|
||||
[role="menuitemradio"][aria-checked="true"],
|
||||
[role="radio"][aria-checked="true"]:not(input)
|
||||
) *:not(svg *) {
|
||||
color: HighlightText !important;
|
||||
background-color: transparent !important;
|
||||
border-color: HighlightText !important;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
/* Today, this month, this year: a frame, so it stays apart from the
|
||||
selection. */
|
||||
[aria-current="date"] {
|
||||
outline: 2px solid CanvasText;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Switches: a track border, and a knob that keeps its fill. On is
|
||||
Highlight with a HighlightText knob on the far side. */
|
||||
[role="switch"] {
|
||||
border: 1px solid ButtonText !important;
|
||||
}
|
||||
[role="switch"] > span {
|
||||
forced-color-adjust: none;
|
||||
background-color: ButtonText !important;
|
||||
}
|
||||
[role="switch"][aria-checked="true"] {
|
||||
background-color: Highlight !important;
|
||||
border-color: Highlight !important;
|
||||
}
|
||||
[role="switch"][aria-checked="true"] > span {
|
||||
background-color: HighlightText !important;
|
||||
}
|
||||
|
||||
/* Unread and status dots, drawn as an empty tinted circle or a filled
|
||||
icon. */
|
||||
.rounded-full:empty:is(.bg-primary, .bg-unread, .bg-success, .bg-destructive, .bg-warning, .bg-info, [class*="bg-red-"], [class*="bg-green-"], [class*="bg-amber-"], [class*="bg-emerald-"], [class*="bg-blue-"], [class*="bg-yellow-"], [class*="bg-orange-"]) {
|
||||
forced-color-adjust: none;
|
||||
background-color: CanvasText !important;
|
||||
}
|
||||
:is(.fill-unread, .fill-primary, .fill-current) {
|
||||
fill: CanvasText;
|
||||
}
|
||||
|
||||
/* Colour swatches: an empty element whose colour is set inline (calendar,
|
||||
tag and theme colours). */
|
||||
:is(span, div, i):empty[style*="background"] {
|
||||
forced-color-adjust: none;
|
||||
}
|
||||
|
||||
/* Calendar events lose their fill; a frame keeps each one apart. */
|
||||
[data-calendar-event] {
|
||||
outline: 1px solid CanvasText;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Count badges drawn as a tinted pill (the rail's unread count). */
|
||||
.rounded-full:not(:empty):is(.bg-primary, .bg-destructive, [class*="bg-red-"]) {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Simple mode: no side panels ---------- */
|
||||
|
||||
@@ -178,6 +178,7 @@ const ToolbarButton = React.forwardRef<HTMLButtonElement, {
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={pressed === undefined ? undefined : pressed}
|
||||
data-active={active || undefined}
|
||||
aria-haspopup={popup}
|
||||
aria-expanded={popup ? !!expanded : undefined}
|
||||
className={cn(
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ToolbarButton = React.forwardRef<HTMLButtonElement, ToolbarButtonPr
|
||||
({ active, className, ...props }, ref) => (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
data-active={active || undefined}
|
||||
className={cn("shrink-0", active ? TOOLBAR_ACTIVE : "text-muted-foreground hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -38,6 +39,7 @@ export const ToolbarSearchAction = React.forwardRef<HTMLButtonElement, React.But
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
data-active={active || undefined}
|
||||
className={cn(
|
||||
"p-1 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
TOUCH_HIT_AREA,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const css = readFileSync(path.join(process.cwd(), 'app', 'accessibility.css'), 'utf8');
|
||||
const css = readFileSync(path.join(process.cwd(), 'app', 'accessibility.css'), 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
/** Selectors of the first top-level rule whose body contains `declaration`. */
|
||||
function selectorsOf(declaration: string): string[] {
|
||||
@@ -73,3 +73,63 @@ describe('global focus outline', () => {
|
||||
expect(css).toMatch(/--focus-ring-color:\s*var\(--color-ring\)/);
|
||||
});
|
||||
});
|
||||
|
||||
/** The body of the top-level `@media (forced-colors: active)` block. */
|
||||
function forcedBlock(): string {
|
||||
const at = css.indexOf('@media (forced-colors: active) {');
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const open = css.indexOf('{', at);
|
||||
let depth = 0;
|
||||
let end = open;
|
||||
for (; end < css.length; end++) {
|
||||
if (css[end] === '{') depth++;
|
||||
if (css[end] === '}' && --depth === 0) break;
|
||||
}
|
||||
return css.slice(open + 1, end).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
}
|
||||
|
||||
/** The selector of the rule in `block` whose body contains `declaration`. */
|
||||
function ruleSelector(block: string, declaration: string): string {
|
||||
const at = block.indexOf(declaration);
|
||||
expect(at).toBeGreaterThan(-1);
|
||||
const open = block.lastIndexOf('{', at);
|
||||
return block.slice(block.lastIndexOf('}', open) + 1, open).trim();
|
||||
}
|
||||
|
||||
describe('forced colours', () => {
|
||||
const block = forcedBlock();
|
||||
const selected = ruleSelector(block, 'background-color: Highlight !important;\n color: HighlightText');
|
||||
|
||||
it.each([
|
||||
['a pressed segment (settings radio group, formatting toggle)', '<button aria-pressed="true">Light</button>'],
|
||||
['the selected tab', '<button role="tab" aria-selected="true">All</button>'],
|
||||
['the open message row', '<div aria-current="true"></div>'],
|
||||
['the current folder or page', '<button aria-current="page">Inbox</button>'],
|
||||
['an active toolbar button', '<button data-active="true"></button>'],
|
||||
['a checked view-mode radio', '<button role="radio" aria-checked="true"></button>'],
|
||||
['a selected option', '<div role="option" aria-selected="true"></div>'],
|
||||
])('maps %s to Highlight', (_name, html) => {
|
||||
expect(el(html).matches(selected)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unpressed segment', '<button aria-pressed="false">Dark</button>'],
|
||||
['an unselected tab', '<button role="tab" aria-selected="false">Unread</button>'],
|
||||
['today without selection', '<button aria-current="date">29</button>'],
|
||||
['a native radio', '<input type="radio" role="radio" aria-checked="true">'],
|
||||
])('leaves %s alone', (_name, html) => {
|
||||
expect(el(html).matches(selected)).toBe(false);
|
||||
});
|
||||
|
||||
it('gives switches a border and a knob that keeps its fill', () => {
|
||||
expect(ruleSelector(block, 'border: 1px solid ButtonText')).toBe('[role="switch"]');
|
||||
const knob = block.slice(block.indexOf('[role="switch"] > span'));
|
||||
expect(knob).toMatch(/^\[role="switch"\] > span \{\s*forced-color-adjust: none;\s*background-color: ButtonText/);
|
||||
expect(block).toMatch(/\[role="switch"\]\[aria-checked="true"\] > span \{\s*background-color: HighlightText/);
|
||||
});
|
||||
|
||||
it('lets a colour swatch keep its colour, and only the swatch', () => {
|
||||
expect(el('<span style="background-color: rgb(1, 2, 3)"></span>').matches(':is(span, div, i):empty[style*="background"]')).toBe(true);
|
||||
expect(block).toContain(':is(span, div, i):empty[style*="background"] {\n forced-color-adjust: none;');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user