Fleet UI: Align toast icon with first line of message (#50449)

This commit is contained in:
RachelElysia
2026-08-03 14:52:30 -07:00
committed by GitHub
parent 74c2b79f05
commit e7a9456044
6 changed files with 92 additions and 55 deletions
+1
View File
@@ -0,0 +1 @@
* Fixed the misaligned icon in `notify.success`/`notify.error` toast notifications so it sits on the first line of the message on both single- and multi-line toasts.
@@ -3,6 +3,7 @@ import classnames from "classnames";
import { toast } from "sonner";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import CopyButton from "components/buttons/CopyButton";
import { Colors } from "styles/var/colors";
import { syntaxHighlight } from "utilities/helpers";
@@ -123,33 +124,26 @@ const ToastCard = ({
</div>
<div className={`${baseClass}__actions`}>
{hasDetail && (
<button
type="button"
className={`${baseClass}__action-button`}
aria-expanded={isOpen}
aria-controls={panelId}
aria-label={
<Button
className={classnames(`${baseClass}__chevron`, {
[`${baseClass}__chevron--open`]: isOpen,
})}
variant="subdued"
icon="chevron-down"
ariaExpanded={isOpen}
ariaControls={panelId}
ariaLabel={
isOpen ? "Collapse error details" : "Expand error details"
}
onClick={toggle}
>
<span
className={classnames(`${baseClass}__chevron`, {
[`${baseClass}__chevron--open`]: isOpen,
})}
>
<Icon name="chevron-down" color="ui-fleet-black-75" />
</span>
</button>
/>
)}
<button
type="button"
className={`${baseClass}__action-button`}
aria-label="Dismiss notification"
<Button
variant="subdued"
icon="close"
ariaLabel="Dismiss notification"
onClick={handleClose}
>
<Icon name="close" color="ui-fleet-black-75" />
</button>
/>
</div>
</div>
{hasDetail && isOpen && (
@@ -74,6 +74,54 @@ export const Success: Story = {
),
};
/**
* MultiLineSuccess — a success message long enough to wrap, so the
* icon's alignment against the first line (vs. later lines) is visible.
* Also exercises rich formatting (a bolded entity name).
*/
export const MultiLineSuccess: Story = {
render: () => (
<>
<ToastNotification />
<TriggerButton
label="Show multi-line success toast"
onClick={() =>
notify.success(
<>
Successfully released <b>MacBook Air</b> from Apple Business. This
is a very long line that should wrap two lines if not three.
</>
)
}
/>
</>
),
};
/**
* MultiLineError — an error message long enough to wrap. Same alignment
* concern as MultiLineSuccess, on the error variant.
*/
export const MultiLineError: Story = {
render: () => (
<>
<ToastNotification />
<TriggerButton
label="Show multi-line error toast"
onClick={() =>
notify.error(
<>
Couldn&apos;t release <b>MacBook Air</b> from Apple Business.
Please try again. If the problem persists, contact your
administrator for help.
</>
)
}
/>
</>
),
};
/**
* Error — click the button to fire a plain error toast (no detail payload).
*/
@@ -2,7 +2,6 @@
// <ToastCard/> via toast.custom(), so all card styling below is ours.
$toast-notification-shadow: 0px 2px 6px 0px rgba(25, 33, 71, 0.1);
$toast-notification-action-size: 36px;
$toast-notification-width: 500px;
.toast-notification {
@@ -80,7 +79,9 @@ $toast-notification-width: 500px;
&__header {
display: flex;
align-items: center;
// Do not align items center — the icon and action-button rows are each
// one text line tall so they line up with the first line of the
// message, which matters when the message wraps.
justify-content: space-between;
gap: $pad-small;
width: 100%;
@@ -127,41 +128,20 @@ $toast-notification-width: 500px;
align-items: center;
gap: $pad-small;
flex-shrink: 0;
// Match __icon's one-line-tall box so the action buttons visually
// track the first line of text when the message wraps. The subdued
// Button children are 36px tall and intentionally overflow this
// ~21px band — the mismatch is what centers each button on the
// first text line without dragging the whole header down.
height: calc(#{$x-small} * #{$line-height});
}
&__action-button {
appearance: none;
background: transparent;
border: 0;
margin: 0;
padding: $pad-small;
width: $toast-notification-action-size;
height: $toast-notification-action-size;
cursor: pointer;
color: $ui-fleet-black-75;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: $border-radius;
transition: background-color 150ms ease-in-out, color 150ms ease-in-out;
&:hover,
&:focus-visible {
background-color: $ui-fleet-black-5;
color: $core-fleet-black;
outline: none;
}
&__chevron svg {
transition: transform 0.25s ease;
}
&__chevron {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 200ms ease-in-out;
&--open {
transform: rotate(180deg);
}
&__chevron--open svg {
transform: rotate(180deg);
}
&__panel {
@@ -179,6 +179,17 @@ describe("Button component", () => {
expect(container.firstChild).toHaveClass("button--icon-only");
expect(container.firstChild).not.toHaveClass("button--with-icon");
});
it("renders aria-controls when ariaControls is provided", () => {
render(<Button ariaControls="menu-1">Open menu</Button>);
expect(screen.getByRole("button")).toHaveAttribute(
"aria-controls",
"menu-1"
);
});
it("omits aria-controls when ariaControls is undefined", () => {
render(<Button>Plain</Button>);
expect(screen.getByRole("button")).not.toHaveAttribute("aria-controls");
});
it("warns in dev when an icon-only button has neither ariaLabel nor title", () => {
const warn = jest
.spyOn(console, "warn")
@@ -65,6 +65,7 @@ export interface IButtonProps {
| "grid"
| "dialog";
ariaExpanded?: boolean;
ariaControls?: string;
ariaLabel?: string;
ariaPressed?: boolean;
/** Small: 1/2 the padding, Wide: 200px */
@@ -150,6 +151,7 @@ class Button extends React.Component<IButtonProps, IButtonState> {
customOnKeyDown,
ariaHasPopup,
ariaExpanded,
ariaControls,
ariaLabel,
ariaPressed,
size,
@@ -235,6 +237,7 @@ class Button extends React.Component<IButtonProps, IButtonState> {
ref={setRef}
aria-haspopup={ariaHasPopup}
aria-expanded={ariaExpanded}
aria-controls={ariaControls}
aria-label={ariaLabel}
aria-pressed={ariaPressed}
>