diff --git a/CHANGELOG.md b/CHANGELOG.md index 8649e22891bd..527b87fb733c 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 8b728ca82e19..c0a123abf5db 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 7d732ca1ccf1..cbf4b29ece79 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 0f56d3430d3a..1305b9415fe6 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 209bcd70178b..014c4973212a 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 169f3b2750bf..f43945f890fd 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 7dfcc4046bb3..968f8d7a16d5 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'], - - // State - // TODO: Remove alpha vars or no? - ['visited', '&:visited'], - - ['target', '&:target'], - ['open', '&:is([open], :popover-open)'], - - // 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 - ['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]) - } + // 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 + staticVariant('visited', ['&:visited']) + staticVariant('target', ['&:target']) + staticVariant('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']) + + // Content + staticVariant('empty', ['&:empty']) + + // 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] *)'])