From abde4c9694a2d5bc5d57e7b513a680d3cc6fe94e Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Tue, 24 Sep 2024 11:43:57 -0400 Subject: [PATCH] Only apply hover on devices that support hover (#14500) This PR updates the `hover` variant to only apply when `@media (hover: hover)` matches. ```diff .hover\:bg-black { &:hover { + @media (hover: hover) { background: black; + } } } ``` This is technically a breaking change because you may have built your site in a way where some interactions depend on hover (like opening a dropdown menu), and were relying on the fact that tapping on mobile triggers hover. To bring back the old hover behavior, users can override the `hover` variant in their CSS file back to the simpler implementation: ```css @import "tailwindcss"; @variant hover (&:hover); ``` I've opted to go with just `@media (hover: hover)` for this because it seems like the best trade-off between the available options. Using `(any-hover: hover)` would mean users would get sticky hover states when tapping on an iPad if they have a mouse or trackpad connected, which feels wrong to me because in those cases touch is still likely the primary method of interaction. Sites built with this feature in mind will be treating hover styles as progressive enhancement, so it seems better to me that using an iPad with a mouse would not have hover styles, vs. having sticky hover styles in the same situation. Of course users can always override this with whatever they want, so making this the default isn't locking anyone in to a particular choice. --------- Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com> Co-authored-by: Robin Malfait --- CHANGELOG.md | 1 + packages/tailwindcss/playwright.config.ts | 26 +++- .../tailwindcss/src/compat/plugin-api.test.ts | 6 +- packages/tailwindcss/src/index.test.ts | 136 ++++++++++++------ packages/tailwindcss/src/utilities.test.ts | 10 +- packages/tailwindcss/src/variants.test.ts | 122 ++++++++++------ packages/tailwindcss/src/variants.ts | 93 +++++------- 7 files changed, 245 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8649e2289..527b87fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preserve explicit transition duration and timing function when overriding transition property ([#14490](https://github.com/tailwindlabs/tailwindcss/pull/14490)) - Change the implementation for `@import` resolution to speed up initial builds ([#14446](https://github.com/tailwindlabs/tailwindcss/pull/14446)) - Remove automatic `var(…)` injection ([#13657](https://github.com/tailwindlabs/tailwindcss/pull/13657)) +- Only apply `:hover` states on devices that support `@media (hover: hover)` ([#14500](https://github.com/tailwindlabs/tailwindcss/pull/14500)) ## [4.0.0-alpha.24] - 2024-09-11 diff --git a/packages/tailwindcss/playwright.config.ts b/packages/tailwindcss/playwright.config.ts index 8b728ca82..c0a123abf 100644 --- a/packages/tailwindcss/playwright.config.ts +++ b/packages/tailwindcss/playwright.config.ts @@ -42,7 +42,31 @@ export default defineConfig({ }, { name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + use: { + ...devices['Desktop Firefox'], + // https://playwright.dev/docs/test-use-options#more-browser-and-context-options + launchOptions: { + // https://playwright.dev/docs/api/class-browsertype#browser-type-launch-option-firefox-user-prefs + firefoxUserPrefs: { + // By default, headless Firefox runs as though no pointers + // capabilities are available. + // https://github.com/microsoft/playwright/issues/7769#issuecomment-966098074 + // + // This impacts our `hover` variant implementation which uses an + // '(hover: hover)' media query to determine if hover is available. + // + // Available values for pointer capabilities: + // NO_POINTER = 0x00; + // COARSE_POINTER = 0x01; + // FINE_POINTER = 0x02; + // HOVER_CAPABLE_POINTER = 0x04; + // + // Setting to 0x02 | 0x04 says the system supports a mouse + 'ui.primaryPointerCapabilities': 0x02 | 0x04, + 'ui.allPointerCapabilities': 0x02 | 0x04, + }, + }, + }, }, /* Test against mobile viewports. */ diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index 7d732ca1c..cbf4b29ec 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -1919,8 +1919,10 @@ describe('matchVariant', () => { "@layer utilities { @media (width >= 100px) { @media (width <= 200px) { - .testmin-\\[100px\\]\\:testmax-\\[200px\\]\\:hover\\:underline:hover { - text-decoration-line: underline; + @media (hover: hover) { + .testmin-\\[100px\\]\\:testmax-\\[200px\\]\\:hover\\:underline:hover { + text-decoration-line: underline; + } } } } diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 0f56d3430..1305b9415 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -35,8 +35,10 @@ describe('compiling CSS', () => { display: flex; } - .hover\\:underline:hover { - text-decoration-line: underline; + @media (hover: hover) { + .hover\\:underline:hover { + text-decoration-line: underline; + } } @media (width >= 768px) { @@ -193,8 +195,10 @@ describe('@apply', () => { text-decoration-line: underline; } - .foo:hover { - background-color: var(--color-blue-500, #3b82f6); + @media (hover: hover) { + .foo:hover { + background-color: var(--color-blue-500, #3b82f6); + } } @media (width >= 768px) { @@ -390,16 +394,20 @@ describe('arbitrary variants', () => { describe('variant stacking', () => { it('should stack simple variants', async () => { expect(await run(['focus:hover:flex'])).toMatchInlineSnapshot(` - ".focus\\:hover\\:flex:focus:hover { - display: flex; + "@media (hover: hover) { + .focus\\:hover\\:flex:focus:hover { + display: flex; + } }" `) }) it('should stack arbitrary variants and simple variants', async () => { expect(await run(['[&_p]:hover:flex'])).toMatchInlineSnapshot(` - ".\\[\\&_p\\]\\:hover\\:flex p:hover { - display: flex; + "@media (hover: hover) { + .\\[\\&_p\\]\\:hover\\:flex p:hover { + display: flex; + } }" `) }) @@ -420,13 +428,17 @@ describe('variant stacking', () => { content: var(--tw-content); } - .before\\:hover\\:flex:before:hover { - display: flex; + @media (hover: hover) { + .before\\:hover\\:flex:before:hover { + display: flex; + } } - .hover\\:before\\:flex:hover:before { - content: var(--tw-content); - display: flex; + @media (hover: hover) { + .hover\\:before\\:flex:hover:before { + content: var(--tw-content); + display: flex; + } } @supports (-moz-orient: inline) { @@ -627,22 +639,24 @@ describe('sorting', () => { ), ), ).toMatchInlineSnapshot(` - ".pointer-events-none { - pointer-events: none; - } + ".pointer-events-none { + pointer-events: none; + } - .flex { - display: flex; - } + .flex { + display: flex; + } + @media (hover: hover) { .hover\\:flex:hover { display: flex; } + } - .focus\\:pointer-events-none:focus { - pointer-events: none; - }" - `) + .focus\\:pointer-events-none:focus { + pointer-events: none; + }" + `) }) /** @@ -672,16 +686,20 @@ describe('sorting', () => { display: flex; } - .hover\\:flex:hover { - display: flex; + @media (hover: hover) { + .hover\\:flex:hover { + display: flex; + } } .focus\\:flex:focus { display: flex; } - .hover\\:focus\\:flex:hover:focus { - display: flex; + @media (hover: hover) { + .hover\\:focus\\:flex:hover:focus { + display: flex; + } } .disabled\\:flex:disabled { @@ -715,44 +733,64 @@ describe('sorting', () => { ].sort(() => Math.random() - 0.5), ), ).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:flex:is(:where(.group):focus *) { display: flex; } - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + display: flex; + } } - .group-hover\\:peer-hover\\:flex:is(:where(.group):hover *):is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + @media (hover: hover) { + .group-hover\\:peer-hover\\:flex:is(:where(.group):hover *):is(:where(.peer):hover ~ *) { + display: flex; + } + } } - .peer-hover\\:group-hover\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):hover *) { - display: flex; + @media (hover: hover) { + @media (hover: hover) { + .peer-hover\\:group-hover\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):hover *) { + display: flex; + } + } } - .group-focus\\:peer-hover\\:flex:is(:where(.group):focus *):is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .group-focus\\:peer-hover\\:flex:is(:where(.group):focus *):is(:where(.peer):hover ~ *) { + display: flex; + } } - .peer-hover\\:group-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):focus *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:group-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.group):focus *) { + display: flex; + } } .peer-focus\\:flex:is(:where(.peer):focus ~ *) { display: flex; } - .group-hover\\:peer-focus\\:flex:is(:where(.group):hover *):is(:where(.peer):focus ~ *) { - display: flex; + @media (hover: hover) { + .group-hover\\:peer-focus\\:flex:is(:where(.group):hover *):is(:where(.peer):focus ~ *) { + display: flex; + } } - .peer-focus\\:group-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.group):hover *) { - display: flex; + @media (hover: hover) { + .peer-focus\\:group-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:peer-focus\\:flex:is(:where(.group):focus *):is(:where(.peer):focus ~ *) { @@ -763,8 +801,10 @@ describe('sorting', () => { display: flex; } - .hover\\:flex:hover { - display: flex; + @media (hover: hover) { + .hover\\:flex:hover { + display: flex; + } }" `) }) @@ -2104,8 +2144,10 @@ describe('@variant', () => { expect(optimizeCss(compiled).trim()).toMatchInlineSnapshot(` "@layer utilities { @media (any-hover: hover) { - .any-hover\\:hover\\:underline:hover { - text-decoration-line: underline; + @media (hover: hover) { + .any-hover\\:hover\\:underline:hover { + text-decoration-line: underline; + } } } }" diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 209bcd701..014c49732 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -15660,10 +15660,12 @@ describe('custom utilities', () => { display: flex; } - .hover\\:foo:hover { - flex-direction: column; - text-decoration-line: underline; - display: flex; + @media (hover: hover) { + .hover\\:foo:hover { + flex-direction: column; + text-decoration-line: underline; + display: flex; + } }" `) }) diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 169f3b275..f43945f89 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -581,16 +581,22 @@ test('focus-within', async () => { test('hover', async () => { expect(await run(['hover:flex', 'group-hover:flex', 'peer-hover:flex'])).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + display: flex; + } } - .hover\\:flex:hover { - display: flex; + @media (hover: hover) { + .hover\\:flex:hover { + display: flex; + } }" `) expect(await run(['hover/foo:flex'])).toEqual('') @@ -615,8 +621,10 @@ test('focus', async () => { test('group-hover group-focus sorting', async () => { expect(await run(['group-hover:flex', 'group-focus:flex'])).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:flex:is(:where(.group):focus *) { @@ -624,8 +632,10 @@ test('group-hover group-focus sorting', async () => { }" `) expect(await run(['group-focus:flex', 'group-hover:flex'])).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:flex:is(:where(.group):focus *) { @@ -741,16 +751,24 @@ test('group-[...]', async () => { display: flex; } - .group-\\[\\&_p\\]\\:hover\\:flex:is(:where(.group) p *):hover { - display: flex; + @media (hover: hover) { + .group-\\[\\&_p\\]\\:hover\\:flex:is(:where(.group) p *):hover { + display: flex; + } } - .hover\\:group-\\[\\&_p\\]\\:flex:hover:is(:where(.group) p *) { - display: flex; + @media (hover: hover) { + .hover\\:group-\\[\\&_p\\]\\:flex:hover:is(:where(.group) p *) { + display: flex; + } } - .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover:is(:where(.group) p *):hover { - display: flex; + @media (hover: hover) { + @media (hover: hover) { + .hover\\:group-\\[\\&_p\\]\\:hover\\:flex:hover:is(:where(.group) p *):hover { + display: flex; + } + } }" `) @@ -786,20 +804,26 @@ test('group-*', async () => { ], ), ).toMatchInlineSnapshot(` - ".group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + "@media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } .group-focus\\:flex:is(:where(.group):focus *) { display: flex; } - .group-focus\\:group-hover\\:flex:is(:where(.group):focus *):is(:where(.group):hover *) { - display: flex; + @media (hover: hover) { + .group-focus\\:group-hover\\:flex:is(:where(.group):focus *):is(:where(.group):hover *) { + display: flex; + } } - .group-hover\\:group-focus\\:flex:is(:where(.group):hover *):is(:where(.group):focus *) { - display: flex; + @media (hover: hover) { + .group-hover\\:group-focus\\:flex:is(:where(.group):hover *):is(:where(.group):focus *) { + display: flex; + } } .group-hocus\\:flex:is(:is(:where(.group):hover, :where(.group):focus) *) { @@ -843,16 +867,22 @@ test('peer-[...]', async () => { display: flex; } - .hover\\:peer-\\[\\&_p\\]\\:flex:hover:is(:where(.peer) p ~ *) { - display: flex; + @media (hover: hover) { + .hover\\:peer-\\[\\&_p\\]\\:flex:hover:is(:where(.peer) p ~ *) { + display: flex; + } } - .peer-\\[\\&_p\\]\\:hover\\:flex:is(:where(.peer) p ~ *):hover { - display: flex; + @media (hover: hover) { + .peer-\\[\\&_p\\]\\:hover\\:flex:is(:where(.peer) p ~ *):hover { + display: flex; + } } - .hover\\:peer-\\[\\&_p\\]\\:focus\\:flex:hover:is(:where(.peer) p ~ *):focus { - display: flex; + @media (hover: hover) { + .hover\\:peer-\\[\\&_p\\]\\:focus\\:flex:hover:is(:where(.peer) p ~ *):focus { + display: flex; + } }" `) @@ -887,20 +917,26 @@ test('peer-*', async () => { ], ), ).toMatchInlineSnapshot(` - ".peer-hover\\:flex:is(:where(.peer):hover ~ *) { - display: flex; + "@media (hover: hover) { + .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + display: flex; + } } .peer-focus\\:flex:is(:where(.peer):focus ~ *) { display: flex; } - .peer-focus\\:peer-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .peer-focus\\:peer-hover\\:flex:is(:where(.peer):focus ~ *):is(:where(.peer):hover ~ *) { + display: flex; + } } - .peer-hover\\:peer-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.peer):focus ~ *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:peer-focus\\:flex:is(:where(.peer):hover ~ *):is(:where(.peer):focus ~ *) { + display: flex; + } } .peer-hocus\\:flex:is(:is(:where(.peer):hover, :where(.peer):focus) ~ *) { @@ -2479,12 +2515,16 @@ test('variant order', async () => { --breakpoint-2xl: 1536px; } - .group-hover\\:flex:is(:where(.group):hover *) { - display: flex; + @media (hover: hover) { + .group-hover\\:flex:is(:where(.group):hover *) { + display: flex; + } } - .peer-hover\\:flex:is(:where(.peer):hover ~ *) { - display: flex; + @media (hover: hover) { + .peer-hover\\:flex:is(:where(.peer):hover ~ *) { + display: flex; + } } .first-letter\\:flex:first-letter { @@ -2625,8 +2665,10 @@ test('variant order', async () => { display: flex; } - .hover\\:flex:hover { - display: flex; + @media (hover: hover) { + .hover\\:flex:hover { + display: flex; + } } .focus\\:flex:focus { diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 7dfcc4046..968f8d7a1 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -413,65 +413,48 @@ export function createVariants(theme: Theme): Variants { ) } - let pseudos: [name: string, selector: string][] = [ - // Positional - ['first', '&:first-child'], - ['last', '&:last-child'], - ['only', '&:only-child'], - ['odd', '&:nth-child(odd)'], - ['even', '&:nth-child(even)'], - ['first-of-type', '&:first-of-type'], - ['last-of-type', '&:last-of-type'], - ['only-of-type', '&:only-of-type'], + // Positional + staticVariant('first', ['&:first-child']) + staticVariant('last', ['&:last-child']) + staticVariant('only', ['&:only-child']) + staticVariant('odd', ['&:nth-child(odd)']) + staticVariant('even', ['&:nth-child(even)']) + staticVariant('first-of-type', ['&:first-of-type']) + staticVariant('last-of-type', ['&:last-of-type']) + staticVariant('only-of-type', ['&:only-of-type']) - // State - // TODO: Remove alpha vars or no? - ['visited', '&:visited'], + // State + staticVariant('visited', ['&:visited']) + staticVariant('target', ['&:target']) + staticVariant('open', ['&:is([open], :popover-open)']) - ['target', '&:target'], - ['open', '&:is([open], :popover-open)'], + // Forms + staticVariant('default', ['&:default']) + staticVariant('checked', ['&:checked']) + staticVariant('indeterminate', ['&:indeterminate']) + staticVariant('placeholder-shown', ['&:placeholder-shown']) + staticVariant('autofill', ['&:autofill']) + staticVariant('optional', ['&:optional']) + staticVariant('required', ['&:required']) + staticVariant('valid', ['&:valid']) + staticVariant('invalid', ['&:invalid']) + staticVariant('in-range', ['&:in-range']) + staticVariant('out-of-range', ['&:out-of-range']) + staticVariant('read-only', ['&:read-only']) - // Forms - ['default', '&:default'], - ['checked', '&:checked'], - ['indeterminate', '&:indeterminate'], - ['placeholder-shown', '&:placeholder-shown'], - ['autofill', '&:autofill'], - ['optional', '&:optional'], - ['required', '&:required'], - ['valid', '&:valid'], - ['invalid', '&:invalid'], - ['in-range', '&:in-range'], - ['out-of-range', '&:out-of-range'], - ['read-only', '&:read-only'], + // Content + staticVariant('empty', ['&:empty']) - // Content - ['empty', '&:empty'], - - // Interactive - ['focus-within', '&:focus-within'], - [ - 'hover', - '&:hover', - // TODO: Update tests for this: - // v => { - // v.nodes = [ - // rule('@media (hover: hover) and (pointer: fine)', [ - // rule('&:hover', v.nodes), - // ]), - // ] - // } - ], - ['focus', '&:focus'], - ['focus-visible', '&:focus-visible'], - ['active', '&:active'], - ['enabled', '&:enabled'], - ['disabled', '&:disabled'], - ] - - for (let [key, value] of pseudos) { - staticVariant(key, [value]) - } + // Interactive + staticVariant('focus-within', ['&:focus-within']) + variants.static('hover', (r) => { + r.nodes = [rule('&:hover', [rule('@media (hover: hover)', r.nodes)])] + }) + staticVariant('focus', ['&:focus']) + staticVariant('focus-visible', ['&:focus-visible']) + staticVariant('active', ['&:active']) + staticVariant('enabled', ['&:enabled']) + staticVariant('disabled', ['&:disabled']) staticVariant('inert', ['&:is([inert], [inert] *)'])