diff --git a/docs/resources/changelog.md b/docs/resources/changelog.md index be3316e36..9f87c5285 100644 --- a/docs/resources/changelog.md +++ b/docs/resources/changelog.md @@ -19,6 +19,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis - Improved icon contrast in `sl-input` - Improved contrast of `sl-switch` - Removed elevation from `sl-color-picker` when rendered inline +- Removed custom `:focus-visible` logic in favor of a directive that outputs `:focus-visible` or `:focus` depending on browser support - Updated to Lit 2.0.0-rc.3 - Updated to lit-html 2.0.0-rc.4 diff --git a/src/components/button/button.styles.ts b/src/components/button/button.styles.ts index 38aa1a2f4..c6998db5e 100644 --- a/src/components/button/button.styles.ts +++ b/src/components/button/button.styles.ts @@ -1,5 +1,6 @@ import { css } from 'lit'; import componentStyles from '../../styles/component.styles'; +import { focusVisibleSelector } from '../../internal/focus-visible'; export default css` ${componentStyles} @@ -80,7 +81,7 @@ export default css` color: rgb(var(--sl-color-primary-700)); } - .button.button--default:focus:not(.button--disabled) { + .button.button--default${focusVisibleSelector}:not(.button--disabled) { background-color: rgb(var(--sl-color-primary-50)); border-color: rgb(var(--sl-color-primary-500)); color: rgb(var(--sl-color-primary-700)); @@ -106,7 +107,7 @@ export default css` color: rgb(var(--sl-color-neutral-0)); } - .button.button--primary:focus:not(.button--disabled) { + .button.button--primary${focusVisibleSelector}:not(.button--disabled) { background-color: rgb(var(--sl-color-primary-500)); border-color: rgb(var(--sl-color-primary-500)); color: rgb(var(--sl-color-neutral-0)); @@ -132,7 +133,7 @@ export default css` color: rgb(var(--sl-color-neutral-0)); } - .button.button--success:focus:not(.button--disabled) { + .button.button--success${focusVisibleSelector}:not(.button--disabled) { background-color: rgb(var(--sl-color-success-600)); border-color: rgb(var(--sl-color-success-600)); color: rgb(var(--sl-color-neutral-0)); @@ -158,7 +159,7 @@ export default css` color: rgb(var(--sl-color-neutral-0)); } - .button.button--neutral:focus:not(.button--disabled) { + .button.button--neutral${focusVisibleSelector}:not(.button--disabled) { background-color: rgb(var(--sl-color-neutral-500)); border-color: rgb(var(--sl-color-neutral-500)); color: rgb(var(--sl-color-neutral-0)); @@ -183,7 +184,7 @@ export default css` color: rgb(var(--sl-color-neutral-0)); } - .button.button--warning:focus:not(.button--disabled) { + .button.button--warning${focusVisibleSelector}:not(.button--disabled) { background-color: rgb(var(--sl-color-warning-500)); border-color: rgb(var(--sl-color-warning-500)); color: rgb(var(--sl-color-neutral-0)); @@ -209,7 +210,7 @@ export default css` color: rgb(var(--sl-color-neutral-0)); } - .button.button--danger:focus:not(.button--disabled) { + .button.button--danger${focusVisibleSelector}:not(.button--disabled) { background-color: rgb(var(--sl-color-danger-500)); border-color: rgb(var(--sl-color-danger-500)); color: rgb(var(--sl-color-neutral-0)); @@ -238,7 +239,7 @@ export default css` color: rgb(var(--sl-color-primary-500)); } - .button--text:focus:not(.button--disabled) { + .button--text${focusVisibleSelector}:not(.button--disabled) { background-color: transparent; border-color: transparent; color: rgb(var(--sl-color-primary-500)); diff --git a/src/components/details/details.styles.ts b/src/components/details/details.styles.ts index 8224d39c2..2c9975aa9 100644 --- a/src/components/details/details.styles.ts +++ b/src/components/details/details.styles.ts @@ -1,5 +1,6 @@ import { css } from 'lit'; import componentStyles from '../../styles/component.styles'; +import { focusVisibleSelector } from '../../internal/focus-visible'; export default css` ${componentStyles} @@ -31,7 +32,7 @@ export default css` outline: none; } - .focus-visible .details__header:focus { + .details__header${focusVisibleSelector} { box-shadow: 0 0 0 var(--sl-focus-ring-width) rgb(var(--sl-color-primary-500) / var(--sl-focus-ring-alpha)); } @@ -39,7 +40,7 @@ export default css` cursor: not-allowed; } - .details--disabled .details__header:focus { + .details--disabled .details__header${focusVisibleSelector} { outline: none; box-shadow: none; } diff --git a/src/components/details/details.ts b/src/components/details/details.ts index 8490d6a62..740c27297 100644 --- a/src/components/details/details.ts +++ b/src/components/details/details.ts @@ -5,7 +5,7 @@ import { animateTo, stopAnimations, shimKeyframesHeightAuto } from '../../intern import { emit } from '../../internal/event'; import { watch } from '../../internal/watch'; import { waitForEvent } from '../../internal/event'; -import { focusVisible } from '../../internal/focus-visible'; +import { focusVisibleSelector } from '../../internal/focus-visible'; import { getAnimation, setDefaultAnimation } from '../../utilities/animation-registry'; import styles from './details.styles'; @@ -55,21 +55,11 @@ export default class SlDetails extends LitElement { /** Disables the details so it can't be toggled. */ @property({ type: Boolean, reflect: true }) disabled = false; - connectedCallback() { - super.connectedCallback(); - this.updateComplete.then(() => focusVisible.observe(this.details)); - } - firstUpdated() { this.body.hidden = !this.open; this.body.style.height = this.open ? 'auto' : '0'; } - disconnectedCallback() { - super.disconnectedCallback(); - focusVisible.unobserve(this.details); - } - /** Shows the details. */ async show() { if (this.open) { diff --git a/src/components/icon-button/icon-button.styles.ts b/src/components/icon-button/icon-button.styles.ts index fed6dec50..07b03278b 100644 --- a/src/components/icon-button/icon-button.styles.ts +++ b/src/components/icon-button/icon-button.styles.ts @@ -1,5 +1,6 @@ import { css } from 'lit'; import componentStyles from '../../styles/component.styles'; +import { focusVisibleSelector } from '../../internal/focus-visible'; export default css` ${componentStyles} @@ -41,7 +42,7 @@ export default css` cursor: not-allowed; } - .focus-visible.icon-button:focus { + .icon-button${focusVisibleSelector} { box-shadow: 0 0 0 var(--sl-focus-ring-width) rgb(var(--sl-color-primary-500) / var(--sl-focus-ring-alpha)); } `; diff --git a/src/components/icon-button/icon-button.ts b/src/components/icon-button/icon-button.ts index 1f742990f..eada30532 100644 --- a/src/components/icon-button/icon-button.ts +++ b/src/components/icon-button/icon-button.ts @@ -2,7 +2,6 @@ import { LitElement, html } from 'lit'; import { customElement, property, query } from 'lit/decorators.js'; import { classMap } from 'lit-html/directives/class-map'; import { ifDefined } from 'lit-html/directives/if-defined'; -import { focusVisible } from '../../internal/focus-visible'; import styles from './icon-button.styles'; import '../icon/icon'; @@ -48,16 +47,6 @@ export default class SlIconButton extends LitElement { /** Disables the button. */ @property({ type: Boolean, reflect: true }) disabled = false; - connectedCallback() { - super.connectedCallback(); - this.updateComplete.then(() => focusVisible.observe(this.button)); - } - - disconnectedCallback() { - super.disconnectedCallback(); - focusVisible.unobserve(this.button); - } - render() { const isLink = this.href ? true : false; diff --git a/src/components/menu-item/menu-item.styles.ts b/src/components/menu-item/menu-item.styles.ts index f1ca375c5..d14fe1aa4 100644 --- a/src/components/menu-item/menu-item.styles.ts +++ b/src/components/menu-item/menu-item.styles.ts @@ -1,5 +1,6 @@ import { css } from 'lit'; import componentStyles from '../../styles/component.styles'; +import { focusVisibleSelector } from '../../internal/focus-visible'; export default css` ${componentStyles} @@ -61,7 +62,7 @@ export default css` } :host(:hover:not([aria-disabled='true'])) .menu-item, - :host(.sl-focus-visible:focus:not([aria-disabled='true'])) .menu-item { + :host(${focusVisibleSelector}:not(.sl-focus-invisible):not([aria-disabled='true'])) .menu-item { outline: none; background-color: rgb(var(--sl-color-primary-600)); color: rgb(var(--sl-color-neutral-0)); diff --git a/src/components/menu/menu.ts b/src/components/menu/menu.ts index f43e61e35..6c75d8ffd 100644 --- a/src/components/menu/menu.ts +++ b/src/components/menu/menu.ts @@ -2,7 +2,7 @@ import { LitElement, html } from 'lit'; import { customElement, query } from 'lit/decorators.js'; import { emit } from '../../internal/event'; import { getTextContent } from '../../internal/slot'; -import { focusVisible } from '../../internal/focus-visible'; +import { hasFocusVisible } from '../../internal/focus-visible'; import type SlMenuItem from '../menu-item/menu-item'; import styles from './menu.styles'; @@ -26,19 +26,6 @@ export default class SlMenu extends LitElement { private typeToSelectString = ''; private typeToSelectTimeout: any; - connectedCallback() { - super.connectedCallback(); - focusVisible.observe(this, { - visible: () => this.getAllItems().map(item => item.classList.add('sl-focus-visible')), - notVisible: () => this.getAllItems().map(item => item.classList.remove('sl-focus-visible')) - }); - } - - disconnectedCallback() { - super.disconnectedCallback(); - focusVisible.unobserve(this); - } - getAllItems(options: { includeDisabled: boolean } = { includeDisabled: true }) { return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => { if (el.getAttribute('role') !== 'menuitem') { @@ -85,15 +72,18 @@ export default class SlMenu extends LitElement { this.typeToSelectTimeout = setTimeout(() => (this.typeToSelectString = ''), 750); this.typeToSelectString += key.toLowerCase(); - // The menu may not have focus, so the focus visible logic may not be triggered. Because we know they're using the - // keyboard, we can force the sl-focus-visible class on each item so the selection shows as expected. - this.getAllItems().map(item => item.classList.add('sl-focus-visible')); + // Restore focus in browsers that don't support :focus-visible when using the keyboard + if (!hasFocusVisible) { + items.map(item => item.classList.remove('sl-focus-invisible')); + } for (const item of items) { const slot = item.shadowRoot!.querySelector('slot:not([name])') as HTMLSlotElement; const label = getTextContent(slot).toLowerCase().trim(); if (label.substring(0, this.typeToSelectString.length) === this.typeToSelectString) { this.setCurrentItem(item); + + // Set focus here to force the browser to show :focus-visible styles item.focus(); break; } @@ -109,6 +99,14 @@ export default class SlMenu extends LitElement { } } + handleKeyUp() { + // Restore focus in browsers that don't support :focus-visible when using the keyboard + if (!hasFocusVisible) { + const items = this.getAllItems(); + items.map(item => item.classList.remove('sl-focus-invisible')); + } + } + handleKeyDown(event: KeyboardEvent) { // Make a selection when pressing enter if (event.key === 'Enter') { @@ -163,7 +161,11 @@ export default class SlMenu extends LitElement { if (target.getAttribute('role') === 'menuitem') { this.setCurrentItem(target as SlMenuItem); - target.focus(); + + // Hide focus in browsers that don't support :focus-visible when using the mouse + if (!hasFocusVisible) { + target.classList.add('sl-focus-invisible'); + } } } @@ -184,6 +186,7 @@ export default class SlMenu extends LitElement { role="menu" @click=${this.handleClick} @keydown=${this.handleKeyDown} + @keyup=${this.handleKeyUp} @mousedown=${this.handleMouseDown} > diff --git a/src/components/rating/rating.styles.ts b/src/components/rating/rating.styles.ts index d83388ac6..80e308e79 100644 --- a/src/components/rating/rating.styles.ts +++ b/src/components/rating/rating.styles.ts @@ -1,5 +1,6 @@ import { css } from 'lit'; import componentStyles from '../../styles/component.styles'; +import { focusVisibleSelector } from '../../internal/focus-visible'; export default css` ${componentStyles} @@ -24,7 +25,7 @@ export default css` outline: none; } - .rating.focus-visible:focus { + .rating${focusVisibleSelector} { box-shadow: 0 0 0 var(--sl-focus-ring-width) rgb(var(--sl-color-primary-500) / var(--sl-focus-ring-alpha)); } diff --git a/src/components/rating/rating.ts b/src/components/rating/rating.ts index 7f6acfc94..fe43f188f 100644 --- a/src/components/rating/rating.ts +++ b/src/components/rating/rating.ts @@ -5,7 +5,6 @@ import { styleMap } from 'lit-html/directives/style-map'; import { unsafeHTML } from 'lit-html/directives/unsafe-html'; import { emit } from '../../internal/event'; import { watch } from '../../internal/watch'; -import { focusVisible } from '../../internal/focus-visible'; import { clamp } from '../../internal/math'; import styles from './rating.styles'; @@ -65,16 +64,6 @@ export default class SlRating extends LitElement { this.rating.blur(); } - connectedCallback() { - super.connectedCallback(); - this.updateComplete.then(() => focusVisible.observe(this.rating)); - } - - disconnectedCallback() { - super.disconnectedCallback(); - focusVisible.unobserve(this.rating); - } - getValueFromMousePosition(event: MouseEvent) { return this.getValueFromXCoordinate(event.clientX); } diff --git a/src/components/tab-group/tab-group.styles.ts b/src/components/tab-group/tab-group.styles.ts index 7feefbf0f..270821e1d 100644 --- a/src/components/tab-group/tab-group.styles.ts +++ b/src/components/tab-group/tab-group.styles.ts @@ -28,11 +28,6 @@ export default css` transition: var(--sl-transition-fast) transform ease, var(--sl-transition-fast) width ease; } - /* Remove the focus ring when the user isn't interacting with a keyboard */ - .tab-group:not(.focus-visible) ::slotted(sl-tab) { - --focus-ring: none; - } - .tab-group--has-scroll-controls .tab-group__nav-container { position: relative; padding: 0 var(--sl-spacing-x-large); diff --git a/src/components/tab-group/tab-group.ts b/src/components/tab-group/tab-group.ts index 230607728..e0cd9738d 100644 --- a/src/components/tab-group/tab-group.ts +++ b/src/components/tab-group/tab-group.ts @@ -3,7 +3,6 @@ import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit-html/directives/class-map'; import { emit } from '../../internal/event'; import { watch } from '../../internal/watch'; -import { focusVisible } from '../../internal/focus-visible'; import { getOffset } from '../../internal/offset'; import { scrollIntoView } from '../../internal/scroll'; import type SlTab from '../tab/tab'; @@ -88,7 +87,6 @@ export default class SlTabGroup extends LitElement { this.syncTabsAndPanels(); this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true }); this.resizeObserver.observe(this.nav); - focusVisible.observe(this.tabGroup); // Set initial tab state when the tabs first become visible const intersectionObserver = new IntersectionObserver((entries, observer) => { @@ -105,7 +103,6 @@ export default class SlTabGroup extends LitElement { disconnectedCallback() { this.mutationObserver.disconnect(); this.resizeObserver.unobserve(this.nav); - focusVisible.unobserve(this.tabGroup); } /** Shows the specified tab panel. */ diff --git a/src/components/tab/tab.styles.ts b/src/components/tab/tab.styles.ts index 2d8027937..c7874aa14 100644 --- a/src/components/tab/tab.styles.ts +++ b/src/components/tab/tab.styles.ts @@ -1,12 +1,11 @@ import { css } from 'lit'; import componentStyles from '../../styles/component.styles'; +import { focusVisibleSelector } from '../../internal/focus-visible'; export default css` ${componentStyles} :host { - --focus-ring: inset 0 0 0 var(--sl-focus-ring-width) rgb(var(--sl-color-primary-500) / var(--sl-focus-ring-alpha)); - display: inline-block; } @@ -33,9 +32,9 @@ export default css` outline: none; } - .tab:focus:not(.tab--disabled) { + .tab${focusVisibleSelector}:not(.tab--disabled) { color: rgb(var(--sl-color-primary-600)); - box-shadow: var(--focus-ring); + box-shadow: inset 0 0 0 var(--sl-focus-ring-width) rgb(var(--sl-color-primary-500) / var(--sl-focus-ring-alpha)); } .tab.tab--active:not(.tab--disabled) { diff --git a/src/internal/focus-visible.ts b/src/internal/focus-visible.ts index c815b0795..7d2440ab5 100644 --- a/src/internal/focus-visible.ts +++ b/src/internal/focus-visible.ts @@ -1,53 +1,26 @@ +import { unsafeCSS } from 'lit'; + // -// Simulates :focus-visible behavior on an element by watching for certain keyboard and mouse heuristics and toggling a -// `focus-visible` class. Works at the component level so no global polyfill is necessary. +// Determines if the current browser supports :focus-visible // -// This will eventually be removed pending better :focus-visible support: https://caniuse.com/#search=focus-visible +export const hasFocusVisible = (() => { + const style = document.createElement('style'); + let isSupported; + + try { + document.head.appendChild(style); + style.sheet!.insertRule(':focus-visible { color: inherit }'); + isSupported = true; + } catch { + isSupported = false; + } finally { + style.remove(); + } + + return isSupported; +})(); + // -const listeners = new WeakMap(); - -interface ObserveOptions { - visible: () => void; - notVisible: () => void; -} - -export function observe(el: HTMLElement, options?: ObserveOptions) { - const keys = ['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageDown', 'PageUp']; - const is = (event: KeyboardEvent) => { - if (keys.includes(event.key)) { - el.classList.add('focus-visible'); - - if (options?.visible) { - options.visible(); - } - } - }; - const isNot = () => { - el.classList.remove('focus-visible'); - - if (options?.notVisible) { - options.notVisible(); - } - }; - listeners.set(el, { is, isNot }); - - el.addEventListener('keydown', is); - el.addEventListener('keyup', is); - el.addEventListener('mousedown', isNot); - el.addEventListener('mouseup', isNot); -} - -export function unobserve(el: HTMLElement) { - const { is, isNot } = listeners.get(el); - - el.classList.remove('focus-visible'); - el.removeEventListener('keydown', is); - el.removeEventListener('keyup', is); - el.removeEventListener('mousedown', isNot); - el.removeEventListener('mouseup', isNot); -} - -export const focusVisible = { - observe, - unobserve -}; +// A selector for Lit stylesheets that outputs `:focus-visible` if the browser supports it and `:focus` otherwise +// +export const focusVisibleSelector = unsafeCSS(hasFocusVisible ? ':focus-visible' : ':focus');