diff --git a/README.md b/README.md index 45b4d81..d750516 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,10 @@ Receives `direction` (`left`, `right`, `up`, `down`), `extraProps` (see below) a This callback HAS to return `true` if you want to proceed with the default directional navigation behavior, or `false` if you want to block the navigation in the specified direction. +##### `onArrowRelease` (function) +Callback that is called when the component is focused and Arrow key is released. +Receives `direction` (`left`, `right`, `up`, `down`), `extraProps` (see below) as argument. + ##### `onFocus` (function) Callback that is called when component gets focus. Receives `FocusableComponentLayout`, `extraProps` and `FocusDetails` as arguments. diff --git a/src/App.tsx b/src/App.tsx index f2063e4..f2d9b04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -153,6 +153,7 @@ function Menu({ focusKey: focusKeyParam }: MenuProps) { onEnterPress: () => {}, onEnterRelease: () => {}, onArrowPress: () => true, + onArrowRelease: () => {}, onFocus: () => {}, onBlur: () => {}, extraProps: { foo: 'bar' } @@ -383,6 +384,85 @@ const ScrollingRows = styled.div` flex-grow: 1; `; +interface ProgressBarWrapperProps { + focused: boolean; +} + +interface ProgressBarProgressProps { + percent: number; + focused: boolean; +} + +const ProgressBarWrapper = styled.div` + position: absolute; + bottom: 95px; + right: 100px; + width: 540px; + height: 24px; + background-color: gray; + border-radius: 21px; + border-color: white; + border-style: solid; + border-width: ${({ focused }) => (focused ? '6px' : 0)}; + box-sizing: border-box; +`; + +const ProgressBarProgress = styled.div` + width: ${({ percent }) => `${percent}%`}; + height: 100%; + background-color: ${({ focused }) => + focused ? 'deepskyblue' : 'dodgerblue'}; + border-radius: 21px; +`; + +const defaultPercent = 10; +const seekPercent = 10; +const delayedTime = 100; +const DIRECTION_RIGHT = 'right'; + +function ProgressBar() { + const [percent, setPercent] = useState(defaultPercent); + const timerRef = useRef(null); + const { ref, focused } = useFocusable({ + onArrowPress: (direction: string) => { + if (direction === DIRECTION_RIGHT && timerRef.current === null) { + timerRef.current = setInterval(() => { + setPercent((prevPercent) => + prevPercent >= 100 ? prevPercent : prevPercent + seekPercent + ); + }, delayedTime); + return true; + } + return true; + }, + onArrowRelease: (direction: string) => { + if (direction === DIRECTION_RIGHT) { + clearInterval(timerRef.current); + timerRef.current = null; + } + } + }); + useEffect(() => { + if (!focused) { + setPercent(defaultPercent); + } + }, [focused]); + useEffect( + () => () => { + if (timerRef.current !== null) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, + [] + ); + return ( + + + + ); +} + function Content() { const { ref, focusKey } = useFocusable(); @@ -415,6 +495,7 @@ function Content() { ? selectedAsset.title : 'Press "Enter" to select an asset'} +
diff --git a/src/SpatialNavigation.ts b/src/SpatialNavigation.ts index b06c8a7..6393216 100644 --- a/src/SpatialNavigation.ts +++ b/src/SpatialNavigation.ts @@ -80,6 +80,7 @@ interface FocusableComponent { onEnterPress: (details?: KeyPressDetails) => void; onEnterRelease: () => void; onArrowPress: (direction: string, details: KeyPressDetails) => boolean; + onArrowRelease: (direction: string) => void; onFocus: (layout: FocusableComponentLayout, details: FocusDetails) => void; onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void; onUpdateFocus: (focused: boolean) => void; @@ -106,6 +107,7 @@ interface FocusableComponentUpdatePayload { onEnterPress: (details?: KeyPressDetails) => void; onEnterRelease: () => void; onArrowPress: (direction: string, details: KeyPressDetails) => boolean; + onArrowRelease: (direction: string) => void; onFocus: (layout: FocusableComponentLayout, details: FocusDetails) => void; onBlur: (layout: FocusableComponentLayout, details: FocusDetails) => void; } @@ -830,6 +832,14 @@ class SpatialNavigationService { if (eventType === KEY_ENTER && this.focusKey) { this.onEnterRelease(); } + + if (this.focusKey && ( + eventType === DIRECTION_LEFT || + eventType === DIRECTION_RIGHT || + eventType === DIRECTION_UP || + eventType === DIRECTION_DOWN)) { + this.onArrowRelease(eventType) + } }; window.addEventListener('keyup', this.keyUpEventListener); @@ -921,6 +931,28 @@ class SpatialNavigationService { ); } + onArrowRelease(direction: string) { + const component = this.focusableComponents[this.focusKey]; + + /* Guard against last-focused component being unmounted at time of onArrowRelease (e.g due to UI fading out) */ + if (!component) { + this.log('onArrowRelease', 'noComponent'); + + return; + } + + /* Suppress onArrowRelease if the last-focused item happens to lose its 'focused' status. */ + if (!component.focusable) { + this.log('onArrowRelease', 'componentNotFocusable'); + + return; + } + + if (component.onArrowRelease) { + component.onArrowRelease(direction); + } + } + /** * Move focus by direction, if you can't use buttons or focusing by key. * @@ -1275,6 +1307,7 @@ class SpatialNavigationService { onEnterPress, onEnterRelease, onArrowPress, + onArrowRelease, onFocus, onBlur, saveLastFocusedChild, @@ -1295,6 +1328,7 @@ class SpatialNavigationService { onEnterPress, onEnterRelease, onArrowPress, + onArrowRelease, onFocus, onBlur, onUpdateFocus, diff --git a/src/__tests__/SpatialNavigation.test.ts b/src/__tests__/SpatialNavigation.test.ts index 0f4e7ce..7e9917d 100644 --- a/src/__tests__/SpatialNavigation.test.ts +++ b/src/__tests__/SpatialNavigation.test.ts @@ -133,7 +133,8 @@ describe('SpatialNavigation', () => { onEnterRelease: () => {}, onFocus: () => {}, onBlur: () => {}, - onArrowPress: () => true + onArrowPress: () => true, + onArrowRelease: () => {}, }); SpatialNavigation.navigateByDirection('right', {}); diff --git a/src/__tests__/domNodes.ts b/src/__tests__/domNodes.ts index ed52543..32c854b 100644 --- a/src/__tests__/domNodes.ts +++ b/src/__tests__/domNodes.ts @@ -36,6 +36,7 @@ export const createRootNode = () => { onFocus: () => {}, onBlur: () => {}, onArrowPress: () => true, + onArrowRelease: () => {}, onUpdateFocus: () => {}, onUpdateHasFocusedChild: () => {} }); @@ -79,6 +80,7 @@ export const createHorizontalLayout = () => { onFocus: () => {}, onBlur: () => {}, onArrowPress: () => true, + onArrowRelease: () => {}, onUpdateFocus: () => {}, onUpdateHasFocusedChild: () => {} }); @@ -118,6 +120,7 @@ export const createHorizontalLayout = () => { onFocus: () => {}, onBlur: () => {}, onArrowPress: () => true, + onArrowRelease: () => {}, onUpdateFocus: () => {}, onUpdateHasFocusedChild: () => {} }); @@ -157,6 +160,7 @@ export const createHorizontalLayout = () => { onFocus: () => {}, onBlur: () => {}, onArrowPress: () => true, + onArrowRelease: () => {}, onUpdateFocus: () => {}, onUpdateHasFocusedChild: () => {} }); @@ -200,6 +204,7 @@ export const createVerticalLayout = () => { onFocus: () => {}, onBlur: () => {}, onArrowPress: () => true, + onArrowRelease: () => {}, onUpdateFocus: () => {}, onUpdateHasFocusedChild: () => {} }); @@ -239,6 +244,7 @@ export const createVerticalLayout = () => { onFocus: () => {}, onBlur: () => {}, onArrowPress: () => true, + onArrowRelease: () => {}, onUpdateFocus: () => {}, onUpdateHasFocusedChild: () => {} }); diff --git a/src/useFocusable.ts b/src/useFocusable.ts index 9a7ba35..a3549fc 100644 --- a/src/useFocusable.ts +++ b/src/useFocusable.ts @@ -30,6 +30,11 @@ export type ArrowPressHandler

= ( details: KeyPressDetails ) => boolean; +export type ArrowReleaseHandler

= ( + direction: string, + props: P, +) => void; + export type FocusHandler

= ( layout: FocusableComponentLayout, props: P, @@ -55,6 +60,7 @@ export interface UseFocusableConfig

{ onEnterPress?: EnterPressHandler

; onEnterRelease?: EnterReleaseHandler

; onArrowPress?: ArrowPressHandler

; + onArrowRelease?: ArrowReleaseHandler

; onFocus?: FocusHandler

; onBlur?: BlurHandler

; extraProps?: P; @@ -81,6 +87,7 @@ const useFocusableHook =

({ onEnterPress = noop, onEnterRelease = noop, onArrowPress = () => true, + onArrowRelease = noop, onFocus = noop, onBlur = noop, extraProps @@ -102,6 +109,10 @@ const useFocusableHook =

({ [extraProps, onArrowPress] ); + const onArrowReleaseHandler = useCallback((direction: string) => { + onArrowRelease(direction, extraProps); + }, [onArrowRelease, extraProps]) + const onFocusHandler = useCallback( (layout: FocusableComponentLayout, details: FocusDetails) => { onFocus(layout, extraProps, details); @@ -149,6 +160,7 @@ const useFocusableHook =

({ onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, + onArrowRelease: onArrowReleaseHandler, onFocus: onFocusHandler, onBlur: onBlurHandler, onUpdateFocus: (isFocused = false) => setFocused(isFocused), @@ -182,6 +194,7 @@ const useFocusableHook =

({ onEnterPress: onEnterPressHandler, onEnterRelease: onEnterReleaseHandler, onArrowPress: onArrowPressHandler, + onArrowRelease: onArrowReleaseHandler, onFocus: onFocusHandler, onBlur: onBlurHandler }); @@ -194,6 +207,7 @@ const useFocusableHook =

({ onEnterPressHandler, onEnterReleaseHandler, onArrowPressHandler, + onArrowReleaseHandler, onFocusHandler, onBlurHandler ]);