Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/notice-icon-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackoverflow/stacks-svelte": minor
---

Add an optional custom SVG icon to the Notice component.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { defineMeta } from "@storybook/addon-svelte-csf";
import { parseClassValue } from "../../storybook-utils";
import NoticeAction from "./NoticeAction.svelte";
import { IconInfo } from "@stackoverflow/stacks-icons/icons";

const NoticeVariants: Variant[] = [
"",
Expand Down Expand Up @@ -39,6 +40,16 @@
class: {
control: "text",
},
icon: {
control: "text",
description:
"Trusted SVG string to use instead of the variant icon. Untitled icons are decorative.",
},
iconTitle: {
control: "text",
description:
"Accessible icon title. Default icons use the variant title; custom icons are decorative when this is omitted or empty.",
},
},
});
</script>
Expand All @@ -51,9 +62,12 @@
I am a dismissible notice
{/snippet}

{#snippet customIconContent()}
A neutral notice with a custom icon
{/snippet}

<Story name="Base">
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#snippet template({ class: classArg, children: _storyChildren, ...args })}
{#snippet template({ class: classArg, ...args })}
<Notice
{...args}
class={parseClassValue(
Expand All @@ -65,8 +79,7 @@
</Story>

<Story name="Dismissible">
<!-- eslint-disable-next-line @typescript-eslint/no-unused-vars -->
{#snippet template({ class: classArg, children: _storyChildren, ...args })}
{#snippet template({ class: classArg, ...args })}
<Notice
{...args}
class={parseClassValue(
Expand Down Expand Up @@ -115,6 +128,20 @@
</div>
</Story>

<Story name="Custom icon" args={{ icon: IconInfo }}>
{#snippet template(args)}
<div class="d-flex fd-column g8">
<Notice {...args} children={customIconContent} />
<p>
Custom icons replace only the icon source. Keep the established
icon pairings for semantic variants, and provide <code
>iconTitle</code
> when a custom icon conveys meaning; otherwise it is decorative.
</p>
</div>
{/snippet}
</Story>

<Story name="Actions" asChild>
<Notice variant="info">
<span>I am a notice with a Custom Action</span>
Expand Down
26 changes: 22 additions & 4 deletions packages/stacks-svelte/src/components/Notice/Notice.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,17 @@
role?: AriaRole | undefined | null;

/**
* The title attribute for the icon
* Accessible icon title. Uses the variant's title for default icons.
* Custom icons are decorative when this is omitted or empty.
*/
iconTitle?: string | undefined;

/**
* The SVG icon to display in place of the variant's default icon.
* The SVG string must be trusted because it is rendered as HTML.
*/
icon?: string | undefined;

/**
* Additional CSS classes added to the element
*/
Expand Down Expand Up @@ -85,6 +92,7 @@
important = false,
role = "status",
iconTitle,
icon,
class: className = "",
dismissible = false,
onDismiss = () => {},
Expand Down Expand Up @@ -115,7 +123,14 @@
return clsx(base, className, classes);
};

const getIcon = (variant?: Variant) => {
const getIcon = (variant?: Variant, customIcon?: string) => {
if (customIcon !== undefined) {
return {
icon: customIcon,
title: iconTitle,
};
}

if (variant == "danger") {
return {
icon: IconAlertFill,
Expand Down Expand Up @@ -155,12 +170,15 @@
};

const classes = $derived(getClasses(className, variant, important));
const iconInfo = $derived(getIcon(variant));
const iconInfo = $derived(getIcon(variant, icon));
</script>

{#if visible}
<div class={classes} {role}>
<span class="s-notice--icon">
<span
class="s-notice--icon"
aria-hidden={icon !== undefined && !iconTitle ? "true" : undefined}
>
<Icon src={iconInfo.icon} title={iconInfo.title} />
</span>
{@render children()}
Expand Down
66 changes: 58 additions & 8 deletions packages/stacks-svelte/src/components/Notice/Notice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { render, screen } from "@testing-library/svelte";
import { createSvelteComponentsSnippet } from "../../../test-utils";
import sinon from "sinon";
import userEvent from "@testing-library/user-event";
import { IconInfo } from "@stackoverflow/stacks-icons/icons";

import Notice from "./Notice.svelte";
import NoticeAction from "./NoticeAction.svelte";
Expand All @@ -20,13 +21,58 @@ describe("Notice", () => {
expect(screen.getByRole("status")).to.exist;
});

it("should render the icon", () => {
it("should render the default icon and title", () => {
render(Notice, { children });
const icon = screen.getByRole("status").querySelector("svg")!;
expect(icon).to.have.class("IconHelp");
expect(icon.querySelector("title")).to.have.text("Help");
expect(icon).not.to.have.attribute("aria-hidden");
expect(icon.parentElement).not.to.have.attribute("aria-hidden");
});

it("renders a neutral notice with an information icon", async () => {
render(Notice, { icon: IconInfo, children });
const notice = screen.getByRole("status");
const icon = notice.querySelector(".s-notice--icon svg")!;

expect(notice).to.have.attribute("class", "s-notice");
expect(icon).to.have.class("IconInfo");
expect(icon.querySelector("title")).not.to.exist;
expect(icon.parentElement).to.have.attribute("aria-hidden", "true");
await expect(notice).to.be.accessible();
});

it("exposes a custom icon with its explicit title", () => {
render(Notice, {
icon: `<svg role="img"></svg>`,
iconTitle: "Custom status",
children,
});
const icon = document.querySelector("svg.svg-icon");
expect(icon).to.exist;
expect(screen.getByRole("status")).to.exist;
expect(screen.getByRole("img", { name: "Custom status" })).to.exist;
});

it("restores the variant icon and title when the override is removed", async () => {
const component = render(Notice, {
variant: "danger",
icon: `<svg class="CustomIcon"></svg>`,
children,
});
const notice = screen.getByRole("status");
expect(notice).to.have.class("s-notice__danger");
expect(notice.querySelector(".CustomIcon")).to.exist;
expect(notice.querySelector(".IconAlertFill")).not.to.exist;
expect(notice.querySelector(".s-notice--icon")).to.have.attribute(
"aria-hidden",
"true"
);
expect(notice.querySelector("title")).not.to.exist;

await component.rerender({ icon: undefined });
const icon = notice.querySelector(".s-notice--icon svg")!;
expect(icon).to.have.class("IconAlertFill");
expect(icon.querySelector("title")).to.have.text("Danger");
expect(icon.parentElement).not.to.have.attribute("aria-hidden");
expect(notice).to.have.class("s-notice__danger");
});

it("should render variant notice", () => {
Expand Down Expand Up @@ -59,6 +105,7 @@ describe("Notice", () => {
render: () => `<span>Dismiss Me</span>`,
})),
dismissible: true,
icon: `<svg class="CustomIcon"></svg>`,
onDismiss: onDismissMock,
i18nDismissButtonLabel: "Chiudi",
});
Expand All @@ -68,7 +115,7 @@ describe("Notice", () => {
expect(closeButton).to.exist;

// Assert that the IconCross is rendered inside the button
const closeIcon = closeButton.querySelector("svg.iconCross");
const closeIcon = closeButton.querySelector("svg.IconCross");
expect(closeIcon).to.exist;

// Assert that the button has the s-notice--dismiss class
Expand All @@ -78,17 +125,20 @@ describe("Notice", () => {
expect(screen.getByText("Dismiss Me")).to.be.visible;

// Check dismiss is clicked correctly
await userEvent.click(closeButton);
expect(onDismissMock).to.have.been.called;
closeButton.focus();
await userEvent.keyboard("{Enter}");
expect(onDismissMock).to.have.been.calledOnce;

// Confirm the notice was hidden
expect(screen.queryByText(text)).to.not.exist;
expect(screen.queryByText("Dismiss Me")).to.not.exist;
expect(screen.queryByRole("status")).to.not.exist;
});

it("should render the notice with a user provided notice action", async () => {
const onclickMock = sinon.spy();
render(Notice, {
children,
icon: `<svg class="CustomIcon"></svg>`,
actions: createSvelteComponentsSnippet([
{
component: NoticeAction,
Expand Down
Loading