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
7 changes: 7 additions & 0 deletions .changeset/pretty-suits-create.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"sit-onyx": minor
---

feat: implement new `OnyxUnstableKey` and `OnyxUnstableShortcut` components with `_unstableUseShortcutSequence` composable

For now, components are marked as experimental/unstable which means that they are still under active development and the API might change in patch or minor releases. Keep an eye on the **[**changelog**]**(**https://onyx.schwarz/development/packages/changelogs/sit-onyx.html**) when using components.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/sit-onyx/src/components.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,16 @@ const COMPONENTS: Components = {
value: "test-value",
},
},
OnyxUnstableKey: {
props: {
keyName: "ctrl",
},
},
OnyxUnstableShortcut: {
props: {
sequence: [{ all: ["ctrl", "c"] }],
},
},
};

describe("components", () => {
Expand Down
226 changes: 226 additions & 0 deletions packages/sit-onyx/src/components/OnyxKey/OnyxKey.ct.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import type { MatrixScreenshotTestOptions } from "@sit-onyx/playwright-utils";
import { expect, test } from "../../playwright/a11y.js";
import { executeMatrixScreenshotTest } from "../../playwright/screenshots.js";
import { CANONICAL_KEYS } from "../../utils/shortcut.js";
import OnyxKey from "./OnyxKey.vue";

const screenshotOptions = {
rows: ["default", "pressed"] as const,
} satisfies Partial<MatrixScreenshotTestOptions>;

test.describe("Screenshot tests", () => {
executeMatrixScreenshotTest({
...screenshotOptions,
name: "Key (modifier keys)",
columns: ["meta", "control", "alt", "shift"],
component: (column, row) => (
<OnyxKey keyName={column} pressed={row === "pressed"} style={{ margin: "0.25rem" }} />
),
});

executeMatrixScreenshotTest({
...screenshotOptions,
name: "Key (navigation keys)",
columns: ["up", "down", "left", "right"],
component: (column, row) => (
<OnyxKey keyName={column} pressed={row === "pressed"} style={{ margin: "0.25rem" }} />
),
});

executeMatrixScreenshotTest({
...screenshotOptions,
name: "Key (special keys)",
columns: ["enter", "space", "tab", "esc"],
component: (column, row) => (
<OnyxKey keyName={column} pressed={row === "pressed"} style={{ margin: "0.25rem" }} />
),
});

executeMatrixScreenshotTest({
...screenshotOptions,
name: "Key (function and editing)",
columns: ["backspace", "delete", "home", "end"],
component: (column, row) => (
<OnyxKey keyName={column} pressed={row === "pressed"} style={{ margin: "0.25rem" }} />
),
});

executeMatrixScreenshotTest({
name: "Key (OS variants)",
columns: ["macOS", "windows", "generic"],
rows: ["meta", "control", "alt", "option"],
component: (column, row) => (
<OnyxKey keyName={row} variant={column} style={{ margin: "0.25rem" }} />
),
});

executeMatrixScreenshotTest({
name: "Key (alphanumeric)",
columns: ["A", "1", "F5", "?"],
rows: ["default", "pressed"],
component: (column, row) => (
<OnyxKey keyName={column} pressed={row === "pressed"} style={{ margin: "0.25rem" }} />
),
});

executeMatrixScreenshotTest({
name: "Key (skeleton)",
columns: ["default"],
rows: ["skeleton"],
component: () => <OnyxKey keyName="enter" skeleton style={{ margin: "0.25rem" }} />,
});
});

test.describe("Interaction tests", () => {
test("should render key with correct visual label", async ({ mount }) => {
// ARRANGE
const component = await mount(<OnyxKey keyName="enter" />);

// ASSERT
await expect(component).toBeVisible();
await expect(component).toHaveAccessibleName("Enter key");
});

test("should show OS-specific symbols for modifier keys", async ({ mount }) => {
// ARRANGE - macOS variant
const macComponent = await mount(<OnyxKey keyName="meta" variant="macOS" />);

// ASSERT - Should show command symbol
await expect(macComponent).toContainText("⌘");

// ARRANGE - Windows variant
const winComponent = await mount(<OnyxKey keyName="meta" variant="windows" />);

// ASSERT - Should show Windows symbol
await expect(winComponent).toContainText("⊞");
});

test("should handle pressed state visually", async ({ mount }) => {
// ARRANGE
const component = await mount(<OnyxKey keyName="enter" pressed />);

// ASSERT
await expect(component).toHaveAttribute("data-pressed", "true");
});

test("should not show pressed attribute when not pressed", async ({ mount }) => {
// ARRANGE
const component = await mount(<OnyxKey keyName="enter" pressed={false} />);

// ASSERT
await expect(component).not.toHaveAttribute("data-pressed");
});

test("should render skeleton state", async ({ mount }) => {
// ARRANGE
const component = await mount(<OnyxKey keyName="enter" skeleton />);

// ASSERT - Using regex
await expect(component).toHaveClass(/onyx-key-skeleton/);
await expect(component).not.toHaveClass(/^onyx-key$/); // Exact match
});

test("should have proper accessibility label", async ({ mount }) => {
// ARRANGE
const component = await mount(<OnyxKey keyName="enter" />);

// ASSERT
await expect(component).toHaveAccessibleName("Enter key");
});

test("should use custom accessibility label when provided", async ({ mount }) => {
// ARRANGE
const component = await mount(<OnyxKey keyName="ctrl" label="Control key for shortcuts" />);

// ASSERT
await expect(component).toHaveAccessibleName("Control key for shortcuts");
});

test("should handle arrow key symbols", async ({ mount }) => {
// ARRANGE
const upComponent = await mount(<OnyxKey keyName="up" />);
// ASSERT
await expect(upComponent).toContainText("↑");

// ARRANGE
const downComponent = await mount(<OnyxKey keyName="down" />);
// ASSERT
await expect(downComponent).toContainText("↓");

// ARRANGE
const leftComponent = await mount(<OnyxKey keyName="left" />);
// ASSERT
await expect(leftComponent).toContainText("←");

// ARRANGE
const rightComponent = await mount(<OnyxKey keyName="right" />);
// ASSERT
await expect(rightComponent).toContainText("→");
});

test("should handle function keys correctly", async ({ mount }) => {
// ARRANGE
const f1Component = await mount(<OnyxKey keyName="F1" />);

// ASSERT
await expect(f1Component).toContainText("F1");
await expect(f1Component).toHaveAccessibleName("F1 key");

// ARRANGE
const f12Component = await mount(<OnyxKey keyName="F12" />);

// ASSERT
await expect(f12Component).toContainText("F12");
await expect(f12Component).toHaveAccessibleName("F12 key");
});

test("should handle alphanumeric keys", async ({ mount }) => {
// ARRANGE
const letterComponent = await mount(<OnyxKey keyName="A" />);
// ASSERT
await expect(letterComponent).toContainText("A");

// ARRANGE
const numberComponent = await mount(<OnyxKey keyName="1" />);
// ASSERT
await expect(numberComponent).toContainText("1");

// ARRANGE
const symbolComponent = await mount(<OnyxKey keyName="?" />);
// ASSERT
await expect(symbolComponent).toContainText("?");
});

test("should handle space key specially", async ({ mount }) => {
// ARRANGE
const macComponent = await mount(<OnyxKey keyName="space" variant="macOS" />);
// ASSERT
await expect(macComponent).toContainText("␣");
await expect(macComponent).toHaveAccessibleName("Space key");

// ARRANGE
const winComponent = await mount(<OnyxKey keyName="space" variant="windows" />);
// ASSERT
await expect(winComponent).toContainText("Space");
await expect(winComponent).toHaveAccessibleName("Space key");
});

test("should handle all canonical keys without errors", async ({ mount }) => {
// ARRANGE & ASSERT - Test all canonical keys can be rendered
const canonicalKeys = CANONICAL_KEYS.filter((key) => key !== "unknown");

for (const keyName of canonicalKeys) {
const component = await mount(<OnyxKey keyName={keyName} />);
await expect(component).toBeVisible();
await expect(component).toHaveAccessibleName(/.+ key$/);
}
});

test("should handle unknown/custom keys gracefully", async ({ mount }) => {
// ARRANGE
const component = await mount(<OnyxKey keyName="CustomKey123" />);

// ASSERT
await expect(component).toHaveAccessibleName("CustomKey123 key");
});
});
96 changes: 96 additions & 0 deletions packages/sit-onyx/src/components/OnyxKey/OnyxKey.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Meta, StoryObj } from "@storybook/vue3-vite";
import { CANONICAL_KEYS } from "../../utils/shortcut.js";
import OnyxKey from "./OnyxKey.vue";

/**
* The key component displays keyboard keys with proper OS-specific symbols and accessibility support.
* Useful for documentation, tutorials, and keyboard shortcut displays.
*/
const meta: Meta<typeof OnyxKey> = {
title: "Support/Key",
component: OnyxKey as Meta["component"],
tags: ["unstable"],
};

export default meta;

type Story = StoryObj<typeof OnyxKey>;

/**
* This example shows a default key.
*/
export const Default = {
args: {
keyName: "enter",
},
} satisfies Story;

/**
* Pressed state visualization.
*/
export const Pressed = {
args: {
keyName: "enter",
pressed: true,
},
} satisfies Story;

/**
* This example shows a skeleton key.
*/
export const Skeleton = {
args: {
skeleton: true,
keyName: "enter",
},
} satisfies Story;

/**
* Example: simple shortcut.
*/
export const Shortcut = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

For displaying shortcuts, we intend to use the OnyxShortcut component right? I'd prefer to remove this example then. Maybe we can update the description at the top to mention that the OnyxShortcut should be used for this?

render: (args) => ({
components: { OnyxKey },
setup: () => ({ args }),
template: `
<div style="display: flex; gap: 0.5rem; align-items: center; color: var(--onyx-color-text-icons-neutral-medium);">
<OnyxKey v-bind="args" keyName="ctrl" />
<span>+</span>
<OnyxKey v-bind="args" keyName="shift" />
<span>+</span>
<OnyxKey v-bind="args" keyName="C" />
</div>
`,
}),
args: {
variant: "auto",
},
} satisfies Story;

