From eb18d759f1a7fe683944d6ff47cdc5379bc1daf9 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Sun, 27 Feb 2022 11:46:55 -0500 Subject: [PATCH] fixes #693 --- docs/components/dialog.md | 23 +++--------- docs/components/drawer.md | 24 +++--------- docs/resources/changelog.md | 2 + src/components/dialog/dialog.ts | 65 +++++++++++++++------------------ src/components/drawer/drawer.ts | 65 +++++++++++++++------------------ src/internal/support.ts | 15 -------- 6 files changed, 74 insertions(+), 120 deletions(-) delete mode 100644 src/internal/support.ts diff --git a/docs/components/dialog.md b/docs/components/dialog.md index ec000f4e3..44a726aa8 100644 --- a/docs/components/dialog.md +++ b/docs/components/dialog.md @@ -222,11 +222,11 @@ const App = () => { ### Customizing Initial Focus -By default, the dialog's panel will gain focus when opened. This allows a subsequent tab press to focus on the first tabbable element within the dialog. To set focus on a different element, listen for and cancel the `sl-initial-focus` event. +By default, the dialog's panel will gain focus when opened. This allows a subsequent tab press to focus on the first tabbable element in the dialog. If you want a different element to have focus, add the `autofocus` attribute to it as shown below. ```html preview - + Close @@ -240,31 +240,20 @@ By default, the dialog's panel will gain focus when opened. This allows a subseq openButton.addEventListener('click', () => dialog.show()); closeButton.addEventListener('click', () => dialog.hide()); - - dialog.addEventListener('sl-initial-focus', event => { - event.preventDefault(); - input.focus({ preventScroll: true }); - }); ``` ```jsx react -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { SlButton, SlDialog, SlInput } from '@shoelace-style/shoelace/dist/react'; const App = () => { - const input = useRef(null); const [open, setOpen] = useState(false); - function handleInitialFocus(event) { - event.preventDefault(); - input.current.focus(); - } - return ( <> - setOpen(false)}> - + setOpen(false)}> + setOpen(false)}> Close @@ -276,6 +265,6 @@ const App = () => { }; ``` -?> Alternatively, you can add the `autofocus` attribute to any form control to customize initial focus without using JavaScript. +?> You can further customize initial focus behavior by canceling the `sl-initial-focus` event and setting focus yourself inside the event handler. [component-metadata:sl-dialog] diff --git a/docs/components/drawer.md b/docs/components/drawer.md index 9b9a62fcb..e215982d4 100644 --- a/docs/components/drawer.md +++ b/docs/components/drawer.md @@ -410,11 +410,11 @@ const App = () => { ### Customizing Initial Focus -By default, the drawer's panel will gain focus when opened. This allows the first tab press to focus on the first tabbable element within the drawer. To set focus on a different element, listen for and cancel the `sl-initial-focus` event. +By default, the drawer's panel will gain focus when opened. This allows a subsequent tab press to focus on the first tabbable element in the drawer. If you want a different element to have focus, add the `autofocus` attribute to it as shown below. ```html preview - + Close @@ -428,31 +428,20 @@ By default, the drawer's panel will gain focus when opened. This allows the firs openButton.addEventListener('click', () => drawer.show()); closeButton.addEventListener('click', () => drawer.hide()); - - drawer.addEventListener('sl-initial-focus', event => { - event.preventDefault(); - input.focus({ preventScroll: true }); - }); ``` ```jsx react -import { useRef, useState } from 'react'; +import { useState } from 'react'; import { SlButton, SlDrawer, SlInput } from '@shoelace-style/shoelace/dist/react'; const App = () => { - const input = useRef(null); const [open, setOpen] = useState(false); - function handleInitialFocus(event) { - event.preventDefault(); - input.current.focus(); - } - return ( <> - setOpen(false)}> - + setOpen(false)}> + setOpen(false)}> Close @@ -464,6 +453,5 @@ const App = () => { }; ``` -?> Alternatively, you can add the `autofocus` attribute to any form control to customize initial focus without using JavaScript. - +?> You can further customize initial focus behavior by canceling the `sl-initial-focus` event and setting focus yourself inside the event handler. [component-metadata:sl-drawer] diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index bfb43a767..ee0fe2073 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -8,6 +8,8 @@ _During the beta period, these restrictions may be relaxed in the event of a mis ## Next +- Improved `autofocus` behavior in Safari for `` and `` [#693](https://github.com/shoelace-style/shoelace/issues/693) +- Removed feature detection for `focus({ preventScroll })` since it no longer works in Safari - Removed path aliasing and third-party dependencies that it required ## 2.0.0-beta.70 diff --git a/src/components/dialog/dialog.ts b/src/components/dialog/dialog.ts index 439f5749c..112a23746 100644 --- a/src/components/dialog/dialog.ts +++ b/src/components/dialog/dialog.ts @@ -7,15 +7,12 @@ import { emit, waitForEvent } from '../../internal/event'; import Modal from '../../internal/modal'; import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll'; import { HasSlotController } from '../../internal/slot'; -import { isPreventScrollSupported } from '../../internal/support'; import { watch } from '../../internal/watch'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; import { LocalizeController } from '../../utilities/localize'; import '../icon-button/icon-button'; import styles from './dialog.styles'; -const hasPreventScroll = isPreventScrollSupported(); - /** * @since 2.0 * @status stable @@ -30,8 +27,8 @@ const hasPreventScroll = isPreventScrollSupported(); * @event sl-after-show - Emitted after the dialog opens and all animations are complete. * @event sl-hide - Emitted when the dialog closes. * @event sl-after-hide - Emitted after the dialog closes and all animations are complete. - * @event sl-initial-focus - Emitted when the dialog opens and the panel gains focus. Calling `event.preventDefault()` - * will prevent focus and allow you to set it on a different element in the dialog, such as an input or button. + * @event sl-initial-focus - Emitted when the dialog opens and is ready to receive focus. Calling + * `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input. * @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to * close the dialog by clicking the close button, clicking the overlay, or pressing escape. Calling * `event.preventDefault()` will keep the dialog open. Avoid using this unless closing the dialog will result in @@ -139,17 +136,6 @@ export default class SlDialog extends LitElement { this.hide(); } - // Sets focus on the first child element with autofocus, falling back to the panel if one isn't found - private setInitialFocus() { - const target = this.querySelector('[autofocus]'); - - if (target) { - (target as HTMLElement).focus({ preventScroll: true }); - } else { - this.panel.focus({ preventScroll: true }); - } - } - handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') { event.stopPropagation(); @@ -167,18 +153,38 @@ export default class SlDialog extends LitElement { lockBodyScrolling(this); + // When the dialog is shown, Safari will attempt to set focus on whatever element has autofocus. This can cause + // the dialogs's animation to jitter (if it starts offscreen), so we'll temporarily remove the attribute, call + // `focus({ preventScroll: true })` ourselves, and add the attribute back afterwards. + // + // Related: https://github.com/shoelace-style/shoelace/issues/693 + // + const autoFocusTarget = this.querySelector('[autofocus]'); + if (autoFocusTarget) { + autoFocusTarget.removeAttribute('autofocus'); + } + await Promise.all([stopAnimations(this.dialog), stopAnimations(this.overlay)]); this.dialog.hidden = false; - // Browsers that support el.focus({ preventScroll }) can set initial focus immediately - if (hasPreventScroll) { - requestAnimationFrame(() => { - const slInitialFocus = emit(this, 'sl-initial-focus', { cancelable: true }); - if (!slInitialFocus.defaultPrevented) { - this.setInitialFocus(); + // Set initial focus + requestAnimationFrame(() => { + const slInitialFocus = emit(this, 'sl-initial-focus', { cancelable: true }); + + if (!slInitialFocus.defaultPrevented) { + // Set focus to the autofocus target and restore the attribute + if (autoFocusTarget) { + (autoFocusTarget as HTMLInputElement).focus({ preventScroll: true }); + } else { + this.panel.focus({ preventScroll: true }); } - }); - } + } + + // Restore the autofocus attribute + if (autoFocusTarget) { + autoFocusTarget.setAttribute('autofocus', ''); + } + }); const panelAnimation = getAnimation(this, 'dialog.show'); const overlayAnimation = getAnimation(this, 'dialog.overlay.show'); @@ -187,17 +193,6 @@ export default class SlDialog extends LitElement { animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options) ]); - // Browsers that don't support el.focus({ preventScroll }) have to wait for the animation to finish before initial - // focus to prevent scrolling issues. See: https://caniuse.com/mdn-api_htmlelement_focus_preventscroll_option - if (!hasPreventScroll) { - requestAnimationFrame(() => { - const slInitialFocus = emit(this, 'sl-initial-focus', { cancelable: true }); - if (!slInitialFocus.defaultPrevented) { - this.setInitialFocus(); - } - }); - } - emit(this, 'sl-after-show'); } else { // Hide diff --git a/src/components/drawer/drawer.ts b/src/components/drawer/drawer.ts index 24825172f..8405eee2e 100644 --- a/src/components/drawer/drawer.ts +++ b/src/components/drawer/drawer.ts @@ -8,15 +8,12 @@ import Modal from '../../internal/modal'; import { lockBodyScrolling, unlockBodyScrolling } from '../../internal/scroll'; import { HasSlotController } from '../../internal/slot'; import { uppercaseFirstLetter } from '../../internal/string'; -import { isPreventScrollSupported } from '../../internal/support'; import { watch } from '../../internal/watch'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; import { LocalizeController } from '../../utilities/localize'; import '../icon-button/icon-button'; import styles from './drawer.styles'; -const hasPreventScroll = isPreventScrollSupported(); - /** * @since 2.0 * @status stable @@ -31,8 +28,8 @@ const hasPreventScroll = isPreventScrollSupported(); * @event sl-after-show - Emitted after the drawer opens and all animations are complete. * @event sl-hide - Emitted when the drawer closes. * @event sl-after-hide - Emitted after the drawer closes and all animations are complete. - * @event sl-initial-focus - Emitted when the drawer opens and the panel gains focus. Calling `event.preventDefault()` will - * prevent focus and allow you to set it on a different element in the drawer, such as an input or button. + * @event sl-initial-focus - Emitted when the drawer opens and is ready to receive focus. Calling + * `event.preventDefault()` will prevent focusing and allow you to set it on a different element, such as an input. * @event {{ source: 'close-button' | 'keyboard' | 'overlay' }} sl-request-close - Emitted when the user attempts to * close the drawer by clicking the close button, clicking the overlay, or pressing escape. Calling * `event.preventDefault()` will keep the drawer open. Avoid using this unless closing the drawer will result in @@ -156,17 +153,6 @@ export default class SlDrawer extends LitElement { this.hide(); } - // Sets focus on the first child element with autofocus, falling back to the panel if one isn't found - private setInitialFocus() { - const target = this.querySelector('[autofocus]'); - - if (target) { - (target as HTMLElement).focus({ preventScroll: true }); - } else { - this.panel.focus({ preventScroll: true }); - } - } - handleKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') { event.stopPropagation(); @@ -187,18 +173,38 @@ export default class SlDrawer extends LitElement { lockBodyScrolling(this); } + // When the drawer is shown, Safari will attempt to set focus on whatever element has autofocus. This causes the + // drawer's animation to jitter, so we'll temporarily remove the attribute, call `focus({ preventScroll: true })` + // ourselves, and add the attribute back afterwards. + // + // Related: https://github.com/shoelace-style/shoelace/issues/693 + // + const autoFocusTarget = this.querySelector('[autofocus]'); + if (autoFocusTarget) { + autoFocusTarget.removeAttribute('autofocus'); + } + await Promise.all([stopAnimations(this.drawer), stopAnimations(this.overlay)]); this.drawer.hidden = false; - // Browsers that support el.focus({ preventScroll }) can set initial focus immediately - if (hasPreventScroll) { - requestAnimationFrame(() => { - const slInitialFocus = emit(this, 'sl-initial-focus', { cancelable: true }); - if (!slInitialFocus.defaultPrevented) { - this.setInitialFocus(); + // Set initial focus + requestAnimationFrame(() => { + const slInitialFocus = emit(this, 'sl-initial-focus', { cancelable: true }); + + if (!slInitialFocus.defaultPrevented) { + // Set focus to the autofocus target and restore the attribute + if (autoFocusTarget) { + (autoFocusTarget as HTMLInputElement).focus({ preventScroll: true }); + } else { + this.panel.focus({ preventScroll: true }); } - }); - } + } + + // Restore the autofocus attribute + if (autoFocusTarget) { + autoFocusTarget.setAttribute('autofocus', ''); + } + }); const panelAnimation = getAnimation(this, `drawer.show${uppercaseFirstLetter(this.placement)}`); const overlayAnimation = getAnimation(this, 'drawer.overlay.show'); @@ -207,17 +213,6 @@ export default class SlDrawer extends LitElement { animateTo(this.overlay, overlayAnimation.keyframes, overlayAnimation.options) ]); - // Browsers that don't support el.focus({ preventScroll }) have to wait for the animation to finish before initial - // focus to prevent scrolling issues. See: https://caniuse.com/mdn-api_htmlelement_focus_preventscroll_option - if (!hasPreventScroll) { - requestAnimationFrame(() => { - const slInitialFocus = emit(this, 'sl-initial-focus', { cancelable: true }); - if (!slInitialFocus.defaultPrevented) { - this.setInitialFocus(); - } - }); - } - emit(this, 'sl-after-show'); } else { // Hide diff --git a/src/internal/support.ts b/src/internal/support.ts deleted file mode 100644 index 79c16a6e0..000000000 --- a/src/internal/support.ts +++ /dev/null @@ -1,15 +0,0 @@ -// -// Determines if the browser supports focus({ preventScroll }) -// -export function isPreventScrollSupported() { - let supported = false; - - document.createElement('div').focus({ - get preventScroll() { - supported = true; - return false; - } - }); - - return supported; -}