Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | 14x 14x 14x 14x 14x 10x 14x 4x 4x 4x 14x 6x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 14x 112x 224x 3x 1x | import {
useEffect,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useOnClickOutside } from "@usefy/use-on-click-outside";
import styles from "./VariantsPopup.module.scss";
export interface VariantsPopupProps {
/** Glyphs to choose from — the base key first, then its variants. */
glyphs: string[];
/** Insert the chosen glyph. */
onSelect: (glyph: string) => void;
/** Close the popup without selecting (Escape / click-outside). */
onClose: () => void;
/** Left offset (px) relative to the keyboard container. */
left: number;
/** Top offset (px) relative to the keyboard container. */
top: number;
/** Accessible label for the menu. */
label?: string;
}
/**
* The accent-variants chooser: a small popup **menu** of alternate glyphs
* (`role="menu"` / `role="menuitem"`, per WAI-ARIA APG) opened by long-pressing
* or right-clicking a key that has `variants`.
*
* Focus moves to the first item on open; Arrow keys (and Home/End) move between
* items; Enter/Space (native `<button>`) select; Escape closes the popup only
* (it `stopPropagation`s so the keyboard itself stays open); a click outside
* closes it. Returning focus to the originating key is handled by the parent.
*/
export function VariantsPopup({
glyphs,
onSelect,
onClose,
left,
top,
label = "Character variants",
}: VariantsPopupProps) {
const ref = useRef<HTMLDivElement>(null);
const itemsRef = useRef<Array<HTMLButtonElement | null>>([]);
const [active, setActive] = useState(0);
useOnClickOutside(ref, onClose);
// Move focus into the popup on open.
useEffect(() => {
itemsRef.current[0]?.focus();
}, []);
const focusItem = (index: number) => {
const next = (index + glyphs.length) % glyphs.length;
setActive(next);
itemsRef.current[next]?.focus();
};
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
// Handle here and stop it bubbling to the keyboard's roving nav.
event.preventDefault();
event.stopPropagation();
focusItem(active + 1);
return;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
event.stopPropagation();
focusItem(active - 1);
return;
case "Home":
event.preventDefault();
event.stopPropagation();
focusItem(0);
return;
case "End":
event.preventDefault();
event.stopPropagation();
focusItem(glyphs.length - 1);
return;
case "Escape":
// Close the popup only — don't let Escape bubble to the keyboard.
event.preventDefault();
event.stopPropagation();
onClose();
return;
default:
return;
}
};
return (
<div
ref={ref}
role="menu"
aria-label={label}
className={styles.popup}
style={{ left, top }}
onKeyDown={onKeyDown}
>
{glyphs.map((glyph, index) => (
<button
key={`${glyph}-${index}`}
ref={(el) => {
itemsRef.current[index] = el;
}}
type="button"
role="menuitem"
tabIndex={index === active ? 0 : -1}
className={styles.item}
onClick={() => onSelect(glyph)}
>
{glyph}
</button>
))}
</div>
);
}
VariantsPopup.displayName = "VariantsPopup";
|