diff --git a/src/Routes.tsx b/src/Routes.tsx index a158e9758..1339526af 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -15,9 +15,13 @@ import {HIDConsole} from './components/panes/hid-console'; import styled from 'styled-components'; const GlobalStyle = createGlobalStyle` - *:focus { + *:focus:not(:focus-visible) { outline: none; } + *:focus-visible { + outline: 2px solid var(--color_accent, #6666ff); + outline-offset: 2px; + } `; const PersistentPane = styled.div<{$active: boolean}>` diff --git a/src/components/two-string/key-group.tsx b/src/components/two-string/key-group.tsx index f8ceaf87c..a246dc8c0 100644 --- a/src/components/two-string/key-group.tsx +++ b/src/components/two-string/key-group.tsx @@ -1,4 +1,4 @@ -import {useMemo} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {getBasicKeyToByte} from 'src/store/definitionsSlice'; import {useAppDispatch, useAppSelector} from 'src/store/hooks'; import {getSelectedKey} from 'src/store/keymapSlice'; @@ -34,6 +34,53 @@ const getPosition = (x: number, y: number): [number, number, number] => [ y - CSSVarObject.keyHeight / 2, 0, ]; + +type GridDirection = 'left' | 'right' | 'up' | 'down'; +const ARROW_DIRECTIONS: Record = { + ArrowLeft: 'left', + ArrowRight: 'right', + ArrowUp: 'up', + ArrowDown: 'down', +}; + +// Finds the closest key in a given direction by weighting how aligned it is +// on the cross-axis (e.g. same row for left/right) ahead of raw distance, +// since the physical layout isn't a strict row/column grid. +const findNextKeyIndex = ( + currentIndex: number, + direction: GridDirection, + positions: {i: number; x: number; y: number}[], +) => { + const current = positions.find((p) => p.i === currentIndex); + if (!current) { + return currentIndex; + } + const isHorizontal = direction === 'left' || direction === 'right'; + const sign = direction === 'right' || direction === 'down' ? 1 : -1; + let bestIndex = currentIndex; + let bestScore = Infinity; + for (const p of positions) { + if (p.i === currentIndex) { + continue; + } + const primaryDelta = + ((isHorizontal ? p.x : p.y) - (isHorizontal ? current.x : current.y)) * + sign; + if (primaryDelta <= 0.5) { + continue; + } + const secondaryDelta = Math.abs( + (isHorizontal ? p.y : p.x) - (isHorizontal ? current.y : current.x), + ); + const score = secondaryDelta * 3 + primaryDelta; + if (score < bestScore) { + bestScore = score; + bestIndex = p.i; + } + } + return bestIndex; +}; + const getRGBArray = (keyColors: number[][]) => { return keyColors.map(([hue, sat]) => { const rgbStr = getRGB({ @@ -74,6 +121,49 @@ export const KeyGroup: React.FC> = (props) => { return getLabels(props, macroExpressions, basicKeyToByte, byteToKey, keycodeLUT); }, [keys, props.matrixKeycodes, macros, props.definition, keycodeLUT]); const {width, height} = calculateKeyboardFrameDimensions(keys); + + const keyRefs = useRef>({}); + const positions = useMemo( + () => + props.keys.reduce<{i: number; x: number; y: number}[]>((acc, k, i) => { + if (!k.d) { + const [x, y] = keysKeys.coords[i].position; + acc.push({i, x, y}); + } + return acc; + }, []), + [keys, keysKeys], + ); + const [focusedIndex, setFocusedIndex] = useState( + () => positions.find((p) => p.i === selectedKeyIndex)?.i ?? positions[0]?.i ?? 0, + ); + useEffect(() => { + if ( + typeof selectedKeyIndex === 'number' && + selectedKeyIndex !== focusedIndex && + positions.some((p) => p.i === selectedKeyIndex) + ) { + setFocusedIndex(selectedKeyIndex); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedKeyIndex]); + + const onGridKeyDown = useCallback( + (evt: React.KeyboardEvent) => { + const direction = ARROW_DIRECTIONS[evt.key]; + if (!direction) { + return; + } + const nextIndex = findNextKeyIndex(focusedIndex, direction, positions); + if (nextIndex !== focusedIndex) { + evt.preventDefault(); + setFocusedIndex(nextIndex); + keyRefs.current[nextIndex]?.focus(); + } + }, + [focusedIndex, positions], + ); + const elems = useMemo(() => { return props.keys.map((k, i) => { return k.d ? null : ( @@ -88,6 +178,10 @@ export const KeyGroup: React.FC> = (props) => { labels, skipFontCheck, )} + rovingTabIndex={i === focusedIndex ? 0 : -1} + containerRef={(el) => { + keyRefs.current[i] = el; + }} /> ); }); @@ -100,11 +194,13 @@ export const KeyGroup: React.FC> = (props) => { keyColorPalette, props.definition.vendorProductId, skipFontCheck, + focusedIndex, ]); return ( {elems} diff --git a/src/components/two-string/unit-key/combo-keycap.tsx b/src/components/two-string/unit-key/combo-keycap.tsx index 04b846993..91a0caa40 100644 --- a/src/components/two-string/unit-key/combo-keycap.tsx +++ b/src/components/two-string/unit-key/combo-keycap.tsx @@ -20,13 +20,18 @@ export const ComboKeycap = (props: any) => { label, canvasRef, onClick, + onKeyDown, onPointerDown, onPointerOver, onPointerOut, disabled, + ariaLabel, + rovingTabIndex, + containerRef, ...otherProps } = props; const [r1, r2] = normalizedRects; + const isInteractive = !disabled && props.mode !== DisplayMode.ConfigureColors; return ( <> @@ -36,6 +41,12 @@ export const ComboKeycap = (props: any) => { onPointerDown={onPointerDown} onPointerOver={onPointerOver} onPointerOut={onPointerOut} + onKeyDown={onKeyDown} + ref={containerRef} + role={isInteractive ? 'button' : undefined} + tabIndex={isInteractive ? rovingTabIndex ?? 0 : undefined} + aria-label={ariaLabel} + aria-pressed={isInteractive ? !!props.selected : undefined} style={{ cursor: !disabled ? 'pointer' : 'initial', position: 'relative', diff --git a/src/components/two-string/unit-key/encoder.tsx b/src/components/two-string/unit-key/encoder.tsx index 4bc619afe..623433263 100644 --- a/src/components/two-string/unit-key/encoder.tsx +++ b/src/components/two-string/unit-key/encoder.tsx @@ -157,9 +157,22 @@ export const EncoderKey = (props: { size: number; style: React.CSSProperties; onClick: (evt: React.MouseEvent) => void; + onKeyDown?: (evt: React.KeyboardEvent) => void; + isInteractive?: boolean; + ariaLabel?: string; + rovingTabIndex?: number; + containerRef?: (el: HTMLDivElement | null) => void; }) => { return ( - + = React.memo((props) => { idx, } = props; const macroData = label && getMacroData(label); + const ariaLabel = label + ? label.tooltipLabel || + label.centerLabel || + (typeof label.label === 'string' ? label.label : undefined) || + [label.topLabel, label.bottomLabel].filter(Boolean).join(' ') || + undefined + : undefined; const [overflowsTexture, setOverflowsTexture] = useState(false); // Hold state for hovered and clicked events const [hovered, hover] = useState(false); @@ -304,9 +311,29 @@ export const Keycap: React.FC = React.memo((props) => { idx, mode, ]); + + const isInteractive = !disabled && props.mode !== DisplayMode.ConfigureColors; + const onKeyDown = useCallback( + (evt: React.KeyboardEvent) => { + if (!isInteractive) { + return; + } + if (evt.key === 'Enter' || evt.key === ' ') { + evt.preventDefault(); + onClick(evt as unknown as React.MouseEvent); + } + }, + [isInteractive, onClick], + ); + return shouldRotate ? ( = React.memo((props) => { = React.memo((props) => { onPointerDown={onPointerDown} onPointerOver={onPointerOver} onPointerOut={onPointerOut} + onKeyDown={onKeyDown} + ref={props.containerRef} + role={isInteractive ? 'button' : undefined} + tabIndex={isInteractive ? props.rovingTabIndex ?? 0 : undefined} + aria-label={ariaLabel} + aria-pressed={isInteractive ? !!selected : undefined} style={{ transform: `translate(${ CSSVarObject.keyWidth / 2 + diff --git a/src/types/keyboard-rendering.ts b/src/types/keyboard-rendering.ts index 8f58e8435..98064632d 100644 --- a/src/types/keyboard-rendering.ts +++ b/src/types/keyboard-rendering.ts @@ -92,6 +92,8 @@ export type KeycapSharedProps = { export type TwoStringKeycapProps = { clipPath: null | string; + containerRef?: (el: HTMLDivElement | null) => void; + rovingTabIndex?: number; } & KeycapSharedProps>; export type ThreeFiberKeycapProps = {