From a28a17f7cb26400f5ffd08b43ba3d555b77c128a Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 19:37:17 +0800 Subject: [PATCH 01/11] feat(ui): popover components --- packages/styles/dist/gitlab-ui.css | 235 +++++++++ packages/styles/src/components.css | 2 + .../styles/src/popovers/bootstrap-popover.css | 143 ++++++ packages/ui/src/base/popover/popover.css | 103 ++++ .../ui/src/base/popover/popover.stories.tsx | 239 +++++++++ packages/ui/src/base/popover/popover.test.tsx | 105 ++++ packages/ui/src/base/popover/popover.tsx | 460 ++++++++++++++++++ packages/ui/src/index.ts | 14 + 8 files changed, 1301 insertions(+) create mode 100644 packages/styles/src/popovers/bootstrap-popover.css create mode 100644 packages/ui/src/base/popover/popover.css create mode 100644 packages/ui/src/base/popover/popover.stories.tsx create mode 100644 packages/ui/src/base/popover/popover.test.tsx create mode 100644 packages/ui/src/base/popover/popover.tsx diff --git a/packages/styles/dist/gitlab-ui.css b/packages/styles/dist/gitlab-ui.css index 4bdd67c..df1d83e 100644 --- a/packages/styles/dist/gitlab-ui.css +++ b/packages/styles/dist/gitlab-ui.css @@ -3693,6 +3693,126 @@ textarea.gl-form-textarea { box-shadow: none; } } +/*! + * Bootstrap popover compatibility required by GlPopover. + * + * Ported from Bootstrap v4.6's _popover.scss as vendored by GitLab UI. Rules + * are scoped to the React component marker. Base UI positions the outer + * positioner, so the popup itself is relative only to its arrow and content. + */ +.gl-popover-positioner { + z-index: 1060; +} +.gl-popover.gl-popover { + position: relative; + top: 0; + left: 0; + display: block; + font-family: var(--gl-font-family-regular); + font-style: normal; + font-weight: var(--gl-font-weight-normal); + line-height: 1.5; + text-align: left; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + white-space: normal; + word-spacing: normal; + line-break: auto; + word-wrap: break-word; + background-clip: padding-box; + outline: 0; +} +.gl-popover.gl-popover > .arrow { + position: absolute; + display: block; + width: 16px; + height: 8px; + margin: 0 6px; +} +.gl-popover.gl-popover > .arrow::before, .gl-popover.gl-popover > .arrow::after { + position: absolute; + display: block; + content: ""; + border-color: transparent; + border-style: solid; +} +.gl-popover.bs-popover-top > .arrow { + bottom: -7px; +} +.gl-popover.bs-popover-top > .arrow::before { + bottom: 0; + border-width: 8px 8px 0; +} +.gl-popover.bs-popover-top > .arrow::after { + bottom: 1px; + border-width: 8px 8px 0; +} +.gl-popover.bs-popover-right > .arrow { + left: -7px; + width: 8px; + height: 16px; + margin: 6px 0; +} +.gl-popover.bs-popover-right > .arrow::before { + left: 0; + border-width: 8px 8px 8px 0; +} +.gl-popover.bs-popover-right > .arrow::after { + left: 1px; + border-width: 8px 8px 8px 0; +} +.gl-popover.bs-popover-bottom > .arrow { + top: -7px; +} +.gl-popover.bs-popover-bottom > .arrow::before { + top: 0; + border-width: 0 8px 8px; +} +.gl-popover.bs-popover-bottom > .arrow::after { + top: 1px; + border-width: 0 8px 8px; +} +.gl-popover.bs-popover-bottom > .popover-header::before { + position: absolute; + top: 0; + left: 50%; + display: block; + width: 16px; + margin-left: -8px; + content: ""; + border-bottom: 1px solid transparent; +} +.gl-popover.bs-popover-left > .arrow { + right: -7px; + width: 8px; + height: 16px; + margin: 6px 0; +} +.gl-popover.bs-popover-left > .arrow::before { + right: 0; + border-width: 8px 0 8px 8px; +} +.gl-popover.bs-popover-left > .arrow::after { + right: 1px; + border-width: 8px 0 8px 8px; +} +.gl-popover .popover-header { + position: relative; + padding: 8px 12px; + margin-bottom: 0; +} +.gl-popover .popover-body { + padding: 8px 12px; +} +.gl-popover.fade { + transition: opacity 0.15s linear; +} +.gl-popover.fade[data-starting-style], .gl-popover.fade[data-ending-style] { + opacity: 0; +} /*! * Bootstrap navigation compatibility required by GlTabs. * @@ -6415,6 +6535,91 @@ input[type="color"].gl-form-input.form-control:disabled { background-color: CanvasText; } } +.gl-popover { + max-width: 280px; + background-color: var(--gl-background-color-overlap); + --tw-border-style: none; + border-style: none; + --tw-drop-shadow-size: drop-shadow(0 0 0.5px var(--tw-drop-shadow-color, var(--gl-shadow-color-default, #05050629))) drop-shadow(0 0.5px 1px var(--tw-drop-shadow-color, var(--gl-shadow-color-default, #05050629))) drop-shadow(0 1px 3px var(--tw-drop-shadow-color, var(--gl-shadow-color-default, #05050629))); + --tw-drop-shadow: drop-shadow(0 0 0.5px var(--gl-shadow-color-default, #05050629)) drop-shadow( 0 0.5px 1px var(--gl-shadow-color-default, #05050629)) drop-shadow( 0 1px 3px var(--gl-shadow-color-default, #05050629)); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + font-size: var(--gl-font-size-sm); + border-radius: var(--gl-feedback-border-radius); +} +.gl-popover .popover-body { + color: var(--gl-text-color-default); +} +.gl-popover.bs-popover-top > .arrow::before { + bottom: 1px; + border-top-color: var(--gl-background-color-overlap); +} +.gl-popover.bs-popover-top > .arrow::after { + bottom: 2px; + border-top-color: var(--gl-background-color-overlap); +} +.gl-popover.bs-popover-bottom > .arrow::before { + top: 1px; + border-bottom-color: var(--gl-background-color-overlap); +} +.gl-popover.bs-popover-bottom > .arrow::after { + top: 2px; + border-bottom-color: var(--gl-background-color-overlap); +} +.gl-popover.bs-popover-bottom > .popover-header::before { + border-color: var(--gl-background-color-overlap); +} +.gl-popover.bs-popover-right > .arrow::before { + left: 1px; + border-right-color: var(--gl-background-color-overlap); +} +.gl-popover.bs-popover-right > .arrow::after { + left: 2px; + border-right-color: var(--gl-background-color-overlap); +} +.gl-popover.bs-popover-left > .arrow::before { + right: 1px; + border-left-color: var(--gl-background-color-overlap); +} +.gl-popover.bs-popover-left > .arrow::after { + right: 2px; + border-left-color: var(--gl-background-color-overlap); +} +.gl-popover .popover-header { + font-weight: var(--gl-heading-scale-200-font-weight); + margin-top: var(--gl-heading-scale-200-margin-top); + font-size: var(--gl-heading-scale-200-font-size); + letter-spacing: var(--gl-heading-scale-200-letter-spacing); + line-height: var(--gl-heading-scale-200-line-height); + color: var(--gl-heading-scale-200-color); + border-bottom-style: var(--tw-border-style); + border-bottom-width: 0px; + padding-bottom: var(--gl-spacing-scale-0); +} +.gl-popover .gl-popover-title { + margin: 0; + color: inherit; + font: inherit; +} +.gl-popover.has-title .popover-header { + overflow-wrap: anywhere; + word-break: normal; + -webkit-hyphens: auto; + hyphens: auto; + display: flex; + align-items: flex-start; + justify-content: space-between; +} +.gl-popover.has-close-button:not(.has-title) .popover-header { + padding-top: var(--gl-spacing-scale-2); +} +.gl-popover .popover-body { + padding-block: var(--gl-spacing-scale-3); + padding-inline: var(--gl-spacing-scale-4); +} +.gl-popover .popover-body > .popover-hr { + margin-block: var(--gl-spacing-scale-5); + margin-inline: var(--gl-spacing-scale-0); +} .gl-progress-bar { display: flex; height: var(--gl-spacing-scale-5); @@ -7075,6 +7280,9 @@ table.gl-table.table-borderless tr > :is(th, td) { white-space: nowrap; border-width: 0; } +.gl-float-right { + float: right; +} .gl-my-3 { margin-block: var(--gl-spacing-scale-3); } @@ -7086,12 +7294,18 @@ table.gl-table.table-borderless tr > :is(th, td) { line-height: var(--gl-heading-scale-300-line-height); color: var(--gl-heading-scale-300-color); } +.-gl-mt-2 { + margin-top: calc(var(--gl-spacing-scale-2) * -1); +} .gl-mt-2 { margin-top: var(--gl-spacing-scale-2); } .gl-mt-3 { margin-top: var(--gl-spacing-scale-3); } +.-gl-mr-3 { + margin-right: calc(var(--gl-spacing-scale-3) * -1); +} .gl-mb-0 { margin-bottom: var(--gl-spacing-scale-0); } @@ -7110,6 +7324,9 @@ table.gl-table.table-borderless tr > :is(th, td) { .-gl-ml-2 { margin-left: calc(var(--gl-spacing-scale-2) * -1); } +.gl-ml-3 { + margin-left: var(--gl-spacing-scale-3); +} .gl-ml-4 { margin-left: var(--gl-spacing-scale-4); } @@ -7134,6 +7351,9 @@ table.gl-table.table-borderless tr > :is(th, td) { .gl-table-row { display: table-row; } +.gl-h-0 { + height: var(--gl-spacing-scale-0); +} .gl-h-4 { height: var(--gl-spacing-scale-4); } @@ -7198,18 +7418,30 @@ table.gl-table.table-borderless tr > :is(th, td) { .gl-list-none { list-style-type: none; } +.gl-grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} .gl-flex-col { flex-direction: column; } +.gl-items-center { + align-items: center; +} .gl-items-start { align-items: flex-start; } .gl-gap-2 { gap: var(--gl-spacing-scale-2); } +.gl-gap-3 { + gap: var(--gl-spacing-scale-3); +} .gl-gap-5 { gap: var(--gl-spacing-scale-5); } +.gl-gap-12 { + gap: var(--gl-spacing-scale-12); +} .gl-self-start { align-self: flex-start; } @@ -7300,6 +7532,9 @@ table.gl-table.table-borderless tr > :is(th, td) { .gl-p-2 { padding: var(--gl-spacing-scale-2); } +.gl-p-12 { + padding: var(--gl-spacing-scale-12); +} .\!gl-px-2 { padding-inline: var(--gl-spacing-scale-2) !important; } diff --git a/packages/styles/src/components.css b/packages/styles/src/components.css index c9aef38..c698023 100644 --- a/packages/styles/src/components.css +++ b/packages/styles/src/components.css @@ -1,5 +1,6 @@ @import "./forms/index.css"; @import "./dropdowns/new-dropdown.css"; +@import "./popovers/bootstrap-popover.css"; @import "./tabs/bootstrap-tabs.css"; @import "./tables/bootstrap-tables.css"; @import "../../ui/src/base/accordion/accordion.css"; @@ -27,6 +28,7 @@ @import "../../ui/src/base/link/link.css"; @import "../../ui/src/base/listbox/listbox.css"; @import "../../ui/src/base/loading-icon/loading-icon.css"; +@import "../../ui/src/base/popover/popover.css"; @import "../../ui/src/base/progress-bar/progress-bar.css"; @import "../../ui/src/base/skeleton-loader/skeleton-loader.css"; @import "../../ui/src/base/table/table.css"; diff --git a/packages/styles/src/popovers/bootstrap-popover.css b/packages/styles/src/popovers/bootstrap-popover.css new file mode 100644 index 0000000..7e563c7 --- /dev/null +++ b/packages/styles/src/popovers/bootstrap-popover.css @@ -0,0 +1,143 @@ +/*! + * Bootstrap popover compatibility required by GlPopover. + * + * Ported from Bootstrap v4.6's _popover.scss as vendored by GitLab UI. Rules + * are scoped to the React component marker. Base UI positions the outer + * positioner, so the popup itself is relative only to its arrow and content. + */ + +.gl-popover-positioner { + z-index: 1060; +} + +.gl-popover.popover { + position: relative; + top: 0; + left: 0; + display: block; + font-family: var(--gl-font-family-regular); + font-style: normal; + font-weight: var(--gl-font-weight-normal); + line-height: 1.5; + text-align: left; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + white-space: normal; + word-spacing: normal; + line-break: auto; + word-wrap: break-word; + background-clip: padding-box; + outline: 0; + + > .arrow { + position: absolute; + display: block; + width: 16px; + height: 8px; + margin: 0 6px; + + &::before, + &::after { + position: absolute; + display: block; + content: ""; + border-color: transparent; + border-style: solid; + } + } +} + +.gl-popover.bs-popover-top > .arrow { + bottom: -7px; + + &::before { + bottom: 0; + border-width: 8px 8px 0; + } + + &::after { + bottom: 1px; + border-width: 8px 8px 0; + } +} + +.gl-popover.bs-popover-right > .arrow { + left: -7px; + width: 8px; + height: 16px; + margin: 6px 0; + + &::before { + left: 0; + border-width: 8px 8px 8px 0; + } + + &::after { + left: 1px; + border-width: 8px 8px 8px 0; + } +} + +.gl-popover.bs-popover-bottom > .arrow { + top: -7px; + + &::before { + top: 0; + border-width: 0 8px 8px; + } + + &::after { + top: 1px; + border-width: 0 8px 8px; + } +} + +.gl-popover.bs-popover-bottom > .popover-header::before { + position: absolute; + top: 0; + left: 50%; + display: block; + width: 16px; + margin-left: -8px; + content: ""; + border-bottom: 1px solid transparent; +} + +.gl-popover.bs-popover-left > .arrow { + right: -7px; + width: 8px; + height: 16px; + margin: 6px 0; + + &::before { + right: 0; + border-width: 8px 0 8px 8px; + } + + &::after { + right: 1px; + border-width: 8px 0 8px 8px; + } +} + +.gl-popover .popover-header { + position: relative; + padding: 8px 12px; + margin-bottom: 0; +} + +.gl-popover .popover-body { + padding: 8px 12px; +} + +.gl-popover.fade { + transition: opacity 0.15s linear; +} + +.gl-popover.fade[data-starting-style], +.gl-popover.fade[data-ending-style] { + opacity: 0; +} diff --git a/packages/ui/src/base/popover/popover.css b/packages/ui/src/base/popover/popover.css new file mode 100644 index 0000000..7ba746c --- /dev/null +++ b/packages/ui/src/base/popover/popover.css @@ -0,0 +1,103 @@ +/** + * Ported from GitLab UI: + * packages/gitlab-ui/src/components/base/popover/popover.scss + */ + +.gl-popover { + max-width: 280px; + @apply gl-bg-overlap; + @apply gl-border-none; + @apply gl-drop-shadow-md; + @apply gl-text-sm; + @apply gl-rounded-feedback; + + .popover-body { + @apply gl-text-default; + } + + &.bs-popover-top { + /* The shadow shifts the apparent arrow edge by one pixel. */ + > .arrow::before { + bottom: 1px; + border-top-color: var(--gl-background-color-overlap); + } + + > .arrow::after { + bottom: 2px; + border-top-color: var(--gl-background-color-overlap); + } + } + + &.bs-popover-bottom { + > .arrow::before { + top: 1px; + border-bottom-color: var(--gl-background-color-overlap); + } + + > .arrow::after { + top: 2px; + border-bottom-color: var(--gl-background-color-overlap); + } + + > .popover-header::before { + border-color: var(--gl-background-color-overlap); + } + } + + &.bs-popover-right { + > .arrow::before { + left: 1px; + border-right-color: var(--gl-background-color-overlap); + } + + > .arrow::after { + left: 2px; + border-right-color: var(--gl-background-color-overlap); + } + } + + &.bs-popover-left { + > .arrow::before { + right: 1px; + border-left-color: var(--gl-background-color-overlap); + } + + > .arrow::after { + right: 2px; + border-left-color: var(--gl-background-color-overlap); + } + } + + .popover-header { + @apply gl-heading-scale-200; + @apply gl-border-b-0; + @apply gl-pb-0; + } + + .gl-popover-title { + margin: 0; + color: inherit; + font: inherit; + } + + &.has-title .popover-header { + @apply gl-hyphens-auto gl-break-anywhere; + display: flex; + align-items: flex-start; + justify-content: space-between; + } + + &.has-close-button:not(.has-title) .popover-header { + @apply gl-pt-2; + } + + .popover-body { + @apply gl-py-3; + @apply gl-px-4; + + > .popover-hr { + @apply gl-my-5; + @apply gl-mx-0; + } + } +} diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx new file mode 100644 index 0000000..928ffda --- /dev/null +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -0,0 +1,239 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState, type CSSProperties } from "react"; +import { expect, fireEvent, fn, userEvent, waitFor, within } from "storybook/test"; +import GlButton from "../button/button"; +import GlLink from "../link/link"; +import GlPopover, { + GlPopoverContent, + GlPopoverTitle, + GlPopoverTrigger, + type GlPopoverPlacement, +} from "./popover"; + +const placements = ["top", "right", "bottom", "left"] satisfies GlPopoverPlacement[]; +const storyLayout: CSSProperties = { + alignItems: "center", + display: "flex", + justifyContent: "center", + minHeight: "320px", +}; + +const meta = { + title: "UI/Base/Popover", + component: GlPopover, + args: { + closeDelay: 0, + delay: 0, + }, + argTypes: { + children: { control: false }, + onOpenChange: { control: false }, + triggers: { + control: "check", + options: ["click", "hover", "focus"], + }, + }, + parameters: { + docs: { + description: { + component: + "Composition-first React port of the [Pajamas popover](https://design.gitlab.com/components/popover/), backed by Base UI Popover semantics.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( +
+ + + Popover + + + Popover title +

A popover provides supplemental, useful information about an element.

+
+
+
+ ), + play: async ({ canvas }) => { + const trigger = canvas.getByRole("button", { name: "Popover" }); + + await userEvent.hover(trigger); + fireEvent.mouseEnter(trigger); + + const dialog = await within(document.body).findByRole("dialog"); + const title = within(dialog).getByRole("heading", { name: "Popover title" }); + + await waitFor(() => expect(dialog).toBeVisible()); + await expect(dialog).toHaveClass("gl-popover", "has-title", "bs-popover-top"); + await expect(dialog.querySelector(".popover-body")).toHaveTextContent( + "A popover provides supplemental, useful information about an element.", + ); + await expect(dialog).toHaveAttribute("aria-labelledby", title.id); + }, +}; + +const closeButtonClick = fn(); + +export const WithCloseButton: Story = { + render: (args) => ( +
+ + + Compliance framework + + + Compliance framework used with Ruby project +

A popover provides supplemental, useful information about an element.

+
+
+
+ ), + play: async ({ canvas }) => { + closeButtonClick.mockClear(); + await userEvent.click(canvas.getByRole("button", { name: "Compliance framework" })); + + const body = within(document.body); + const dialog = await body.findByRole("dialog"); + const closeButton = within(dialog).getByRole("button", { name: "Close" }); + + await expect(dialog).toHaveClass("has-title", "has-close-button"); + await userEvent.click(closeButton); + await expect(closeButtonClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(body.queryByRole("dialog")).not.toBeInTheDocument()); + }, +}; + +export const TextLinks: Story = { + render: (args) => ( +
+ + + Learn about popovers + + + A popover can contain rich content and actionable elements. + + Read the Pajamas guidance + + + +
+ ), + play: async ({ canvas }) => { + const trigger = canvas.getByRole("button", { name: "Learn about popovers" }); + + await userEvent.click(trigger); + await waitFor(() => expect(trigger).toHaveFocus()); + + const body = within(document.body); + const dialog = await body.findByRole("dialog"); + const closeButton = within(dialog).getByRole("button", { name: "Close" }); + const link = within(dialog).getByRole("link", { name: "Read the Pajamas guidance" }); + + await expect(dialog).toHaveClass("has-close-button"); + await expect(dialog).not.toHaveClass("has-title"); + await userEvent.tab(); + await expect(closeButton).toHaveFocus(); + await userEvent.tab(); + await expect(link).toHaveFocus(); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(body.queryByRole("dialog")).not.toBeInTheDocument()); + }, +}; + +export const OnClick: Story = { + render: (args) => ( +
+ + + Click trigger + + + Click-triggered popover +

This popover toggles only when its trigger is clicked.

+
+
+
+ ), + play: async ({ canvas }) => { + const trigger = canvas.getByRole("button", { name: "Click trigger" }); + const body = within(document.body); + + await userEvent.hover(trigger); + await expect(body.queryByRole("dialog")).not.toBeInTheDocument(); + + await userEvent.click(trigger); + const dialog = await body.findByRole("dialog"); + + await expect(dialog).not.toHaveClass("fade"); + await userEvent.click(trigger); + await waitFor(() => expect(body.queryByRole("dialog")).not.toBeInTheDocument()); + }, +}; + +function ControlledManualPopover() { + const [open, setOpen] = useState(false); + + return ( +
+ setOpen((value) => !value)}>Toggle externally + + + Manual trigger + + + Controlled popover +

The trigger does not mutate the popover state.

+
+
+
+ ); +} + +export const ControlledManual: Story = { + render: () => , + play: async ({ canvas }) => { + const body = within(document.body); + + await userEvent.click(canvas.getByRole("button", { name: "Manual trigger" })); + await expect(body.queryByRole("dialog")).not.toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("button", { name: "Toggle externally" })); + const dialog = await body.findByRole("dialog"); + + await expect(dialog).toHaveAttribute("data-open"); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(body.queryByRole("dialog")).not.toBeInTheDocument()); + }, +}; + +export const Placements: Story = { + render: () => ( +
+ {placements.map((placement) => ( + + + {placement} + + + {placement} placement + Popover content + + + ))} +
+ ), +}; diff --git a/packages/ui/src/base/popover/popover.test.tsx b/packages/ui/src/base/popover/popover.test.tsx new file mode 100644 index 0000000..d2b62c5 --- /dev/null +++ b/packages/ui/src/base/popover/popover.test.tsx @@ -0,0 +1,105 @@ +import { Fragment, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import GlPopover, { + GlPopoverContent, + GlPopoverTitle, + GlPopoverTrigger, + type GlPopoverProps, + type GlPopoverTriggerMode, +} from "./popover"; + +vi.mock("@gitlab/svgs/dist/icons.svg", () => ({ default: "/path/to/icons.svg" })); + +function renderPopover(children: ReactNode = "Popover body", rootProps: GlPopoverProps = {}) { + return renderToStaticMarkup( + + + + + {children} + , + ); +} + +describe("GlPopover", () => { + it("composes the child element as a dialog trigger", () => { + const markup = renderPopover(); + + expect(markup).toContain(""); + expect(markup).toContain("aria-haspopup=\"dialog\""); + expect(markup).not.toContain("aria-expanded=\"true\""); + }); + + it("supports an initially open uncontrolled state", () => { + const markup = renderPopover(undefined, { defaultOpen: true }); + + expect(markup).toContain("aria-expanded=\"true\""); + // Base UI does not render portal content or its generated ID during SSR. + expect(markup).not.toContain("role=\"dialog\""); + }); + + it("disables the composed trigger", () => { + const markup = renderPopover(undefined, { disabled: true }); + + expect(markup).toContain("disabled=\"\""); + }); + + it("accepts the typed trigger modes", () => { + expectTypeOf() + .toEqualTypeOf(); + }); + + it("requires the trigger to be inside GlPopover", () => { + expect(() => renderToStaticMarkup( + + + , + )).toThrowError("GlPopoverTrigger must be used inside GlPopover."); + }); + + it("requires the content to be inside GlPopover", () => { + expect(() => renderToStaticMarkup( + Popover body, + )).toThrowError("GlPopoverContent must be used inside GlPopover."); + }); + + it("requires the title to be a direct child of GlPopoverContent", () => { + expect(() => renderPopover( +
+ Nested title +
, + )).toThrowError( + "GlPopoverTitle must be used as a direct child of GlPopoverContent. Fragments are supported.", + ); + }); + + it("allows the title through a Fragment", () => { + expect(() => renderPopover( + + Popover title + Popover body + , + )).not.toThrow(); + }); + + it("rejects duplicate titles", () => { + expect(() => renderPopover( + <> + First title + Second title + , + )).toThrowError("GlPopoverContent accepts only one GlPopoverTitle child."); + }); + + it("rejects a title outside GlPopoverContent", () => { + expect(() => renderToStaticMarkup( + + Popover title + , + )).toThrowError( + "GlPopoverTitle must be used as a direct child of GlPopoverContent. Fragments are supported.", + ); + }); +}); diff --git a/packages/ui/src/base/popover/popover.tsx b/packages/ui/src/base/popover/popover.tsx new file mode 100644 index 0000000..ac58bf0 --- /dev/null +++ b/packages/ui/src/base/popover/popover.tsx @@ -0,0 +1,460 @@ +/** + * Ported from GitLab UI: + * packages/gitlab-ui/src/components/base/popover/popover.vue + * + * Adaptations: + * - Vue's target, title slot, and default slot map to compound React parts. + * - Base UI supplies positioning, dismissal, portal, and dialog semantics. + * - Vue's show model maps to open/defaultOpen/onOpenChange. + */ + +import { + Children, + Fragment, + createContext, + forwardRef, + isValidElement, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + useState, + type CSSProperties, + type MouseEventHandler, + type ReactElement, + type ReactNode, +} from "react"; +import { Popover as BasePopover } from "@base-ui/react/popover"; +import { cva } from "class-variance-authority"; +import GlButton from "../button/button"; + +export type GlPopoverPlacement = "top" | "right" | "bottom" | "left"; +export type GlPopoverTriggerMode = "click" | "hover" | "focus"; + +export type GlPopoverProps = { + children?: ReactNode; + /** How long to wait before closing a hover-opened popover, in milliseconds. */ + closeDelay?: number; + /** Whether the popover is initially open when uncontrolled. */ + defaultOpen?: boolean; + /** How long to wait before opening on hover or focus, in milliseconds. */ + delay?: number; + /** Prevents the trigger from opening the popover. */ + disabled?: boolean; + /** Called when user interaction requests an open-state change. */ + onOpenChange?: (open: boolean) => void; + /** Controlled open state. */ + open?: boolean; + /** Enabled trigger modes. An empty array provides manual, controlled behavior. */ + triggers?: readonly GlPopoverTriggerMode[]; +}; + +export type GlPopoverTriggerProps = { + /** A single element that receives the trigger behavior and ARIA attributes. */ + children: ReactElement; + /** Set to false when the child does not ultimately render a native button. */ + nativeButton?: BasePopover.Trigger.Props["nativeButton"]; +}; + +type PopupProps = Omit< + BasePopover.Popup.Props, + "children" | "className" | "finalFocus" | "initialFocus" | "render" | "role" | "style" +>; + +export type GlPopoverContentProps = PopupProps & { + /** Collision boundary. `"viewport"` ignores clipping ancestors. */ + boundary?: "viewport" | "clipping-ancestors" | Element; + /** Space between the popover and its collision boundary, in pixels. */ + boundaryPadding?: number; + /** A title part followed by arbitrary body content. */ + children?: ReactNode; + /** Extra class applied to the popup. */ + className?: string; + /** Accessible label for the optional close button. */ + closeButtonLabel?: string; + /** Portal container element, shadow root, or selector. Defaults to document.body. */ + container?: HTMLElement | ShadowRoot | string | null; + /** Disables the fade transition. */ + noFade?: boolean; + /** Called when the optional close button is clicked. */ + onCloseButtonClick?: MouseEventHandler; + /** Preferred side of the trigger. */ + placement?: GlPopoverPlacement; + /** Displays a close button in the popover header. */ + showCloseButton?: boolean; + style?: CSSProperties; +}; + +export type GlPopoverTitleProps = Omit & { + className?: string; +}; + +type PopoverContextValue = { + closeDelay: number; + delay: number; + disabled: boolean; + open: boolean; + requestOpenChange(open: boolean): void; + triggerId: string; + triggerModes: ReadonlySet; +}; + +type ResolvedPopoverContent = { + body: ReactNode[]; + title: ReactElement | null; +}; + +const DEFAULT_TRIGGERS: readonly GlPopoverTriggerMode[] = ["hover", "focus"]; +const PopoverContext = createContext(null); +const PopoverTitleContext = createContext(false); + +const popoverVariants = cva(["popover", "gl-popover"], { + variants: { + fade: { + false: null, + true: "fade", + }, + hasCloseButton: { + false: null, + true: "has-close-button", + }, + hasTitle: { + false: null, + true: "has-title", + }, + placement: { + bottom: "bs-popover-bottom", + left: "bs-popover-left", + right: "bs-popover-right", + top: "bs-popover-top", + }, + }, + defaultVariants: { + fade: true, + hasCloseButton: false, + hasTitle: false, + placement: "top", + }, +}); +const closeButtonVariants = cva([], { + variants: { + withoutTitle: { + false: null, + true: "gl-float-right gl-mt-2", + }, + }, + defaultVariants: { + withoutTitle: false, + }, +}); +const popoverTitleVariants = cva("gl-popover-title"); + +function usePopoverContext(componentName: string): PopoverContextValue { + const context = useContext(PopoverContext); + if(!context) throw new Error(`${componentName} must be used inside GlPopover.`); + return context; +} + +function modeForChangeReason( + reason: BasePopover.Root.ChangeEventReason, +): GlPopoverTriggerMode | null { + switch(reason) { + case "trigger-hover": + return "hover"; + case "trigger-focus": + return "focus"; + case "trigger-press": + return "click"; + default: + return null; + } +} + +function resolveContainer( + container: GlPopoverContentProps["container"], +): HTMLElement | ShadowRoot | null | undefined { + if(typeof container !== "string") return container; + if(typeof document === "undefined") return null; + + try { + return document.querySelector(container); + } catch { + return null; + } +} + +function resolveBoundary( + boundary: GlPopoverContentProps["boundary"], +): BasePopover.Positioner.Props["collisionBoundary"] { + // Floating UI always intersects the supplied boundary with its viewport root + // boundary. An empty list therefore opts out of clipping ancestors. + return boundary === "viewport" ? [] : boundary; +} + +function physicalPlacement(side: BasePopover.Popup.State["side"]): GlPopoverPlacement { + if(side === "inline-start") return "left"; + if(side === "inline-end") return "right"; + return side; +} + +function containsPopoverTitle(children: ReactNode): boolean { + let containsTitle = false; + + Children.forEach(children, (child) => { + if(containsTitle || !isValidElement<{ children?: ReactNode }>(child)) return; + + containsTitle = child.type === GlPopoverTitle + || containsPopoverTitle(child.props.children); + }); + + return containsTitle; +} + +function resolvePopoverContent(children: ReactNode): ResolvedPopoverContent { + const result: ResolvedPopoverContent = { body: [], title: null }; + + const visit = (child: ReactNode) => { + if(child === null || child === undefined || typeof child === "boolean") return; + + if(isValidElement<{ children?: ReactNode }>(child) && child.type === Fragment) { + Children.forEach(child.props.children, visit); + return; + } + + if(isValidElement(child) && child.type === GlPopoverTitle) { + if(result.title) { + throw new Error("GlPopoverContent accepts only one GlPopoverTitle child."); + } + result.title = child as ResolvedPopoverContent["title"]; + return; + } + + if(isValidElement<{ children?: ReactNode }>(child) + && containsPopoverTitle(child.props.children)) { + throw new Error( + "GlPopoverTitle must be used as a direct child of GlPopoverContent. " + + "Fragments are supported.", + ); + } + + result.body.push(child); + }; + + Children.forEach(children, visit); + return result; +} + +export default function GlPopover({ + children, + closeDelay = 150, + defaultOpen = false, + delay = 50, + disabled = false, + onOpenChange, + open, + triggers = DEFAULT_TRIGGERS, +}: GlPopoverProps) { + const generatedId = useId(); + const triggerId = `gl-popover-trigger-${generatedId}`; + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const isControlled = open !== undefined; + const isOpen = open ?? uncontrolledOpen; + const triggerModes = useMemo(() => new Set(triggers), [triggers]); + + const requestOpenChange = useCallback((nextOpen: boolean) => { + if(nextOpen === isOpen || (nextOpen && disabled)) return; + + if(!isControlled) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen); + }, [disabled, isControlled, isOpen, onOpenChange]); + + const handleOpenChange = useCallback(( + nextOpen: boolean, + details: BasePopover.Root.ChangeEventDetails, + ) => { + const triggerMode = modeForChangeReason(details.reason); + if((triggerMode && !triggerModes.has(triggerMode)) || (nextOpen && disabled)) { + details.cancel(); + return; + } + + requestOpenChange(nextOpen); + }, [disabled, requestOpenChange, triggerModes]); + + const context = useMemo(() => ({ + closeDelay, + delay, + disabled, + open: isOpen, + requestOpenChange, + triggerId, + triggerModes, + }), [closeDelay, delay, disabled, isOpen, requestOpenChange, triggerId, triggerModes]); + + return ( + + + {children} + + + ); +} + +export function GlPopoverTrigger({ + children, + nativeButton = true, +}: GlPopoverTriggerProps) { + const context = usePopoverContext("GlPopoverTrigger"); + const focusTimerRef = useRef | null>(null); + const trigger = Children.only(children); + + const clearFocusTimer = useCallback(() => { + if(focusTimerRef.current === null) return; + + clearTimeout(focusTimerRef.current); + focusTimerRef.current = null; + }, []); + + useEffect(() => clearFocusTimer, [clearFocusTimer]); + + const handleFocus = () => { + if( + context.disabled + || context.open + || !context.triggerModes.has("focus") + ) return; + + clearFocusTimer(); + if(context.delay <= 0) { + context.requestOpenChange(true); + return; + } + + focusTimerRef.current = setTimeout(() => { + focusTimerRef.current = null; + context.requestOpenChange(true); + }, context.delay); + }; + + const handleBlur = () => { + if(!context.open) clearFocusTimer(); + }; + + const handleClick = () => { + if(context.triggerModes.has("click")) clearFocusTimer(); + }; + + return ( + + ); +} + +export const GlPopoverContent = forwardRef( + function GlPopoverContent({ + boundary = "viewport", + boundaryPadding = 5, + children, + className, + closeButtonLabel = "Close", + container, + noFade = false, + onCloseButtonClick, + placement = "top", + showCloseButton = false, + style, + ...popupProps + }, forwardedRef) { + usePopoverContext("GlPopoverContent"); + const content = resolvePopoverContent(children); + const hasTitle = content.title !== null; + const hasBody = content.body.length > 0; + const portalContainer = resolveContainer(container); + const collisionBoundary = resolveBoundary(boundary); + const popupClassName = (state: BasePopover.Popup.State) => popoverVariants({ + className, + fade: !noFade, + hasCloseButton: showCloseButton, + hasTitle, + placement: physicalPlacement(state.side), + }); + + return ( + + + + + {hasTitle || showCloseButton ? ( +
+ {hasTitle ? ( + + {content.title} + + ) : null} + {showCloseButton ? ( +
+ + )} /> +
+ ) : null} +
+ ) : null} + {hasBody ?
{content.body}
: null} +
+
+
+ ); + }, +); + +export const GlPopoverTitle = forwardRef( + function GlPopoverTitle({ className, ...titleProps }, forwardedRef) { + const isDirectContentChild = useContext(PopoverTitleContext); + if(!isDirectContentChild) { + throw new Error( + "GlPopoverTitle must be used as a direct child of GlPopoverContent. " + + "Fragments are supported.", + ); + } + + return ( + + ); + }, +); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 99a059f..9fc9572 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -224,6 +224,20 @@ export type { GlLoadingIconSize, GlLoadingIconVariant, } from "./base/loading-icon/loading-icon"; +export { + default as GlPopover, + GlPopoverContent, + GlPopoverTitle, + GlPopoverTrigger, +} from "./base/popover/popover"; +export type { + GlPopoverContentProps, + GlPopoverPlacement, + GlPopoverProps, + GlPopoverTitleProps, + GlPopoverTriggerMode, + GlPopoverTriggerProps, +} from "./base/popover/popover"; export { default as GlProgressBar } from "./base/progress-bar/progress-bar"; export type { GlProgressBarProps, From 90da6beafd4ecac13ebfc871f991079ba3836679 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 20:25:11 +0800 Subject: [PATCH 02/11] fix(popover): preserve clipping-ancestor collision handling --- packages/ui/src/base/popover/popover.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/base/popover/popover.tsx b/packages/ui/src/base/popover/popover.tsx index ac58bf0..a523f00 100644 --- a/packages/ui/src/base/popover/popover.tsx +++ b/packages/ui/src/base/popover/popover.tsx @@ -64,7 +64,7 @@ type PopupProps = Omit< >; export type GlPopoverContentProps = PopupProps & { - /** Collision boundary. `"viewport"` ignores clipping ancestors. */ + /** Collision boundary. Defaults to clipping ancestors; `"viewport"` ignores them. */ boundary?: "viewport" | "clipping-ancestors" | Element; /** Space between the popover and its collision boundary, in pixels. */ boundaryPadding?: number; @@ -185,8 +185,8 @@ function resolveContainer( } } -function resolveBoundary( - boundary: GlPopoverContentProps["boundary"], +export function resolvePopoverBoundary( + boundary: GlPopoverContentProps["boundary"] = "clipping-ancestors", ): BasePopover.Positioner.Props["collisionBoundary"] { // Floating UI always intersects the supplied boundary with its viewport root // boundary. An empty list therefore opts out of clipping ancestors. @@ -367,7 +367,7 @@ export function GlPopoverTrigger({ export const GlPopoverContent = forwardRef( function GlPopoverContent({ - boundary = "viewport", + boundary, boundaryPadding = 5, children, className, @@ -385,7 +385,7 @@ export const GlPopoverContent = forwardRef 0; const portalContainer = resolveContainer(container); - const collisionBoundary = resolveBoundary(boundary); + const collisionBoundary = resolvePopoverBoundary(boundary); const popupClassName = (state: BasePopover.Popup.State) => popoverVariants({ className, fade: !noFade, From 2a3202edc0b8ed9075e4148c50f1234b6f387f27 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 20:42:34 +0800 Subject: [PATCH 03/11] fix(popover): aria-label fallback to popover trigger if there is not title --- .../ui/src/base/popover/popover.stories.tsx | 2 ++ packages/ui/src/base/popover/popover.test.tsx | 22 +++++++++++++++ packages/ui/src/base/popover/popover.tsx | 27 +++++++++++++++++-- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx index 928ffda..09d7ff5 100644 --- a/packages/ui/src/base/popover/popover.stories.tsx +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -140,6 +140,8 @@ export const TextLinks: Story = { await expect(dialog).toHaveClass("has-close-button"); await expect(dialog).not.toHaveClass("has-title"); + await expect(dialog).toHaveAccessibleName("Learn about popovers"); + await expect(dialog).toHaveAttribute("aria-labelledby", trigger.id); await userEvent.tab(); await expect(closeButton).toHaveFocus(); await userEvent.tab(); diff --git a/packages/ui/src/base/popover/popover.test.tsx b/packages/ui/src/base/popover/popover.test.tsx index d2b62c5..f847222 100644 --- a/packages/ui/src/base/popover/popover.test.tsx +++ b/packages/ui/src/base/popover/popover.test.tsx @@ -5,6 +5,7 @@ import GlPopover, { GlPopoverContent, GlPopoverTitle, GlPopoverTrigger, + resolvePopoverFallbackLabelledBy, type GlPopoverProps, type GlPopoverTriggerMode, } from "./popover"; @@ -51,6 +52,27 @@ describe("GlPopover", () => { .toEqualTypeOf(); }); + it("uses the trigger to name a titleless popover", () => { + expect(resolvePopoverFallbackLabelledBy(false, "trigger-id", undefined, undefined)) + .toBe("trigger-id"); + }); + + it.each([ + [true, undefined, undefined], + [false, "Explicit label", undefined], + [false, undefined, "external-label"], + ] as const)( + "does not add a trigger fallback when another accessible name is available", + (hasTitle, ariaLabel, ariaLabelledBy) => { + expect(resolvePopoverFallbackLabelledBy( + hasTitle, + "trigger-id", + ariaLabel, + ariaLabelledBy, + )).toBeUndefined(); + }, + ); + it("requires the trigger to be inside GlPopover", () => { expect(() => renderToStaticMarkup( diff --git a/packages/ui/src/base/popover/popover.tsx b/packages/ui/src/base/popover/popover.tsx index a523f00..830988a 100644 --- a/packages/ui/src/base/popover/popover.tsx +++ b/packages/ui/src/base/popover/popover.tsx @@ -68,7 +68,10 @@ export type GlPopoverContentProps = PopupProps & { boundary?: "viewport" | "clipping-ancestors" | Element; /** Space between the popover and its collision boundary, in pixels. */ boundaryPadding?: number; - /** A title part followed by arbitrary body content. */ + /** + * A title part followed by arbitrary body content. Without a title or an + * explicit accessible name, the dialog is labelled by its trigger. + */ children?: ReactNode; /** Extra class applied to the popup. */ className?: string; @@ -193,6 +196,16 @@ export function resolvePopoverBoundary( return boundary === "viewport" ? [] : boundary; } +export function resolvePopoverFallbackLabelledBy( + hasTitle: boolean, + triggerId: string, + ariaLabel: BasePopover.Popup.Props["aria-label"], + ariaLabelledBy: BasePopover.Popup.Props["aria-labelledby"], +): string | undefined { + if(hasTitle || ariaLabel || ariaLabelledBy) return undefined; + return triggerId; +} + function physicalPlacement(side: BasePopover.Popup.State["side"]): GlPopoverPlacement { if(side === "inline-start") return "left"; if(side === "inline-end") return "right"; @@ -380,12 +393,21 @@ export const GlPopoverContent = forwardRef 0; const portalContainer = resolveContainer(container); const collisionBoundary = resolvePopoverBoundary(boundary); + const fallbackLabelledBy = resolvePopoverFallbackLabelledBy( + hasTitle, + context.triggerId, + popupProps["aria-label"], + popupProps["aria-labelledby"], + ); + const fallbackAccessibleNameProps = fallbackLabelledBy + ? { "aria-labelledby": fallbackLabelledBy } + : {}; const popupClassName = (state: BasePopover.Popup.State) => popoverVariants({ className, fade: !noFade, @@ -405,6 +427,7 @@ export const GlPopoverContent = forwardRef Date: Sat, 5 Sep 2026 20:45:09 +0800 Subject: [PATCH 04/11] style(popover): use span inside popover content in story --- packages/ui/src/base/popover/popover.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx index 09d7ff5..708eba7 100644 --- a/packages/ui/src/base/popover/popover.stories.tsx +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -91,7 +91,7 @@ export const WithCloseButton: Story = { onCloseButtonClick={closeButtonClick} showCloseButton> Compliance framework used with Ruby project -

A popover provides supplemental, useful information about an element.

+ A popover provides supplemental, useful information about an element. @@ -160,7 +160,7 @@ export const OnClick: Story = {
Click-triggered popover -

This popover toggles only when its trigger is clicked.

+ This popover toggles only when its trigger is clicked.
@@ -198,7 +198,7 @@ function ControlledManualPopover() { Controlled popover -

The trigger does not mutate the popover state.

+ The trigger does not mutate the popover state.
From dee9275eab73d1c800081e499cc8bdd422f482b8 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 21:25:42 +0800 Subject: [PATCH 05/11] fix(popover): coordinate active trigger state - prevent one trigger mode from closing while another remains active - clear stale focus timers across controlled state changes --- .../ui/src/base/popover/popover.stories.tsx | 20 +- packages/ui/src/base/popover/popover.test.tsx | 19 ++ packages/ui/src/base/popover/popover.tsx | 208 +++++++++++++++--- 3 files changed, 213 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx index 708eba7..7adde7e 100644 --- a/packages/ui/src/base/popover/popover.stories.tsx +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -55,7 +55,7 @@ export const Default: Story = { Popover title -

A popover provides supplemental, useful information about an element.

+ A popover provides supplemental, useful information about an element.
@@ -63,8 +63,8 @@ export const Default: Story = { play: async ({ canvas }) => { const trigger = canvas.getByRole("button", { name: "Popover" }); - await userEvent.hover(trigger); - fireEvent.mouseEnter(trigger); + await userEvent.tab(); + await expect(trigger).toHaveFocus(); const dialog = await within(document.body).findByRole("dialog"); const title = within(dialog).getByRole("heading", { name: "Popover title" }); @@ -75,6 +75,20 @@ export const Default: Story = { "A popover provides supplemental, useful information about an element.", ); await expect(dialog).toHaveAttribute("aria-labelledby", title.id); + + await userEvent.hover(trigger); + fireEvent.mouseEnter(trigger); + await userEvent.unhover(trigger); + fireEvent.mouseLeave(trigger, { relatedTarget: document.body }); + fireEvent.mouseMove(document.body); + await expect(dialog).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(within(document.body).queryByRole("dialog")).not.toBeInTheDocument()); + + await userEvent.hover(trigger); + fireEvent.mouseEnter(trigger); + await within(document.body).findByRole("dialog"); }, }; diff --git a/packages/ui/src/base/popover/popover.test.tsx b/packages/ui/src/base/popover/popover.test.tsx index f847222..2c9141e 100644 --- a/packages/ui/src/base/popover/popover.test.tsx +++ b/packages/ui/src/base/popover/popover.test.tsx @@ -6,6 +6,7 @@ import GlPopover, { GlPopoverTitle, GlPopoverTrigger, resolvePopoverFallbackLabelledBy, + shouldCancelPopoverTriggerClose, type GlPopoverProps, type GlPopoverTriggerMode, } from "./popover"; @@ -52,6 +53,24 @@ describe("GlPopover", () => { .toEqualTypeOf(); }); + it.each([ + [false, "trigger-hover", true, true], + [false, "trigger-press", true, true], + [false, "focus-out", true, true], + [false, "outside-press", true, false], + [false, "trigger-hover", false, false], + [true, "trigger-hover", true, false], + ] as const)( + "coordinates close requests across active trigger modes", + (nextOpen, reason, hasActiveTrigger, expected) => { + expect(shouldCancelPopoverTriggerClose( + nextOpen, + reason, + hasActiveTrigger, + )).toBe(expected); + }, + ); + it("uses the trigger to name a titleless popover", () => { expect(resolvePopoverFallbackLabelledBy(false, "trigger-id", undefined, undefined)) .toBe("trigger-id"); diff --git a/packages/ui/src/base/popover/popover.tsx b/packages/ui/src/base/popover/popover.tsx index 830988a..5ced862 100644 --- a/packages/ui/src/base/popover/popover.tsx +++ b/packages/ui/src/base/popover/popover.tsx @@ -95,11 +95,16 @@ export type GlPopoverTitleProps = Omit & { }; type PopoverContextValue = { + activateFocusTrigger(): void; + activateHoverTrigger(): void; closeDelay: number; delay: number; + deactivateFocusTrigger(): void; + deactivateHoverTrigger(): void; disabled: boolean; open: boolean; - requestOpenChange(open: boolean): void; + resetActiveTriggers(): void; + toggleClickTrigger(): void; triggerId: string; triggerModes: ReadonlySet; }; @@ -175,6 +180,16 @@ function modeForChangeReason( } } +export function shouldCancelPopoverTriggerClose( + nextOpen: boolean, + reason: BasePopover.Root.ChangeEventReason, + hasActiveTrigger: boolean, +): boolean { + return !nextOpen + && hasActiveTrigger + && (modeForChangeReason(reason) !== null || reason === "focus-out"); +} + function resolveContainer( container: GlPopoverContentProps["container"], ): HTMLElement | ShadowRoot | null | undefined { @@ -275,6 +290,11 @@ export default function GlPopover({ const isControlled = open !== undefined; const isOpen = open ?? uncontrolledOpen; const triggerModes = useMemo(() => new Set(triggers), [triggers]); + // Base UI owns hover and click, while focus is adapted here. Track them + // together so leaving one mode cannot close while another remains active. + const activeTriggerModesRef = useRef(new Set()); + const focusTimerRef = useRef | null>(null); + const previousOpenRef = useRef(isOpen); const requestOpenChange = useCallback((nextOpen: boolean) => { if(nextOpen === isOpen || (nextOpen && disabled)) return; @@ -282,6 +302,62 @@ export default function GlPopover({ if(!isControlled) setUncontrolledOpen(nextOpen); onOpenChange?.(nextOpen); }, [disabled, isControlled, isOpen, onOpenChange]); + const requestOpenChangeRef = useRef(requestOpenChange); + requestOpenChangeRef.current = requestOpenChange; + + const cancelPendingFocusOpen = useCallback(() => { + if(focusTimerRef.current === null) return; + + clearTimeout(focusTimerRef.current); + focusTimerRef.current = null; + }, []); + + const deactivateFocusTrigger = useCallback(() => { + activeTriggerModesRef.current.delete("focus"); + cancelPendingFocusOpen(); + }, [cancelPendingFocusOpen]); + + const activateFocusTrigger = useCallback(() => { + if(disabled || !triggerModes.has("focus")) return; + + activeTriggerModesRef.current.add("focus"); + cancelPendingFocusOpen(); + if(isOpen) return; + + if(delay <= 0) { + requestOpenChangeRef.current(true); + return; + } + + focusTimerRef.current = setTimeout(() => { + focusTimerRef.current = null; + if(activeTriggerModesRef.current.has("focus")) requestOpenChangeRef.current(true); + }, delay); + }, [cancelPendingFocusOpen, delay, disabled, isOpen, triggerModes]); + + const activateHoverTrigger = useCallback(() => { + if(disabled || !triggerModes.has("hover")) return; + + activeTriggerModesRef.current.add("hover"); + }, [disabled, triggerModes]); + + const deactivateHoverTrigger = useCallback(() => { + activeTriggerModesRef.current.delete("hover"); + }, []); + + const toggleClickTrigger = useCallback(() => { + if(disabled || !triggerModes.has("click")) return; + + cancelPendingFocusOpen(); + const activeTriggerModes = activeTriggerModesRef.current; + if(activeTriggerModes.has("click")) activeTriggerModes.delete("click"); + else activeTriggerModes.add("click"); + }, [cancelPendingFocusOpen, disabled, triggerModes]); + + const resetActiveTriggers = useCallback(() => { + activeTriggerModesRef.current.clear(); + cancelPendingFocusOpen(); + }, [cancelPendingFocusOpen]); const handleOpenChange = useCallback(( nextOpen: boolean, @@ -293,18 +369,80 @@ export default function GlPopover({ return; } + const activeTriggerModes = activeTriggerModesRef.current; + if(nextOpen && triggerMode) activeTriggerModes.add(triggerMode); + if(!nextOpen) { + if(triggerMode) activeTriggerModes.delete(triggerMode); + else if(details.reason === "focus-out") activeTriggerModes.delete("focus"); + } + + if(shouldCancelPopoverTriggerClose( + nextOpen, + details.reason, + activeTriggerModes.size > 0, + )) { + details.cancel(); + return; + } + + if(nextOpen) cancelPendingFocusOpen(); requestOpenChange(nextOpen); - }, [disabled, requestOpenChange, triggerModes]); + }, [ + cancelPendingFocusOpen, + disabled, + requestOpenChange, + triggerModes, + ]); + + useEffect(() => { + const wasOpen = previousOpenRef.current; + previousOpenRef.current = isOpen; + + if(isOpen) cancelPendingFocusOpen(); + else if(wasOpen) resetActiveTriggers(); + }, [cancelPendingFocusOpen, isOpen, resetActiveTriggers]); + + useEffect(() => { + if(disabled) { + resetActiveTriggers(); + return; + } + + activeTriggerModesRef.current.forEach((triggerMode) => { + if(!triggerModes.has(triggerMode)) activeTriggerModesRef.current.delete(triggerMode); + }); + if(!triggerModes.has("focus")) cancelPendingFocusOpen(); + }, [cancelPendingFocusOpen, disabled, resetActiveTriggers, triggerModes]); + + useEffect(() => cancelPendingFocusOpen, [cancelPendingFocusOpen]); const context = useMemo(() => ({ + activateFocusTrigger, + activateHoverTrigger, closeDelay, delay, + deactivateFocusTrigger, + deactivateHoverTrigger, disabled, open: isOpen, - requestOpenChange, + resetActiveTriggers, + toggleClickTrigger, + triggerId, + triggerModes, + }), [ + activateFocusTrigger, + activateHoverTrigger, + closeDelay, + delay, + deactivateFocusTrigger, + deactivateHoverTrigger, + disabled, + isOpen, + resetActiveTriggers, + toggleClickTrigger, triggerId, triggerModes, - }), [closeDelay, delay, disabled, isOpen, requestOpenChange, triggerId, triggerModes]); + ]); return ( @@ -324,43 +462,33 @@ export function GlPopoverTrigger({ nativeButton = true, }: GlPopoverTriggerProps) { const context = usePopoverContext("GlPopoverTrigger"); - const focusTimerRef = useRef | null>(null); const trigger = Children.only(children); - const clearFocusTimer = useCallback(() => { - if(focusTimerRef.current === null) return; - - clearTimeout(focusTimerRef.current); - focusTimerRef.current = null; - }, []); - - useEffect(() => clearFocusTimer, [clearFocusTimer]); - - const handleFocus = () => { - if( - context.disabled - || context.open - || !context.triggerModes.has("focus") - ) return; + useEffect( + () => context.resetActiveTriggers, + [context.resetActiveTriggers], + ); - clearFocusTimer(); - if(context.delay <= 0) { - context.requestOpenChange(true); - return; - } + const handleFocus: NonNullable = (event) => { + if(!event.currentTarget.matches(":focus-visible")) return; - focusTimerRef.current = setTimeout(() => { - focusTimerRef.current = null; - context.requestOpenChange(true); - }, context.delay); + context.activateFocusTrigger(); }; const handleBlur = () => { - if(!context.open) clearFocusTimer(); + if(!context.open) context.deactivateFocusTrigger(); }; const handleClick = () => { - if(context.triggerModes.has("click")) clearFocusTimer(); + context.toggleClickTrigger(); + }; + + const handleMouseEnter = () => { + context.activateHoverTrigger(); + }; + + const handleMouseLeave = () => { + context.deactivateHoverTrigger(); }; return ( @@ -373,6 +501,8 @@ export function GlPopoverTrigger({ onBlur={handleBlur} onClick={handleClick} onFocus={handleFocus} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} openOnHover={context.triggerModes.has("hover")} render={trigger} /> ); @@ -388,6 +518,8 @@ export const GlPopoverContent = forwardRef = (event) => { + onMouseEnter?.(event); + if(event.baseUIHandlerPrevented) return; + + context.activateHoverTrigger(); + }; + const handleMouseLeave: NonNullable = (event) => { + onMouseLeave?.(event); + if(event.baseUIHandlerPrevented) return; + + context.deactivateHoverTrigger(); + }; return ( @@ -431,6 +575,8 @@ export const GlPopoverContent = forwardRef From a9fb5d23945cea1acd8585015d5b92f593123cf5 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 21:52:48 +0800 Subject: [PATCH 06/11] fix(popover): preserve custom trigger ids --- packages/ui/src/base/popover/popover.stories.tsx | 3 ++- packages/ui/src/base/popover/popover.tsx | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx index 7adde7e..fa8fc7c 100644 --- a/packages/ui/src/base/popover/popover.stories.tsx +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -130,7 +130,7 @@ export const TextLinks: Story = {
- Learn about popovers + Learn about popovers A popover can contain rich content and actionable elements. @@ -143,6 +143,7 @@ export const TextLinks: Story = { ), play: async ({ canvas }) => { const trigger = canvas.getByRole("button", { name: "Learn about popovers" }); + await expect(trigger).toHaveAttribute("id", "text-links-popover-trigger"); await userEvent.click(trigger); await waitFor(() => expect(trigger).toHaveFocus()); diff --git a/packages/ui/src/base/popover/popover.tsx b/packages/ui/src/base/popover/popover.tsx index 5ced862..11aaa07 100644 --- a/packages/ui/src/base/popover/popover.tsx +++ b/packages/ui/src/base/popover/popover.tsx @@ -107,6 +107,7 @@ type PopoverContextValue = { toggleClickTrigger(): void; triggerId: string; triggerModes: ReadonlySet; + updateTriggerId(triggerId: string): void; }; type ResolvedPopoverContent = { @@ -284,8 +285,9 @@ export default function GlPopover({ open, triggers = DEFAULT_TRIGGERS, }: GlPopoverProps) { - const generatedId = useId(); - const triggerId = `gl-popover-trigger-${generatedId}`; + const generatedTriggerId = useId(); + const defaultTriggerId = `gl-popover-trigger-${generatedTriggerId}`; + const [triggerId, setTriggerId] = useState(defaultTriggerId); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const isControlled = open !== undefined; const isOpen = open ?? uncontrolledOpen; @@ -429,6 +431,7 @@ export default function GlPopover({ toggleClickTrigger, triggerId, triggerModes, + updateTriggerId: setTriggerId, }), [ activateFocusTrigger, activateHoverTrigger, @@ -463,6 +466,13 @@ export function GlPopoverTrigger({ }: GlPopoverTriggerProps) { const context = usePopoverContext("GlPopoverTrigger"); const trigger = Children.only(children); + const childId = (trigger.props as { id?: string }).id; + const actualId = childId ?? context.triggerId; + const updateTriggerId = context.updateTriggerId; + + useEffect(() => { + updateTriggerId(actualId); + }, [actualId, updateTriggerId]); useEffect( () => context.resetActiveTriggers, @@ -496,7 +506,7 @@ export function GlPopoverTrigger({ closeDelay={context.closeDelay} delay={context.delay} disabled={context.disabled} - id={context.triggerId} + id={actualId} nativeButton={nativeButton} onBlur={handleBlur} onClick={handleClick} From 28ba614a35ebbe3c0dfb10bd5f7412b174a06574 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 21:58:42 +0800 Subject: [PATCH 07/11] test(popover): cover focus-visible trigger behavior --- .../ui/src/base/popover/popover.stories.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx index fa8fc7c..1dac792 100644 --- a/packages/ui/src/base/popover/popover.stories.tsx +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -166,6 +166,35 @@ export const TextLinks: Story = { }, }; +export const OnFocus: Story = { + render: (args) => ( +
+ + + Focus trigger + + + Focus-triggered popover + This popover opens only for keyboard-style focus. + + +
+ ), + play: async ({ canvas }) => { + const trigger = canvas.getByRole("button", { name: "Focus trigger" }); + const body = within(document.body); + + await expect(body.queryByRole("dialog")).not.toBeInTheDocument(); + + await userEvent.tab(); + await expect(trigger).toHaveFocus(); + await body.findByRole("dialog"); + + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(body.queryByRole("dialog")).not.toBeInTheDocument()); + }, +}; + export const OnClick: Story = { render: (args) => (
From decdcfdbb23aa80f24623807933ec6307d7591cc Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 22:11:12 +0800 Subject: [PATCH 08/11] fix(popover): preserve modal portal containment - default the portal to the trigger's nearest modal content - fall back to the document body outside modals --- .../ui/src/base/popover/popover.stories.tsx | 26 +++++++++++++++++++ packages/ui/src/base/popover/popover.tsx | 15 +++++++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx index 1dac792..4baa195 100644 --- a/packages/ui/src/base/popover/popover.stories.tsx +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -266,6 +266,32 @@ export const ControlledManual: Story = { }, }; +export const InsideModal: Story = { + render: (args) => ( +
+ + + Modal popover + + + Popover in a modal + The portal remains inside the nearest modal content container. + + +
+ ), + play: async ({ canvas }) => { + const modalContent = canvas.getByTestId("modal-content"); + + await userEvent.click(canvas.getByRole("button", { name: "Modal popover" })); + const dialog = await within(document.body).findByRole("dialog"); + + await expect(modalContent).toContainElement(dialog); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(within(document.body).queryByRole("dialog")).not.toBeInTheDocument()); + }, +}; + export const Placements: Story = { render: () => (
diff --git a/packages/ui/src/base/popover/popover.tsx b/packages/ui/src/base/popover/popover.tsx index 11aaa07..50529f1 100644 --- a/packages/ui/src/base/popover/popover.tsx +++ b/packages/ui/src/base/popover/popover.tsx @@ -77,7 +77,7 @@ export type GlPopoverContentProps = PopupProps & { className?: string; /** Accessible label for the optional close button. */ closeButtonLabel?: string; - /** Portal container element, shadow root, or selector. Defaults to document.body. */ + /** Portal container. Defaults to the nearest `.modal-content`, then document.body. */ container?: HTMLElement | ShadowRoot | string | null; /** Disables the fade transition. */ noFade?: boolean; @@ -104,7 +104,9 @@ type PopoverContextValue = { disabled: boolean; open: boolean; resetActiveTriggers(): void; + setTriggerElement(triggerElement: HTMLElement | null): void; toggleClickTrigger(): void; + triggerElement: HTMLElement | null; triggerId: string; triggerModes: ReadonlySet; updateTriggerId(triggerId: string): void; @@ -193,7 +195,11 @@ export function shouldCancelPopoverTriggerClose( function resolveContainer( container: GlPopoverContentProps["container"], + triggerElement: HTMLElement | null, ): HTMLElement | ShadowRoot | null | undefined { + if(container === undefined) { + return triggerElement?.closest(".modal-content") ?? undefined; + } if(typeof container !== "string") return container; if(typeof document === "undefined") return null; @@ -288,6 +294,7 @@ export default function GlPopover({ const generatedTriggerId = useId(); const defaultTriggerId = `gl-popover-trigger-${generatedTriggerId}`; const [triggerId, setTriggerId] = useState(defaultTriggerId); + const [triggerElement, setTriggerElement] = useState(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); const isControlled = open !== undefined; const isOpen = open ?? uncontrolledOpen; @@ -428,7 +435,9 @@ export default function GlPopover({ disabled, open: isOpen, resetActiveTriggers, + setTriggerElement, toggleClickTrigger, + triggerElement, triggerId, triggerModes, updateTriggerId: setTriggerId, @@ -443,6 +452,7 @@ export default function GlPopover({ isOpen, resetActiveTriggers, toggleClickTrigger, + triggerElement, triggerId, triggerModes, ]); @@ -503,6 +513,7 @@ export function GlPopoverTrigger({ return ( 0; - const portalContainer = resolveContainer(container); + const portalContainer = resolveContainer(container, context.triggerElement); const collisionBoundary = resolvePopoverBoundary(boundary); const fallbackLabelledBy = resolvePopoverFallbackLabelledBy( hasTitle, From 335afdc4aeff5a825a56e666a68ceaf6756ada3a Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 22:28:30 +0800 Subject: [PATCH 09/11] fix(popover): keep disabled triggers interactive --- .../ui/src/base/popover/popover.stories.tsx | 28 +++++++++++++++++++ packages/ui/src/base/popover/popover.test.tsx | 5 ++-- packages/ui/src/base/popover/popover.tsx | 3 +- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx index 4baa195..0088e94 100644 --- a/packages/ui/src/base/popover/popover.stories.tsx +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -225,6 +225,34 @@ export const OnClick: Story = { }, }; +const disabledPopoverTriggerClick = fn(); + +export const Disabled: Story = { + render: (args) => ( +
+ + + Disabled popover + + + Disabled popover + This content must remain closed. + + +
+ ), + play: async ({ canvas }) => { + disabledPopoverTriggerClick.mockClear(); + const trigger = canvas.getByRole("button", { name: "Disabled popover" }); + + await expect(trigger).not.toBeDisabled(); + await userEvent.click(trigger); + await expect(disabledPopoverTriggerClick).toHaveBeenCalledTimes(1); + await expect(trigger).toHaveFocus(); + await expect(within(document.body).queryByRole("dialog")).not.toBeInTheDocument(); + }, +}; + function ControlledManualPopover() { const [open, setOpen] = useState(false); diff --git a/packages/ui/src/base/popover/popover.test.tsx b/packages/ui/src/base/popover/popover.test.tsx index 2c9141e..2e23dde 100644 --- a/packages/ui/src/base/popover/popover.test.tsx +++ b/packages/ui/src/base/popover/popover.test.tsx @@ -42,10 +42,11 @@ describe("GlPopover", () => { expect(markup).not.toContain("role=\"dialog\""); }); - it("disables the composed trigger", () => { + it("leaves the composed trigger enabled when the popover is disabled", () => { const markup = renderPopover(undefined, { disabled: true }); - expect(markup).toContain("disabled=\"\""); + expect(markup).not.toContain("disabled=\"\""); + expect(markup).not.toContain("aria-disabled=\"true\""); }); it("accepts the typed trigger modes", () => { diff --git a/packages/ui/src/base/popover/popover.tsx b/packages/ui/src/base/popover/popover.tsx index 50529f1..98df7c9 100644 --- a/packages/ui/src/base/popover/popover.tsx +++ b/packages/ui/src/base/popover/popover.tsx @@ -41,7 +41,7 @@ export type GlPopoverProps = { defaultOpen?: boolean; /** How long to wait before opening on hover or focus, in milliseconds. */ delay?: number; - /** Prevents the trigger from opening the popover. */ + /** Prevents the popover from opening without disabling the trigger element. */ disabled?: boolean; /** Called when user interaction requests an open-state change. */ onOpenChange?: (open: boolean) => void; @@ -516,7 +516,6 @@ export function GlPopoverTrigger({ ref={context.setTriggerElement} closeDelay={context.closeDelay} delay={context.delay} - disabled={context.disabled} id={actualId} nativeButton={nativeButton} onBlur={handleBlur} From 859ecc9330ceb408920038d71297ddf5b292ac24 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 22:32:36 +0800 Subject: [PATCH 10/11] test(popover): add dedicated no-fade story --- .../ui/src/base/popover/popover.stories.tsx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/base/popover/popover.stories.tsx b/packages/ui/src/base/popover/popover.stories.tsx index 0088e94..c71806f 100644 --- a/packages/ui/src/base/popover/popover.stories.tsx +++ b/packages/ui/src/base/popover/popover.stories.tsx @@ -202,7 +202,7 @@ export const OnClick: Story = { Click trigger - + Click-triggered popover This popover toggles only when its trigger is clicked. @@ -217,14 +217,29 @@ export const OnClick: Story = { await expect(body.queryByRole("dialog")).not.toBeInTheDocument(); await userEvent.click(trigger); - const dialog = await body.findByRole("dialog"); + await body.findByRole("dialog"); - await expect(dialog).not.toHaveClass("fade"); await userEvent.click(trigger); await waitFor(() => expect(body.queryByRole("dialog")).not.toBeInTheDocument()); }, }; +export const NoFade: Story = { + render: (args) => ( +
+ + + Popover without fade + + + Popover without fade + This popover renders without the fade transition class. + + +
+ ) +}; + const disabledPopoverTriggerClick = fn(); export const Disabled: Story = { From 633ede81108aca0f99e4804702fdd4bc7230d3f6 Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 22:56:25 +0800 Subject: [PATCH 11/11] fix(popover): align arrows with base ui positioning - remove Bootstrap cross-axis arrow margins - prevent CSS from offsetting Base UI-computed coordinates --- packages/styles/dist/gitlab-ui.css | 3 --- packages/styles/src/popovers/bootstrap-popover.css | 3 --- 2 files changed, 6 deletions(-) diff --git a/packages/styles/dist/gitlab-ui.css b/packages/styles/dist/gitlab-ui.css index df1d83e..2fb0113 100644 --- a/packages/styles/dist/gitlab-ui.css +++ b/packages/styles/dist/gitlab-ui.css @@ -3730,7 +3730,6 @@ textarea.gl-form-textarea { display: block; width: 16px; height: 8px; - margin: 0 6px; } .gl-popover.gl-popover > .arrow::before, .gl-popover.gl-popover > .arrow::after { position: absolute; @@ -3754,7 +3753,6 @@ textarea.gl-form-textarea { left: -7px; width: 8px; height: 16px; - margin: 6px 0; } .gl-popover.bs-popover-right > .arrow::before { left: 0; @@ -3789,7 +3787,6 @@ textarea.gl-form-textarea { right: -7px; width: 8px; height: 16px; - margin: 6px 0; } .gl-popover.bs-popover-left > .arrow::before { right: 0; diff --git a/packages/styles/src/popovers/bootstrap-popover.css b/packages/styles/src/popovers/bootstrap-popover.css index 7e563c7..8ea6c64 100644 --- a/packages/styles/src/popovers/bootstrap-popover.css +++ b/packages/styles/src/popovers/bootstrap-popover.css @@ -37,7 +37,6 @@ display: block; width: 16px; height: 8px; - margin: 0 6px; &::before, &::after { @@ -68,7 +67,6 @@ left: -7px; width: 8px; height: 16px; - margin: 6px 0; &::before { left: 0; @@ -110,7 +108,6 @@ right: -7px; width: 8px; height: 16px; - margin: 6px 0; &::before { right: 0;