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
2 changes: 1 addition & 1 deletion apps/www/src/content/docs/components/command/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Visual divider between groups. The separator is hidden automatically while the u

### Shortcut

A `<kbd>` element for keyboard hints. Typically passed as `trailingIcon` on `Command.Item`.
Keyboard hints for an item, typically passed as `trailingIcon` on `Command.Item`. Built on [`Kbd`](/docs/components/kbd): it renders a `Kbd.Group` of `ghost` keys and forwards every prop.

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

Expand Down
12 changes: 12 additions & 0 deletions apps/www/src/content/docs/components/command/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ export interface CommandSeparatorProps {
}

export interface CommandShortcutProps {
/**
* The keys to display. A whitespace-separated string is split into one key
* per token, so `"⌘ K"` renders two keys.
*/
children?: React.ReactNode;

/**
* Visual style variant, applied to every key in the shortcut.
* @defaultValue "ghost"
*/
variant?: 'solid' | 'ghost';

/** Additional CSS class names. */
className?: string;
}
Expand Down
121 changes: 121 additions & 0 deletions apps/www/src/content/docs/components/kbd/demo.ts

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.

Add examples of usage in Input

Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use client';

import type { ComponentPropsType } from '@/components/demo/types';
import { getPropsString } from '@/lib/utils';

export const getCode = (props: ComponentPropsType) => {
const { children, ...rest } = props;

return `<Kbd${getPropsString(rest)}>${children}</Kbd>`;
};

export const playground = {
type: 'playground',
controls: {
variant: {
type: 'select',
options: ['solid', 'ghost'],
defaultValue: 'solid'
},
children: {
type: 'text',
initialValue: 'Esc'
}
},
getCode
};

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

export const variantDemo = {
type: 'code',
code: `<Flex gap={7} align="center">
<Kbd.Group>
<Kbd aria-label="Command">⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
<Kbd.Group variant="ghost">
<Kbd aria-label="Command">⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
</Flex>`
};

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

