-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Expand file tree
/
Copy pathKbd.tsx
More file actions
217 lines (200 loc) · 6.11 KB
/
Copy pathKbd.tsx
File metadata and controls
217 lines (200 loc) · 6.11 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
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
// Copyright (c) Meta Platforms, Inc. and affiliates.
'use client';
/**
* @file Kbd.tsx
* @input Uses React, StyleX, theme tokens
* @output Exports Kbd component and KbdProps
* @position Core implementation; renders styled keyboard shortcut indicators
*
* SYNC: When modified, update:
* - /packages/core/src/Kbd/index.ts
* - /packages/cli/assets/templates/blocks/components/Kbd/ (showcase blocks)
*/
import React, {useSyncExternalStore} from 'react';
import * as stylex from '@stylexjs/stylex';
import {mergeProps} from '../utils';
import type {BaseProps} from '../BaseProps';
import {themeProps} from '../utils/themeProps';
import {
colorVars,
spacingVars,
radiusVars,
typographyVars,
fontWeightVars,
typeScaleVars,
} from '../theme/tokens.stylex';
const styles = stylex.create({
wrapper: {
display: 'inline-flex',
alignItems: 'center',
gap: spacingVars['--spacing-1'],
flexShrink: 0,
},
kbd: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: spacingVars['--spacing-5'],
height: spacingVars['--spacing-5'],
paddingInline: spacingVars['--spacing-1'],
borderRadius: radiusVars['--radius-inner'],
backgroundColor: colorVars['--color-neutral'],
borderBottomWidth: '2px',
borderBottomStyle: 'solid',
borderBottomColor: colorVars['--color-border-emphasized'],
color: colorVars['--color-text-secondary'],
fontFamily: typographyVars['--font-family-body'],
fontSize: typeScaleVars['--text-supporting-size'],
fontWeight: fontWeightVars['--font-weight-medium'],
lineHeight: typeScaleVars['--text-supporting-leading'],
userSelect: 'none',
},
});
/**
* Map of modifier key names to display symbols.
* Note: `mod` is not in this map — it resolves dynamically via platform
* detection inside the component.
*/
const KEY_DISPLAY: Record<string, string> = {
ctrl: '\u2303', // ⌃
alt: '\u2325', // ⌥
shift: '\u21E7', // ⇧
enter: '\u21B5', // ↵
backspace: '\u232B', // ⌫
escape: 'Esc',
tab: '\u21E5', // ⇥
up: '\u2191',
down: '\u2193',
left: '\u2190',
right: '\u2192',
plus: '+',
};
/**
* Resolves a key name to its display string. Handles the platform-aware
* `mod` key (⌘ on macOS, Ctrl on other platforms) and falls back to
* KEY_DISPLAY or uppercased key name.
*/
function getKeyDisplay(key: string, isMac: boolean): string {
if (key === 'mod') {
return isMac ? '\u2318' : 'Ctrl';
}
return KEY_DISPLAY[key] ?? key.toUpperCase();
}
/**
* Spoken-word labels for screen readers. The visual `KEY_DISPLAY` uses glyphs
* (⌘, ⇧, ↵, …) that assistive tech cannot announce meaningfully, so the
* accessible name for the shortcut is built from these words instead.
*/
const KEY_LABEL: Record<string, string> = {
ctrl: 'Control',
alt: 'Alt',
shift: 'Shift',
enter: 'Enter',
backspace: 'Backspace',
escape: 'Escape',
tab: 'Tab',
up: 'Up arrow',
down: 'Down arrow',
left: 'Left arrow',
right: 'Right arrow',
plus: 'Plus',
};
function getKeyLabel(key: string, isMac: boolean): string {
if (key === 'mod') {
return isMac ? 'Command' : 'Control';
}
return KEY_LABEL[key] ?? key.toUpperCase();
}
function subscribeToPlatformChanges(): () => void {
return () => {};
}
function getServerPlatformSnapshot(): boolean {
return false;
}
/**
* Detects whether the current platform is macOS/iOS.
* Prefers the User-Agent Client Hints API when it names a platform (modern
* Chrome/Edge), falls back to navigator.platform (deprecated but universally
* supported) when it is absent or blank.
*/
function detectMac(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
// Prefer User-Agent Client Hints API (not deprecated)
const uaData = 'userAgentData' in navigator ? navigator.userAgentData : null;
if (uaData && typeof uaData === 'object' && 'platform' in uaData) {
const uaPlatform = (uaData as {platform?: unknown}).platform;
// A blank platform is no answer, not a negative one. Builds that rewrite
// their client-hints identity ship '', so fall through rather than
// reading it as "not Apple".
if (typeof uaPlatform === 'string' && uaPlatform.trim() !== '') {
return /mac/i.test(uaPlatform);
}
}
// Fallback: navigator.platform (deprecated but still shipped everywhere)
return /Mac|iPhone|iPad|iPod/.test(navigator.platform ?? '');
}
export interface KbdProps extends BaseProps<HTMLSpanElement> {
ref?: React.Ref<HTMLSpanElement>;
/**
* Keyboard shortcut string. Use "+" to separate keys.
* Special keys: mod (Cmd on Mac), ctrl, alt, shift, enter, backspace, escape.
* Use "plus" to render a literal "+" key (e.g. "shift+plus").
*
* @example
* ```
* "mod+k"
* "mod+shift+p"
* "shift+plus"
* "enter"
* ```
*/
keys: string;
}
/**
* Displays a keyboard shortcut as styled <kbd> elements.
*
* A general-purpose component for rendering keyboard shortcuts
* anywhere in the system — tooltips, menus, documentation, etc.
*
* Platform-aware: `mod` renders as ⌘ on macOS and Ctrl elsewhere.
* SSR-safe — defers platform detection through useSyncExternalStore to avoid
* hydration mismatches.
*
* @example
* ```
* <Kbd keys="mod+k" />
* ```
*/
export function Kbd({keys, ref, xstyle, className, style, ...rest}: KbdProps) {
const isMac = useSyncExternalStore(
subscribeToPlatformChanges,
detectMac,
getServerPlatformSnapshot,
);
const parts = keys.split('+').map(key => key.trim().toLowerCase());
// Screen-reader name: the joined spoken labels (e.g. "Command + K"), since
// the visual glyphs below are announced meaninglessly by assistive tech.
const accessibleName = parts.map(key => getKeyLabel(key, isMac)).join(' + ');
return (
<span
{...rest}
ref={ref}
role="img"
aria-label={accessibleName}
{...mergeProps(
themeProps('kbd'),
stylex.props(styles.wrapper, xstyle),
className,
style,
)}>
{parts.map(key => (
<kbd key={key} aria-hidden="true" {...stylex.props(styles.kbd)}>
{getKeyDisplay(key, isMac)}
</kbd>
))}
</span>
);
}
Kbd.displayName = 'Kbd';