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 +}