Skip to content

Commit

Permalink
Fix focus styles showing up when using the mouse (#2347)
Browse files Browse the repository at this point in the history
* update playground examples to use a shared `Button`

* expose a `ui-focus-visible` variant

* keep track of a `data-headlessui-focus-visible` attribute

* do not set the `tabindex`

The focus was always set, but the ring wasn't showing up. This was also
focusing a ring when the browser decided not the add one.

Let's make the browser decide when to show this or not.

* update changelog
  • Loading branch information
RobinMalfait authored Mar 10, 2023
1 parent 9a7dcfc commit 0c0601f
Show file tree
Hide file tree
Showing 26 changed files with 258 additions and 216 deletions.
4 changes: 3 additions & 1 deletion packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Fixed

- Fix focus styles showing up when using the mouse ([#2347](https://github.com/tailwindlabs/headlessui/pull/2347))

## [1.7.13] - 2023-03-03

Expand Down
50 changes: 41 additions & 9 deletions packages/@headlessui-react/src/utils/focus-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,47 @@ export function restoreFocusIfNecessary(element: HTMLElement | null) {
})
}

// The method of triggering an action, this is used to determine how we should
// restore focus after an action has been performed.
enum ActivationMethod {
/* If the action was triggered by a keyboard event. */
Keyboard = 0,

/* If the action was triggered by a mouse / pointer / ... event.*/
Mouse = 1,
}

// We want to be able to set and remove the `data-headlessui-mouse` attribute on the `html` element.
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
document.addEventListener(
'keydown',
(event) => {
if (event.metaKey || event.altKey || event.ctrlKey) {
return
}

document.documentElement.dataset.headlessuiFocusVisible = ''
},
true
)

document.addEventListener(
'click',
(event) => {
// Event originated from an actual mouse click
if (event.detail === ActivationMethod.Mouse) {
delete document.documentElement.dataset.headlessuiFocusVisible
}

// Event originated from a keyboard event that triggered the `click` event
else if (event.detail === ActivationMethod.Keyboard) {
document.documentElement.dataset.headlessuiFocusVisible = ''
}
},
true
)
}

export function focusElement(element: HTMLElement | null) {
element?.focus({ preventScroll: true })
}
Expand Down Expand Up @@ -232,14 +273,5 @@ export function focusIn(
next.select()
}

// This is a little weird, but let me try and explain: There are a few scenario's
// in chrome for example where a focused `<a>` tag does not get the default focus
// styles and sometimes they do. This highly depends on whether you started by
// clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
// then the active element (document.activeElement) is this anchor, which is expected.
// However in that case the default focus styles are not applied *unless* you
// also add this tabindex.
if (!next.hasAttribute('tabindex')) next.setAttribute('tabindex', '0')

return FocusResult.Success
}
2 changes: 1 addition & 1 deletion packages/@headlessui-tailwindcss/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@
},
"devDependencies": {
"esbuild": "^0.11.18",
"tailwindcss": "^3.2.4"
"tailwindcss": "^3.2.7"
}
}
16 changes: 16 additions & 0 deletions packages/@headlessui-tailwindcss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ let css = String.raw
function run(input: string, config: any, plugin = tailwind) {
let { currentTestName } = expect.getState()

// @ts-ignore
return postcss(plugin(config)).process(input, {
from: `${path.resolve(__filename)}?test=${currentTestName}`,
})
Expand Down Expand Up @@ -52,6 +53,21 @@ it('should generate the inverse "not" css for an exposed state', async () => {
})
})

