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:
Gerwin Kuijntjes
2026-10-06 21:31:11 +02:00
parent 1e10aa9bd6
commit 682fc086d1
3 changed files with 116 additions and 1 deletions
+38
View File
@@ -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;
+1 -1
View File
@@ -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}
+77
View File
@@ -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"\](?!\))/);
}
});
});