mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: show the pointer cursor on buttons and other controls
Tailwind v4's preflight no longer gives buttons a pointer cursor, so most buttons, menu items, tabs, switches and selects showed the arrow. A base-layer rule in globals.css gives them the pointer again and gives disabled ones not-allowed. Cursor utilities on components still take precedence.
This commit is contained in:
@@ -212,6 +212,44 @@ optgroup {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Tailwind v4's preflight no longer gives buttons a pointer cursor, so most
|
||||
controls showed the arrow. Give everything clickable the pointer again, and
|
||||
disabled controls not-allowed, as the components that style it themselves
|
||||
already do. This sits in the base layer so a cursor utility on a component
|
||||
(cursor-grab, cursor-text, disabled:cursor-default) still wins. */
|
||||
@layer base {
|
||||
button:not(:disabled),
|
||||
select:not(:disabled),
|
||||
input:is([type="checkbox"], [type="radio"], [type="color"]):not(:disabled),
|
||||
summary,
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
[role="tab"]:not([aria-disabled="true"]),
|
||||
[role="switch"]:not([aria-disabled="true"]),
|
||||
[role="checkbox"]:not([aria-disabled="true"]),
|
||||
[role="radio"]:not([aria-disabled="true"]),
|
||||
[role="option"]:not([aria-disabled="true"]),
|
||||
[role="menuitem"]:not([aria-disabled="true"]),
|
||||
[role="menuitemcheckbox"]:not([aria-disabled="true"]),
|
||||
[role="menuitemradio"]:not([aria-disabled="true"]) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
select:disabled,
|
||||
input:is([type="checkbox"], [type="radio"], [type="color"]):disabled,
|
||||
[role="button"][aria-disabled="true"],
|
||||
[role="tab"][aria-disabled="true"],
|
||||
[role="switch"][aria-disabled="true"],
|
||||
[role="checkbox"][aria-disabled="true"],
|
||||
[role="radio"][aria-disabled="true"],
|
||||
[role="option"][aria-disabled="true"],
|
||||
[role="menuitem"][aria-disabled="true"],
|
||||
[role="menuitemcheckbox"][aria-disabled="true"],
|
||||
[role="menuitemradio"][aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* Minimalist scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -597,7 +597,7 @@ export function FilterRuleModal({
|
||||
type="email"
|
||||
className="flex-1 min-w-[180px]"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-sm text-foreground">
|
||||
<label className="flex items-center gap-1.5 text-sm text-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!action.keepCopy}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Tailwind v4's preflight resets buttons to the default cursor, so buttons,
|
||||
* menu items, tabs and switches showed the arrow instead of the pointer.
|
||||
* globals.css gives them the pointer back from the base layer, where a cursor
|
||||
* utility on a component still overrides it.
|
||||
*
|
||||
* jsdom does not cascade layered stylesheets, so lock the stylesheet rules.
|
||||
* Only rules inside `@layer base` count: unlayered, they would beat the
|
||||
* utilities.
|
||||
*/
|
||||
const css = readFileSync(path.join(process.cwd(), 'app', 'globals.css'), 'utf8');
|
||||
|
||||
/** Return the bodies of every top-level `@layer base { ... }` block. */
|
||||
function baseLayers(): string[] {
|
||||
const bodies: string[] = [];
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = css.indexOf('\n@layer base {', from);
|
||||
if (idx === -1) return bodies;
|
||||
const open = css.indexOf('{', idx);
|
||||
let depth = 0;
|
||||
let end = open;
|
||||
for (; end < css.length; end++) {
|
||||
if (css[end] === '{') depth++;
|
||||
if (css[end] === '}' && --depth === 0) break;
|
||||
}
|
||||
bodies.push(css.slice(open + 1, end));
|
||||
from = end;
|
||||
}
|
||||
}
|
||||
|
||||
/** Selectors of each rule in a block, keyed by the cursor the rule sets. */
|
||||
function cursorRules(block: string): Map<string, string[]> {
|
||||
const rules = new Map<string, string[]>();
|
||||
for (const m of block.matchAll(/([^{}]+)\{\s*cursor:\s*([a-z-]+);\s*\}/g)) {
|
||||
const selectors = m[1].split(',').map((s) => s.trim());
|
||||
rules.set(m[2], [...(rules.get(m[2]) ?? []), ...selectors]);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
describe('pointer cursor on clickable controls', () => {
|
||||
const rules = cursorRules(baseLayers().join('\n'));
|
||||
const pointer = rules.get('pointer') ?? [];
|
||||
const notAllowed = rules.get('not-allowed') ?? [];
|
||||
|
||||
it.each([
|
||||
'button:not(:disabled)',
|
||||
'select:not(:disabled)',
|
||||
'[role="button"]:not([aria-disabled="true"])',
|
||||
'[role="tab"]:not([aria-disabled="true"])',
|
||||
'[role="switch"]:not([aria-disabled="true"])',
|
||||
'[role="option"]:not([aria-disabled="true"])',
|
||||
'[role="menuitem"]:not([aria-disabled="true"])',
|
||||
])('gives %s the pointer', (selector) => {
|
||||
expect(pointer).toContain(selector);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'button:disabled',
|
||||
'select:disabled',
|
||||
'[role="button"][aria-disabled="true"]',
|
||||
'[role="menuitem"][aria-disabled="true"]',
|
||||
])('gives %s not-allowed', (selector) => {
|
||||
expect(notAllowed).toContain(selector);
|
||||
});
|
||||
|
||||
it('never gives a disabled control the pointer', () => {
|
||||
for (const selector of pointer) {
|
||||
expect(selector).not.toMatch(/:disabled(?!\))|\[aria-disabled="true"\](?!\))/);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user