Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
769d367
feat(scraps): add hotkey entrypoint
natemoo-re Mar 24, 2026
8f5ac7d
ref(scraps): use scraps hotkey
natemoo-re Mar 24, 2026
d0538b1
fix(scraps): update spy
natemoo-re Mar 24, 2026
272d982
ref(scraps): remove getKeyCode
natemoo-re Mar 24, 2026
237579f
ref(scraps): mock isMac for hotkey
natemoo-re Mar 24, 2026
cde9bf3
ref(scraps): add hotkey doc
natemoo-re Mar 24, 2026
f7a1957
ref(scraps): use styled
natemoo-re Mar 24, 2026
ab66d88
ref(scraps): remove unused exports
natemoo-re Mar 24, 2026
c384fbe
ref(scraps): add kbd support to Prose
natemoo-re Mar 24, 2026
63ca481
docs(scraps): use InlineCode instead of kbd
natemoo-re Mar 24, 2026
12830bb
ref(scraps): fix hotkey test
natemoo-re Mar 24, 2026
13cd1db
ref(scraps): remove hotkeys from unowned
natemoo-re Mar 24, 2026
d72f38d
ref(scraps): use hotkey
natemoo-re Mar 26, 2026
132ad0a
fix(scraps): tweak hotkey styles
natemoo-re Mar 26, 2026
edddc9e
fix(scraps): update story categories
natemoo-re Mar 26, 2026
355cf91
ref(scraps): use userEvent.keyboard
natemoo-re Mar 26, 2026
eadbbf6
ref(scraps): use hardcode keycode
natemoo-re Mar 26, 2026
c5da569
Merge branch 'master' into scraps/kbd
natemoo-re Mar 26, 2026
dff61a5
fix(scraps): add log for missing hotkey glyphs
natemoo-re Mar 26, 2026
dd22ad6
Merge branch 'master' into scraps/kbd
natemoo-re Mar 26, 2026
39d85a4
Merge branch 'master' into scraps/kbd
natemoo-re Mar 26, 2026
c55d300
feat(scraps): add hotkey variants
natemoo-re Mar 26, 2026
8eb33d8
ref(scraps): update hotkey
natemoo-re Mar 26, 2026
c5f9eb4
Merge branch 'master' into scraps/kbd
natemoo-re Mar 26, 2026
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
2 changes: 0 additions & 2 deletions .github/codeowners-coverage-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -752,8 +752,6 @@ static/app/components/hook.spec.tsx
static/app/components/hook.tsx
static/app/components/hookOrDefault.spec.tsx
static/app/components/hookOrDefault.tsx
static/app/components/hotkeysLabel.spec.tsx
static/app/components/hotkeysLabel.tsx
static/app/components/hovercard.spec.tsx
static/app/components/hovercard.tsx
static/app/components/iconCircledNumber.tsx
Expand Down
5 changes: 3 additions & 2 deletions static/app/components/alerts/pageBanner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import styled from '@emotion/styled';
import replaysDeadRageBackground from 'sentry-images/spot/replay-dead-rage-changelog.svg';

import {Button, LinkButton} from '@sentry/scraps/button';
import {InlineCode} from '@sentry/scraps/code';
import {ExternalLink} from '@sentry/scraps/link';

