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 <malfait.robin@gmail.com>
This commit is contained in:
Adam Wathan 2024-09-24 11:43:57 -04:00 committed by GitHub
parent a270e2c8f1
commit abde4c9694
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 245 additions and 149 deletions

View File

@ -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

View File

@ -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. */

View File

@ -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;
}
}
}
}

View File

@ -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;
}
}
}
}"

View File

@ -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;
}
}"
`)
})

View File

@ -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 {

View File

@ -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] *)'])