it('should generate the ui-focus-visible variant', async () => {
let config = {
content: [{ raw: html`<div class="ui-focus-visible:underline"></div>` }],
plugins: [hui],
}

return run('@tailwind utilities', config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
:where([data-headlessui-focus-visible]) .ui-focus-visible\:underline:focus {
text-decoration-line: underline;
}
`)
})
})

describe('custom prefix', () => {
it('should generate css for an exposed state', async () => {
let config = {
Expand Down
2 changes: 2 additions & 0 deletions packages/@headlessui-tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export default plugin.withOptions<Options>(({ prefix = 'ui' } = {}) => {
`&[data-headlessui-state]:not([data-headlessui-state~="${state}"])`,
`:where([data-headlessui-state]:not([data-headlessui-state~="${state}"])) &:not([data-headlessui-state])`,
])

addVariant(`${prefix}-focus-visible`, ':where([data-headlessui-focus-visible]) &:focus')
}
}
})
4 changes: 3 additions & 1 deletion packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Fixed

- Fix focus styles showing up when using the mouse ([#2347](https://github.com/tailwindlabs/headlessui/pull/2347))

## [1.7.12] - 2023-03-03

Expand Down
50 changes: 41 additions & 9 deletions packages/@headlessui-vue/src/utils/focus-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,47 @@ export function restoreFocusIfNecessary(element: HTMLElement | null) {
})
}

// The method of triggering an action, this is used to determine how we should
// restore focus after an action has been performed.
enum ActivationMethod {
/* If the action was triggered by a keyboard event. */
Keyboard = 0,

/* If the action was triggered by a mouse / pointer / ... event.*/
Mouse = 1,
}

// We want to be able to set and remove the `data-headlessui-mouse` attribute on the `html` element.
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
document.addEventListener(
'keydown',
(event) => {
if (event.metaKey || event.altKey || event.ctrlKey) {
return
}

document.documentElement.dataset.headlessuiFocusVisible = ''
},
true
)

document.addEventListener(
'click',
(event) => {
// Event originated from an actual mouse click
if (event.detail === ActivationMethod.Mouse) {
delete document.documentElement.dataset.headlessuiFocusVisible
}

// Event originated from a keyboard event that triggered the `click` event
else if (event.detail === ActivationMethod.Keyboard) {
document.documentElement.dataset.headlessuiFocusVisible = ''
}
},
true
)
}

export function focusElement(element: HTMLElement | null) {
element?.focus({ preventScroll: true })
}
Expand Down Expand Up @@ -226,14 +267,5 @@ export function focusIn(
next.select()
}

// This is a little weird, but let me try and explain: There are a few scenario's
// in chrome for example where a focused `<a>` tag does not get the default focus
// styles and sometimes they do. This highly depends on whether you started by
// clicking or by using your keyboard. When you programmatically add focus `anchor.focus()`
// then the active element (document.activeElement) is this anchor, which is expected.
// However in that case the default focus styles are not applied *unless* you
// also add this tabindex.
if (!next.hasAttribute('tabindex')) next.setAttribute('tabindex', '0')

return FocusResult.Success
}
20 changes: 20 additions & 0 deletions packages/playground-react/components/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ComponentProps, forwardRef, ReactNode } from 'react'

function classNames(...classes: (string | false | undefined | null)[]) {
return classes.filter(Boolean).join(' ')
}

export let Button = forwardRef<
HTMLButtonElement,
ComponentProps<'button'> & { children?: ReactNode }
>(({ className, ...props }, ref) => (
<button
ref={ref}
type="button"
className={classNames(
'ui-focus-visible:ring-2 ui-focus-visible:ring-offset-2 flex items-center rounded-md border border-gray-300 bg-white px-2 py-1 ring-gray-500 ring-offset-gray-100 focus:outline-none',
className
)}
{...props}
/>
))
2 changes: 1 addition & 1 deletion packages/playground-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-flatpickr": "^3.10.9",
"tailwindcss": "^0.0.0-insiders.83b4811"
"tailwindcss": "^3.2.7"
}
}
39 changes: 19 additions & 20 deletions packages/playground-react/pages/combinations/form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Switch, RadioGroup, Listbox, Combobox } from '@headlessui/react'
import { classNames } from '../../utils/class-names'
import { Button } from '../../components/button'

function Section({ title, children }) {
return (
Expand Down Expand Up @@ -170,26 +171,24 @@ export default function App() {
{({ value }) => (
<>
<div className="relative">
<span className="inline-block w-full rounded-md shadow-sm">
<Listbox.Button className="relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:text-sm sm:leading-5">
<span className="block truncate">{value?.name?.first}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>
</span>
<Listbox.Button as={Button} className="w-full">
<span className="block truncate">{value?.name?.first}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<svg
className="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</Listbox.Button>

<div className="absolute z-10 mt-1 w-full rounded-md bg-white shadow-lg">
<Listbox.Options className="shadow-xs max-h-60 overflow-auto rounded-md py-1 text-base leading-6 focus:outline-none sm:text-sm sm:leading-5">
Expand Down
15 changes: 8 additions & 7 deletions packages/playground-react/pages/combinations/tabs-in-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { useState } from 'react'
import { Dialog, Tab } from '@headlessui/react'
import { Button } from '../../components/button'

export default function App() {
let [open, setOpen] = useState(false)

return (
<>
<button onClick={() => setOpen(true)}>Open dialog</button>
<div className="p-12">
<Button onClick={() => setOpen(true)}>Open dialog</Button>
<Dialog open={open} onClose={setOpen} className="fixed inset-0 grid place-content-center">
<div className="fixed inset-0 bg-gray-500/70" />
<Dialog.Panel className="inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<Tab.Group>
<Tab.List>
<Tab className="px-3 py-2">Tab 1</Tab>
<Tab className="px-3 py-2">Tab 2</Tab>
<Tab className="px-3 py-2">Tab 3</Tab>
<Tab.List className="flex gap-4 py-4">
<Tab as={Button}>Tab 1</Tab>
<Tab as={Button}>Tab 2</Tab>
<Tab as={Button}>Tab 3</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel className="px-3 py-2">Panel 1</Tab.Panel>
Expand All @@ -26,6 +27,6 @@ export default function App() {
</div>
</Dialog.Panel>
</Dialog>
</>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'
import { Combobox } from '@headlessui/react'

import { classNames } from '../../utils/class-names'
import { Button } from '../../components/button'

let everybody = [
'Wade Cooper',
Expand Down Expand Up @@ -64,7 +65,7 @@ export default function Home() {
onChange={(e) => setQuery(e.target.value)}
className="border-none px-3 py-1 outline-none"
/>
<Combobox.Button className="cursor-default border-l bg-gray-100 px-1 text-indigo-600 focus:outline-none">
<Combobox.Button as={Button}>
<span className="pointer-events-none flex items-center px-2">
<svg
className="h-5 w-5 text-gray-400"
Expand Down
14 changes: 5 additions & 9 deletions packages/playground-react/pages/dialog/dialog-focus-issue.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { useState } from 'react'
import { Dialog } from '@headlessui/react'
import { Button } from '../../components/button'

function Modal(props) {
return (
<Dialog className="relative z-50" {...props}>
<div className="fixed inset-0 bg-green-500 bg-opacity-90 backdrop-blur backdrop-filter" />
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Dialog.Panel className="relative m-5 w-full max-w-3xl rounded-lg bg-white p-10 shadow">
<button className="m-5 rounded-lg bg-blue-600 py-2 px-5 text-white">One</button>
<button className="m-5 rounded-lg bg-blue-600 py-2 px-5 text-white">Two</button>
<Dialog.Panel className="relative m-5 flex w-full max-w-3xl gap-4 rounded-lg bg-white p-10 shadow">
<Button>One</Button>
<Button>Two</Button>
</Dialog.Panel>
</div>
</div>
Expand All @@ -23,12 +24,7 @@ export default function DialogFocusIssue() {
return (
<div className="p-10">
<h1 className="py-2 text-3xl font-semibold">Headless UI Focus Jump</h1>
<button
className="my-5 rounded-lg bg-blue-600 py-2 px-5 text-white"
onClick={() => setIsOpen(true)}
>
Open Dialog
</button>
<Button onClick={() => setIsOpen(true)}>Open Dialog</Button>
<div className="bg-white p-20"></div>
<div className="bg-gray-100 p-20"></div>
<div className="bg-gray-200 p-20"></div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react'
import { useState } from 'react'
import { Dialog } from '@headlessui/react'
import { Button } from '../../components/button'

if (typeof document !== 'undefined') {
class MyCustomElement extends HTMLElement {
Expand Down Expand Up @@ -49,12 +50,7 @@ export default function App() {

return (
<div>
<button
className="m-4 rounded border-0 bg-gray-500 px-3 py-1 font-medium text-white hover:bg-gray-600"
onClick={() => setOpen(true)}
>
open
</button>
<Button onClick={() => setOpen(true)}>open</Button>
<Dialog open={open} onClose={() => setOpen(false)}>
<div className="fixed inset-0 z-50 bg-gray-900/75 backdrop-blur-lg">
<div>
Expand Down
Loading

0 comments on commit 0c0601f

Please sign in to comment.