export const separatorDemo = {
type: 'code',
tabs: [
{
name: 'Plus',
code: `<Kbd.Group>
<Kbd aria-label="Command">⌘</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: `<Text size="small" variant="secondary">
Press <Kbd.Group><Kbd aria-label="Command">⌘</Kbd><Kbd>K</Kbd></Kbd.Group> to open the command palette.
</Text>`
};

export const withInputDemo = {
type: 'code',
code: `<Input
placeholder="Search projects"
trailingIcon={<Kbd variant="ghost" aria-label="Command K">⌘K</Kbd>}
/>`
};

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 variant="ghost">
<Kbd aria-label="Command">⌘</Kbd>
<Kbd>K</Kbd>
</Kbd.Group>
</Flex>
</Tooltip.Content>
</Tooltip>`
};
110 changes: 110 additions & 0 deletions apps/www/src/content/docs/components/kbd/index.mdx

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.

Let's add a playground too since we are introducing variants now

Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
title: Kbd
description: A component for displaying keyboard keys and shortcuts.
source: packages/raystack/components/kbd
tag: new
---

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

<Demo data={playground} />

## 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

Groups multiple keyboard keys for key combinations.

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

### Variants

`solid` is the default and suits standalone hints. Use `ghost` on surfaces that already have their own background, such as a menu row, a tooltip, or an input.

<Demo data={variantDemo} />

### Sequences

Wrap keys in `Kbd.Group` to show a chord. Setting `variant` on the group applies it to every key inside.

<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. Separator text takes the surrounding typography rather than the key styling.

<Demo data={separatorDemo} />

### Inline with text

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

<Demo data={withTextDemo} />
Comment on lines +87 to +91

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.

Even tho the example says Inline with text, the actual example is rendering in side a Flex, so it's misleading


### In an input

Surface a focus shortcut in a search field. Use a single `ghost` key here — the input's trailing slot is sized for an icon, so a multi-key `Kbd.Group` will be clipped.

<Demo data={withInputDemo} />

### 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. It has no ARIA role of its own and is not exposed as a separate accessible object, so it does not change how surrounding content is announced.
- Symbol-only keys such as `⌘`, `⇧`, or `↵` are not announced usefully on their own — they are read by their Unicode names, if at all. 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.
- Keys ignore pointer events and text selection, so clicking or dragging across a menu row does not highlight the key labels.
29 changes: 29 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,29 @@
import type { ReactNode } from 'react';

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

/**
* Visual style variant. Inherited from a parent `Kbd.Group` when set there.
* @defaultValue "solid"
*/
variant?: 'solid' | 'ghost';
Comment on lines +7 to +11

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.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
fd -i 'kbd|command' . | head -80
printf '%s\n' '--- Kbd props ---'
cat -n apps/www/src/content/docs/components/kbd/props.ts
printf '%s\n' '--- Command props ---'
sed -n '110,165p' apps/www/src/content/docs/components/command/props.ts
printf '%s\n' '--- relevant symbols ---'
rg -n --glob '!node_modules' 'KbdProps|KbdGroupProps|CommandShortcutProps|forwardRef|<kbd|...props|variant' apps/www/src packages src 2>/dev/null | head -240

Repository: raystack/apsara

Length of output: 26103


🏁 Script executed:

#!/bin/bash
set -eu
for f in \
  apps/www/src/components/kbd/kbd.tsx \
  packages/raystack/components/kbd/kbd.tsx \
  packages/raystack/components/command/command.tsx \
  packages/raystack/components/command/command-misc.tsx \
  apps/www/src/content/docs/components/kbd/index.mdx \
  apps/www/src/content/docs/components/command/index.mdx
do
  if [ -f "$f" ]; then
    printf '\n--- %s (%s lines) ---\n' "$f" "$(wc -l < "$f")"
    ast-grep outline "$f" || true
  fi
done
printf '\n--- implementation excerpts ---\n'
cat -n apps/www/src/components/kbd/kbd.tsx
printf '\n--- package Kbd ---\n'
cat -n packages/raystack/components/kbd/kbd.tsx
printf '\n--- command references ---\n'
rg -n -C 5 'Shortcut|shortcut|Kbd' packages/raystack/components/command apps/www/src/content/docs/components/command apps/www/src/content/docs/components/kbd apps/www/src/components/kbd

Repository: raystack/apsara

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- package metadata ---'
rg -n -C 3 '"react"|"typescript"|`@types/react`|auto-type-table' \
  package.json pnpm-lock.yaml yarn.lock package-lock.json \
  packages apps 2>/dev/null | head -220

printf '%s\n' '--- documentation prop inheritance patterns ---'
rg -n -C 2 'ComponentProps|HTMLAttributes|ButtonHTMLAttributes|extends .*Props|native attributes|forward.*ref' \
  apps/www/src/content/docs/components -g 'props.ts' | head -260

printf '%s\n' '--- relevant tests ---'
sed -n '315,365p' packages/raystack/components/command/__tests__/command.test.tsx
sed -n '1,150p' packages/raystack/components/kbd/__tests__/kbd.test.tsx

printf '%s\n' '--- exports ---'
rg -n -C 3 'components/kbd|components/command|export.*Kbd|export.*Command' packages/raystack -g '*.ts' -g '*.tsx' -g 'package.json' | head -180

Repository: raystack/apsara

Length of output: 42826


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
import re

docs = {
    "KbdProps": Path("apps/www/src/content/docs/components/kbd/props.ts"),
    "KbdGroupProps": Path("apps/www/src/content/docs/components/kbd/props.ts"),
    "CommandShortcutProps": Path("apps/www/src/content/docs/components/command/props.ts"),
}
impl = Path("packages/raystack/components/kbd/kbd.tsx").read_text()
command = Path("packages/raystack/components/command/command-misc.tsx").read_text()

print("--- implementation contracts ---")
for name in ("KbdProps", "KbdGroupProps"):
    m = re.search(rf"(?:export )?type {name}\s*=\s*([^;]+);", impl)
    print(f"{name}: {m.group(1).strip() if m else 'NOT FOUND'}")
m = re.search(r"export type CommandShortcutProps\s*=\s*([^;]+);", command)
print(f"CommandShortcutProps: {m.group(1).strip() if m else 'NOT FOUND'}")

print("--- forwarding sites ---")
for name, text, pattern in [
    ("KbdRoot", impl, r"<kbd\b[\s\S]*?\{\.\.\.props\}"),
    ("KbdGroup", impl, r"<kbd\b[\s\S]*?\{\.\.\.props\}"),
    ("CommandShortcut", command, r"<Kbd\.Group[\s\S]*?\{\.\.\.props\}"),
]:
    print(f"{name}: {'yes' if re.search(pattern, text) else 'no'}")

print("--- documented native/ref surface ---")
for name, path in docs.items():
    text = path.read_text()
    block = re.search(
        rf"export interface {name}\s*\{{([\s\S]*?)\n\}}", text
    )
    body = block.group(1) if block else ""
    native = bool(re.search(r"ComponentProps|HTMLAttributes|KbdProps|KbdGroupProps", body))
    ref = bool(re.search(r"\bref\b|Ref", body))
    print(f"{name}: native-base={'yes' if native else 'no'}, ref-member={'yes' if ref else 'no'}")

print("--- forwarding tests ---")
tests = Path("packages/raystack/components/kbd/__tests__/kbd.test.tsx").read_text()
command_tests = Path("packages/raystack/components/command/__tests__/command.test.tsx").read_text()
for label, text, needles in [
    ("Kbd", tests, ("forwards arbitrary props", "forwards ref")),
    ("Command.Shortcut", command_tests, ("forwards props", "forwards ref")),
]:
    print(f"{label}: " + ", ".join(f"{n}={'yes' if n in text else 'no'}" for n in needles))
PY

printf '%s\n' '--- type-table and generated-doc configuration ---'
rg -n -C 4 'auto-type-table|fumadocs-typescript|fumadocs-docgen|TypeTable|props\.ts' \
  apps/www package.json pnpm-lock.yaml 2>/dev/null | head -260

Repository: raystack/apsara

Length of output: 17302


Align the documentation prop tables with the published component types.

The package already types and forwards native <kbd> attributes and refs. The three documentation interfaces omit this surface, so their generated tables do not match the documented id, title, and aria-* support. Extend or reuse the package types in the documentation tables.

📍 Affects 2 files
  • apps/www/src/content/docs/components/kbd/props.ts#L7-L11 (this comment)
  • apps/www/src/content/docs/components/kbd/props.ts#L21-L25
  • apps/www/src/content/docs/components/command/props.ts#L135-L146
🤖 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/props.ts` around lines 7 - 11, The
documentation prop interfaces in
apps/www/src/content/docs/components/kbd/props.ts (lines 7-11 and 21-25) and
apps/www/src/content/docs/components/command/props.ts (lines 135-146) omit
native element attributes and refs. Extend or reuse the published Kbd and
Command component types so the generated tables include supported properties
such as id, title, and aria-* attributes while preserving the existing
component-specific props.


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

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

/**
* Visual style variant applied to every key in the group.
* @defaultValue "solid"
*/
variant?: 'solid' | 'ghost';
Comment on lines +21 to +25

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

Use one variant contract in all API documentation.

The runtime treats group and shortcut variants as inherited defaults. Explicit child Kbd variants override them. The documentation currently describes the variant as unconditional and describes Command.Shortcut as always ghost.

  • apps/www/src/content/docs/components/kbd/props.ts#L21-L25: Document the group variant as the default inherited by child keys.
  • apps/www/src/content/docs/components/command/props.ts#L141-L145: Document that child variants override the shortcut variant.
  • apps/www/src/content/docs/components/command/index.mdx#L100-L100: State that ghost is the default, not the only supported variant.
📍 Affects 3 files
  • apps/www/src/content/docs/components/kbd/props.ts#L21-L25 (this comment)
  • apps/www/src/content/docs/components/command/props.ts#L141-L145
  • apps/www/src/content/docs/components/command/index.mdx#L100-L100
🤖 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/props.ts` around lines 21 - 25,
Align the variant documentation across all three sites: in
apps/www/src/content/docs/components/kbd/props.ts lines 21-25, describe the
group variant as the inherited default for child keys, with explicit child Kbd
variants taking precedence; in
apps/www/src/content/docs/components/command/props.ts lines 141-145, document
that child variants override the shortcut variant; and in
apps/www/src/content/docs/components/command/index.mdx line 100, state that
ghost is the default variant rather than the only supported variant.


/** Additional CSS class names. */
className?: string;
}
55 changes: 55 additions & 0 deletions packages/raystack/components/command/__tests__/command.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { Kbd } from '../../kbd';
import kbdStyles from '../../kbd/kbd.module.css';
import { Command } from '../command';
import styles from '../command.module.css';

Expand Down Expand Up @@ -315,4 +317,57 @@ describe('Command', () => {
});
});
});

