From 70efd9044a2a7fb751182f8dc9dd89f13a35ac06 Mon Sep 17 00:00:00 2001 From: Liam Ross Date: Wed, 15 Apr 2020 09:42:49 -0700 Subject: [PATCH] fix(Thumbnail): focus now appears on arrow nav as well as tabbing --- src/components/Thumbnail/Thumbnail.tsx | 5 +- .../Thumbnail/thumbnailFocusObservable.ts | 80 +++++++++++++++++++ src/hooks/useAccessibleFocus.ts | 23 ++++-- 3 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 src/components/Thumbnail/thumbnailFocusObservable.ts diff --git a/src/components/Thumbnail/Thumbnail.tsx b/src/components/Thumbnail/Thumbnail.tsx index 70d68e2..9479da2 100644 --- a/src/components/Thumbnail/Thumbnail.tsx +++ b/src/components/Thumbnail/Thumbnail.tsx @@ -1,12 +1,13 @@ import classnames from 'classnames'; import React, { MouseEvent, ReactNode, ReactText, useRef } from 'react'; import { FileLike } from '../../data'; -import { useFile, useFocus, useAccessibleFocus } from '../../hooks'; +import { useAccessibleFocus, useFile, useFocus } from '../../hooks'; import { ClickableDiv, ClickableDivProps } from '../ClickableDiv'; import { EditableText } from '../EditableText'; import { FileSkeleton } from '../FileSkeleton'; import { Image } from '../Image'; import { ToolButton } from '../ToolButton'; +import { thumbnailFocusObservable } from './thumbnailFocusObservable'; export interface ThumbnailButtonProps { key: ReactText; @@ -91,7 +92,7 @@ export function Thumbnail({ onBlur, ...divProps }: ThumbnailProps) { - const isUserTabbing = useAccessibleFocus(); + const isUserTabbing = useAccessibleFocus(thumbnailFocusObservable); const thumbnailRef = useRef(null); diff --git a/src/components/Thumbnail/thumbnailFocusObservable.ts b/src/components/Thumbnail/thumbnailFocusObservable.ts new file mode 100644 index 0000000..88a2c76 --- /dev/null +++ b/src/components/Thumbnail/thumbnailFocusObservable.ts @@ -0,0 +1,80 @@ +import { FocusObservable } from '../../hooks'; + +const validKeys = [ + // Valid keys for thumbnail accessibility. + 'Tab', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', +]; + +class ThumbnailFocusObservable implements FocusObservable { + private _subscribers: Function[]; + private _isUserTabbing: boolean; + + constructor() { + this._subscribers = []; + this._isUserTabbing = false; + } + + get value() { + return this._isUserTabbing; + } + + subscribe(subscriber: Function) { + // If adding first subscriber, begin listening to document. + if (this._subscribers.length === 0) { + if (this._isUserTabbing) { + this._tabToMouseListener(); + } else { + this._mouseToTabListener(); + } + } + const exists = this._subscribers.includes(subscriber); + if (!exists) this._subscribers.push(subscriber); + return this._unsubscribe(subscriber); + } + + private _unsubscribe(subscriber: Function) { + return () => { + this._subscribers = this._subscribers.filter(s => s !== subscriber); + // If no subscribers, stop listening to document. + if (this._subscribers.length === 0) this._removeAllListeners(); + }; + } + + private _setIsUserTabbing(isUserTabbing: boolean) { + this._isUserTabbing = isUserTabbing; + this._subscribers.forEach(subscriber => subscriber()); + } + + private _handleFirstTab = (event: KeyboardEvent) => { + if (validKeys.includes(event.key)) { + this._setIsUserTabbing(true); + this._tabToMouseListener(); + } + }; + + private _handleFirstMouse = () => { + this._setIsUserTabbing(false); + this._mouseToTabListener(); + }; + + private _tabToMouseListener() { + window.removeEventListener('keydown', this._handleFirstTab); + window.addEventListener('mousedown', this._handleFirstMouse); + } + + private _mouseToTabListener() { + window.removeEventListener('mousedown', this._handleFirstMouse); + window.addEventListener('keydown', this._handleFirstTab); + } + + private _removeAllListeners() { + window.removeEventListener('mousedown', this._handleFirstMouse); + window.removeEventListener('keydown', this._handleFirstTab); + } +} + +export const thumbnailFocusObservable = new ThumbnailFocusObservable(); diff --git a/src/hooks/useAccessibleFocus.ts b/src/hooks/useAccessibleFocus.ts index 4e71a7e..f4227fc 100644 --- a/src/hooks/useAccessibleFocus.ts +++ b/src/hooks/useAccessibleFocus.ts @@ -3,19 +3,26 @@ import { useEffect, useState } from 'react'; /** * Will return true if the user is using keyboard navigation, or false if they * are using their mouse. The returned value will be true if the Tab key was - * used more recently than mouse click, and false if not. + * used more recently than mouse click, and false if not. You can also provide + * custom behavior by passing your own observable. + * @param observable Optional custom observable. */ -export function useAccessibleFocus() { - const [isUserTabbing, setIsUserTabbing] = useState(observable.isUserTabbing); +export function useAccessibleFocus(observable: FocusObservable = tabObservable) { + const [isUserTabbing, setIsUserTabbing] = useState(observable.value); useEffect(() => { - return observable.subscribe(() => setIsUserTabbing(observable.isUserTabbing)); - }, []); + return observable.subscribe(() => setIsUserTabbing(observable.value)); + }, [observable]); return isUserTabbing; } -class AccessibleFocusObservable { +export interface FocusObservable { + value: boolean; + subscribe(subscriber: Function): void; +} + +class AccessibleFocusObservable implements FocusObservable { private _subscribers: Function[]; private _isUserTabbing: boolean; @@ -24,7 +31,7 @@ class AccessibleFocusObservable { this._isUserTabbing = false; } - get isUserTabbing() { + get value() { return this._isUserTabbing; } @@ -83,4 +90,4 @@ class AccessibleFocusObservable { } } -const observable = new AccessibleFocusObservable(); +const tabObservable = new AccessibleFocusObservable();