import {PageBanner} from 'sentry/components/alerts/pageBanner';
Expand Down Expand Up @@ -42,7 +43,7 @@ export default Storybook.story('PageBanner', story => {
<Fragment>
<p>
This example renders an X in the top-right corner. You can wire it up with
something like <kbd>useDismissAlert()</kbd>.
something like <InlineCode>useDismissAlert()</InlineCode>.
</p>
<p>
Is Dismissed? <var>{String(isDismissed)}</var>
Expand Down Expand Up @@ -72,7 +73,7 @@ export default Storybook.story('PageBanner', story => {
<Fragment>
<p>
The banner will resize if it's shrunk really narrow. To make it expand inside a
flex parent set <kbd>flex-grow:1</kbd>.
flex parent set <InlineCode>flex-grow:1</InlineCode>.
</p>
<p>
<Button size="sm" onClick={() => setFlexGrow(!flexGrow)}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {createContext, useContext, useReducer, useRef} from 'react';

import {useHotkeys} from '@sentry/scraps/hotkey';

import {
openCommandPaletteDeprecated,
toggleCommandPalette,
} from 'sentry/actionCreators/modal';
import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types';
import {unreachable} from 'sentry/utils/unreachable';
import {useHotkeys} from 'sentry/utils/useHotkeys';
import {useOrganization} from 'sentry/utils/useOrganization';

export type LinkedList = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {Fragment} from 'react';
import bgPattern from 'sentry-images/spot/mobile-hero.jpg';
import onboardingFrameworkSelectionJavascript from 'sentry-images/spot/replay-dead-rage-changelog.svg';

import {InlineCode} from '@sentry/scraps/code';

import {NegativeSpaceContainer} from 'sentry/components/container/negativeSpaceContainer';
import * as Storybook from 'sentry/stories';

Expand All @@ -13,7 +15,7 @@ export default Storybook.story('NegativeSpaceContainer', story => {
A <Storybook.JSXNode name="NegativeSpaceContainer" /> is a container with a
diagonal pattern for a background. It will preserve the aspect ratio of whatever
is inside it. It's a flex element, so the children are free to expand/contract
depending on whether things like <kbd>flex-grow: 1</kbd> are set.
depending on whether things like <InlineCode>flex-grow: 1</InlineCode> are set.
</p>
<p>Here's one with nothing inside it:</p>
<NegativeSpaceContainer
Expand Down
3 changes: 2 additions & 1 deletion static/app/components/copyToClipboardButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Fragment} from 'react';

import {Button} from '@sentry/scraps/button';
import {Kbd} from '@sentry/scraps/hotkey';

import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
import {IconLink} from 'sentry/icons';
Expand All @@ -14,7 +15,7 @@ export default Storybook.story('CopyToClipboardButton', story => {
<p>
By default the button will stick the{' '}
<Storybook.JSXProperty name="text" value={String} /> value onto your clipboard; as
if you typed <kbd>CTRL+C</kbd> or <kbd>CMD+C</kbd>. It'll show toast messages, and
if you typed <Kbd>CTRL+C</Kbd> or <Kbd>CMD+C</Kbd>. It'll show toast messages, and
includes <Storybook.JSXProperty name="onCopy" value={Function} /> &
<Storybook.JSXProperty name="onError" value={Function} /> callbacks.
</p>
Expand Down
144 changes: 144 additions & 0 deletions static/app/components/core/hotkey/hotkey.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
---
title: Hotkey
description: Components for displaying keyboard shortcuts with automatic platform-aware modifier rendering.
category: utilities
source: '@sentry/scraps/hotkey'
resources:
js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/hotkey/index.tsx
a11y:
WCAG 1.3.1: https://www.w3.org/TR/WCAG22/#info-and-relationships
---

import {Hotkey, Kbd} from '@sentry/scraps/hotkey';
import {Flex} from '@sentry/scraps/layout';
import {Text} from '@sentry/scraps/text';

import * as Storybook from 'sentry/stories';

export const documentation = import('!!type-loader!@sentry/scraps/hotkey');

The `<Hotkey>` component displays keyboard shortcuts using semantic `<kbd>` elements with automatic platform-aware modifier rendering. On Mac, `command` renders as `⌘`; on other platforms it renders as `CTRL`. The `<Kbd>` component renders a single styled key cap.

## Hotkey

`<Hotkey>` accepts the same key string format as the `useHotkeys` hook, making it easy to display the shortcuts you've already registered.

<Storybook.Demo>
<Flex gap="xl" align="center">
<Flex gap="sm" align="center">
<Text>Save:</Text>
<Hotkey value="command+s" />
</Flex>
<Flex gap="sm" align="center">
<Text>Search:</Text>
<Hotkey value="command+k" />
</Flex>
<Flex gap="sm" align="center">
<Text>Copy:</Text>
<Hotkey value="command+c" />
</Flex>
</Flex>
</Storybook.Demo>

```jsx
<Hotkey value="command+s" />
<Hotkey value="command+k" />
<Hotkey value="command+c" />
```

### Platform Mapping

Modifiers are automatically mapped per platform. No fallback arrays needed for standard modifier differences.

| Key name | Mac | Other platforms |
| --------- | --- | --------------- |
| `command` | `⌘` | `CTRL` |
| `ctrl` | `⌃` | `CTRL` |
| `alt` | `⌥` | `ALT` |
| `option` | `⌥` | `ALT` |
| `shift` | `⇧` | `⇧` |

### Complex Shortcuts

<Storybook.Demo>
<Flex gap="xl" align="center">
<Flex gap="sm" align="center">
<Text>Undo:</Text>
<Hotkey value="command+z" />
</Flex>
<Flex gap="sm" align="center">
<Text>Redo:</Text>
<Hotkey value="command+shift+z" />
</Flex>
<Flex gap="sm" align="center">
<Text>Navigate:</Text>
<Hotkey value="shift+up" />
</Flex>
</Flex>
</Storybook.Demo>

```jsx
<Hotkey value="command+z" />
<Hotkey value="command+shift+z" />
<Hotkey value="shift+up" />
```

### Array Form

Pass an array when the actual keys (not just modifiers) differ across platforms. The first combo is used.

```jsx
<Hotkey value={['command+backspace', 'delete']} />
```

## Kbd

`<Kbd>` renders a single styled key cap. Use it for standalone key references in prose or documentation.

<Storybook.Demo>
<Flex gap="lg" align="center">
<Text>
Press <Kbd>Esc</Kbd> to close
</Text>
<Text>
Use <Kbd>Tab</Kbd> to navigate
</Text>
<Text>
Hit <Kbd>Enter</Kbd> to confirm
</Text>
</Flex>
</Storybook.Demo>

```jsx
<Text>
Press <Kbd>Esc</Kbd> to close
</Text>
<Text>
Use <Kbd>Tab</Kbd> to navigate
</Text>
```

## useHotkeys Hook

The `useHotkeys` hook registers keyboard shortcuts. It uses the same key string format as `<Hotkey>`, so you can display exactly what you register.

```jsx
import {Hotkey, useHotkeys} from '@sentry/scraps/hotkey';

function MyComponent() {
useHotkeys([
{match: 'command+k', callback: () => openSearch()},
{match: ['command+/', 'ctrl+/'], callback: () => openHelp()},
]);

return (
<Text>
Search: <Hotkey value="command+k" />
</Text>
);
}
```

## Accessibility

Both `<Hotkey>` and `<Kbd>` render semantic `<kbd>` HTML elements. `<Hotkey>` uses nested `<kbd>` elements (an outer `<kbd>` wrapping inner `<kbd>` elements for each key), which is the correct HTML semantics for a key combination.
101 changes: 101 additions & 0 deletions static/app/components/core/hotkey/hotkey.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {render, screen} from 'sentry-test/reactTestingLibrary';

import {Hotkey} from '@sentry/scraps/hotkey';

jest.mock('@react-aria/utils', () => ({
...jest.requireActual('@react-aria/utils'),
isMac: jest.fn(() => false),
}));

const {isMac} = jest.requireMock<{isMac: jest.Mock}>('@react-aria/utils');

describe('Hotkey', () => {
describe('mac platform', () => {
beforeEach(() => {
isMac.mockReturnValue(true);
});

it('renders command as ⌘', () => {
render(<Hotkey value="command+k" />);
expect(screen.getByText('\u2318')).toBeInTheDocument();
expect(screen.getByText('K')).toBeInTheDocument();
});

it('renders ctrl as ⌃', () => {
render(<Hotkey value="ctrl+alt+delete" />);
expect(screen.getByText('\u2303')).toBeInTheDocument();
expect(screen.getByText('\u2325')).toBeInTheDocument();
expect(screen.getByText('DEL')).toBeInTheDocument();
});

it('renders option as ⌥', () => {
render(<Hotkey value="option+x" />);
expect(screen.getByText('\u2325')).toBeInTheDocument();
expect(screen.getByText('X')).toBeInTheDocument();
});
});

describe('non-mac platform', () => {
beforeEach(() => {
isMac.mockReturnValue(false);
});

it('renders command as CTRL', () => {
render(<Hotkey value="command+k" />);
expect(screen.getByText('CTRL')).toBeInTheDocument();
expect(screen.getByText('K')).toBeInTheDocument();
});

it('renders ctrl as CTRL', () => {
render(<Hotkey value="ctrl+alt+delete" />);
expect(screen.getByText('CTRL')).toBeInTheDocument();
expect(screen.getByText('ALT')).toBeInTheDocument();
expect(screen.getByText('DEL')).toBeInTheDocument();
});

it('renders option as ALT', () => {
render(<Hotkey value="option+x" />);
expect(screen.getByText('ALT')).toBeInTheDocument();
expect(screen.getByText('X')).toBeInTheDocument();
});

it('renders shift as ⇧', () => {
render(<Hotkey value="shift+up" />);
expect(screen.getByText('\u21e7')).toBeInTheDocument();
expect(screen.getByText('\u2191')).toBeInTheDocument();
});
});

describe('string input', () => {
beforeEach(() => {
isMac.mockReturnValue(true);
});

it('accepts a single string', () => {
render(<Hotkey value="command+/" />);
expect(screen.getByText('\u2318')).toBeInTheDocument();
expect(screen.getByText('/')).toBeInTheDocument();
});
});

describe('array input', () => {
beforeEach(() => {
isMac.mockReturnValue(true);
});

it('uses first combo from array', () => {
render(<Hotkey value={['command+backspace', 'delete']} />);
expect(screen.getByText('\u2318')).toBeInTheDocument();
expect(screen.getByText('\u232b')).toBeInTheDocument();
});
});

describe('semantic HTML', () => {
it('renders nested kbd elements', () => {
render(<Hotkey value="command+k" />);
const kbdElements = document.querySelectorAll('kbd');
// Outer wrapper + 2 inner keys
expect(kbdElements).toHaveLength(3);
});
});
});
Loading
Loading