fix: address Storybook review findings

This commit is contained in:
vince 2026-06-18 19:01:53 +02:00
parent d95f064189
commit 17c9d24e31
8 changed files with 121 additions and 20 deletions

View file

@ -1,7 +1,7 @@
import type { StorybookConfig } from "@storybook/sveltekit"; import type { StorybookConfig } from "@storybook/sveltekit";
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|ts|svelte)"], stories: ["../src/**/*.stories.@(js|ts|svelte)"],
addons: [ addons: [
"@storybook/addon-svelte-csf", "@storybook/addon-svelte-csf",
"@storybook/addon-a11y", "@storybook/addon-a11y",

View file

@ -1,6 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { HTMLButtonAttributes } from "svelte/elements";
import IconGlyph from "./IconGlyph.svelte"; import IconGlyph from "./IconGlyph.svelte";
type ButtonProps = Omit<HTMLButtonAttributes, "type"> & {
label: string;
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "default" | "compact";
icon?: string;
loading?: boolean;
type?: "button" | "submit" | "reset";
};
let { let {
label, label,
variant = "primary", variant = "primary",
@ -9,19 +19,14 @@
disabled = false, disabled = false,
loading = false, loading = false,
type = "button", type = "button",
}: { class: className = "",
label: string; ...buttonProps
variant?: "primary" | "secondary" | "danger" | "ghost"; }: ButtonProps = $props();
size?: "default" | "compact";
icon?: string;
disabled?: boolean;
loading?: boolean;
type?: "button" | "submit" | "reset";
} = $props();
</script> </script>
<button <button
class="ui-button" {...buttonProps}
class={className ? `ui-button ${className}` : "ui-button"}
data-variant={variant} data-variant={variant}
data-size={size} data-size={size}
data-loading={loading} data-loading={loading}

View file

@ -1,20 +1,33 @@
<script lang="ts"> <script lang="ts">
import type { HTMLButtonAttributes } from "svelte/elements";
import IconGlyph from "./IconGlyph.svelte"; import IconGlyph from "./IconGlyph.svelte";
type IconButtonProps = Omit<HTMLButtonAttributes, "type"> & {
icon: string;
label: string;
active?: boolean;
type?: "button" | "submit" | "reset";
};
let { let {
icon, icon,
label, label,
active = false, active = false,
disabled = false, disabled = false,
}: { type = "button",
icon: string; class: className = "",
label: string; ...buttonProps
active?: boolean; }: IconButtonProps = $props();
disabled?: boolean;
} = $props();
</script> </script>
<button class="icon-button" aria-label={label} data-active={active} {disabled}> <button
{...buttonProps}
class={className ? `icon-button ${className}` : "icon-button"}
aria-label={label}
data-active={active}
{disabled}
{type}
>
<IconGlyph name={icon} size="sm" /> <IconGlyph name={icon} size="sm" />
</button> </button>

View file

@ -1,7 +1,9 @@
import { render } from "svelte/server"; import { render } from "svelte/server";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import Button from "./Button.svelte";
import DashboardFrame from "./DashboardFrame.svelte"; import DashboardFrame from "./DashboardFrame.svelte";
import FooterCell from "./FooterCell.svelte"; import FooterCell from "./FooterCell.svelte";
import IconButton from "./IconButton.svelte";
import ServiceRow from "./ServiceRow.svelte"; import ServiceRow from "./ServiceRow.svelte";
import TelemetryCard from "./TelemetryCard.svelte"; import TelemetryCard from "./TelemetryCard.svelte";
import { dashboardPreviewFixtures } from "../fixtures"; import { dashboardPreviewFixtures } from "../fixtures";
@ -94,4 +96,31 @@ describe("dashboard UI components", () => {
expect(withoutProgress.body).not.toContain("telemetry-card__bar"); expect(withoutProgress.body).not.toContain("telemetry-card__bar");
expect(withProgress.body).toContain("--metric-progress: 42%"); expect(withProgress.body).toContain("--metric-progress: 42%");
}); });
test("base button controls forward native attributes", () => {
const button = render(Button, {
props: {
label: "Refresh",
id: "refresh-action",
class: "custom-action",
"aria-controls": "refresh-target",
},
});
const iconButton = render(IconButton, {
props: {
icon: "mdi:refresh",
label: "Refresh status",
id: "refresh-icon-action",
class: "custom-icon-action",
"aria-expanded": "false",
},
});
expect(button.body).toContain("id=\"refresh-action\"");
expect(button.body).toContain("class=\"ui-button custom-action ");
expect(button.body).toContain("aria-controls=\"refresh-target\"");
expect(iconButton.body).toContain("id=\"refresh-icon-action\"");
expect(iconButton.body).toContain("class=\"icon-button custom-icon-action ");
expect(iconButton.body).toContain("aria-expanded=\"false\"");
});
}); });

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { onMount, tick, type Snippet } from "svelte";
let {
children,
selector = "button, a[href]",
}: {
children?: Snippet;
selector?: string;
} = $props();
let root: HTMLDivElement;
onMount(async () => {
await tick();
root.querySelector<HTMLElement>(selector)?.focus();
});
</script>
<div class="focus-preview" bind:this={root}>
{@render children?.()}
</div>
<style>
.focus-preview {
display: inline-grid;
padding: var(--ui-space-2);
}
.focus-preview :global(:is(a, button)) {
box-shadow: var(--ui-focus-ring);
}
</style>