/**
* Displays all supported canonical keys from CANONICAL_KEYS.
* Useful for visual verification of the mapping.
*/
export const AllCanonicalKeys = {
render: (args) => {
const keys = CANONICAL_KEYS.filter((key) => key !== "unknown");

return {
components: { OnyxKey },
setup: () => ({ args, keys }),
template: `
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<OnyxKey
v-for="key in keys"
:key="key"
v-bind="args"
:keyName="key"
/>
</div>
`,
};
},
args: {
variant: "macOS",
},
} satisfies Story;
Comment on lines +70 to +96
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
/**
* Displays all supported canonical keys from CANONICAL_KEYS.
* Useful for visual verification of the mapping.
*/
export const AllCanonicalKeys = {
render: (args) => {
const keys = CANONICAL_KEYS.filter((key) => key !== "unknown");
return {
components: { OnyxKey },
setup: () => ({ args, keys }),
template: `
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">
<OnyxKey
v-for="key in keys"
:key="key"
v-bind="args"
:keyName="key"
/>
</div>
`,
};
},
args: {
variant: "macOS",
},
} satisfies Story;
/**
* Displays all supported canonical keys from CANONICAL_KEYS.
* Useful for visual verification of the mapping.
*/
export const AllCanonicalKeys = createAdvancedStoryExample(
"OnyxKey",
"CanonicalKeysExample",
) satisfies Story;

The downside of using render functions in Storybook is that the generated code snippet in the docs does not reflect the actual code written here. To solve this, we created a createAdvancedStorybookExample() utility function. With it, you can create a dedicated .vue file inside e.g. OnyxKey/examples/CanonicalKeysExample.vue and it will then be rendered as story as well as including the correct source code from the vue file as code snippet

Loading