) => event.preventDefault());
+function WrappedCustomActions() {
+ return (
+
+
+
+ );
+}
+
const meta = {
title: "UI/Base/Alert",
component: GlAlert,
@@ -94,6 +102,7 @@ export const UndismissibleDangerWithActions: Story = {
Primary action
@@ -101,6 +110,7 @@ export const UndismissibleDangerWithActions: Story = {
category="secondary"
href="#"
onClick={onSecondaryAction}
+ size="small"
variant="default">
Secondary action
@@ -113,13 +123,20 @@ export const UndismissibleDangerWithActions: Story = {
const alert = canvas.getByRole("alert");
- await expect(alert).toHaveClass("gl-alert-danger", "gl-alert-not-dismissible");
+ await expect(alert).toHaveClass(
+ "gl-alert-danger",
+ "gl-alert-has-actions",
+ "gl-alert-not-dismissible",
+ );
await expect(within(alert).queryByRole("button", { name: "Dismiss" })).toBeNull();
- await userEvent.click(canvas.getByRole("button", { name: "Primary action" }));
+ const primary = canvas.getByRole("button", { name: "Primary action" });
+ await expect(primary).toHaveClass("btn-sm");
+ await userEvent.click(primary);
await expect(onPrimaryAction).toHaveBeenCalledTimes(1);
const secondary = canvas.getByRole("button", { name: "Secondary action" });
+ await expect(secondary).toHaveClass("btn-sm");
await expect(secondary).toHaveAttribute("href", "#");
await userEvent.click(secondary);
await expect(onSecondaryAction).toHaveBeenCalledTimes(1);
@@ -128,14 +145,13 @@ export const UndismissibleDangerWithActions: Story = {
export const CustomActions: Story = {
render: (args) => (
-
+
Lorem ipsum dolor sit amet
-
-
-
+
),
play: async ({ canvas }) => {
+ await expect(canvas.getByRole("status")).toHaveClass("gl-alert-has-actions");
await expect(canvas.getByRole("button", { name: "Custom action" })).toBeInTheDocument();
},
};
@@ -159,8 +175,8 @@ export const Variants: Story = {
{variant} lorem ipsum dolor sit amet
- Primary
- Secondary
+ Primary
+ Secondary
))}
diff --git a/packages/ui/src/base/alert/alert.test.tsx b/packages/ui/src/base/alert/alert.test.tsx
index 22913d2..cff1f4d 100644
--- a/packages/ui/src/base/alert/alert.test.tsx
+++ b/packages/ui/src/base/alert/alert.test.tsx
@@ -49,7 +49,10 @@ describe("GlAlert", () => {
});
it("does not render any actions", () => {
- expect(renderAlert()).not.toContain("gl-alert-actions");
+ const markup = renderAlert();
+
+ expect(markup).not.toContain("gl-alert-actions");
+ expect(markup).not.toContain("gl-alert-has-actions");
});
it("renders the description content", () => {
@@ -144,7 +147,9 @@ describe("GlAlert", () => {
<>
Alert message
- Primary action
+
+ Primary action
+
>
));
@@ -153,15 +158,17 @@ describe("GlAlert", () => {
"",
);
expect(markup).toContain("");
+ expect(markup).toContain("gl-alert-has-actions");
expect(markup).toContain("btn-confirm");
+ expect(markup).toContain("btn-sm");
expect(markup).toContain("Primary action");
});
it("renders composed button and link actions", () => {
const markup = renderAlert({}, (
- Retry
- Cancel
+ Retry
+ Cancel
));
@@ -196,6 +203,30 @@ describe("GlAlert", () => {
expect(markup).toContain("Description");
expect(markup).toContain("Actions");
expect(markup).not.toContain("Hidden actions");
+ expect(markup).toContain("gl-alert-has-actions");
+ });
+
+ it("supports an explicit hasActions override for wrapper components", () => {
+ function WrappedActions() {
+ return Wrapped actions;
+ }
+
+ const automaticMarkup = renderAlert({}, );
+ const declaredMarkup = renderAlert({ hasActions: true }, );
+
+ expect(automaticMarkup).toContain("gl-alert-actions");
+ expect(automaticMarkup).not.toContain("gl-alert-has-actions");
+ expect(declaredMarkup).toContain("gl-alert-has-actions");
+ });
+
+ it("lets an explicit false hasActions override automatic detection", () => {
+ const markup = renderAlert(
+ { hasActions: false },
+ Actions,
+ );
+
+ expect(markup).toContain("gl-alert-actions");
+ expect(markup).not.toContain("gl-alert-has-actions");
});
it("accepts ordinary, wrapped, and repeated content", () => {
@@ -246,7 +277,9 @@ describe("GlAlert", () => {
,
);
- expect(markup).toContain("gl-alert gl-alert-info custom-alert");
+ expect(markup).toContain(
+ "gl-alert gl-alert-info gl-alert-has-actions custom-alert",
+ );
expect(markup).toContain("id=\"system-alert\"");
expect(markup).toContain("lang=\"en\"");
expect(markup).toContain("gl-alert-body custom-description");
diff --git a/packages/ui/src/base/alert/alert.tsx b/packages/ui/src/base/alert/alert.tsx
index 4d43474..4a83931 100644
--- a/packages/ui/src/base/alert/alert.tsx
+++ b/packages/ui/src/base/alert/alert.tsx
@@ -6,7 +6,11 @@
* - Vue's title prop and body/action slots map to the title prop and optional
* compound description/action helpers.
* - The `dismiss` event maps to `onDismiss`; action behavior belongs to the
- * controls composed inside `GlAlertActions`.
+ * controls composed inside `GlAlertActions`. Standard `GlButton` actions
+ * use the upstream small size while arbitrary custom actions keep their
+ * consumer-defined sizing.
+ * - Statically visible `GlAlertActions` children are detected automatically;
+ * `hasActions` covers actions returned by opaque wrapper components.
* - The exposed `focus()` method maps to the forwarded div ref; the
* `gl-focus` class is applied only when the alert itself is focused
* programmatically (e.g. `ref.current.focus()`), mirroring the upstream
@@ -17,7 +21,9 @@
*/
import {
+ Children,
forwardRef,
+ isValidElement,
useRef,
useState,
type FocusEventHandler,
@@ -43,6 +49,8 @@ export type GlAlertProps = Omit, "children" | "ti
dismissLabel?: string;
/** The header level used for the title (h1–h6). Set an appropriate value for the context where the alert is used. */
headerLevel?: GlAlertHeaderLevel;
+ /** Overrides automatic action detection. Set this when a wrapper component renders GlAlertActions internally. */
+ hasActions?: boolean;
/** Emitted when the dismiss button is clicked. */
onDismiss?: MouseEventHandler;
/** The `aria-live` attribute on the alert. Only use `"assertive"` if the alert requires immediate user action. */
@@ -87,6 +95,10 @@ const alertVariants = cva("gl-alert", {
false: null,
true: "gl-alert-has-title",
},
+ hasActions: {
+ false: null,
+ true: "gl-alert-has-actions",
+ },
hasProgrammaticFocus: {
false: null,
true: "gl-focus",
@@ -131,12 +143,21 @@ export const GlAlertActions = forwardRef(
},
);
+function hasAlertActions(children: ReactNode): boolean {
+ return Children.toArray(children).some((child) => {
+ if(!isValidElement<{ children?: ReactNode }>(child)) return false;
+ if(child.type === GlAlertActions) return true;
+ return hasAlertActions(child.props.children);
+ });
+}
+
const GlAlert = forwardRef(function GlAlert({
children,
className,
dismissible = true,
dismissLabel = "Dismiss",
headerLevel = 2,
+ hasActions: hasActionsProp,
onBlur,
onDismiss,
onFocus,
@@ -150,6 +171,7 @@ const GlAlert = forwardRef(function GlAlert({
const [hasProgrammaticFocus, setHasProgrammaticFocus] = useState(false);
const pointerInteractionRef = useRef(false);
+ const hasActions = hasActionsProp ?? hasAlertActions(children);
const hasTitle = Boolean(title);
const TitleTag = headingTags[headerLevel];
@@ -188,6 +210,7 @@ const GlAlert = forwardRef(function GlAlert({
className={alertVariants({
className,
dismissible,
+ hasActions,
hasProgrammaticFocus,
hasTitle,
sticky,