Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/keybinding-hint-platform-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

`KeybindingHint`: display the `Meta` key correctly on platforms other than macOS and Windows. The `Meta`, `Alt`, and `Mod` keys are now resolved based on the detected platform: Apple platforms (macOS and iOS) show `⌘`/`⌥`, Windows shows `Win`, and all other platforms show `Meta`/`Alt`.
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,19 @@
background-color: var(--button-primary-bgColor-rest);
padding: var(--base-size-12);
}

.PlatformTable {
/* stylelint-disable-next-line primer/borders -- collapse is a layout keyword, not a border size */
border-collapse: collapse;
}

.PlatformCell {
padding: var(--base-size-8) var(--base-size-12);
text-align: left;
border: var(--borderWidth-thin) solid var(--borderColor-default);
}

.PlatformHeader {
color: var(--fgColor-muted);
font-weight: var(--base-text-weight-semibold);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {Meta, StoryObj} from '@storybook/react-vite'
import {KeybindingHint, type KeybindingHintProps} from '.'
import {PlatformOverrideProvider, type Platform} from './platform'
import classes from './KeybindingHint.features.stories.module.css'

export default {
Expand Down Expand Up @@ -38,3 +39,59 @@ export const OnPrimary: StoryObj<KeybindingHintProps> = {
}

export const Small = {args: {keys: chord, size: 'small'}}

const platforms: Array<{platform: Platform; label: string}> = [
{platform: 'mac', label: 'Apple (macOS / iOS)'},
{platform: 'windows', label: 'Windows'},
{platform: 'other', label: 'Other (Linux, Android, …)'},
]

const modifierKeys = ['Mod', 'Meta', 'Alt', 'Control', 'Shift']

/**
* Demonstrates how each modifier key renders across platforms. The platform is faked via
* `PlatformOverrideProvider` so all variations can be previewed regardless of the device
* actually running Storybook.
*/
export const Platforms: StoryObj<KeybindingHintProps> = {
render: ({format = 'full', ...args}) => (
<table className={classes.PlatformTable}>
<thead>
<tr>
<th scope="col" className={`${classes.PlatformCell} ${classes.PlatformHeader}`}>
Platform
</th>
{modifierKeys.map(key => (
<th scope="col" className={`${classes.PlatformCell} ${classes.PlatformHeader}`} key={key}>
{key}
</th>
))}
</tr>
</thead>
<tbody>
{platforms.map(({platform, label}) => (
<tr key={platform}>
<th scope="row" className={`${classes.PlatformCell} ${classes.PlatformHeader}`}>
{label}
</th>
{modifierKeys.map(key => (
<td key={key} className={classes.PlatformCell}>
<PlatformOverrideProvider value={platform}>
<KeybindingHint {...args} format={format} keys={`${key}+K`} />
</PlatformOverrideProvider>
</td>
))}
</tr>
))}
</tbody>
</table>
),
args: {format: 'full'},
argTypes: {
format: {
control: 'radio',
options: ['condensed', 'full'],
},
keys: {table: {disable: true}},
},
}
23 changes: 23 additions & 0 deletions packages/react/src/KeybindingHint/KeybindingHint.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,27 @@ describe('getAccessibleKeybindingHintString', () => {

it('returns "control" for "mod" on non-MacOS', () =>
expect(getAccessibleKeybindingHintString('Mod+x', false)).toBe('control x'))

it('returns "command" for "mod" on Apple platforms', () =>
expect(getAccessibleKeybindingHintString('Mod+x', 'mac')).toBe('command x'))

it('returns "control" for "mod" on non-Apple platforms', () => {
expect(getAccessibleKeybindingHintString('Mod+x', 'windows')).toBe('control x')
expect(getAccessibleKeybindingHintString('Mod+x', 'other')).toBe('control x')
})

it('returns "command" for "meta" on Apple platforms', () =>
expect(getAccessibleKeybindingHintString('Meta+x', 'mac')).toBe('command x'))

it('returns "Windows" for "meta" on Windows', () =>
expect(getAccessibleKeybindingHintString('Meta+x', 'windows')).toBe('Windows x'))

it('returns "meta" for "meta" on other platforms', () =>
expect(getAccessibleKeybindingHintString('Meta+x', 'other')).toBe('meta x'))

it('returns "option" for "alt" on Apple platforms and "alt" elsewhere', () => {
expect(getAccessibleKeybindingHintString('Alt+x', 'mac')).toBe('option x')
expect(getAccessibleKeybindingHintString('Alt+x', 'windows')).toBe('alt x')
expect(getAccessibleKeybindingHintString('Alt+x', 'other')).toBe('alt x')
})
})
8 changes: 7 additions & 1 deletion packages/react/src/KeybindingHint/KeybindingHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {memo} from 'react'
import Text from '../Text'
import type {KeybindingHintProps} from './props'
import {accessibleSequenceString, Sequence} from './components/Sequence'
import type {Platform} from './platform'

import classes from './KeybindingHint.module.css'
import {clsx} from 'clsx'
Expand Down Expand Up @@ -37,5 +38,10 @@ KeybindingHint.displayName = 'KeybindingHint'
*
* NOTE that this string should _only_ be used when building `aria-label` or `aria-description` props (never rendered
* visibly) and should nearly always also be paired with a visible hint for sighted users.
*
* The `platform` argument controls how platform-specific keys (such as `Meta`, `Alt`, and `Mod`) are named. For
* backwards compatibility, a `boolean` may be passed instead, where `true` is treated as `'mac'` and `false` as
* `'other'`.
*/
export const getAccessibleKeybindingHintString = accessibleSequenceString
export const getAccessibleKeybindingHintString = (sequence: string, platform: Platform | boolean) =>
accessibleSequenceString(sequence, typeof platform === 'boolean' ? (platform ? 'mac' : 'other') : platform)
5 changes: 3 additions & 2 deletions packages/react/src/KeybindingHint/components/Chord.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Text from '../../Text'
import type {KeybindingHintProps} from '../props'
import {Key} from './Key'
import {accessibleKeyName} from '../key-names'
import type {Platform} from '../platform'
import {clsx} from 'clsx'
import classes from './Chord.module.css'

Expand Down Expand Up @@ -56,7 +57,7 @@ export const Chord = ({keys, format = 'condensed', variant = 'normal', size = 'n
)

/** Plain string version of `Chord` for use in `aria` string attributes. */
export const accessibleChordString = (chord: string, isMacOS: boolean) =>
export const accessibleChordString = (chord: string, platform: Platform) =>
splitChord(chord)
.map(key => accessibleKeyName(key, isMacOS))
.map(key => accessibleKeyName(key, platform))
.join(' ')
8 changes: 4 additions & 4 deletions packages/react/src/KeybindingHint/components/Key.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import VisuallyHidden from '../../_VisuallyHidden'
import {accessibleKeyName, condensedKeyName, fullKeyName} from '../key-names'
import type {KeybindingHintFormat} from '../props'
import {useIsMacOS} from '../../hooks/useIsMacOS'
import {usePlatform} from '../platform'

interface KeyProps {
name: string
Expand All @@ -10,12 +10,12 @@ interface KeyProps {

/** Renders a single key with accessible alternative text. */
export const Key = ({name, format}: KeyProps) => {
const isMacOS = useIsMacOS()
const platform = usePlatform()

return (
<>
<VisuallyHidden>{accessibleKeyName(name, isMacOS)}</VisuallyHidden>
<span aria-hidden>{format === 'condensed' ? condensedKeyName(name, isMacOS) : fullKeyName(name, isMacOS)}</span>
<VisuallyHidden>{accessibleKeyName(name, platform)}</VisuallyHidden>
<span aria-hidden>{format === 'condensed' ? condensedKeyName(name, platform) : fullKeyName(name, platform)}</span>
</>
)
}
5 changes: 3 additions & 2 deletions packages/react/src/KeybindingHint/components/Sequence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Fragment} from 'react'
import type {KeybindingHintProps} from '../props'
import VisuallyHidden from '../../_VisuallyHidden'
import {accessibleChordString, Chord} from './Chord'
import type {Platform} from '../platform'

const splitSequence = (sequence: string) => sequence.split(' ')

Expand All @@ -21,7 +22,7 @@ export const Sequence = ({keys, ...chordProps}: KeybindingHintProps) =>
))

/** Plain string version of `Sequence` for use in `aria` string attributes. */
export const accessibleSequenceString = (sequence: string, isMacOS: boolean) =>
export const accessibleSequenceString = (sequence: string, platform: Platform) =>
splitSequence(sequence)
.map(chord => accessibleChordString(chord, isMacOS))
.map(chord => accessibleChordString(chord, platform))
.join(' then ')
25 changes: 14 additions & 11 deletions packages/react/src/KeybindingHint/key-names.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type {Platform} from './platform'

/** 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()
Expand All @@ -9,13 +11,13 @@ const capitalize = ([first, ...rest]: string) => (first?.toUpperCase() ?? '') +
/**
* Short-form iconic versions of keys. These should be intuitive (not archaic) and match icons on keyboards.
*/
export const condensedKeyName = (key: string, isMacOS: boolean) =>
export const condensedKeyName = (key: string, platform: Platform) =>
({
alt: isMacOS ? '⌥' : 'Alt', // the alt key _is_ the option key on MacOS - in the browser there is no "option" key
alt: platform === 'mac' ? '⌥' : '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: platform === 'mac' ? '⌘' : platform === 'windows' ? 'Win' : 'Meta',
mod: platform === 'mac' ? '⌘' : '⌃',
pageup: 'PgUp',
pagedown: 'PgDn',
arrowup: '↑',
Expand All @@ -39,10 +41,11 @@ export const condensedKeyName = (key: string, isMacOS: boolean) =>
* 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, isMacOS: boolean) =>
export const fullKeyName = (key: string, platform: Platform) =>
({
alt: isMacOS ? 'Option' : 'Alt',
mod: isMacOS ? 'Command' : 'Control',
alt: platform === 'mac' ? 'Option' : 'Alt',
meta: platform === 'mac' ? 'Command' : platform === 'windows' ? 'Windows' : 'Meta',
mod: platform === 'mac' ? 'Command' : 'Control',
'+': 'Plus',
pageup: 'Page Up',
pagedown: 'Page Down',
Expand All @@ -59,11 +62,11 @@ export const fullKeyName = (key: string, isMacOS: boolean) =>
* readers from expressing punctuation in speech, ie, reading a long pause instead of the
* word "period".
*/
export const accessibleKeyName = (key: string, isMacOS: boolean) =>
export const accessibleKeyName = (key: string, platform: Platform) =>
({
alt: isMacOS ? 'option' : 'alt',
meta: isMacOS ? 'command' : 'Windows',
mod: isMacOS ? 'command' : 'control',
alt: platform === 'mac' ? 'option' : 'alt',
meta: platform === 'mac' ? 'command' : platform === 'windows' ? 'Windows' : 'meta',
mod: platform === 'mac' ? 'command' : 'control',
// Screen readers may not be able to pronounce concatenated words - this provides a better experience
pageup: 'page up',
pagedown: 'page down',
Expand Down
62 changes: 62 additions & 0 deletions packages/react/src/KeybindingHint/platform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {isMacOS as ssrUnsafeIsMacOS} from '@primer/behaviors/utils'
import {createContext, useContext, useSyncExternalStore} from 'react'

/**
* The platform categories that affect how keyboard shortcut keys are displayed.
*
* - `mac`: Apple platforms (macOS and iOS/iPadOS), which use the Command and Option keys.
* - `windows`: Windows, which uses the Windows (Meta) key.
* - `other`: Any other platform (e.g. Linux, Android), where the Meta key does not map to a
* consistent label.
*/
export type Platform = 'mac' | 'windows' | 'other'

// No-op. The platform never changes at runtime, so there is nothing to
// subscribe to. Hoisted to avoid creating a new function on every call.
const subscribe = () => () => {}

/** SSR-unsafe detection of iOS/iPadOS (in addition to macOS, which is detected separately). */
const ssrUnsafeIsIOS = () => {
if (typeof navigator === 'undefined') return false
return /iphone|ipad|ipod/i.test(navigator.platform) || /iphone|ipad|ipod/i.test(navigator.userAgent)
}

/** SSR-unsafe detection of Windows. */
const ssrUnsafeIsWindows = () => {
if (typeof navigator === 'undefined') return false
return /^win/i.test(navigator.platform)
}

const getSnapshot = (): Platform => {
if (ssrUnsafeIsMacOS() || ssrUnsafeIsIOS()) return 'mac'
if (ssrUnsafeIsWindows()) return 'windows'
return 'other'
}

// Safe default for SSR since we can't detect the platform on the server.
const getServerSnapshot = (): Platform => 'other'

/**
* Allows overriding the detected platform. This is primarily intended for testing and
* Storybook, where we want to preview how keyboard hints render on platforms other than the
* one actually running. A `null` value (the default) means "use the detected platform".
*/
const PlatformOverrideContext = createContext<Platform | null>(null)

export const PlatformOverrideProvider = PlatformOverrideContext.Provider

/**
* SSR-safe hook for determining the current platform category used when displaying
* keyboard shortcut keys.
*
* Mirrors the approach of `useIsMacOS`: on the client it reads the real value immediately,
* and on the server it returns a safe default (`'other'`).
*
* If a `PlatformOverrideProvider` is present with a non-null value, that value is used
* instead of the detected platform.
*/
export function usePlatform(): Platform {
const override = useContext(PlatformOverrideContext)
const detected = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
return override ?? detected
}
7 changes: 4 additions & 3 deletions packages/react/src/TooltipV2/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {Children, useEffect, useRef, useState, useMemo, type ForwardRefExoticComponent} from 'react'
import {useId, useProvidedRefOrCreate, useOnEscapePress, useIsMacOS} from '../hooks'
import {useId, useProvidedRefOrCreate, useOnEscapePress} from '../hooks'
import {invariant} from '../utils/invariant'
import {warning} from '../utils/warning'
import {getAnchoredPosition} from '@primer/behaviors'
Expand All @@ -8,6 +8,7 @@ import {isSupported, apply} from '@oddbird/popover-polyfill/fn'
import {clsx} from 'clsx'
import classes from './Tooltip.module.css'
import {getAccessibleKeybindingHintString, KeybindingHint, type KeybindingHintProps} from '../KeybindingHint'
import {usePlatform} from '../KeybindingHint/platform'
import VisuallyHidden from '../_VisuallyHidden'
import useSafeTimeout from '../hooks/useSafeTimeout'
import type {SlotMarker} from '../utils/types'
Expand Down Expand Up @@ -272,7 +273,7 @@ export const Tooltip: ForwardRefExoticComponent<
[isPopoverOpen],
)

const isMacOS = useIsMacOS()
const platform = usePlatform()
const hasAriaLabel = 'aria-label' in rest

// Normalize keybindingHint to an array for uniform rendering
Expand Down Expand Up @@ -370,7 +371,7 @@ export const Tooltip: ForwardRefExoticComponent<
to duplicate the symbols and key names. To work around this, we exclude the hint from being part of the
label and instead render the plain keybinding description string. */}
<VisuallyHidden>
({keybindingHints.map(hint => getAccessibleKeybindingHintString(hint, isMacOS)).join(' or ')})
({keybindingHints.map(hint => getAccessibleKeybindingHintString(hint, platform)).join(' or ')})
</VisuallyHidden>
</span>
<span
Expand Down
8 changes: 4 additions & 4 deletions packages/react/src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {Dialog} from '../Dialog'
import {Button, IconButton} from '../Button'
import {ActionList} from '../ActionList'
import {getAccessibleKeybindingHintString} from '../KeybindingHint'
import {useIsMacOS} from '../hooks'
import {usePlatform} from '../KeybindingHint/platform'
import {Tooltip} from '../TooltipV2'
import {isSlot} from '../utils/is-slot'
import type {FCWithSlotMarker} from '../utils/types'
Expand Down Expand Up @@ -259,7 +259,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
const [isSubTreeEmpty, setIsSubTreeEmpty] = React.useState(!hasSubTree)
const [actionCommandPressed, setActionCommandPressed] = React.useState(false)
const [isFocused, setIsFocused] = React.useState(false)
const isMacOS = useIsMacOS()
const platform = usePlatform()

// Set the expanded state and cache it
const setIsExpandedWithCache = React.useCallback(
Expand Down Expand Up @@ -335,8 +335,8 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
slots.trailingVisual ? trailingVisualId : null,
].filter(Boolean)

const shortcut = `Shift+${isMacOS ? 'Meta' : 'Control'}+U`
const trailingActionShortcutText = `Press (${getAccessibleKeybindingHintString(shortcut, isMacOS)}) for more actions.`
const shortcut = `Shift+${platform === 'mac' ? 'Meta' : 'Control'}+U`
const trailingActionShortcutText = `Press (${getAccessibleKeybindingHintString(shortcut, platform)}) for more actions.`

return (
<ItemContext.Provider
Expand Down
Loading