View file

@ -1,6 +1,7 @@
<script module lang="ts"> <script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf"; import { defineMeta } from "@storybook/addon-svelte-csf";
import IconButton from "../components/IconButton.svelte"; import IconButton from "../components/IconButton.svelte";
import FocusPreview from "./FocusPreview.svelte";
const { Story } = defineMeta({ const { Story } = defineMeta({
title: "Base UI/IconButton", title: "Base UI/IconButton",
@ -14,4 +15,8 @@
<Story name="Inspect" args={{ icon: "mdi:magnify-scan", label: "Inspect" }} /> <Story name="Inspect" args={{ icon: "mdi:magnify-scan", label: "Inspect" }} />
<Story name="Disabled" args={{ icon: "mdi:refresh", label: "Disabled", disabled: true }} /> <Story name="Disabled" args={{ icon: "mdi:refresh", label: "Disabled", disabled: true }} />
<Story name="Active" args={{ icon: "mdi:refresh", label: "Active", active: true }} /> <Story name="Active" args={{ icon: "mdi:refresh", label: "Active", active: true }} />
<Story name="Focus Visible" args={{ icon: "mdi:keyboard", label: "Focus visible", active: true }} /> <Story name="Focus Visible" asChild>
<FocusPreview>
<IconButton icon="mdi:keyboard" label="Focus visible" />
</FocusPreview>
</Story>

View file

@ -1,6 +1,7 @@
<script module lang="ts"> <script module lang="ts">
import { defineMeta } from "@storybook/addon-svelte-csf"; import { defineMeta } from "@storybook/addon-svelte-csf";
import ServiceRow from "../components/ServiceRow.svelte"; import ServiceRow from "../components/ServiceRow.svelte";
import FocusPreview from "./FocusPreview.svelte";
import { serviceRows } from "./story-data"; import { serviceRows } from "./story-data";
const { Story } = defineMeta({ const { Story } = defineMeta({
@ -16,4 +17,8 @@
<Story name="Stale" args={{ service: serviceRows.stale }} /> <Story name="Stale" args={{ service: serviceRows.stale }} />
<Story name="No Link" args={{ service: serviceRows.noLink }} /> <Story name="No Link" args={{ service: serviceRows.noLink }} />
<Story name="Long Title Description" args={{ service: serviceRows.long }} /> <Story name="Long Title Description" args={{ service: serviceRows.long }} />
<Story name="Keyboard Focus" args={{ service: serviceRows.normal }} /> <Story name="Keyboard Focus" asChild>
<FocusPreview selector="a">
<ServiceRow service={serviceRows.normal} />
</FocusPreview>
</Story>

View file

@ -3,6 +3,7 @@ import { join } from "node:path";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
const root = process.cwd(); const root = process.cwd();
const componentsDir = join(root, "src/lib/ui/components");
const storiesDir = join(root, "src/lib/ui/stories"); const storiesDir = join(root, "src/lib/ui/stories");
const requiredStoryFiles = [ const requiredStoryFiles = [
@ -57,6 +58,16 @@ describe("Storybook inventory", () => {
} }
}); });
test("keeps component and story files paired as the UI inventory changes", () => {
const componentStoryFiles = readdirSync(componentsDir)
.filter((filename) => filename.endsWith(".svelte"))
.map((filename) => filename.replace(".svelte", ".stories.svelte"));
for (const filename of componentStoryFiles) {
expect(existsSync(join(storiesDir, filename)), `${filename} is missing`).toBe(true);
}
});
test("keeps Storybook fixtures generic and content-free", () => { test("keeps Storybook fixtures generic and content-free", () => {
const storyText = readdirSync(storiesDir) const storyText = readdirSync(storiesDir)
.filter((filename) => filename.endsWith(".svelte") || filename.endsWith(".ts")) .filter((filename) => filename.endsWith(".svelte") || filename.endsWith(".ts"))