Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}>`
Expand Down
98 changes: 97 additions & 1 deletion src/components/two-string/key-group.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, GridDirection> = {
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({
Expand Down Expand Up @@ -74,6 +121,49 @@ export const KeyGroup: React.FC<KeyGroupProps<React.MouseEvent>> = (props) => {
return getLabels(props, macroExpressions, basicKeyToByte, byteToKey, keycodeLUT);
}, [keys, props.matrixKeycodes, macros, props.definition, keycodeLUT]);
const {width, height} = calculateKeyboardFrameDimensions(keys);

const keyRefs = useRef<Record<number, HTMLDivElement | null>>({});
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 : (
Expand All @@ -88,6 +178,10 @@ export const KeyGroup: React.FC<KeyGroupProps<React.MouseEvent>> = (props) => {
labels,
skipFontCheck,
)}
rovingTabIndex={i === focusedIndex ? 0 : -1}
containerRef={(el) => {
keyRefs.current[i] = el;
}}
/>
);
});
Expand All @@ -100,11 +194,13 @@ export const KeyGroup: React.FC<KeyGroupProps<React.MouseEvent>> = (props) => {
keyColorPalette,
props.definition.vendorProductId,
skipFontCheck,
focusedIndex,
]);
return (
<KeyGroupContainer
height={height}
width={width}
onKeyDown={onGridKeyDown}
style={{pointerEvents: props.selectable ? 'all' : 'none'}}
>
{elems}
Expand Down
11 changes: 11 additions & 0 deletions src/components/two-string/unit-key/combo-keycap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<KeycapContainer {...otherProps}>
Expand All @@ -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',
Expand Down
15 changes: 14 additions & 1 deletion src/components/two-string/unit-key/encoder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<EncoderKeyContainer onClick={props.onClick} style={props.style}>
<EncoderKeyContainer
onClick={props.onClick}
onKeyDown={props.onKeyDown}
ref={props.containerRef}
role={props.isInteractive ? 'button' : undefined}
tabIndex={props.isInteractive ? props.rovingTabIndex ?? 0 : undefined}
aria-label={props.ariaLabel ?? 'Encoder'}
style={props.style}
>
<EncoderKeyContent2
$size={props.size && +props.size}
$innerPadding={(5 * props.size) / 52}
Expand Down
35 changes: 35 additions & 0 deletions src/components/two-string/unit-key/keycap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ export const Keycap: React.FC<TwoStringKeycapProps> = 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);
Expand Down Expand Up @@ -304,9 +311,29 @@ export const Keycap: React.FC<TwoStringKeycapProps> = 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 ? (
<EncoderKey
onClick={onClick}
onKeyDown={onKeyDown}
isInteractive={isInteractive}
ariaLabel={ariaLabel}
rovingTabIndex={props.rovingTabIndex}
containerRef={props.containerRef}
size={textureWidth * CSSVarObject.keyWidth}
style={{
transform: `translate(${
Expand All @@ -326,6 +353,8 @@ export const Keycap: React.FC<TwoStringKeycapProps> = React.memo((props) => {
<ComboKeycap
{...props}
onClick={onClick}
onKeyDown={onKeyDown}
ariaLabel={ariaLabel}
onPointerDown={onPointerDown}
onPointerOver={onPointerOver}
onPointerOut={onPointerOut}
Expand Down Expand Up @@ -356,6 +385,12 @@ export const Keycap: React.FC<TwoStringKeycapProps> = 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 +
Expand Down
2 changes: 2 additions & 0 deletions src/types/keyboard-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export type KeycapSharedProps<T> = {

export type TwoStringKeycapProps = {
clipPath: null | string;
containerRef?: (el: HTMLDivElement | null) => void;
rovingTabIndex?: number;
} & KeycapSharedProps<React.MouseEvent<Element, MouseEvent>>;

export type ThreeFiberKeycapProps = {
Expand Down