diff --git a/packages/react/src/KeybindingHint/components/Chord.tsx b/packages/react/src/KeybindingHint/components/Chord.tsx
index b1eb89287af..9bc7dbcde05 100644
--- a/packages/react/src/KeybindingHint/components/Chord.tsx
+++ b/packages/react/src/KeybindingHint/components/Chord.tsx
@@ -64,4 +64,7 @@ export const Chord = ({keys, format = 'condensed', variant = 'normal'}: Keybindi
)
/** Plain string version of `Chord` for use in `aria` string attributes. */
-export const accessibleChordString = (chord: string) => splitChord(chord).map(accessibleKeyName).join(' ')
+export const accessibleChordString = (chord: string, isMacOS: boolean) =>
+ splitChord(chord)
+ .map(key => accessibleKeyName(key, isMacOS))
+ .join(' ')
diff --git a/packages/react/src/KeybindingHint/components/Key.tsx b/packages/react/src/KeybindingHint/components/Key.tsx
index 7ae3775954c..eb803924f09 100644
--- a/packages/react/src/KeybindingHint/components/Key.tsx
+++ b/packages/react/src/KeybindingHint/components/Key.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import VisuallyHidden from '../../_VisuallyHidden'
import {accessibleKeyName, condensedKeyName, fullKeyName} from '../key-names'
import type {KeybindingHintFormat} from '../props'
+import {useIsMacOS} from '../../hooks/useIsMacOS'
interface KeyProps {
name: string
@@ -9,9 +10,13 @@ interface KeyProps {
}
/** Renders a single key with accessible alternative text. */
-export const Key = ({name, format}: KeyProps) => (
- <>
- {accessibleKeyName(name)}
- {format === 'condensed' ? condensedKeyName(name) : fullKeyName(name)}
- >
-)
+export const Key = ({name, format}: KeyProps) => {
+ const isMacOS = useIsMacOS()
+
+ return (
+ <>
+ {accessibleKeyName(name, isMacOS)}
+ {format === 'condensed' ? condensedKeyName(name, isMacOS) : fullKeyName(name, isMacOS)}
+ >
+ )
+}
diff --git a/packages/react/src/KeybindingHint/components/Sequence.tsx b/packages/react/src/KeybindingHint/components/Sequence.tsx
index 2fe7f5e43da..ce4dd5297f1 100644
--- a/packages/react/src/KeybindingHint/components/Sequence.tsx
+++ b/packages/react/src/KeybindingHint/components/Sequence.tsx
@@ -21,5 +21,7 @@ export const Sequence = ({keys, format = 'condensed', variant = 'normal'}: Keybi
))
/** Plain string version of `Sequence` for use in `aria` string attributes. */
-export const accessibleSequenceString = (sequence: string) =>
- splitSequence(sequence).map(accessibleChordString).join(', then ')
+export const accessibleSequenceString = (sequence: string, isMacOS: boolean) =>
+ splitSequence(sequence)
+ .map(chord => accessibleChordString(chord, isMacOS))
+ .join(', then ')
diff --git a/packages/react/src/KeybindingHint/key-names.ts b/packages/react/src/KeybindingHint/key-names.ts
index eab7f860e56..de5eb5e95bb 100644
--- a/packages/react/src/KeybindingHint/key-names.ts
+++ b/packages/react/src/KeybindingHint/key-names.ts
@@ -1,5 +1,3 @@
-import {isMacOS} from '@primer/behaviors/utils'
-
/** Converts the first character of the string to upper case and the remaining to lower case. */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const capitalize = ([first, ...rest]: string) => (first?.toUpperCase() ?? '') + rest.join('').toLowerCase()
@@ -8,19 +6,16 @@ const capitalize = ([first, ...rest]: string) => (first?.toUpperCase() ?? '') +
// would be realistically used in shortcuts. For example, the Pause/Break key is not necessary
// because it is not found on many keyboards.
-// These are methods instead of plain objects to delay calling isMacOS (which depends on
-// `window.navigator`) and avoid SSR issues
-
/**
* Short-form iconic versions of keys. These should be intuitive (not archaic) and match icons on keyboards.
*/
-export const condensedKeyName = (key: string) =>
+export const condensedKeyName = (key: string, isMacOS: boolean) =>
({
- alt: isMacOS() ? '⌥' : 'Alt', // the alt key _is_ the option key on MacOS - in the browser there is no "option" key
+ alt: isMacOS ? '⌥' : 'Alt', // the alt key _is_ the option key on MacOS - in the browser there is no "option" key
control: '⌃',
shift: '⇧',
- meta: isMacOS() ? '⌘' : 'Win',
- mod: isMacOS() ? '⌘' : '⌃',
+ meta: isMacOS ? '⌘' : 'Win',
+ mod: isMacOS ? '⌘' : '⌃',
pageup: 'PgUp',
pagedown: 'PgDn',
arrowup: '↑',
@@ -44,10 +39,10 @@ export const condensedKeyName = (key: string) =>
* Specific key displays for 'full' format. We still do show some icons (ie punctuation)
* because that's more intuitive, but for the rest of keys we show the standard key name.
*/
-export const fullKeyName = (key: string) =>
+export const fullKeyName = (key: string, isMacOS: boolean) =>
({
- alt: isMacOS() ? 'Option' : 'Alt',
- mod: isMacOS() ? 'Command' : 'Control',
+ alt: isMacOS ? 'Option' : 'Alt',
+ mod: isMacOS ? 'Command' : 'Control',
'+': 'Plus',
pageup: 'Page Up',
pagedown: 'Page Down',
@@ -64,11 +59,11 @@ export const fullKeyName = (key: string) =>
* readers from expressing punctuation in speech, ie, reading a long pause instead of the
* word "period".
*/
-export const accessibleKeyName = (key: string) =>
+export const accessibleKeyName = (key: string, isMacOS: boolean) =>
({
- alt: isMacOS() ? 'option' : 'alt',
- meta: isMacOS() ? 'command' : 'Windows',
- mod: isMacOS() ? 'command' : 'control',
+ alt: isMacOS ? 'option' : 'alt',
+ meta: isMacOS ? 'command' : 'Windows',
+ mod: isMacOS ? 'command' : 'control',
// Screen readers may not be able to pronounce concatenated words - this provides a better experience
pageup: 'page up',
pagedown: 'page down',
diff --git a/packages/react/src/__tests__/KeybindingHint.test.tsx b/packages/react/src/__tests__/KeybindingHint.test.tsx
index 1358c735d3f..bee45ff08e5 100644
--- a/packages/react/src/__tests__/KeybindingHint.test.tsx
+++ b/packages/react/src/__tests__/KeybindingHint.test.tsx
@@ -77,13 +77,21 @@ describe('KeybindingHint', () => {
})
describe('getAccessibleKeybindingHintString', () => {
- it('returns full readable key names', () => expect(getAccessibleKeybindingHintString('{')).toBe('left curly brace'))
+ it('returns full readable key names', () =>
+ expect(getAccessibleKeybindingHintString('{', false)).toBe('left curly brace'))
- it('joins keys in a chord with space', () => expect(getAccessibleKeybindingHintString('Command+U')).toBe('command u'))
+ it('joins keys in a chord with space', () =>
+ expect(getAccessibleKeybindingHintString('Command+U', false)).toBe('command u'))
it('sorts modifiers in standard order', () =>
- expect(getAccessibleKeybindingHintString('Alt+Shift+Command+%')).toBe('alt shift command percent'))
+ expect(getAccessibleKeybindingHintString('Alt+Shift+Command+%', false)).toBe('alt shift command percent'))
it('joins chords in a sequence with "then"', () =>
- expect(getAccessibleKeybindingHintString('Alt+9 x y')).toBe('alt 9, then x, then y'))
+ expect(getAccessibleKeybindingHintString('Alt+9 x y', false)).toBe('alt 9, then x, then y'))
+
+ it('returns "command" for "mod" on MacOS', () =>
+ expect(getAccessibleKeybindingHintString('Mod+x', true)).toBe('command x'))
+
+ it('returns "control" for "mod" on non-MacOS', () =>
+ expect(getAccessibleKeybindingHintString('Mod+x', false)).toBe('control x'))
})
diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts
index 01d1d97c4aa..d8259f5fd96 100644
--- a/packages/react/src/hooks/index.ts
+++ b/packages/react/src/hooks/index.ts
@@ -15,3 +15,4 @@ export {useMenuKeyboardNavigation} from './useMenuKeyboardNavigation'
export {useMnemonics} from './useMnemonics'
export {useRefObjectAsForwardedRef} from './useRefObjectAsForwardedRef'
export {useId} from './useId'
+export {useIsMacOS} from './useIsMacOS'
diff --git a/packages/react/src/hooks/useIsMacOS.ts b/packages/react/src/hooks/useIsMacOS.ts
new file mode 100644
index 00000000000..daa51906290
--- /dev/null
+++ b/packages/react/src/hooks/useIsMacOS.ts
@@ -0,0 +1,16 @@
+import {isMacOS as ssrUnsafeIsMacOS} from '@primer/behaviors/utils'
+import {useEffect, useState} from 'react'
+
+/**
+ * SSR-safe hook for determining if the current platform is MacOS. When rendering
+ * server-side, will default to non-MacOS and then re-render in an effect if the
+ * client turns out to be a MacOS device.
+ */
+export function useIsMacOS() {
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ const [isMacOS, setIsMacOS] = useState(() => (window !== undefined ? ssrUnsafeIsMacOS() : false))
+
+ useEffect(() => setIsMacOS(ssrUnsafeIsMacOS()), [])
+
+ return isMacOS
+}