describe('Command.Shortcut', () => {
it('splits a string of keys into individual keys', () => {
render(<Command.Shortcut>⌘ K</Command.Shortcut>);
expect(screen.getByText('⌘')).toBeInTheDocument();
expect(screen.getByText('K')).toBeInTheDocument();
});

it('renders each key through Kbd', () => {
render(<Command.Shortcut>⌘ K</Command.Shortcut>);
const key = screen.getByText('⌘');
expect(key.tagName).toBe('KBD');
expect(key).toHaveClass(kbdStyles['kbd']);
});

it('defaults its keys to the ghost variant', () => {
render(<Command.Shortcut>⌘ K</Command.Shortcut>);
expect(screen.getByText('⌘')).toHaveClass(kbdStyles['kbd-ghost']);
});

it('allows the variant to be overridden', () => {
render(<Command.Shortcut variant='solid'>⌘ K</Command.Shortcut>);
expect(screen.getByText('⌘')).toHaveClass(kbdStyles['kbd-solid']);
});

it('forwards props and merges className onto the group', () => {
const { container } = render(
<Command.Shortcut className='custom' aria-label='Command K'>
⌘ K
</Command.Shortcut>
);
const group = container.querySelector('[data-slot="command-shortcut"]');
expect(group).toHaveClass('custom');
expect(group).toHaveClass(styles.shortcut);
expect(group).toHaveAttribute('aria-label', 'Command K');
});

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

it('does not double-wrap element children in a second key', () => {
const { container } = render(
<Command.Shortcut>
<Kbd>⌘</Kbd>
</Command.Shortcut>
);
const keys = container.querySelectorAll(`.${kbdStyles['kbd']}`);
expect(keys).toHaveLength(1);
});
});
});
Loading
Loading