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
87 changes: 87 additions & 0 deletions apps/www/src/content/docs/components/kbd/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client';

export const preview = {
type: 'code',
code: `<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>`
Comment on lines +5 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add aria-label to every symbol-only key example.

These examples use symbols as the only key content. They do not follow the accessibility guidance on apps/www/src/content/docs/components/kbd/index.mdx line 94. Add labels such as <Kbd aria-label="Command">⌘</Kbd>.

  • apps/www/src/content/docs/components/kbd/demo.ts#L5-L8: Label the Command key.
  • apps/www/src/content/docs/components/kbd/demo.ts#L13-L19: Label the Command, Shift, and Enter keys.
  • apps/www/src/content/docs/components/kbd/demo.ts#L24-L34: Label the Command and Shift keys.
  • apps/www/src/content/docs/components/kbd/demo.ts#L42-L46: Label the Command key.
  • apps/www/src/content/docs/components/kbd/demo.ts#L61-L68: Label the Command key.
  • apps/www/src/content/docs/components/kbd/demo.ts#L73-L86: Label the Command key.
📍 Affects 1 file
  • apps/www/src/content/docs/components/kbd/demo.ts#L5-L8 (this comment)
  • apps/www/src/content/docs/components/kbd/demo.ts#L13-L19
  • apps/www/src/content/docs/components/kbd/demo.ts#L24-L34
  • apps/www/src/content/docs/components/kbd/demo.ts#L42-L46
  • apps/www/src/content/docs/components/kbd/demo.ts#L61-L68
  • apps/www/src/content/docs/components/kbd/demo.ts#L73-L86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/www/src/content/docs/components/kbd/demo.ts` around lines 5 - 8, In
apps/www/src/content/docs/components/kbd/demo.ts, add descriptive aria-label
values to every symbol-only Kbd example: label Command at lines 5-8, 13-19,
24-34, 42-46, 61-68, and 73-86; additionally label Shift and Enter in the 13-19
example and Shift in the 24-34 example. Use labels such as “Command,” “Shift,”
and “Enter” while preserving the displayed symbols.

};

export const singleDemo = {
type: 'code',
code: `<Flex gap={5} align="center">
<Kbd>Esc</Kbd>
<Kbd>⌘</Kbd>
<Kbd>⇧</Kbd>
<Kbd>↵</Kbd>
<Kbd>Tab</Kbd>
</Flex>`
};

export const groupDemo = {
type: 'code',
code: `<Flex gap={7} align="center">
<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>⇧</Kbd>
<Kbd>P</Kbd>
</Kbd.Group>
</Flex>`
};

export const separatorDemo = {
type: 'code',
tabs: [
{
name: 'Plus',
code: `<Kbd.Group>
<Kbd>⌘</Kbd>
+
<Kbd>K</Kbd>
</Kbd.Group>`
},
{
name: 'Then',
code: `<Kbd.Group>
<Kbd>G</Kbd>
then
<Kbd>P</Kbd>
</Kbd.Group>`
}
]
};

export const withTextDemo = {
type: 'code',
code: `<Flex gap={3} align="center">
<Text size="small" variant="secondary">Press</Text>
<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
<Text size="small" variant="secondary">to open the command palette</Text>
</Flex>`
};

export const withTooltipDemo = {
type: 'code',
code: `<Tooltip>
<Tooltip.Trigger render={<Button variant="outline" />}>
Search
</Tooltip.Trigger>
<Tooltip.Content>
<Flex gap={3} align="center">
Open search
<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
</Flex>
</Tooltip.Content>
</Tooltip>`
};
96 changes: 96 additions & 0 deletions apps/www/src/content/docs/components/kbd/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
---
title: Kbd
description: Displays a keyboard key or a shortcut sequence.
source: packages/raystack/components/kbd
tag: new
---

import {
preview,
singleDemo,
groupDemo,
separatorDemo,
withTextDemo,
withTooltipDemo,
} from "./demo.ts";

<Demo data={preview} />

## Anatomy

Import and assemble the component. A single `Kbd` renders one key; wrap several in `Kbd.Group` to show a sequence.

