Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 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
6 changes: 6 additions & 0 deletions .changeset/rude-heads-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@salt-ds/lab": minor
---

- Added Kbd component in Labs.
- Added Keyboard shortcut pattern.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"react": "^18.3.1",
"react-docgen-typescript": "2.4.0",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^5.1.0",
"react-resizable-panels": "^3.0.0",
"react-router": "^7.6.3",
"storybook": "^9.0.4",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.keyboardShortcuts-description {
padding: var(--salt-spacing-25) 0;
}
.keyboardShortcuts-actions-title {
font-weight: var(--salt-text-heading-fontWeight);
}
table.saltTable td.keyboardShortcuts-td {
padding: calc(var(--salt-spacing-50)) var(--salt-spacing-100);
}
.keyboardShortcuts-dialog .saltTable {
--table-row-height: calc(var(--salt-size-base) + var(--salt-spacing-100));
}
.keyboardShortcuts-shortcuts {
padding: var(--salt-spacing-75) 0;
}
.keyboardShortcuts-kbd {
padding: var(--salt-spacing-50) 0;
}
.keyboardShortcuts-dialog .saltDialogContent-inner {
overflow-y: hidden;
}
.keyboardShortcuts-dialog .keyboardShortcuts-tableScroll {
overflow-y: auto;
max-height: 40vh;
position: "relative";
}

.keyboardShortcuts-connector {
display: inline-block;
vertical-align: middle;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Enter a non-match into the filter and the layout collapses.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@honey-chang should I add some min-height to maintain the height of the dialog when search has no match?

Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
import {
Button,
Dialog,
DialogContent,
FlexLayout,
FormFieldHelperText,
Input,
StackLayout,
Switch,
Text,
} from "@salt-ds/core";
import { FilterIcon } from "@salt-ds/icons";
import { Kbd, Table, TBody, TD, TH, THead, TR } from "@salt-ds/lab";
import type { Meta } from "@storybook/react-vite";
import React, { type ChangeEvent, type FC, useState } from "react";
import { HotkeysProvider, useHotkeys } from "react-hotkeys-hook";
Copy link
Contributor

Choose a reason for hiding this comment

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

are there any types we can use from there ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I found two types. Not many

import "./keyboard-shortcuts.stories.css";

export default {
title: "Patterns/Keyboard Shortcuts",
} as Meta;

type Shortcut = {
label: string;
keys: string[];
description?: string;
};

const shortcutList: Shortcut[] = [
{
label: "Open command palette",
keys: ["meta+option+p"],
},
{
label: "Next",
keys: ["meta+shift+e"],
},
{
label: "Previous",
keys: ["meta+e"],
},
{
label: "Duplicate ticket",
keys: ["meta+d"],
description: "Make a copy of your ticket",
},
{
label: "Set direction to buy",
keys: ["meta+b"],
},
{
label: "Set direction to sell",
keys: ["meta+s"],
},
{
label: "Bottom of list",
keys: ["meta+end"],
},
{
label: "Top of list",
keys: ["meta+home"],
},
{
label: "Test",
keys: ["meta+u", "meta+y"],
description: "Trigger test action with Cmd+U or Cmd+Y",
},
];

function displayKeyName(key: string): string {
// todo, detect the OS for meta and display ctrl and command accordingly.
if (key === "meta") return "ctrl";
if (key === "option") return "option";
if (key === "shift") return "shift";
return key;
Copy link
Contributor

@mark-tate mark-tate Dec 19, 2025

Choose a reason for hiding this comment

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

Maybe

return function displayKeyName(key: string): string {
  const isMac = navigator.platform.toUpperCase().includes('MAC');
  
  const keyMap: Record<string, string> = {
    meta: isMac ? "⌘" : "ctrl",
    option: isMac ? "⌥" : "alt",
    shift: isMac ? "⇧" : "shift",
  };
  
  return keyMap[key] ?? key;
}

}

function highlightTextMatch(text: string, query: string): React.ReactNode {
if (!query) return text;
const regex = new RegExp(`(${query})`, "gi");
return text
.split(regex)
.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<strong key={i}>{part}</strong>
) : (
part
),
);
}

