-
Notifications
You must be signed in to change notification settings - Fork 202
Expand file tree
/
Copy pathKbdHint.tsx
More file actions
111 lines (101 loc) · 3.48 KB
/
Copy pathKbdHint.tsx
File metadata and controls
111 lines (101 loc) · 3.48 KB
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
/**
* KbdHint
*
* Renders styled keyboard shortcut hints (<kbd> tags) with screen reader
* support, dark mode / high contrast token consistency, and responsive
* layouts.
*
* Key design choices:
* - `separator` prop controls the glyph displayed between keys:
* '/' (default) — for alternate keys, e.g. "← / →"
* '+' — for chord combinations, e.g. "Ctrl + K"
* - An `.sr-only` span carries the full text alternative so screen readers
* never read the visual separator or repeat key labels redundantly.
* - All colours reference CSS custom properties from `src/index.css`; no
* one-off hex values.
*
* WCAG 2.1 AA Conformance:
* - Screen reader fallback via .sr-only element explaining the shortcut.
* - Uses design tokens for colors, borders, and dark/high-contrast mode
* support.
* - Visual separator (`aria-hidden="true"`) keeps reading order clean.
*/
import React from 'react';
import './KbdHint.css';
export interface KbdHintProps {
/** Key or sequence of keys to display (e.g. "Esc", ["←", "→"], ["Ctrl", "K"]) */
keys: string | string[];
/** Optional descriptive label shown next to the shortcut keys */
label?: string;
/** Optional detailed description for screen readers */
description?: string;
/**
* Glyph displayed between multiple keys.
* '/' (default) — for alternate keys: ← / →
* '+' — for chord combos: Ctrl + K
*/
separator?: '/' | '+';
/** Visual variant: 'inline' (default) or 'badge' (boxed container) */
variant?: 'inline' | 'badge';
/** Additional CSS class names */
className?: string;
/** Optional override for root container aria-label */
'aria-label'?: string;
}
export function KbdHint({
keys,
label,
description,
separator = '/',
variant = 'inline',
className = '',
'aria-label': ariaLabel,
}: KbdHintProps) {
const isMac = typeof window !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
const keyList = (Array.isArray(keys) ? keys : [keys]).map(key => {
if (key.toLowerCase() === 'cmd' || key.toLowerCase() === 'ctrl' || key.toLowerCase() === 'cmd/ctrl') {
return isMac ? '⌘' : 'Ctrl';
}
return key;
});
// Bail out early for an empty key list — nothing to render
if (keyList.length === 0) return null;
const keysText = keyList.join(' ');
const srText =
description ?? (label ? `${label} (${keysText})` : `Keyboard shortcut: ${keysText}`);
const containerClasses = [
'kbd-hint-container',
variant === 'badge' ? 'kbd-hint-badge' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<span
className={containerClasses}
aria-label={ariaLabel ?? srText}
role="group"
>
{/* Full text alternative for screen readers */}
<span className="sr-only">{srText}</span>
{/* Visual key chips — hidden from the AT reading order since the
aria-label on the container already conveys the same meaning */}
<span className="kbd-hint-group" aria-hidden="true">
{keyList.map((key, index) => (
<React.Fragment key={`${key}-${index}`}>
{index > 0 && (
<span className="kbd-hint-separator">{separator}</span>
)}
<kbd className="kbd-hint-key">{key}</kbd>
</React.Fragment>
))}
</span>
{label && (
<span className="kbd-hint-label" aria-hidden="true">
{label}
</span>
)}
</span>
);
}
export default KbdHint;