```tsx
import { Kbd } from "@raystack/apsara";

<Kbd>Esc</Kbd>

<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
```

## API Reference

Both parts render a `<kbd>` element and forward any native attributes (`id`, `title`, `aria-label`, …) to it.

### Root

A single keyboard key. Renders a `<kbd>` element.

<auto-type-table path="./props.ts" name="KbdProps" />

### Group

Spaces a sequence of keys evenly. Also renders a `<kbd>`: per the HTML spec, a `kbd` nested inside a `kbd` represents an individual key within a larger input, which is exactly what a shortcut sequence is.

<auto-type-table path="./props.ts" name="KbdGroupProps" />

### Slots

Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot):

| Slot | Element |
|------|---------|
| `kbd` | Each individual key |
| `kbd-group` | The `Kbd.Group` wrapper |

## Examples

### Single keys

Use `Kbd` on its own for a one-key hint. Keys share a minimum width so a narrow `K` lines up with a wide `⌘`.

<Demo data={singleDemo} />

### Sequences

Wrap keys in `Kbd.Group` to show a chord.

<Demo data={groupDemo} />

### Separators

`Kbd.Group` renders whatever you put between the keys, so separators are plain text. Use `+` for keys pressed together and a word like `then` for keys pressed in order.

<Demo data={separatorDemo} />

### Inline with text

Keys sit on the text baseline, so they can be dropped into a sentence.

<Demo data={withTextDemo} />

### In a tooltip

A common use is surfacing a shortcut alongside the action it triggers.

<Demo data={withTooltipDemo} />

## Accessibility

- `Kbd` is presentational and renders the semantic `<kbd>` element, which screen readers announce as keyboard input.
- Symbol-only keys such as `⌘`, `⇧`, or `↵` are not announced usefully on their own. Add an `aria-label` when the symbol is the only cue: `<Kbd aria-label="Command">⌘</Kbd>`.
- Keys are not focusable and carry no interaction. Keep the shortcut wired to a real handler elsewhere — `Kbd` only displays it.
- Text selection is disabled so dragging across a menu row does not highlight the key labels.
17 changes: 17 additions & 0 deletions apps/www/src/content/docs/components/kbd/props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';

export interface KbdProps {
/** The key to display, e.g. `⌘`, `Esc`, or `Enter`. */
children?: ReactNode;

/** Additional CSS class names. */
className?: string;
}

export interface KbdGroupProps {
/** The keys in the sequence, plus any plain-text separators between them. */
children?: ReactNode;

/** Additional CSS class names. */
className?: string;
}
38 changes: 38 additions & 0 deletions packages/raystack/components/kbd/__tests__/data-slots.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots';
import { Kbd } from '../kbd';

describe('Kbd data-slot contract', () => {
it('exposes slots for every rendered part', () => {
const { container } = render(
<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
);
expectSlots(container, ['kbd-group', 'kbd']);
});

it('marks each key with the same slot name', () => {
const { container } = render(
<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
);
expect(getAllSlots(container, 'kbd')).toHaveLength(2);
});

it('drops the group slot when no group is rendered', () => {
const { container } = render(<Kbd>Esc</Kbd>);
expectSlots(container, ['kbd']);
expect(getSlot(container, 'kbd-group')).toBeNull();
});

it('lets callers override the slot name', () => {
const { container } = render(<Kbd data-slot='custom'>Esc</Kbd>);
expect(getSlot(container, 'custom')).not.toBeNull();
expect(getSlot(container, 'kbd')).toBeNull();
});
});
119 changes: 119 additions & 0 deletions packages/raystack/components/kbd/__tests__/kbd.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it } from 'vitest';
import { Kbd } from '../kbd';
import styles from '../kbd.module.css';

