From 255d870e90a663006fd85d3dcda742a524dd145a Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 26 Sep 2026 10:41:16 +0800 Subject: [PATCH 1/3] fix(alert): sync condensed upstream layout (#135) Apply the compact spacing and alignment styles, mark alerts containing actions, and use small buttons in the standard action examples. --- packages/styles/dist/gitlab-ui.css | 40 +++++++++-------- packages/ui/src/base/alert/alert.css | 45 ++++++++++---------- packages/ui/src/base/alert/alert.stories.tsx | 17 ++++++-- packages/ui/src/base/alert/alert.test.tsx | 20 ++++++--- packages/ui/src/base/alert/alert.tsx | 20 ++++++++- 5 files changed, 89 insertions(+), 53 deletions(-) diff --git a/packages/styles/dist/gitlab-ui.css b/packages/styles/dist/gitlab-ui.css index 971f40b..af8e6a9 100644 --- a/packages/styles/dist/gitlab-ui.css +++ b/packages/styles/dist/gitlab-ui.css @@ -4441,43 +4441,44 @@ } } .gl-alert { - padding-block: var(--gl-spacing-scale-5); + padding-block: var(--gl-spacing-scale-3); font-size: var(--gl-font-size-base); border-radius: var(--gl-alert-border-radius); - padding-inline: calc(3rem + var(--gl-alert-padding-x, 0px)); + padding-inline: calc(2rem + var(--gl-alert-padding-x, 0px)); position: relative; border: 1px solid; } .gl-alert-not-dismissible { - padding-right: calc(1rem + var(--gl-alert-padding-x, 0px)); + padding-right: calc(0.75rem + var(--gl-alert-padding-x, 0px)); } .gl-alert .gl-link:not(.gl-label-link) { text-decoration-line: underline; } + .gl-alert.gl-alert-has-actions { + padding-bottom: var(--gl-spacing-scale-4); + } .gl-alert-sticky { position: sticky; top: 0; } .gl-alert-title { - font-weight: var(--gl-heading-scale-400-font-weight); - margin-top: var(--gl-heading-scale-400-margin-top); - font-size: var(--gl-heading-scale-400-font-size); - letter-spacing: var(--gl-heading-scale-400-letter-spacing); - line-height: var(--gl-heading-scale-400-line-height); - color: var(--gl-heading-scale-400-color); - margin-top: calc(var(--gl-spacing-scale-1) * -1); + font-weight: var(--gl-heading-scale-300-font-weight); + margin-top: var(--gl-heading-scale-300-margin-top); + font-size: var(--gl-heading-scale-300-font-size); + letter-spacing: var(--gl-heading-scale-300-letter-spacing); + line-height: var(--gl-heading-scale-300-line-height); + color: var(--gl-heading-scale-300-color); margin-bottom: var(--gl-spacing-scale-2); + --tw-leading: var(--gl-line-height-20); + line-height: var(--gl-line-height-20); } .gl-alert-icon-container { position: absolute; - top: 1rem; - left: calc(1rem + var(--gl-alert-padding-x, 0px)); + top: var(--gl-spacing-scale-3); + left: calc(0.5rem + var(--gl-alert-padding-x, 0px)); display: flex; align-items: center; - height: 1.5rem; - } - .gl-alert-has-title .gl-alert-icon-container { - top: 0.75rem; + height: 1.25rem; } .gl-alert-body { --tw-leading: var(--gl-line-height-20); @@ -4518,11 +4519,8 @@ } .gl-dismiss-btn { position: absolute; - top: 0.75rem; - right: calc(0.75rem + var(--gl-alert-padding-x, 0px)); - } - .gl-alert-has-title .gl-dismiss-btn { - top: 0.75rem; + top: calc(0.5rem - (1.5rem - 1.25rem) / 2); + right: calc(0.25rem + var(--gl-alert-padding-x, 0px)); } .gl-alert-danger { background-color: var(--gl-alert-danger-background-color); diff --git a/packages/ui/src/base/alert/alert.css b/packages/ui/src/base/alert/alert.css index 5633202..1947080 100644 --- a/packages/ui/src/base/alert/alert.css +++ b/packages/ui/src/base/alert/alert.css @@ -3,10 +3,11 @@ * packages/gitlab-ui/src/components/base/alert/alert.scss * * Sass constants are expanded to match the upstream compiled output: - * `$gl-spacing-scale-9` is `3rem`, `$gl-spacing-scale-5` is `1rem`, - * `$gl-spacing-scale-4` is `.75rem`, `$gl-spacing-scale-1` is `.25rem`, - * and `$gl-line-height-24` is `1.5rem`. The `gl-container-width-up(sm)` - * mixin is written out as the established local `@container`/`@media` pair. + * `$gl-spacing-scale-7` is `2rem`, `$gl-spacing-scale-4` is `.75rem`, + * `$gl-spacing-scale-3` is `.5rem`, `$gl-spacing-scale-2` is `.25rem`, + * `$gl-line-height-20` is `1.25rem`, and `$gl-button-small-size` is `1.5rem`. + * The `gl-container-width-up(sm)` mixin is written out as the established + * local `@container`/`@media` pair. * Compound action controls receive the upstream `gl-alert-action` styles * through direct-child selectors instead of an internally applied class. */ @@ -19,20 +20,24 @@ * in GitLab. */ .gl-alert { - @apply gl-py-5; + @apply gl-py-3; @apply gl-text-base; border-radius: var(--gl-alert-border-radius); - padding-inline: calc(3rem + var(--gl-alert-padding-x, 0px)); + padding-inline: calc(2rem + var(--gl-alert-padding-x, 0px)); position: relative; border: 1px solid; &-not-dismissible { - padding-right: calc(1rem + var(--gl-alert-padding-x, 0px)); + padding-right: calc(0.75rem + var(--gl-alert-padding-x, 0px)); } .gl-link:not(.gl-label-link) { @apply gl-underline; } + + &.gl-alert-has-actions { + @apply gl-pb-4; + } } .gl-alert-sticky { @@ -41,20 +46,19 @@ } .gl-alert-title { - @apply gl-heading-scale-400 -gl-mt-1 gl-mb-2; + @apply gl-heading-scale-300; + @apply gl-mb-2; + @apply gl-leading-20; } .gl-alert-icon-container { - position: absolute; - top: 1rem; - left: calc(1rem + var(--gl-alert-padding-x, 0px)); + @apply gl-absolute; + @apply gl-top-3; + left: calc(0.5rem + var(--gl-alert-padding-x, 0px)); @apply gl-flex; @apply gl-items-center; - height: 1.5rem; - - .gl-alert-has-title & { - top: 0.75rem; - } + /* Match line-height on first line of title or slot text content */ + height: 1.25rem; } .gl-alert-body { @@ -96,12 +100,9 @@ /* This is the replacement for .gl-alert-dismiss */ .gl-dismiss-btn { position: absolute; - top: 0.75rem; - right: calc(0.75rem + var(--gl-alert-padding-x, 0px)); - - .gl-alert-has-title & { - top: 0.75rem; - } + /* Pull the button up by half its overhang so it aligns with first line title or slot text content */ + top: calc(0.5rem - (1.5rem - 1.25rem) / 2); + right: calc(0.25rem + var(--gl-alert-padding-x, 0px)); } /* Variants */ diff --git a/packages/ui/src/base/alert/alert.stories.tsx b/packages/ui/src/base/alert/alert.stories.tsx index 551e861..5345ad4 100644 --- a/packages/ui/src/base/alert/alert.stories.tsx +++ b/packages/ui/src/base/alert/alert.stories.tsx @@ -94,6 +94,7 @@ export const UndismissibleDangerWithActions: Story = { Primary action @@ -101,6 +102,7 @@ export const UndismissibleDangerWithActions: Story = { category="secondary" href="#" onClick={onSecondaryAction} + size="small" variant="default"> Secondary action @@ -113,13 +115,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); @@ -159,8 +168,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..5052f4d 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", () => { "

Alert message

", ); 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,7 @@ describe("GlAlert", () => { expect(markup).toContain("Description"); expect(markup).toContain("Actions"); expect(markup).not.toContain("Hidden actions"); + expect(markup).toContain("gl-alert-has-actions"); }); it("accepts ordinary, wrapped, and repeated content", () => { @@ -246,7 +254,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..07a5502 100644 --- a/packages/ui/src/base/alert/alert.tsx +++ b/packages/ui/src/base/alert/alert.tsx @@ -6,7 +6,9 @@ * - 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. * - 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 +19,9 @@ */ import { + Children, forwardRef, + isValidElement, useRef, useState, type FocusEventHandler, @@ -87,6 +91,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,6 +139,14 @@ 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, @@ -150,6 +166,7 @@ const GlAlert = forwardRef(function GlAlert({ const [hasProgrammaticFocus, setHasProgrammaticFocus] = useState(false); const pointerInteractionRef = useRef(false); + const hasActions = hasAlertActions(children); const hasTitle = Boolean(title); const TitleTag = headingTags[headerLevel]; @@ -188,6 +205,7 @@ const GlAlert = forwardRef(function GlAlert({ className={alertVariants({ className, dismissible, + hasActions, hasProgrammaticFocus, hasTitle, sticky, From 8c9e03f4d74ab6a70c2452dc04a0d8a80f80ac2c Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 26 Sep 2026 10:56:49 +0800 Subject: [PATCH 2/3] fix(alert): support wrapped action components --- packages/ui/src/base/alert/alert.stories.tsx | 15 +++++++++---- packages/ui/src/base/alert/alert.test.tsx | 23 ++++++++++++++++++++ packages/ui/src/base/alert/alert.tsx | 7 +++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/base/alert/alert.stories.tsx b/packages/ui/src/base/alert/alert.stories.tsx index 5345ad4..3d097f0 100644 --- a/packages/ui/src/base/alert/alert.stories.tsx +++ b/packages/ui/src/base/alert/alert.stories.tsx @@ -18,6 +18,14 @@ const variants = [ const onPrimaryAction = fn(); const onSecondaryAction = fn((event: MouseEvent) => event.preventDefault()); +function WrappedCustomActions() { + return ( + + + + ); +} + const meta = { title: "UI/Base/Alert", component: GlAlert, @@ -137,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(); }, }; diff --git a/packages/ui/src/base/alert/alert.test.tsx b/packages/ui/src/base/alert/alert.test.tsx index 5052f4d..cff1f4d 100644 --- a/packages/ui/src/base/alert/alert.test.tsx +++ b/packages/ui/src/base/alert/alert.test.tsx @@ -206,6 +206,29 @@ describe("GlAlert", () => { 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", () => { function WrappedDescription() { return Wrapped description; diff --git a/packages/ui/src/base/alert/alert.tsx b/packages/ui/src/base/alert/alert.tsx index 07a5502..4a83931 100644 --- a/packages/ui/src/base/alert/alert.tsx +++ b/packages/ui/src/base/alert/alert.tsx @@ -9,6 +9,8 @@ * 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 @@ -47,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. */ @@ -153,6 +157,7 @@ const GlAlert = forwardRef(function GlAlert({ dismissible = true, dismissLabel = "Dismiss", headerLevel = 2, + hasActions: hasActionsProp, onBlur, onDismiss, onFocus, @@ -166,7 +171,7 @@ const GlAlert = forwardRef(function GlAlert({ const [hasProgrammaticFocus, setHasProgrammaticFocus] = useState(false); const pointerInteractionRef = useRef(false); - const hasActions = hasAlertActions(children); + const hasActions = hasActionsProp ?? hasAlertActions(children); const hasTitle = Boolean(title); const TitleTag = headingTags[headerLevel]; From 3e4176b563584dafa79a64388874dce545c9635e Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 26 Sep 2026 11:07:27 +0800 Subject: [PATCH 3/3] docs(alert): sync action examples and `hasActions` guidance --- docs/en/components/alert.mdx | 5 +++-- docs/zh/components/alert.mdx | 5 +++-- examples/alert/AlertActionsExample.tsx | 8 ++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/en/components/alert.mdx b/docs/en/components/alert.mdx index ebdf753..b4aec5e 100644 --- a/docs/en/components/alert.mdx +++ b/docs/en/components/alert.mdx @@ -18,7 +18,7 @@ import { GlButton } from "gitlab-ui-react/button"; Your preferences were updated. - Review changes + Review changes ``` @@ -51,7 +51,7 @@ Choose a variant for the message's meaning, not merely its visual emphasis. Use ## Titles and actions -Add a short title when it helps users identify what triggered the alert. Put up to two explicit recovery or continuation controls in `GlAlertActions`; use a confirm primary action and a default secondary action. +Add a short title when it helps users identify what triggered the alert. Put up to two explicit recovery or continuation controls in `GlAlertActions`; use small buttons with a confirm primary action and a default secondary action. If a wrapper component renders `GlAlertActions`, set `hasActions` on `GlAlert` so the alert preserves the action spacing. Your preferences were updated. - Review changes + Review changes ``` @@ -51,7 +51,7 @@ import { GlButton } from "gitlab-ui-react/button"; ## 标题与操作 -当标题有助于用户识别触发警报的事件时,可以添加一个简短标题。在 `GlAlertActions` 中最多放置两个明确的恢复或继续操作;主要操作使用 confirm 变体,次要操作使用 default 变体。 +当标题有助于用户识别触发警报的事件时,可以添加一个简短标题。在 `GlAlertActions` 中最多放置两个明确的恢复或继续操作;使用小尺寸按钮,主要操作使用 confirm 变体,次要操作使用 default 变体。如果由包装组件渲染 `GlAlertActions`,请在 `GlAlert` 上设置 `hasActions`,以保留操作区域的间距。 - Retry - View details + + Retry + + + View details + );