diff --git a/apps/www/src/content/docs/components/command/index.mdx b/apps/www/src/content/docs/components/command/index.mdx index f3b03b5f8..1f0527ac9 100644 --- a/apps/www/src/content/docs/components/command/index.mdx +++ b/apps/www/src/content/docs/components/command/index.mdx @@ -97,7 +97,7 @@ Visual divider between groups. The separator is hidden automatically while the u ### Shortcut -A `` 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. diff --git a/apps/www/src/content/docs/components/command/props.ts b/apps/www/src/content/docs/components/command/props.ts index 5b1b54945..906c6580b 100644 --- a/apps/www/src/content/docs/components/command/props.ts +++ b/apps/www/src/content/docs/components/command/props.ts @@ -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; } diff --git a/apps/www/src/content/docs/components/kbd/demo.ts b/apps/www/src/content/docs/components/kbd/demo.ts new file mode 100644 index 000000000..c5a6f6dca --- /dev/null +++ b/apps/www/src/content/docs/components/kbd/demo.ts @@ -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 `${children}`; +}; + +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: ` + Esc + + + + Tab + ` +}; + +export const variantDemo = { + type: 'code', + code: ` + + + K + + + + K + + ` +}; + +export const groupDemo = { + type: 'code', + code: ` + + + K + + + + + P + + ` +}; + +export const separatorDemo = { + type: 'code', + tabs: [ + { + name: 'Plus', + code: ` + + + + K + ` + }, + { + name: 'Then', + code: ` + G + then + P + ` + } + ] +}; + +export const withTextDemo = { + type: 'code', + code: ` + Press K to open the command palette. + ` +}; + +export const withInputDemo = { + type: 'code', + code: `⌘K} + />` +}; + +export const withTooltipDemo = { + type: 'code', + code: ` + }> + Search + + + + Open search + + + K + + + + ` +}; diff --git a/apps/www/src/content/docs/components/kbd/index.mdx b/apps/www/src/content/docs/components/kbd/index.mdx new file mode 100644 index 000000000..8607491e8 --- /dev/null +++ b/apps/www/src/content/docs/components/kbd/index.mdx @@ -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"; + + + +## 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"; + +Esc + + + + K + +``` + +## API Reference + +Both parts render a `` element and forward any native attributes (`id`, `title`, `aria-label`, …) to it. + +### Root + +A single keyboard key. Renders a `` element. + + + +### Group + +Groups multiple keyboard keys for key combinations. + + + +### 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 `⌘`. + + + +### 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. + + + +### Sequences + +Wrap keys in `Kbd.Group` to show a chord. Setting `variant` on the group applies it to every key inside. + + + +### 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. + + + +### Inline with text + +Keys sit on the text baseline, so they can be dropped straight into a sentence. + + + +### 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. + + + +### In a tooltip + +A common use is surfacing a shortcut alongside the action it triggers. + + + +## Accessibility + +- `Kbd` is presentational and renders the semantic `` 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: ``. +- 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. diff --git a/apps/www/src/content/docs/components/kbd/props.ts b/apps/www/src/content/docs/components/kbd/props.ts new file mode 100644 index 000000000..311b8701a --- /dev/null +++ b/apps/www/src/content/docs/components/kbd/props.ts @@ -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'; + + /** 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'; + + /** Additional CSS class names. */ + className?: string; +} diff --git a/packages/raystack/components/command/__tests__/command.test.tsx b/packages/raystack/components/command/__tests__/command.test.tsx index 2cdefaf0d..1b298f1c3 100644 --- a/packages/raystack/components/command/__tests__/command.test.tsx +++ b/packages/raystack/components/command/__tests__/command.test.tsx @@ -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'; @@ -315,4 +317,57 @@ describe('Command', () => { }); }); }); + + describe('Command.Shortcut', () => { + it('splits a string of keys into individual keys', () => { + render(⌘ K); + expect(screen.getByText('⌘')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + it('renders each key through Kbd', () => { + render(⌘ K); + const key = screen.getByText('⌘'); + expect(key.tagName).toBe('KBD'); + expect(key).toHaveClass(kbdStyles['kbd']); + }); + + it('defaults its keys to the ghost variant', () => { + render(⌘ K); + expect(screen.getByText('⌘')).toHaveClass(kbdStyles['kbd-ghost']); + }); + + it('allows the variant to be overridden', () => { + render(⌘ K); + expect(screen.getByText('⌘')).toHaveClass(kbdStyles['kbd-solid']); + }); + + it('forwards props and merges className onto the group', () => { + const { container } = render( + + ⌘ K + + ); + 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(); + render(⌘ K); + expect(ref.current?.tagName).toBe('KBD'); + }); + + it('does not double-wrap element children in a second key', () => { + const { container } = render( + + + + ); + const keys = container.querySelectorAll(`.${kbdStyles['kbd']}`); + expect(keys).toHaveLength(1); + }); + }); }); diff --git a/packages/raystack/components/command/command-misc.tsx b/packages/raystack/components/command/command-misc.tsx index a1563995a..8bcefe9d8 100644 --- a/packages/raystack/components/command/command-misc.tsx +++ b/packages/raystack/components/command/command-misc.tsx @@ -2,7 +2,8 @@ import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'; import { cx } from 'class-variance-authority'; -import { type ComponentProps } from 'react'; +import { Fragment, isValidElement } from 'react'; +import { Kbd, type KbdGroupProps } from '../kbd'; import styles from './command.module.css'; import { useCommandContext } from './command-root'; @@ -63,11 +64,12 @@ export const CommandSeparator = ({ }; CommandSeparator.displayName = 'Command.Separator'; -export type CommandShortcutProps = ComponentProps<'span'>; +export type CommandShortcutProps = KbdGroupProps; export const CommandShortcut = ({ className, children, + variant = 'ghost', ...props }: CommandShortcutProps) => { const keys = @@ -78,21 +80,22 @@ export const CommandShortcut = ({ : [children]; return ( - - {keys.map((key, index) => ( - - {key} - - ))} - + {keys.map((key, index) => + isValidElement(key) ? ( + {key} + ) : ( + + {key} + + ) + )} + ); }; CommandShortcut.displayName = 'Command.Shortcut'; diff --git a/packages/raystack/components/command/command.module.css b/packages/raystack/components/command/command.module.css index 0cbdd837e..0eaa63891 100644 --- a/packages/raystack/components/command/command.module.css +++ b/packages/raystack/components/command/command.module.css @@ -103,22 +103,8 @@ } .shortcut { - display: inline-flex; - align-items: center; justify-content: flex-end; - gap: var(--rs-space-1); white-space: nowrap; - font-family: var(--rs-font-body); - font-weight: var(--rs-font-weight-regular); - font-size: var(--rs-font-size-micro); - line-height: var(--rs-line-height-micro); - letter-spacing: var(--rs-letter-spacing-micro); - color: var(--rs-color-foreground-base-tertiary); -} - -.shortcutKey { - font: inherit; - color: inherit; } .empty { diff --git a/packages/raystack/components/kbd/__tests__/data-slots.test.tsx b/packages/raystack/components/kbd/__tests__/data-slots.test.tsx new file mode 100644 index 000000000..577ef4b09 --- /dev/null +++ b/packages/raystack/components/kbd/__tests__/data-slots.test.tsx @@ -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( + + + K + + ); + expectSlots(container, ['kbd-group', 'kbd']); + }); + + it('marks each key with the same slot name', () => { + const { container } = render( + + + K + + ); + expect(getAllSlots(container, 'kbd')).toHaveLength(2); + }); + + it('drops the group slot when no group is rendered', () => { + const { container } = render(Esc); + expectSlots(container, ['kbd']); + expect(getSlot(container, 'kbd-group')).toBeNull(); + }); + + it('lets callers override the slot name', () => { + const { container } = render(Esc); + expect(getSlot(container, 'custom')).not.toBeNull(); + expect(getSlot(container, 'kbd')).toBeNull(); + }); +}); diff --git a/packages/raystack/components/kbd/__tests__/kbd.test.tsx b/packages/raystack/components/kbd/__tests__/kbd.test.tsx new file mode 100644 index 000000000..79a353504 --- /dev/null +++ b/packages/raystack/components/kbd/__tests__/kbd.test.tsx @@ -0,0 +1,174 @@ +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(Ctrl); + expect(screen.getByText('Ctrl')).toBeInTheDocument(); + }); + + it('renders a kbd element', () => { + render(Ctrl); + expect(screen.getByText('Ctrl').tagName).toBe('KBD'); + }); + + it('applies the base class', () => { + render(Ctrl); + expect(screen.getByText('Ctrl')).toHaveClass(styles.kbd); + }); + + it('merges a custom className with the base class', () => { + render(Ctrl); + const kbd = screen.getByText('Ctrl'); + expect(kbd).toHaveClass(styles.kbd); + expect(kbd).toHaveClass('custom'); + }); + + it('forwards arbitrary props to the element', () => { + render(Ctrl); + expect(screen.getByText('Ctrl')).toHaveAttribute( + 'aria-label', + 'Control key' + ); + }); + + it('forwards ref', () => { + const ref = createRef(); + render(Ctrl); + expect(ref.current).toBeInstanceOf(HTMLElement); + expect(ref.current?.tagName).toBe('KBD'); + }); + }); + + describe('Variants', () => { + it('applies the solid variant by default', () => { + render(Ctrl); + expect(screen.getByText('Ctrl')).toHaveClass(styles['kbd-solid']); + }); + + it('applies the ghost variant when requested', () => { + render(Ctrl); + const kbd = screen.getByText('Ctrl'); + expect(kbd).toHaveClass(styles['kbd-ghost']); + expect(kbd).not.toHaveClass(styles['kbd-solid']); + }); + + it('inherits the variant from a parent group', () => { + render( + + + K + + ); + expect(screen.getByText('⌘')).toHaveClass(styles['kbd-ghost']); + expect(screen.getByText('K')).toHaveClass(styles['kbd-ghost']); + }); + + it('lets a key override the variant inherited from its group', () => { + render( + + + K + + ); + expect(screen.getByText('⌘')).toHaveClass(styles['kbd-solid']); + expect(screen.getByText('K')).toHaveClass(styles['kbd-ghost']); + }); + + it('falls back to solid for keys in a group with no variant', () => { + render( + + K + + ); + expect(screen.getByText('K')).toHaveClass(styles['kbd-solid']); + }); + + it('does not put a key variant class on the group', () => { + const { container } = render( + + K + + ); + const group = container.querySelector(`.${styles['kbd-group']}`); + expect(group).not.toHaveClass(styles['kbd-ghost']); + }); + }); + + describe('Kbd.Group', () => { + it('renders every key it contains', () => { + render( + + + K + + ); + expect(screen.getByText('⌘')).toBeInTheDocument(); + expect(screen.getByText('K')).toBeInTheDocument(); + }); + + it('renders a kbd element so nested keys stay semantic', () => { + const { container } = render( + + K + + ); + const group = container.querySelector(`.${styles['kbd-group']}`); + expect(group?.tagName).toBe('KBD'); + }); + + it('applies the group class, not the key class', () => { + const { container } = render( + + K + + ); + 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( + + K + + ); + const group = container.querySelector(`.${styles['kbd-group']}`); + expect(group).toHaveClass('custom'); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + K + + ); + expect(ref.current?.tagName).toBe('KBD'); + }); + + it('allows plain text separators between keys', () => { + render( + + +K + + ); + 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'); + }); + }); +}); diff --git a/packages/raystack/components/kbd/index.tsx b/packages/raystack/components/kbd/index.tsx new file mode 100644 index 000000000..a170b82e2 --- /dev/null +++ b/packages/raystack/components/kbd/index.tsx @@ -0,0 +1 @@ +export { Kbd, type KbdGroupProps, type KbdProps } from './kbd'; diff --git a/packages/raystack/components/kbd/kbd.module.css b/packages/raystack/components/kbd/kbd.module.css new file mode 100644 index 000000000..b899ad6f9 --- /dev/null +++ b/packages/raystack/components/kbd/kbd.module.css @@ -0,0 +1,38 @@ +.kbd, +.kbd-group { + display: inline-flex; + align-items: center; + width: fit-content; + pointer-events: none; + user-select: none; +} + +.kbd { + justify-content: center; + box-sizing: border-box; + height: var(--rs-space-6); + min-width: var(--rs-space-6); + padding: 0 var(--rs-space-2); + border-radius: var(--rs-radius-1); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + white-space: nowrap; +} + +.kbd-solid { + background: var(--rs-color-background-neutral-primary); + color: var(--rs-color-foreground-base-secondary); +} + +.kbd-ghost { + background: transparent; + color: var(--rs-color-foreground-base-secondary); +} + +.kbd-group { + gap: var(--rs-space-2); + font: inherit; +} diff --git a/packages/raystack/components/kbd/kbd.tsx b/packages/raystack/components/kbd/kbd.tsx new file mode 100644 index 000000000..ad9a6ccbc --- /dev/null +++ b/packages/raystack/components/kbd/kbd.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { type ComponentProps, createContext, useContext } from 'react'; +import styles from './kbd.module.css'; + +const kbd = cva(styles['kbd'], { + variants: { + variant: { + solid: styles['kbd-solid'], + ghost: styles['kbd-ghost'] + } + }, + defaultVariants: { + variant: 'solid' + } +}); + +type KbdVariant = NonNullable['variant']>; + +const KbdGroupContext = createContext(undefined); + +export type KbdProps = ComponentProps<'kbd'> & VariantProps; + +const KbdRoot = ({ className, variant, ...props }: KbdProps) => { + const groupVariant = useContext(KbdGroupContext); + + return ( + + ); +}; + +KbdRoot.displayName = 'Kbd'; + +export type KbdGroupProps = ComponentProps<'kbd'> & VariantProps; + +const KbdGroup = ({ className, variant, ...props }: KbdGroupProps) => ( + + + +); + +KbdGroup.displayName = 'Kbd.Group'; + +export const Kbd = Object.assign(KbdRoot, { + Group: KbdGroup +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 562cbdb2d..c48d4e57e 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -97,6 +97,7 @@ export { IconButton } from './components/icon-button'; export { Image } from './components/image'; export { Indicator } from './components/indicator'; export { Input } from './components/input'; +export { Kbd } from './components/kbd'; export { Label } from './components/label'; export { Link } from './components/link'; export { List } from './components/list';