describe('Kbd', () => {
describe('Basic Rendering', () => {
it('renders its children', () => {
render(<Kbd>Ctrl</Kbd>);
expect(screen.getByText('Ctrl')).toBeInTheDocument();
});

it('renders a kbd element', () => {
render(<Kbd>Ctrl</Kbd>);
expect(screen.getByText('Ctrl').tagName).toBe('KBD');
});

it('applies the base class', () => {
render(<Kbd>Ctrl</Kbd>);
expect(screen.getByText('Ctrl')).toHaveClass(styles.kbd);
});

it('merges a custom className with the base class', () => {
render(<Kbd className='custom'>Ctrl</Kbd>);
const kbd = screen.getByText('Ctrl');
expect(kbd).toHaveClass(styles.kbd);
expect(kbd).toHaveClass('custom');
});

it('forwards arbitrary props to the element', () => {
render(<Kbd aria-label='Control key'>Ctrl</Kbd>);
expect(screen.getByText('Ctrl')).toHaveAttribute(
'aria-label',
'Control key'
);
});

it('forwards ref', () => {
const ref = createRef<HTMLElement>();
render(<Kbd ref={ref}>Ctrl</Kbd>);
expect(ref.current).toBeInstanceOf(HTMLElement);
expect(ref.current?.tagName).toBe('KBD');
});
});

describe('Kbd.Group', () => {
it('renders every key it contains', () => {
render(
<Kbd.Group>
<Kbd>⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
);
expect(screen.getByText('⌘')).toBeInTheDocument();
expect(screen.getByText('K')).toBeInTheDocument();
});

it('renders a kbd element so nested keys stay semantic', () => {
const { container } = render(
<Kbd.Group>
<Kbd>K</Kbd>
</Kbd.Group>
);
const group = container.querySelector(`.${styles['kbd-group']}`);
expect(group?.tagName).toBe('KBD');
});

it('applies the group class, not the key class', () => {
const { container } = render(
<Kbd.Group>
<Kbd>K</Kbd>
</Kbd.Group>
);
const group = container.querySelector(`.${styles['kbd-group']}`);
expect(group).not.toHaveClass(styles.kbd);
});

it('merges a custom className with the group class', () => {
const { container } = render(
<Kbd.Group className='custom'>
<Kbd>K</Kbd>
</Kbd.Group>
);
const group = container.querySelector(`.${styles['kbd-group']}`);
expect(group).toHaveClass('custom');
});

it('forwards ref', () => {
const ref = createRef<HTMLElement>();
render(
<Kbd.Group ref={ref}>
<Kbd>K</Kbd>
</Kbd.Group>
);
expect(ref.current?.tagName).toBe('KBD');
});

it('allows plain text separators between keys', () => {
render(
<Kbd.Group>
<Kbd>⌘</Kbd>+<Kbd>K</Kbd>
</Kbd.Group>
);
expect(screen.getByText('+')).toBeInTheDocument();
});
});

describe('Composition', () => {
it('exposes Group off the root', () => {
expect(Kbd.Group).toBeDefined();
});

it('sets displayName on both parts', () => {
expect(Kbd.displayName).toBe('Kbd');
expect(Kbd.Group.displayName).toBe('Kbd.Group');
});
});
});
1 change: 1 addition & 0 deletions packages/raystack/components/kbd/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Kbd } from './kbd';
32 changes: 32 additions & 0 deletions packages/raystack/components/kbd/kbd.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* normalize.css sets `kbd { font-family: monospace }`, so both parts restore
the body font explicitly rather than relying on inheritance. */

.kbd,
.kbd-group {
display: inline-flex;
align-items: center;
color: var(--rs-color-foreground-base-tertiary);
font-family: var(--rs-font-body);
font-size: var(--rs-font-size-mini);
line-height: var(--rs-line-height-mini);
letter-spacing: var(--rs-letter-spacing-mini);
}

.kbd {
justify-content: center;
box-sizing: border-box;
height: var(--rs-space-6);
/* Square minimum so a narrow "K" reads the same width as a wide "⌘". */
min-width: var(--rs-space-6);
padding: 0 var(--rs-space-2);
border-radius: var(--rs-radius-1);
background: var(--rs-color-background-neutral-primary);
font-weight: var(--rs-font-weight-medium);
white-space: nowrap;
user-select: none;
}

/* Spacing container only — the nested keys carry the chip treatment. */
.kbd-group {
gap: var(--rs-space-2);
}
Loading
Loading