From a064741adb3b93bbab6ab98b02f399759eb9f88d Mon Sep 17 00:00:00 2001 From: NriotHrreion Date: Sat, 5 Sep 2026 13:04:18 +0800 Subject: [PATCH 01/14] feat(ui): tabs components --- packages/styles/dist/gitlab-ui.css | 231 ++++++ packages/styles/src/components.css | 2 + packages/styles/src/tabs/bootstrap-tabs.css | 33 + packages/ui/src/base/tabs/tab.tsx | 59 ++ packages/ui/src/base/tabs/tabs.css | 193 +++++ packages/ui/src/base/tabs/tabs.stories.tsx | 247 +++++++ packages/ui/src/base/tabs/tabs.test.tsx | 247 +++++++ packages/ui/src/base/tabs/tabs.tsx | 755 ++++++++++++++++++++ packages/ui/src/index.ts | 20 + 9 files changed, 1787 insertions(+) create mode 100644 packages/styles/src/tabs/bootstrap-tabs.css create mode 100644 packages/ui/src/base/tabs/tab.tsx create mode 100644 packages/ui/src/base/tabs/tabs.css create mode 100644 packages/ui/src/base/tabs/tabs.stories.tsx create mode 100644 packages/ui/src/base/tabs/tabs.test.tsx create mode 100644 packages/ui/src/base/tabs/tabs.tsx diff --git a/packages/styles/dist/gitlab-ui.css b/packages/styles/dist/gitlab-ui.css index 418043b..4bdd67c 100644 --- a/packages/styles/dist/gitlab-ui.css +++ b/packages/styles/dist/gitlab-ui.css @@ -3693,6 +3693,32 @@ textarea.gl-form-textarea { box-shadow: none; } } +/*! + * Bootstrap navigation compatibility required by GlTabs. + * + * Ported from Bootstrap v4.6's _nav.scss as vendored by GitLab UI. Rules are + * scoped to the React component marker so unrelated consumer navigation is not + * affected. + */ +.gl-tabs .nav { + display: flex; + flex-wrap: wrap; + padding-left: 0; + margin-bottom: 0; + list-style: none; +} +.gl-tabs .nav-link { + display: block; + text-decoration: none; +} +.gl-tabs .nav-link:hover, .gl-tabs .nav-link:focus-visible { + text-decoration: none; +} +.gl-tabs .nav-justified > .nav-item { + flex-basis: 0; + flex-grow: 1; + text-align: center; +} .gl-table-responsive { container-type: inline-size; display: block; @@ -6505,6 +6531,211 @@ table.gl-table:is( .b-table-stacked, .b-table-stacked-sm, .b-table-stacked-md, . table.gl-table.table-borderless tr > :is(th, td) { border: 0; } +.gl-tabs-wrapper { + position: relative; + display: flex; + flex-wrap: wrap; + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--gl-border-color-default); +} +.gl-tabs-nav { + flex-grow: 1; + min-width: 0; +} +.gl-tab-nav-item { + position: relative; + padding-inline: var(--gl-spacing-scale-4); + padding-block: var(--gl-spacing-scale-5); + --tw-leading: 1rem; + line-height: 1rem; + font-size: var(--gl-font-size-base); + display: flex; + justify-content: center; + border-style: var(--tw-border-style); + border-width: 1px; + border-style: solid; + border-color: var(--gl-border-color-default); + height: 100%; + color: var(--gl-action-neutral-foreground-color-default); + background-color: var(--gl-action-neutral-background-color-default); + border-color: var(--gl-action-neutral-border-color-default); +} +.gl-tab-nav-item:hover { + color: var(--gl-action-neutral-foreground-color-hover); + background-color: var(--gl-action-neutral-background-color-hover); + border-color: var(--gl-action-neutral-border-color-hover); + } +.gl-tab-nav-item:focus-visible { + color: var(--gl-action-neutral-foreground-color-focus); + background-color: var(--gl-action-neutral-background-color-focus); + border-color: var(--gl-action-neutral-border-color-focus); + } +.gl-tab-nav-item:active { + color: var(--gl-action-neutral-foreground-color-active); + background-color: var(--gl-action-neutral-background-color-active); + border-color: var(--gl-action-neutral-border-color-active); + } +.gl-tab-nav-item{ + font-family: inherit; + text-align: inherit; + transition: box-shadow 0.2s cubic-bezier(0.22, 0.61, 0.36, 1), background-color 0.2s cubic-bezier(0.22, 0.61, 0.36, 1), border-radius 0.1s cubic-bezier(0.22, 0.61, 0.36, 1); +} +.gl-tab-nav-item::before { + position: absolute; + right: -1px; + bottom: -1px; + left: -1px; + border-bottom: 2px solid transparent; + content: ""; + translate: 0 2px; + transition: border-bottom 0.2s cubic-bezier(0.22, 0.61, 0.36, 1), translate 0.2s cubic-bezier(0.22, 0.61, 0.36, 1); +} +@media (prefers-reduced-motion: reduce) { + .gl-tab-nav-item, .gl-tab-nav-item::before { + transition-duration: 0.01ms; + } +} +.gl-tab-nav-item:hover:not(.gl-tab-nav-item-active)::before { + border-bottom-color: var(--gl-border-color-strong); + translate: 0; +} +.gl-tab-nav-item:focus-visible { + border-radius: var(--gl-border-radius-default); +} +.gl-tab-nav-item.disabled { + pointer-events: auto; + cursor: not-allowed; + color: var(--gl-action-disabled-foreground-color); +} +.gl-tab-nav-item.disabled:hover { + --tw-shadow: 0 0 #0000; + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} +.gl-tab-nav-item > .gl-tab-counter-badge { + font-weight: inherit; + margin-left: var(--gl-spacing-scale-2); +} +.gl-tab-content { + padding-block: var(--gl-spacing-scale-3); + font-size: var(--gl-font-size-base); + --tw-leading: 1rem; + line-height: 1rem; + color: var(--gl-text-color-default); +} +.gl-tab-nav-item-active { + --tw-font-weight: var(--gl-font-weight-bold); + font-weight: var(--gl-font-weight-bold); + z-index: 1; +} +.gl-tab-nav-item-active:active, .gl-tab-nav-item-active:focus-visible, .gl-tab-nav-item-active:focus-visible:active { + box-shadow: inset 0 0 0 1px var(--gl-action-neutral-border-color-active), 0 0 0 1px var(--gl-focus-ring-inner-color), 0 0 0 3px var(--gl-focus-ring-outer-color); + outline: none; +} +.gl-scrollable-tabs-nav .gl-tab-nav-item-active:active, .gl-scrollable-tabs-nav .gl-tab-nav-item-active:focus-visible, .gl-scrollable-tabs-nav .gl-tab-nav-item-active:focus-visible:active { + box-shadow: inset 0 0 0 2px var(--gl-focus-ring-outer-color), inset 0 0 0 3px var(--gl-focus-ring-inner-color), inset 0 0 0 1px var(--gl-focus-ring-inner-color); +} +.gl-scrollable-tabs-nav .gl-tab-nav-item-active:active::before, .gl-scrollable-tabs-nav .gl-tab-nav-item-active:focus-visible::before, .gl-scrollable-tabs-nav .gl-tab-nav-item-active:focus-visible:active::before { + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 0 0 1px 1px; +} +.gl-tab-nav-item-active:active::before, .gl-tab-nav-item-active:focus-visible::before, .gl-tab-nav-item-active:focus-visible:active::before { + right: 0; + left: 0; + border-radius: 0 0 2px 2px; +} +.gl-tab-nav-item-active::before { + border-bottom-color: var(--gl-tab-selected-indicator-color-default); + translate: 0; +} +.gl-tabs-before, .gl-tabs-after { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--gl-spacing-scale-3); +} +.gl-tabs-after { + justify-content: flex-end; +} +.gl-tab-actions { + order: -1; + margin-top: var(--gl-spacing-scale-3); + margin-bottom: var(--gl-spacing-scale-3); + display: flex; + width: 100%; + flex-grow: 0; + flex-direction: column; + gap: var(--gl-spacing-scale-3); +} +@media (min-width: 768px) { + .gl-tab-actions { + order: 0; + margin: var(--gl-spacing-scale-0); + width: auto; + flex-direction: row; + align-items: center; + justify-content: flex-end; + } +} +.gl-tabs-fade { + position: absolute; + top: var(--gl-spacing-scale-0); + bottom: var(--gl-spacing-scale-0); + z-index: var(--gl-zindex-2); + width: var(--gl-spacing-scale-8); + padding: var(--gl-spacing-scale-2); + display: flex; +} +.gl-tabs-fade-left { + left: var(--gl-spacing-scale-0); + background-image: linear-gradient( to left, rgba(255, 255, 255, 0), var(--gl-background-color-default) 33% ); +} +.gl-tabs-fade-right { + right: var(--gl-spacing-scale-0); + justify-content: flex-end; + background-image: linear-gradient( to right, rgba(255, 255, 255, 0), var(--gl-background-color-default) 33% ); +} +.gl-tabs-fade-icon-button { + border-style: var(--tw-border-style); + border-width: 1px; + border-style: solid; + border-color: var(--gl-border-color-default); + color: var(--gl-action-neutral-foreground-color-default); + background-color: var(--gl-action-neutral-background-color-default); + border-color: var(--gl-action-neutral-border-color-default); +} +.gl-tabs-fade-icon-button:hover { + color: var(--gl-action-neutral-foreground-color-hover); + background-color: var(--gl-action-neutral-background-color-hover); + border-color: var(--gl-action-neutral-border-color-hover); + } +.gl-tabs-fade-icon-button:focus-visible { + color: var(--gl-action-neutral-foreground-color-focus); + background-color: var(--gl-action-neutral-background-color-focus); + border-color: var(--gl-action-neutral-border-color-focus); + } +.gl-tabs-fade-icon-button:active { + color: var(--gl-action-neutral-foreground-color-active); + background-color: var(--gl-action-neutral-background-color-active); + border-color: var(--gl-action-neutral-border-color-active); + } +.gl-tabs-fade-icon-button{ + padding: var(--gl-spacing-scale-0); + height: 100%; +} +.gl-tabs .gl-scrollable-tabs-nav { + overflow: hidden; + position: relative; + flex-wrap: nowrap; +} +@media (forced-colors: active) { + .gl-tab-nav-item-active:active, .gl-tab-nav-item-active:focus-visible, .gl-tab-nav-item-active:focus-visible:active { + outline: 2px solid LinkText; + } +} .gl-toggle-wrapper { --tw-leading: 1rem; line-height: 1rem; diff --git a/packages/styles/src/components.css b/packages/styles/src/components.css index 99051f9..c9aef38 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 "./tabs/bootstrap-tabs.css"; @import "./tables/bootstrap-tables.css"; @import "../../ui/src/base/accordion/accordion.css"; @import "../../ui/src/base/alert/alert.css"; @@ -29,5 +30,6 @@ @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"; +@import "../../ui/src/base/tabs/tabs.css"; @import "../../ui/src/base/toggle/toggle.css"; @import "../../ui/src/base/tooltip/tooltip.css"; diff --git a/packages/styles/src/tabs/bootstrap-tabs.css b/packages/styles/src/tabs/bootstrap-tabs.css new file mode 100644 index 0000000..88ce093 --- /dev/null +++ b/packages/styles/src/tabs/bootstrap-tabs.css @@ -0,0 +1,33 @@ +/*! + * Bootstrap navigation compatibility required by GlTabs. + * + * Ported from Bootstrap v4.6's _nav.scss as vendored by GitLab UI. Rules are + * scoped to the React component marker so unrelated consumer navigation is not + * affected. + */ + +.gl-tabs { + .nav { + display: flex; + flex-wrap: wrap; + padding-left: 0; + margin-bottom: 0; + list-style: none; + } + + .nav-link { + display: block; + text-decoration: none; + + &:hover, + &:focus-visible { + text-decoration: none; + } + } + + .nav-justified > .nav-item { + flex-basis: 0; + flex-grow: 1; + text-align: center; + } +} diff --git a/packages/ui/src/base/tabs/tab.tsx b/packages/ui/src/base/tabs/tab.tsx new file mode 100644 index 0000000..cc17b5d --- /dev/null +++ b/packages/ui/src/base/tabs/tab.tsx @@ -0,0 +1,59 @@ +/** + * Ported from GitLab UI: + * packages/gitlab-ui/src/components/base/tabs/tab/tab.vue + * + * GlTab is a declarative child of GlTabs. GlTabs reads its props to render the + * corresponding Base UI tab trigger and panel in their required DOM regions. + */ + +import type { + ButtonHTMLAttributes, + HTMLAttributes, + ReactNode, +} from "react"; +import type { ClassValue } from "cn"; + +export type GlTabButtonProps = Omit< + ButtonHTMLAttributes, + "children" | "className" | "disabled" | "role" | "type" +>; + +export type GlTabPanelProps = Omit< + HTMLAttributes, + "children" | "className" | "hidden" | "role" +>; + +export type GlTabProps = { + /** The content rendered in this tab's panel. */ + children?: ReactNode; + /** Prevents the tab from being selected. */ + disabled?: boolean; + /** Mounts the panel only while it is active. Overrides GlTabs.lazy. */ + lazy?: boolean; + /** Additional attributes applied to the tab panel. */ + panelProps?: GlTabPanelProps; + /** Additional clsx-compatible classes applied to the tab panel. */ + panelClassName?: ClassValue; + /** Query string value used when GlTabs synchronizes selection with the URL. */ + queryParamValue?: string | null; + /** Numeric badge displayed after the tab title. Negative values are hidden. */ + tabCount?: number | null; + /** Screen-reader context for tabCount, for example "15 open issues". */ + tabCountSrText?: string | null; + /** Additional attributes applied to the tab button. */ + tabProps?: GlTabButtonProps; + /** The accessible and visible tab title. */ + title: ReactNode; + /** Additional clsx-compatible classes applied to the tab list item. */ + titleItemClassName?: ClassValue; + /** Additional clsx-compatible classes applied to the tab button. */ + titleClassName?: ClassValue; +}; + +/** + * Declarative tab descriptor. It is consumed by GlTabs and does not render on + * its own. + */ +export default function GlTab(_: GlTabProps) { + return null; +} diff --git a/packages/ui/src/base/tabs/tabs.css b/packages/ui/src/base/tabs/tabs.css new file mode 100644 index 0000000..362baae --- /dev/null +++ b/packages/ui/src/base/tabs/tabs.css @@ -0,0 +1,193 @@ +/** + * Ported from GitLab UI: + * packages/gitlab-ui/src/components/base/tabs/tabs/tabs.scss + * + * GlTabsBefore, GlTabsAfter, and the single-DOM GlTabActions layout are React + * adaptations of the upstream toolbar slots and action regions. + */ + +.gl-tabs-wrapper { + @apply gl-border-b gl-relative gl-flex gl-flex-wrap; +} + +.gl-tabs-nav { + @apply gl-grow; + min-width: 0; +} + +.gl-tab-nav-item { + position: relative; + @apply gl-px-4; + @apply gl-py-5; + @apply gl-leading-normal; + @apply gl-text-base; + @apply gl-flex; + @apply gl-justify-center; + @apply gl-border; + @apply gl-h-full; + @apply gl-action-neutral-colors; + font-family: inherit; + text-align: inherit; + transition: + box-shadow 0.2s cubic-bezier(0.22, 0.61, 0.36, 1), + background-color 0.2s cubic-bezier(0.22, 0.61, 0.36, 1), + border-radius 0.1s cubic-bezier(0.22, 0.61, 0.36, 1); + + &::before { + position: absolute; + right: -1px; + bottom: -1px; + left: -1px; + border-bottom: 2px solid transparent; + content: ""; + translate: 0 2px; + transition: + border-bottom 0.2s cubic-bezier(0.22, 0.61, 0.36, 1), + translate 0.2s cubic-bezier(0.22, 0.61, 0.36, 1); + } + + @media (prefers-reduced-motion: reduce) { + &, + &::before { + transition-duration: 0.01ms; + } + } + + &:hover:not(.gl-tab-nav-item-active)::before { + border-bottom-color: var(--gl-border-color-strong); + translate: 0; + } + + &:focus-visible { + @apply gl-rounded-default; + } + + &.disabled { + @apply gl-pointer-events-auto; + @apply gl-cursor-not-allowed; + color: var(--gl-action-disabled-foreground-color); + + &:hover { + @apply gl-shadow-none; + } + } + + > .gl-tab-counter-badge { + font-weight: inherit; + @apply gl-ml-2; + } +} + +.gl-tab-content { + @apply gl-py-3; + @apply gl-text-base; + @apply gl-leading-normal; + @apply gl-text-default; +} + +.gl-tab-nav-item-active { + @apply gl-font-bold; + z-index: 1; + + &:active, + &:focus-visible, + &:focus-visible:active { + box-shadow: + inset 0 0 0 1px var(--gl-action-neutral-border-color-active), + 0 0 0 1px var(--gl-focus-ring-inner-color), + 0 0 0 3px var(--gl-focus-ring-outer-color); + outline: none; + + .gl-scrollable-tabs-nav & { + box-shadow: + inset 0 0 0 2px var(--gl-focus-ring-outer-color), + inset 0 0 0 3px var(--gl-focus-ring-inner-color), + inset 0 0 0 1px var(--gl-focus-ring-inner-color); + + &::before { + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 0 0 1px 1px; + } + } + + &::before { + right: 0; + left: 0; + border-radius: 0 0 2px 2px; + } + } + + &::before { + border-bottom-color: var(--gl-tab-selected-indicator-color-default); + translate: 0; + } +} + +.gl-tabs-before, +.gl-tabs-after { + @apply gl-flex gl-shrink-0 gl-items-center gl-gap-3; +} + +.gl-tabs-after { + @apply gl-justify-end; +} + +.gl-tab-actions { + order: -1; + @apply gl-mt-3 gl-mb-3 gl-flex gl-w-full gl-grow-0 gl-flex-col gl-gap-3; +} + +@media (min-width: 768px) { + .gl-tab-actions { + order: 0; + @apply gl-m-0 gl-w-auto gl-flex-row gl-items-center gl-justify-end; + } +} + +.gl-tabs-fade { + @apply gl-absolute gl-bottom-0 gl-top-0 gl-z-2; + @apply gl-w-8; + @apply gl-p-2; + @apply gl-flex; +} + +.gl-tabs-fade-left { + @apply gl-left-0; + background-image: linear-gradient( + to left, + rgba(255, 255, 255, 0), + var(--gl-background-color-default) 33% + ); +} + +.gl-tabs-fade-right { + @apply gl-right-0 gl-justify-end; + background-image: linear-gradient( + to right, + rgba(255, 255, 255, 0), + var(--gl-background-color-default) 33% + ); +} + +.gl-tabs-fade-icon-button { + @apply gl-border; + @apply gl-action-neutral-colors; + @apply gl-p-0; + @apply gl-h-full; +} + +.gl-tabs .gl-scrollable-tabs-nav { + @apply gl-overflow-hidden; + position: relative; + @apply gl-flex-nowrap; +} + +@media (forced-colors: active) { + .gl-tab-nav-item-active:active, + .gl-tab-nav-item-active:focus-visible, + .gl-tab-nav-item-active:focus-visible:active { + outline: 2px solid LinkText; + } +} diff --git a/packages/ui/src/base/tabs/tabs.stories.tsx b/packages/ui/src/base/tabs/tabs.stories.tsx new file mode 100644 index 0000000..bf8b762 --- /dev/null +++ b/packages/ui/src/base/tabs/tabs.stories.tsx @@ -0,0 +1,247 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, fn, userEvent, waitFor } from "storybook/test"; +import GlButton from "../button/button"; +import GlTab from "./tab"; +import GlTabs, { + GlScrollableTabs, + GlTabActions, + GlTabsAfter, + GlTabsBefore, + type GlTabsProps, +} from "./tabs"; + +const meta = { + title: "UI/Base/Tabs", + component: GlTabs, + args: { + defaultValue: 0, + justified: false, + lazy: false, + onValueChange: fn(), + queryParamName: "tab", + syncActiveTabWithQueryParams: false, + }, + argTypes: { + children: { control: false }, + contentClassName: { control: false }, + empty: { control: false }, + navClassName: { control: false }, + value: { control: false }, + }, + parameters: { + docs: { + description: { + component: + "See the [Pajamas tabs documentation](https://design.gitlab.com/components/tabs) for usage and implementation guidance.", + }, + }, + }, + render: (args) => ( + + Overview panel + Activity panel + Members panel + + ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ args, canvas }) => { + const user = userEvent.setup(); + const [overview, activity, members] = canvas.getAllByRole("tab"); + + await expect(overview).toHaveAttribute("aria-selected", "true"); + await expect(canvas.getByRole("tabpanel")).toHaveTextContent("Overview panel"); + + await user.pointer({ keys: "[MouseLeft>]", target: activity }); + await expect(activity).toHaveFocus(); + await expect(overview).toHaveAttribute("aria-selected", "true"); + await expect(activity).toHaveAttribute("aria-selected", "false"); + + await user.pointer({ keys: "[/MouseLeft]", target: activity }); + await expect(args.onValueChange).toHaveBeenLastCalledWith(1); + await expect(activity).toHaveAttribute("aria-selected", "true"); + await expect(canvas.getByRole("tabpanel")).toHaveTextContent("Activity panel"); + + activity.focus(); + await user.keyboard("{ArrowRight}"); + await expect(members).toHaveFocus(); + await expect(members).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("{ArrowRight}"); + await expect(members).toHaveFocus(); + }, +}; + +function ControlledTabsExample(props: GlTabsProps) { + const [value, setValue] = useState(1); + return ( + <> + setValue(0)}>Select overview + { + setValue(nextValue); + props.onValueChange?.(nextValue); + }} + value={value}> + Overview panel + Activity panel + + + ); +} + +export const Controlled: Story = { + render: (args) => , + play: async ({ canvas }) => { + const overview = canvas.getByRole("tab", { name: "Overview" }); + const activity = canvas.getByRole("tab", { name: "Activity" }); + + await expect(activity).toHaveAttribute("aria-selected", "true"); + await userEvent.click(canvas.getByRole("button", { name: "Select overview" })); + await expect(overview).toHaveAttribute("aria-selected", "true"); + }, +}; + +export const Justified: Story = { + args: { justified: true }, +}; + +export const CountsAndDisabled: Story = { + render: (args) => ( + + + All issues + + + Open issues + + Closed issues + + ), + play: async ({ canvas }) => { + const all = canvas.getByRole("tab", { name: /All 42 issues/ }); + const open = canvas.getByRole("tab", { name: /Open 15 open issues/ }); + const closed = canvas.getByRole("tab", { name: "Closed" }); + + await expect(all).toBeInTheDocument(); + await expect(closed).toBeDisabled(); + + all.focus(); + await userEvent.keyboard("{End}"); + await expect(open).toHaveFocus(); + await expect(open).toHaveAttribute("aria-selected", "true"); + + await userEvent.keyboard("{ArrowRight}"); + await expect(open).toHaveFocus(); + }, +}; + +export const Empty: Story = { + args: { children: null, empty: "No tabs are available." }, + render: (args) => , + play: async ({ canvas }) => { + await expect(canvas.getByText("No tabs are available.")).toBeInTheDocument(); + await expect(canvas.queryByRole("tab")).not.toBeInTheDocument(); + }, +}; + +export const RegionsAndActions: Story = { + render: (args) => ( + + + + + All issues + Open issues + + Export + + + Cancel + Save changes + + + ), + play: async ({ canvas }) => { + const tablist = canvas.getByRole("tablist"); + const before = canvas.getByRole("button", { name: "Refresh issues" }); + const after = canvas.getByRole("button", { name: "Export" }); + + await expect(tablist.contains(before)).toBe(false); + await expect(tablist.contains(after)).toBe(false); + await expect(canvas.getAllByRole("button", { name: "Save changes" })).toHaveLength(1); + await expect(canvas.getByRole("toolbar", { name: "Issue actions" })).toBeInTheDocument(); + }, +}; + +function QuerySyncTabs(props: GlTabsProps) { + const [value, setValue] = useState(0); + useState(() => { + const url = new URL(window.location.href); + url.searchParams.set("gl-tabs-story-view", "open"); + window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); + }); + + return ( + { + setValue(nextValue); + props.onValueChange?.(nextValue); + }} + queryParamName="gl-tabs-story-view" + syncActiveTabWithQueryParams + value={value}> + All issues + Open issues + + ); +} + +export const QuerySynchronization: Story = { + render: (args) => , + play: async ({ canvas }) => { + const all = canvas.getByRole("tab", { name: "All" }); + const open = canvas.getByRole("tab", { name: "Open" }); + + await waitFor(() => expect(open).toHaveAttribute("aria-selected", "true")); + const url = new URL(window.location.href); + url.searchParams.set("gl-tabs-story-view", "all"); + window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); + window.dispatchEvent(new PopStateEvent("popstate")); + await waitFor(() => expect(all).toHaveAttribute("aria-selected", "true")); + }, +}; + +export const LazyPanels: Story = { + args: { lazy: true }, +}; + +export const Scrollable: Story = { + render: () => ( +
+ + {Array.from({ length: 30 }, (_, index) => ( + + Panel {index + 1} + + ))} + +
+ ), + play: async ({ canvas }) => { + const tablist = canvas.getByRole("tablist"); + const scrollRight = await canvas.findByRole("button", { name: "Scroll right" }); + + await waitFor(() => expect(tablist.scrollWidth).toBeGreaterThan(tablist.clientWidth)); + await userEvent.click(scrollRight); + await waitFor(() => expect(tablist.scrollLeft).toBeGreaterThan(0)); + await expect(canvas.getByRole("button", { name: "Scroll left" })).toBeInTheDocument(); + }, +}; diff --git a/packages/ui/src/base/tabs/tabs.test.tsx b/packages/ui/src/base/tabs/tabs.test.tsx new file mode 100644 index 0000000..00a5075 --- /dev/null +++ b/packages/ui/src/base/tabs/tabs.test.tsx @@ -0,0 +1,247 @@ +import { Fragment, type ComponentProps, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import GlButton from "../button/button"; +import GlTab from "./tab"; +import GlTabs, { + GlScrollableTabs, + GlTabActions, + GlTabsAfter, + GlTabsBefore, +} from "./tabs"; + +vi.mock("@gitlab/svgs/dist/icons.svg", () => ({ default: "/path/to/icons.svg" })); + +const defaultTabs = ( + <> + First panel + Second panel + +); + +function renderTabs( + props: Partial> = {}, + children: ReactNode = defaultTabs, +) { + return renderToStaticMarkup({children}); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("GlTabs", () => { + it("renders the GitLab tab structure and forwards root attributes", () => { + const markup = renderTabs({ className: "custom-root", title: "Example tabs" }); + + expect(markup).toMatch(/^]*class="tabs gl-tabs custom-root"/); + expect(markup).toMatch(/]*class="gl-tabs-wrapper"/); + expect(markup).toMatch(/]*role="tablist"/); + expect(markup).toMatch(/]*class="nav gl-tabs-nav"/); + expect(markup).toContain("title=\"Example tabs\""); + expect(markup.match(/role="tab"/g)).toHaveLength(2); + expect(markup.match(/role="tabpanel"/g)).toHaveLength(2); + expect(markup).toContain("gl-tab-nav-item-active"); + expect(markup).toContain("aria-selected=\"true\""); + expect(markup).toContain("aria-setsize=\"2\""); + expect(markup).toContain("aria-posinset=\"1\""); + }); + + it("applies navigation, content, title item, title, and panel classes", () => { + const markup = renderTabs( + { + contentClassName: ["custom-content", { "conditional-content": true }], + navClassName: ["custom-nav", { "conditional-nav": true }], + }, + + First panel + , + ); + + for(const className of [ + "custom-content", + "conditional-content", + "custom-nav", + "conditional-nav", + "custom-panel", + "conditional-panel", + "custom-title", + "conditional-title", + "custom-item", + "conditional-item", + ]) { + expect(markup).toContain(className); + } + }); + + it("renders justified tabs", () => { + expect(renderTabs({ justified: true })).toContain("nav-justified"); + }); + + it("forwards restricted attributes to the tab and panel", () => { + const markup = renderTabs({}, ( + + First panel + + )); + + expect(markup).toMatch(/]*id="first-tab"[^>]*title="Tab title"/); + expect(markup).toMatch(/]*id="first-panel"[^>]*title="Panel title"/); + }); + + describe("tab counts", () => { + it("renders zero with hidden badge text and visible screen-reader context", () => { + const markup = renderTabs({}, ( + + Issue panel + + )); + + expect(markup).toContain("gl-tab-counter-badge"); + expect(markup).toContain("aria-hidden=\"true\""); + expect(markup).toContain("No issues"); + }); + + it.each([-1, null, undefined])("does not render a badge for %s", (tabCount) => { + expect(renderTabs({}, ( + Issue panel + ))).not.toContain("gl-tab-counter-badge"); + }); + + it("warns when a displayed count has no screen-reader context", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + renderTabs({}, Issue panel); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining("[GlTab]")); + }); + }); + + it("keeps inactive panels mounted unless lazy is enabled", () => { + expect(renderTabs()).toContain("Second panel"); + expect(renderTabs({ lazy: true })).not.toContain("Second panel"); + }); + + it("allows a tab to override root lazy behavior", () => { + const markup = renderTabs({ lazy: true }, ( + <> + First panel + Second panel + + )); + + expect(markup).toContain("Second panel"); + }); + + it("renders empty content when there are no tabs", () => { + const markup = renderTabs({ empty: "No tabs available" }, null); + + expect(markup).toContain("
No tabs available
"); + expect(markup).not.toContain("role=\"tab\""); + }); + + it("renders regions outside the tablist in fixed order and actions only once", () => { + const markup = renderTabs({}, ( + <> + After + First panel + Save + Before + + )); + const beforeIndex = markup.indexOf("gl-tabs-before"); + const tablistIndex = markup.indexOf("role=\"tablist\""); + const afterIndex = markup.indexOf("gl-tabs-after"); + const actionsIndex = markup.indexOf("gl-tab-actions"); + + expect(beforeIndex).toBeLessThan(tablistIndex); + expect(tablistIndex).toBeLessThan(afterIndex); + expect(afterIndex).toBeLessThan(actionsIndex); + expect(markup.match(/>Save", tablistIndex); + expect(beforeIndex).toBeLessThan(tablistIndex); + expect(afterIndex).toBeGreaterThan(tablistEndIndex); + }); + + it("supports arrays, fragments, and conditional children", () => { + const showSecond = true; + const markup = renderTabs({}, [ + First panel, + + {showSecond ? Second panel : null} + , + ]); + + expect(markup.match(/role="tab"/g)).toHaveLength(2); + }); + + it.each([ + ["GlTabsBefore", <>], + ["GlTabsAfter", <>], + ["GlTabActions", <>], + ])("rejects duplicate %s regions", (regionName, children) => { + expect(() => renderTabs({}, children)).toThrow(`at most one ${regionName}`); + }); + + it("rejects opaque wrapper children", () => { + function WrappedTab() { + return Wrapped panel; + } + + expect(() => renderTabs({}, )).toThrow("only accepts GlTab"); + }); +}); + +describe("GlScrollableTabs", () => { + it("adds the scrollable list and hidden labeled controls", () => { + const markup = renderToStaticMarkup( + + {defaultTabs} + , + ); + + expect(markup).toContain("gl-scrollable-tabs-nav"); + expect(markup).toMatch(/