diff --git a/.changeset/notice-icon-override.md b/.changeset/notice-icon-override.md new file mode 100644 index 0000000000..3bb34e67b4 --- /dev/null +++ b/.changeset/notice-icon-override.md @@ -0,0 +1,5 @@ +--- +"@stackoverflow/stacks-svelte": minor +--- + +Add an optional custom SVG icon to the Notice component. diff --git a/packages/stacks-svelte/src/components/Notice/Notice.stories.svelte b/packages/stacks-svelte/src/components/Notice/Notice.stories.svelte index 1f7d170ec5..cedb7e4e41 100644 --- a/packages/stacks-svelte/src/components/Notice/Notice.stories.svelte +++ b/packages/stacks-svelte/src/components/Notice/Notice.stories.svelte @@ -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[] = [ "", @@ -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.", + }, }, }); @@ -51,9 +62,12 @@ I am a dismissible notice {/snippet} +{#snippet customIconContent()} + A neutral notice with a custom icon +{/snippet} + - - {#snippet template({ class: classArg, children: _storyChildren, ...args })} + {#snippet template({ class: classArg, ...args })} - - {#snippet template({ class: classArg, children: _storyChildren, ...args })} + {#snippet template({ class: classArg, ...args })} + + {#snippet template(args)} +
+ +

+ Custom icons replace only the icon source. Keep the established + icon pairings for semantic variants, and provide iconTitle when a custom icon conveys meaning; otherwise it is decorative. +

+
+ {/snippet} +
+ I am a notice with a Custom Action diff --git a/packages/stacks-svelte/src/components/Notice/Notice.svelte b/packages/stacks-svelte/src/components/Notice/Notice.svelte index f9bdde7b3e..92c84dc9f6 100644 --- a/packages/stacks-svelte/src/components/Notice/Notice.svelte +++ b/packages/stacks-svelte/src/components/Notice/Notice.svelte @@ -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 */ @@ -85,6 +92,7 @@ important = false, role = "status", iconTitle, + icon, class: className = "", dismissible = false, onDismiss = () => {}, @@ -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, @@ -155,12 +170,15 @@ }; const classes = $derived(getClasses(className, variant, important)); - const iconInfo = $derived(getIcon(variant)); + const iconInfo = $derived(getIcon(variant, icon)); {#if visible}
- + {@render children()} diff --git a/packages/stacks-svelte/src/components/Notice/Notice.test.ts b/packages/stacks-svelte/src/components/Notice/Notice.test.ts index a9ecde1c8f..25b01373aa 100644 --- a/packages/stacks-svelte/src/components/Notice/Notice.test.ts +++ b/packages/stacks-svelte/src/components/Notice/Notice.test.ts @@ -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"; @@ -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: ``, + 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: ``, + 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", () => { @@ -59,6 +105,7 @@ describe("Notice", () => { render: () => `Dismiss Me`, })), dismissible: true, + icon: ``, onDismiss: onDismissMock, i18nDismissButtonLabel: "Chiudi", }); @@ -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 @@ -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: ``, actions: createSvelteComponentsSnippet([ { component: NoticeAction,