diff --git a/src/lib/Button/Button.tsx b/src/lib/Button/Button.tsx index 39b5e67a..45951fd4 100644 --- a/src/lib/Button/Button.tsx +++ b/src/lib/Button/Button.tsx @@ -13,7 +13,7 @@ const ButtonRoot = styled(Flex, { 'lineHeight': '$base', 'fontWeight': 500, 'transition': '0.2s ease-in-out', - '$outline': 1, + '$outline': 0, 'font': '$default', 'border': '1px solid transparent', 'br': '$md', diff --git a/src/lib/FocusRing/focus-visible.ts b/src/lib/FocusRing/focus-visible.ts new file mode 100644 index 00000000..c974a19c --- /dev/null +++ b/src/lib/FocusRing/focus-visible.ts @@ -0,0 +1,207 @@ +import { isServer } from '../utils'; + +export const attribute = 'data-focus-visible'; + +if (!isServer) { + applyFocusVisiblePolyfill(); +} + +/** + * Applies the :focus-visible polyfill at the given scope. + * A scope in this case is either the top-level Document or a Shadow Root. + * + * @see https://github.com/WICG/focus-visible + */ +function applyFocusVisiblePolyfill() { + let hadKeyboardEvent = true; + let hadFocusVisibleRecently = false; + let hadFocusVisibleRecentlyTimeout: number; + + const events = [ + 'mousemove', + 'mousedown', + 'mouseup', + 'pointermove', + 'pointerdown', + 'pointerup', + 'touchmove', + 'touchstart', + 'touchend', + ]; + + /** + * If the most recent user interaction was via the keyboard; + * and the key press did not include a meta, alt/option, or control key; + * then the modality is keyboard. Otherwise, the modality is not keyboard. + * Apply `focus-visible` to any current active element and keep track + * of our keyboard modality state with `hadKeyboardEvent`. + * @param {KeyboardEvent} e + */ + function onKeyDown(e: KeyboardEvent) { + if (e.metaKey || e.altKey || e.ctrlKey) { + return; + } + + if (document.activeElement && isValidFocusTarget(document.activeElement)) { + addFocusVisible(document.activeElement); + } + + hadKeyboardEvent = true; + } + + /** + * If at any point a user clicks with a pointing device, ensure that we change + * the modality away from keyboard. + * This avoids the situation where a user presses a key on an already focused + * element, and then clicks on a different element, focusing it with a + * pointing device, while we still think we're in keyboard modality. + * @param {PointerEvent} e + */ + function onPointerDown() { + hadKeyboardEvent = false; + } + + /** + * On `focus`, add the `focus-visible` class to the target if: + * - the target received focus as a result of keyboard navigation, or + * - the event target is an element that will likely require interaction + * via the keyboard (e.g. a text box) + * @param {Event} e + */ + function onFocus(e: FocusEvent) { + // Prevent IE from focusing the document or HTML element. + if (!isValidFocusTarget(e.target)) { + return; + } + + if (hadKeyboardEvent) { + addFocusVisible(e.target); + } + } + + /** + * On `blur`, remove the `focus-visible` class from the target. + * @param {Event} e + */ + function onBlur(e: FocusEvent) { + if (!isValidFocusTarget(e.target)) { + return; + } + + if (isFocused(e.target)) { + // To detect a tab/window switch, we look for a blur event followed + // rapidly by a visibility change. + // If we don't see a visibility change within 100ms, it's probably a + // regular focus change. + hadFocusVisibleRecently = true; + window.clearTimeout(hadFocusVisibleRecentlyTimeout); + hadFocusVisibleRecentlyTimeout = window.setTimeout(function () { + hadFocusVisibleRecently = false; + }, 100); + removeFocusVisible(e.target); + } + } + + /** + * If the user changes tabs, keep track of whether or not the previously + * focused element had .focus-visible. + * @param {Event} e + */ + function onVisibilityChange() { + if (document.visibilityState === 'hidden') { + // If the tab becomes active again, the browser will handle calling focus + // on the element (Safari actually calls it twice). + // If this tab change caused a blur on an element with focus-visible, + // re-apply the class when the user switches back to the tab. + if (hadFocusVisibleRecently) { + hadKeyboardEvent = true; + } + addInitialPointerMoveListeners(); + } + } + + /** + * Add a group of listeners to detect usage of any pointing devices. + * These listeners will be added when the polyfill first loads, and anytime + * the window is blurred, so that they are active when the window regains + * focus. + */ + function addInitialPointerMoveListeners() { + events.forEach((ev) => document.addEventListener(ev, onInitialPointerMove)); + } + + function removeInitialPointerMoveListeners() { + events.forEach((ev) => document.removeEventListener(ev, onInitialPointerMove)); + } + + /** + * When the polfyill first loads, assume the user is in keyboard modality. + * If any event is received from a pointing device (e.g. mouse, pointer, + * touch), turn off keyboard modality. + * This accounts for situations where focus enters the page from the URL bar. + * @param {Event} e + */ + function onInitialPointerMove(e: Event) { + // Work around a Safari quirk that fires a mousemove on whenever the + // window blurs, even if you're tabbing out of the page. ¯\_(ツ)_/¯ + if ((e.target as Element)?.nodeName.toLowerCase() === 'html') { + return; + } + + hadKeyboardEvent = false; + removeInitialPointerMoveListeners(); + } + + // For some kinds of state, we are interested in changes at the global scope + // only. For example, global pointer input, global key presses and global + // visibility change should affect the state at every scope: + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('mousedown', onPointerDown, true); + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('touchstart', onPointerDown, true); + document.addEventListener('visibilitychange', onVisibilityChange, true); + + addInitialPointerMoveListeners(); + + // For focus and blur, we specifically care about state changes in the local + // scope. This is because focus / blur events that originate from within a + // shadow root are not re-dispatched from the host element if it was already + // the active element in its own scope: + document.addEventListener('focus', onFocus, true); + document.addEventListener('blur', onBlur, true); +} + +/** + * Helper function for legacy browsers and iframes which sometimes focus + * elements like document, body, and non-interactive SVG. + * @param {Element} el + */ +function isValidFocusTarget(el: Element | EventTarget | null) { + return ( + el && + el !== document && + (el as Element).nodeName !== 'HTML' && + (el as Element).nodeName !== 'BODY' && + 'setAttribute' in el + ); +} + +/** + * Add the `focus-visible` class to the given element if it was not added by + * the author. + * @param {Element} el + */ +function addFocusVisible(el: Element | EventTarget | null) { + (el as Element)?.setAttribute(attribute, ''); +} +/** + * Remove the `focus-visible` class from the given element if it was not + * originally added by the author. + * @param {Element} el + */ +function removeFocusVisible(el: Element | EventTarget | null) { + (el as Element)?.removeAttribute(attribute); +} +function isFocused(el: Element | EventTarget | null) { + return (el as Element)?.getAttribute(attribute) !== undefined; +} diff --git a/src/lib/stitches.config.ts b/src/lib/stitches.config.ts index 647fcd5e..50a96b53 100644 --- a/src/lib/stitches.config.ts +++ b/src/lib/stitches.config.ts @@ -1,4 +1,5 @@ import { createStyled } from '@stitches/react'; +import { attribute } from './FocusRing/focus-visible'; import colors from './theme/colors'; import { scales } from './theme/scales'; import { isServer } from './utils'; @@ -158,8 +159,8 @@ export const { styled, css } = createStyled({ $outline: (offset: number) => ({ ':not(:disabled)': { - 'position': 'relative', 'outline': 'none', + 'position': 'relative', '::before': { content: `''`, @@ -179,9 +180,8 @@ export const { styled, css } = createStyled({ }, ':focus': { outline: 'none', - boxShadow: 'none', }, - ':focus:focus-visible::before': { + [`:focus[${attribute}]:before`]: { boxShadow: '0 0 0 2px $text', }, }),