const KeyboardShortcuts: FC = () => {
const [open, setOpen] = useState<boolean>(false);
const [shortcutsEnabled, setShortcutsEnabled] = useState<boolean>(true);
const [filter, setFilter] = useState<string>("");

useHotkeys(
"meta+option+p",
(e) => {
e.preventDefault();
alert("Open command palette triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+shift+e",
(e) => {
e.preventDefault();
alert("Next triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+e",
(e) => {
e.preventDefault();
alert("Previous triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+d",
(e) => {
e.preventDefault();
alert("Duplicate ticket triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+b",
(e) => {
shortcutsEnabled && alert("Set direction to buy triggered");
},
{ enabled: shortcutsEnabled },
[shortcutsEnabled],
);
useHotkeys(
"meta+s",
(e) => {
e.preventDefault();
alert("Set direction to sell triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+end",
(e) => {
e.preventDefault();
alert("Bottom of list triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+home",
(e) => {
e.preventDefault();
alert("Top of list triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+u",
(e) => {
e.preventDefault();
alert("Test shortcut triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+y",
(e) => {
e.preventDefault();
alert("Test shortcut triggered");
},
{ enabled: shortcutsEnabled },
);
useHotkeys(
"meta+shift+k",
(event) => {
event.preventDefault();
setFilter("");
setOpen(true);
},
{ enabled: shortcutsEnabled },
);

const filteredShortcuts: Shortcut[] = shortcutList.filter((s) =>
s.label.toLowerCase().includes(filter.trim().toLowerCase()),
);

const handleDialogOpen = (): void => {
setFilter("");
setOpen(true);
};
const handleDialogChange = (value: boolean): void => {
setOpen(value);
if (value) setFilter("");
};
const handleSwitchChange = (event: ChangeEvent<HTMLInputElement>): void =>
setShortcutsEnabled(event.target.checked);
const handleFilterChange = (event: ChangeEvent<HTMLInputElement>): void =>
setFilter(event.target.value);

return (
<HotkeysProvider>
<StackLayout gap={1}>
<Button data-testid="dialog-button" onClick={handleDialogOpen}>
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this toggle open/close?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But the dialog button is not visible when the dialog is open. Visually not able to toggle it.

Keyboard shortcuts panel
</Button>
<FlexLayout align="center" gap={1}>
<Text>hit </Text>
Copy link
Contributor

Choose a reason for hiding this comment

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

Could it just be press ?

Term Usage Why
Press ✅ Standard Official term in WCAG, ARIA, and UI guidelines
Hit ❌ Informal Sounds aggressive, not professional

<FlexLayout align="center" gap={0}>
<Kbd>meta</Kbd>+<Kbd>shift</Kbd>+<Kbd>K</Kbd>
</FlexLayout>
<Text>to open the keyboard shortcuts panel </Text>
</FlexLayout>
</StackLayout>
<Dialog
open={open}
onOpenChange={handleDialogChange}
id="keyboard-shortcuts-dialog"
size="medium"
className="keyboardShortcuts-dialog"
>
<DialogContent>
<StackLayout gap={3}>
<FlexLayout gap={1}>
<Switch
checked={shortcutsEnabled}
onChange={handleSwitchChange}
/>
<FlexLayout className="keyboardShortcuts-description">
<Text>Turn on keyboard shortcuts</Text>
</FlexLayout>
</FlexLayout>
Copy link
Contributor

@jake-costa jake-costa Dec 16, 2025

Choose a reason for hiding this comment

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

Suggested change
<FlexLayout gap={1}>
<Switch
checked={shortcutsEnabled}
onChange={handleSwitchChange}
/>
<FlexLayout className="keyboardShortcuts-description">
<Text>Turn on keyboard shortcuts</Text>
</FlexLayout>
</FlexLayout>
<Switch
checked={shortcutsEnabled}
onChange={handleSwitchChange}
label="Turn on keyboard shortcuts"
/>

Not sure if there was a specific reason we split the label out into a separate block, but I think this would be a bit cleaner + more consistent with the Switch pattern if we use the label prop. Also, doing it this way ensures the Switch has an accessible name. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

makes sense, i missed the api for switch. Have added the label as a part of switch.

{shortcutsEnabled && (
<StackLayout gap={1}>
<Text className="keyboardShortcuts-actions-title" styleAs="h3">
Actions
</Text>
<StackLayout gap={filteredShortcuts.length ? 3 : 0.75}>
Copy link
Contributor

Choose a reason for hiding this comment

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

gap = 0.75?

<Input
onChange={handleFilterChange}
value={filter}
bordered
variant="secondary"
placeholder="Filter actions"
startAdornment={<FilterIcon color="secondary" />}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
startAdornment={<FilterIcon color="secondary" />}
startAdornment={<FilterIcon color="secondary" aria-hidden="true" />}

Should add aria-hidden to properly hide the SVG from screen readers.

aria-label="Filter actions"
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
aria-label="Filter actions"
inputProps={{ "aria-label": "Filter actions" }}

I think we should use inputProps here so the aria-label is applied to the actual <input> element (not the outer wrapper <div>). Thoughts?

/>
{filteredShortcuts.length ? (
<StackLayout className="keyboardShortcuts-tableScroll">
<Table>
<THead>
<TR>
<TH>Action</TH>
<TH>Key combination</TH>
</TR>
</THead>
<TBody>
{filteredShortcuts.map((shortcut, idx) => (
<TR key={shortcut.label + idx}>
<TD className="keyboardShortcuts-td">
<StackLayout
gap={0.5}
className="keyboardShortcuts-shortcuts"
>
<Text>
{highlightTextMatch(shortcut.label, filter)}
</Text>
{shortcut.description && (
<Text color="secondary">
{shortcut.description}
</Text>
)}
</StackLayout>
</TD>
<TD className="keyboardShortcuts-td">
<FlexLayout gap={0.5} wrap>
{shortcut.keys.map((combo, comboIdx) => (
<FlexLayout
align="center"
gap={0.5}
key={combo + comboIdx}
className="keyboardShortcuts-kbd"
wrap
>
{combo.split("+").map((key, idx, arr) => (
<React.Fragment key={key + idx}>
<Kbd aria-label={displayKeyName(key)}>
Copy link
Contributor

@jake-costa jake-costa Dec 16, 2025

Choose a reason for hiding this comment

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

Suggested change
<Kbd aria-label={displayKeyName(key)}>
<Kbd>

aria-label isn't needed since the value matches the text

{displayKeyName(key)}
</Kbd>
{idx < arr.length - 1 && (
<Text>+</Text>
)}
</React.Fragment>
))}
{comboIdx < shortcut.keys.length - 1 && (
<Text>,</Text>
)}
</FlexLayout>
))}
</FlexLayout>
</TD>
</TR>
))}
</TBody>
</Table>
</StackLayout>
) : (
<FormFieldHelperText color="secondary">
No actions found
</FormFieldHelperText>
)}
</StackLayout>
</StackLayout>
)}
</StackLayout>
</DialogContent>
</Dialog>
</HotkeysProvider>
);
};

export const WithDialog = KeyboardShortcuts.bind({});
1 change: 1 addition & 0 deletions packages/lab/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export {
type InputLegacyProps as InputProps,
StaticInputAdornment,
} from "./input-legacy";
export * from "./kbd";
export * from "./layer-layout";
export * from "./list";
export type {
Expand Down
18 changes: 18 additions & 0 deletions packages/lab/src/kbd/Kbd.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.saltKbd {
display: inline-flex;
box-sizing: border-box;
--saltText-fontFamily: var(--salt-text-code-fontFamily);
width: fit-content;
border-radius: var(--salt-palette-corner-weaker);
border: var(--salt-size-fixed-100) solid var(--salt-container-primary-borderColor);
background: var(--salt-container-primary-background);
box-shadow: 0 var(--salt-size-fixed-100) 0 0 var(--salt-container-primary-borderColor);
height: calc(var(--salt-size-base) - var(--salt-spacing-100));
align-items: center;
padding: 0 var(--salt-spacing-50);
text-transform: capitalize;
}

.saltKbd .saltText {
width: fit-content;
}
Loading
Loading