diff --git a/packages/styles/dist/gitlab-ui.css b/packages/styles/dist/gitlab-ui.css index 2ffb54d..971f40b 100644 --- a/packages/styles/dist/gitlab-ui.css +++ b/packages/styles/dist/gitlab-ui.css @@ -9012,6 +9012,9 @@ .gl-border-b-dropdown-divider { border-bottom-color: var(--gl-dropdown-divider-color); } + .gl-bg-default { + background-color: var(--gl-background-color-default); + } .gl-bg-transparent { background-color: transparent; } @@ -9057,6 +9060,9 @@ .gl-p-2 { padding: var(--gl-spacing-scale-2); } + .gl-p-4 { + padding: var(--gl-spacing-scale-4); + } .gl-p-5 { padding: var(--gl-spacing-scale-5); } diff --git a/packages/ui/src/base/listbox/listbox.tsx b/packages/ui/src/base/listbox/listbox.tsx index 212d641..142bac2 100644 --- a/packages/ui/src/base/listbox/listbox.tsx +++ b/packages/ui/src/base/listbox/listbox.tsx @@ -523,6 +523,7 @@ export const GlListboxTrigger = forwardRef( children, { block, + buttonTextClasses: block ? "gl-w-full" : undefined, category, disabled: disabled || context.disabled, icon, diff --git a/packages/ui/src/base/sorting/index.ts b/packages/ui/src/base/sorting/index.ts new file mode 100644 index 0000000..8dbd0f7 --- /dev/null +++ b/packages/ui/src/base/sorting/index.ts @@ -0,0 +1,14 @@ +export { default as GlSorting } from "./sorting.js"; + +export type { + GlSortingProps, +} from "./sorting.js"; + +export { useSorting } from "./use-sorting.js"; + +export type { + GlSortingOption, + GlSortingSortBy, + UseSortingOptions, + UseSortingResult, +} from "./use-sorting.js"; diff --git a/packages/ui/src/base/sorting/sorting.stories.tsx b/packages/ui/src/base/sorting/sorting.stories.tsx new file mode 100644 index 0000000..bdf395b --- /dev/null +++ b/packages/ui/src/base/sorting/sorting.stories.tsx @@ -0,0 +1,240 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; +import { GlSorting, useSorting, type GlSortingOption, type GlSortingProps, type GlSortingSortBy } from "./index"; + +const sortOptions: readonly GlSortingOption[] = [ + { value: "created", text: "Created date" }, + { value: "updated", text: "Updated date" }, + { value: "relevant", text: "Most relevant", directionToggleDisabled: true }, +]; + +const meta = { + title: "UI/Base/Sorting", + component: GlSorting, + args: { + sortOptions, + defaultSortBy: "created", + onSortByChange: fn(), + onSortDirectionChange: fn(), + }, + argTypes: { + onSortByChange: { control: false }, + onSortDirectionChange: { control: false }, + }, + parameters: { + docs: { + description: { + component: + "Single-component React port of [Pajamas Sorting](https://design.gitlab.com/components/sorting/). " + + "Use sortBy/isAscending with their callbacks, or defaultSortBy/defaultIsAscending for local state. " + + "useSorting returns the current field and direction, setters, toggleSortDirection, selectedSortOption, " + + "directionToggleDisabled, and sortingProps for . " + + "Data sorting, fetching, persistence, pagination resets, and result announcements belong to the caller. " + + "Pajamas recommends at most ten options.", + }, + }, + }, + decorators: [(Story) =>
], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ args, canvas }) => { + const trigger = canvas.getByRole("button", { name: "Sort by: Created date" }); + + await expect(trigger).toHaveClass("btn-block"); + + const triggerRect = trigger.getBoundingClientRect(); + const triggerStyle = getComputedStyle(trigger); + const textRect = trigger.querySelector(".gl-new-dropdown-button-text")!.getBoundingClientRect(); + const caretRect = trigger.querySelector(".gl-new-dropdown-chevron")!.getBoundingClientRect(); + + await expect(Math.abs(textRect.left - triggerRect.left - parseFloat(triggerStyle.paddingLeft) - 1)) + .toBeLessThanOrEqual(1); + await expect(Math.abs(triggerRect.right - caretRect.right - parseFloat(triggerStyle.paddingRight) - 1)) + .toBeLessThanOrEqual(1); + + trigger.focus(); + await userEvent.keyboard("{ArrowDown}"); + + const listbox = await canvas.findByRole("listbox"); + const panel = listbox.closest(".gl-new-dropdown-panel")!; + + await waitFor(() => expect(Math.abs(panel.getBoundingClientRect().right - triggerRect.right)) + .toBeLessThanOrEqual(1)); + + const created = within(listbox).getByRole("option", { name: "Created date" }); + + await expect(created).toHaveAttribute("aria-selected", "true"); + await waitFor(() => expect(created).toHaveFocus()); + + await userEvent.keyboard("{ArrowDown}{Enter}"); + + await waitFor(() => expect(trigger).toHaveFocus()); + await expect(trigger).toHaveAccessibleName("Sort by: Updated date"); + await expect(args.onSortByChange).toHaveBeenCalledTimes(1); + await expect(args.onSortByChange).toHaveBeenLastCalledWith("updated"); + + await userEvent.keyboard("{ArrowDown}"); + await canvas.findByRole("listbox"); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveFocus()); + + await userEvent.click(canvas.getByRole("button", { name: "Sort direction: descending" })); + const direction = canvas.getByRole("button", { name: "Sort direction: ascending" }); + + await expect(direction.querySelector("[data-testid=\"sort-lowest-icon\"]")).not.toBeNull(); + await expect(args.onSortDirectionChange).toHaveBeenLastCalledWith(true); + + await userEvent.keyboard(" "); + await expect(args.onSortDirectionChange).toHaveBeenLastCalledWith(false); + }, +}; + +function ControlledSorting(args: GlSortingProps) { + const [sortBy, setSortBy] = useState(args.sortBy ?? "created"); + const [isAscending, setIsAscending] = useState(args.isAscending ?? false); + return ( + { setSortBy(value); args.onSortByChange?.(value); }} + onSortDirectionChange={(value) => { setIsAscending(value); args.onSortDirectionChange?.(value); }} /> + ); +} + +export const Controlled: Story = { + render: (args) => , + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("button", { name: "Sort by: Created date" })); + await userEvent.click(await canvas.findByRole("option", { name: "Updated date" })); + + await expect(canvas.getByRole("button", { name: "Sort by: Updated date" })).toBeVisible(); + + await userEvent.click(canvas.getByRole("button", { name: "Sort direction: descending" })); + await expect(canvas.getByRole("button", { name: "Sort direction: ascending" })).toBeVisible(); + }, +}; + +function HookSorting(args: GlSortingProps) { + const { sortBy, isAscending, sortingProps } = useSorting(args); + return ( + <> + + {sortBy}: {isAscending ? "ascending" : "descending"} + + ); +} + +export const WithHook: Story = { + parameters: { + docs: { + source: { + code: `import { GlSorting, useSorting } from "gitlab-ui-react/sorting"; + +function SortingExample() { + const { sortBy, isAscending, sortingProps } = useSorting({ + sortOptions: [ + { value: "created", text: "Created date" }, + { value: "updated", text: "Updated date" }, + ], + defaultSortBy: "created", + defaultIsAscending: false, + }); + + // Use sortBy and isAscending to sort or fetch the caller's data. + return ; +}`, + }, + }, + }, + render: (args) => , + play: async ({ canvas }) => { + await expect(canvas.getByLabelText("Current sorting")).toHaveTextContent("created: descending"); + + await userEvent.click(canvas.getByRole("button", { name: "Sort by: Created date" })); + await userEvent.click(await canvas.findByRole("option", { name: "Updated date" })); + await userEvent.click(canvas.getByRole("button", { name: "Sort direction: descending" })); + + await expect(canvas.getByLabelText("Current sorting")).toHaveTextContent("updated: ascending"); + }, +}; + +export const DisabledDirectionToggle: Story = { + args: { defaultSortBy: "relevant" }, + play: async ({ args, canvas }) => { + const direction = canvas.getByRole("button", { name: "Sort direction unavailable for Most relevant" }); + + await expect(direction).toHaveAttribute("aria-disabled", "true"); + await expect(direction).not.toHaveAttribute("disabled"); + + canvas.getByRole("button", { name: "Sort by: Most relevant" }).focus(); + await userEvent.tab(); + + await expect(direction).toHaveFocus(); + + const tooltip = await within(document.body).findByRole("tooltip"); + + await waitFor(() => expect(tooltip).toBeVisible()); + await expect(tooltip).toHaveTextContent("Sort direction unavailable for Most relevant"); + + await userEvent.keyboard("{Enter} "); + await userEvent.click(direction); + + await expect(args.onSortDirectionChange).not.toHaveBeenCalled(); + + await userEvent.keyboard("{Escape}"); + await userEvent.click(canvas.getByRole("button", { name: "Sort by: Most relevant" })); + await userEvent.click(await canvas.findByRole("option", { name: "Created date" })); + + const enabledDirection = canvas.getByRole("button", { name: "Sort direction: descending" }); + + await expect(enabledDirection).toBe(direction); + await expect(enabledDirection).not.toHaveAttribute("aria-disabled", "true"); + + await userEvent.click(enabledDirection); + + await expect(args.onSortDirectionChange).toHaveBeenCalledTimes(1); + await expect(args.onSortDirectionChange).toHaveBeenLastCalledWith(true); + }, +}; + +export const StateMatrix: Story = { + render: () => ( +
+ {[false, true].map((dark) => ( +
+

{dark ? "Dark" : "Light"}

+
+ + + + + +
+
+ ))} +
+ ), + play: async ({ canvas }) => { + const groups = canvas.getAllByRole("group"); + + await expect(groups).toHaveLength(10); + + for(const group of groups) { + await expect(getComputedStyle(group).display).toBe("flex"); + + const buttons = within(group).getAllByRole("button"); + + await expect(getComputedStyle(buttons[0]).borderTopRightRadius).toBe("0px"); + await expect(getComputedStyle(buttons[1]).borderTopLeftRadius).toBe("0px"); + } + + const light = within(groups[0]).getByRole("button", { name: "Sort by: Created date" }); + const dark = within(groups[5]).getByRole("button", { name: "Sort by: Created date" }); + + await expect(getComputedStyle(light).backgroundColor).not.toBe(getComputedStyle(dark).backgroundColor); + }, +}; diff --git a/packages/ui/src/base/sorting/sorting.test.tsx b/packages/ui/src/base/sorting/sorting.test.tsx new file mode 100644 index 0000000..c90f0ce --- /dev/null +++ b/packages/ui/src/base/sorting/sorting.test.tsx @@ -0,0 +1,345 @@ +// @vitest-environment jsdom + +import { StrictMode, createRef, type PropsWithChildren } from "react"; +import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterAll, afterEach, beforeAll, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { + GlSorting, + useSorting, + type GlSortingOption, + type GlSortingProps, + type UseSortingOptions, +} from "./index"; + +const sortOptions: readonly GlSortingOption[] = [ + { value: "created", text: "Created date" }, + { value: "updated", text: "Updated date" }, + { value: "relevant", text: "Most relevant", directionToggleDisabled: true }, +]; + +const originalScrollIntoView = Object.getOwnPropertyDescriptor(Element.prototype, "scrollIntoView"); +beforeAll(() => { + // JSDOM has no scrolling layout; listbox focus behavior is exercised below. + Object.defineProperty(Element.prototype, "scrollIntoView", { configurable: true, value: vi.fn() }); +}); +afterAll(() => { + if(originalScrollIntoView) { + Object.defineProperty(Element.prototype, "scrollIntoView", originalScrollIntoView); + } else { + Reflect.deleteProperty(Element.prototype, "scrollIntoView"); + } +}); +afterEach(cleanup); + +describe("GlSorting", () => { + it("renders a single grouped control with descending and block defaults", () => { + render(); + const group = screen.getByRole("group"); + const trigger = screen.getByRole("button", { name: "Sort by:" }); + const direction = screen.getByRole("button", { name: "Sort direction: descending" }); + + expect(group.className).toContain("gl-sorting gl-flex"); + expect(trigger.className).toContain("btn-block"); + expect(trigger.querySelector(".gl-button-text")?.className).toContain("gl-w-full"); + expect(trigger.getAttribute("aria-haspopup")).toBe("listbox"); + expect(direction.querySelector("[data-testid=\"sort-highest-icon\"]")).not.toBeNull(); + expect(screen.getAllByRole("button")).toHaveLength(2); + expectTypeOf<"children" extends keyof GlSortingProps ? true : false>().toEqualTypeOf(); + }); + + it.each([undefined, null, ""])("uses selected text when text is %s", (text) => { + render(); + expect(screen.getByRole("button", { name: "Sort by: Created date" })).toBeTruthy(); + }); + + it("updates custom text and the accessible prefix independently of the field", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: "Sort by: Sorting options" })).toBeTruthy(); + rerender(); + expect(screen.getByRole("button", { name: "Order by: Updated date" })).toBeTruthy(); + }); + + it.each([ + [0, "Zero"], + ["", "Empty key"], + ] as const)("preserves the %s field value", async (value, text) => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: `Sort by: ${text}` })); + expect((await screen.findByRole("option", { name: text })).getAttribute("aria-selected")) + .toBe("true"); + }); + + it("supports option selection and suppresses repeated selections", async () => { + const user = userEvent.setup(); + const onSortByChange = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Sort by: Created date" })); + const selected = await screen.findByRole("option", { name: "Created date" }); + expect(selected.getAttribute("aria-selected")).toBe("true"); + await user.click(selected); + expect(onSortByChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Sort by: Created date" })); + await user.click(await screen.findByRole("option", { name: "Updated date" })); + expect(onSortByChange).toHaveBeenCalledExactlyOnceWith("updated"); + expect(screen.getByRole("button", { name: "Sort by: Updated date" })).toBeTruthy(); + }); + + it("requests controlled changes and waits for new props", async () => { + const user = userEvent.setup(); + const onSortByChange = vi.fn(); + const onSortDirectionChange = vi.fn(); + const { rerender } = render( + , + ); + await user.click(screen.getByRole("button", { name: "Sort by: Created date" })); + await user.click(await screen.findByRole("option", { name: "Updated date" })); + await user.click(screen.getByRole("button", { name: "Sort direction: descending" })); + expect(onSortByChange).toHaveBeenCalledExactlyOnceWith("updated"); + expect(onSortDirectionChange).toHaveBeenCalledExactlyOnceWith(true); + expect(screen.getByRole("button", { name: "Sort by: Created date" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sort direction: descending" })).toBeTruthy(); + + rerender(); + expect(screen.getByRole("button", { name: "Sort by: Updated date" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Sort direction: ascending" }) + .querySelector("[data-testid=\"sort-lowest-icon\"]")).not.toBeNull(); + }); + + it("toggles uncontrolled direction with pointer and keyboard", async () => { + const user = userEvent.setup(); + const onSortDirectionChange = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "Sort direction: descending" })); + expect(onSortDirectionChange).toHaveBeenLastCalledWith(true); + await user.keyboard(" "); + expect(onSortDirectionChange).toHaveBeenLastCalledWith(false); + expect(onSortDirectionChange).toHaveBeenCalledTimes(2); + }); + + it("inherits keyboard navigation, selection, Escape and focus restoration", async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole("button", { name: "Sort by: Created date" }); + trigger.focus(); + await user.keyboard("{ArrowDown}"); + const created = await screen.findByRole("option", { name: "Created date" }); + await waitFor(() => expect(document.activeElement).toBe(created)); + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(screen.getByRole("option", { name: "Updated date" })); + await user.keyboard("{Enter}"); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(trigger.textContent).toBe("Updated date"); + await user.keyboard("{ArrowDown}"); + await screen.findByRole("listbox"); + await user.keyboard("{Escape}"); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(trigger.getAttribute("aria-expanded")).not.toBe("true"); + }); + + it("keeps a disabled direction button focusable without activating it", async () => { + const user = userEvent.setup(); + const onSortDirectionChange = vi.fn(); + render(); + const direction = screen.getByRole("button", { name: "Sort direction unavailable for Most relevant" }); + expect(direction.getAttribute("aria-disabled")).toBe("true"); + expect(direction.hasAttribute("disabled")).toBe(false); + screen.getByRole("button", { name: "Sort by: Most relevant" }).focus(); + await user.tab(); + expect(document.activeElement).toBe(direction); + await user.keyboard("{Enter} "); + await user.click(direction); + expect(onSortDirectionChange).not.toHaveBeenCalled(); + }); + + it("uses generic and overridden disabled names and restores directional behavior", async () => { + const user = userEvent.setup(); + const { rerender } = render(); + expect(screen.getByRole("button", { name: "Sort direction unavailable" })).toBeTruthy(); + rerender(); + expect(screen.getByRole("button", { name: "Custom reason" }).getAttribute("aria-disabled")).toBe("true"); + rerender(); + const direction = screen.getByRole("button", { name: "Sort direction: descending" }); + expect(direction.getAttribute("aria-disabled")).not.toBe("true"); + await user.click(direction); + expect(screen.getByRole("button", { name: "Sort direction: ascending" })).toBeTruthy(); + }); + + it("forwards classes, attributes, block and the root ref", () => { + const ref = createRef(); + render(); + expect(ref.current).toBe(screen.getByRole("group")); + expect(ref.current?.title).toBe("Sorting controls"); + expect(ref.current?.className).toContain("root-class"); + expect(ref.current?.querySelector(".gl-listbox")?.className).toContain("dropdown-class"); + const trigger = screen.getByRole("button", { name: "Sort by:" }); + expect(trigger.className).toContain("trigger-class"); + expect(trigger.className).not.toContain("btn-block"); + expect(trigger.querySelector(".gl-button-text")?.className).not.toContain("gl-w-full"); + expect(screen.getByRole("button", { name: "Sort direction: descending" }).className) + .toContain("sorting-direction-button direction-class"); + }); + + it("associates unique prefix and text IDs across instances", () => { + render(<>); + const ids = ["First", "Second"].flatMap((text) => { + const trigger = screen.getByRole("button", { name: `Sort by: ${text}` }); + const references = trigger.getAttribute("aria-labelledby")!.split(" "); + expect(references).toHaveLength(2); + expect(document.getElementById(references[0])?.textContent).toBe("Sort by:"); + expect(document.getElementById(references[1])?.textContent).toBe(text); + return references; + }); + expect(new Set(ids).size).toBe(4); + }); +}); + +describe("useSorting", () => { + it("defaults to no field and descending without options", () => { + const { result } = renderHook(() => useSorting()); + expect(result.current.sortBy).toBeNull(); + expect(result.current.isAscending).toBe(false); + expect(result.current.selectedSortOption).toBeUndefined(); + expect(result.current.directionToggleDisabled).toBe(false); + expect(result.current.sortingProps.sortOptions).toEqual([]); + }); + + it("updates uncontrolled state and exposes directly bindable props", () => { + const { result } = renderHook(() => useSorting({ sortOptions, defaultSortBy: "created" })); + act(() => result.current.sortingProps.onSortByChange("updated")); + act(() => result.current.sortingProps.onSortDirectionChange(true)); + expect(result.current.sortBy).toBe("updated"); + expect(result.current.selectedSortOption).toBe(sortOptions[1]); + expect(result.current.isAscending).toBe(true); + expect(result.current.sortingProps.sortOptions).toBe(sortOptions); + expect(result.current.sortingProps.sortBy).toBe("updated"); + expect(result.current.sortingProps.isAscending).toBe(true); + act(() => result.current.toggleSortDirection()); + expect(result.current.isAscending).toBe(false); + }); + + it("does not emit redundant changes or emit while synchronizing props", () => { + const onSortByChange = vi.fn(); + const onSortDirectionChange = vi.fn(); + const { result, rerender } = renderHook((options: UseSortingOptions) => useSorting(options), { + initialProps: { sortOptions, sortBy: "created", isAscending: false, onSortByChange, onSortDirectionChange }, + }); + act(() => result.current.setSortBy("created")); + act(() => result.current.setIsAscending(false)); + rerender({ sortOptions, sortBy: "updated", isAscending: true, onSortByChange, onSortDirectionChange }); + expect(onSortByChange).not.toHaveBeenCalled(); + expect(onSortDirectionChange).not.toHaveBeenCalled(); + expect(result.current.sortBy).toBe("updated"); + expect(result.current.isAscending).toBe(true); + }); + + it("only requests changes in controlled mode, including an explicitly null field", () => { + const onSortByChange = vi.fn(); + const onSortDirectionChange = vi.fn(); + const { result } = renderHook(() => useSorting({ + sortOptions, sortBy: null, defaultSortBy: "created", isAscending: false, + onSortByChange, onSortDirectionChange, + })); + act(() => result.current.setSortBy("updated")); + act(() => result.current.toggleSortDirection()); + expect(onSortByChange).toHaveBeenCalledExactlyOnceWith("updated"); + expect(onSortDirectionChange).toHaveBeenCalledExactlyOnceWith(true); + expect(result.current.sortBy).toBeNull(); + expect(result.current.isAscending).toBe(false); + }); + + it.each(["field", "direction"])("supports independent controlled %s state", (controlled) => { + const { result } = renderHook(() => useSorting({ + sortOptions, defaultSortBy: "created", + sortBy: controlled === "field" ? "created" : undefined, + isAscending: controlled === "direction" ? false : undefined, + })); + act(() => result.current.setSortBy("updated")); + act(() => result.current.setIsAscending(true)); + expect(result.current.sortBy).toBe(controlled === "field" ? "created" : "updated"); + expect(result.current.isAscending).toBe(controlled !== "direction"); + }); + + it("uses defaults only at initialization and retains unknown or removed fields", () => { + const initialProps: UseSortingOptions = { + sortOptions, defaultSortBy: "created", defaultIsAscending: true, + }; + const { result, rerender } = renderHook((options: UseSortingOptions) => useSorting(options), { initialProps }); + rerender({ sortOptions: [], defaultSortBy: "updated", defaultIsAscending: false }); + expect(result.current.sortBy).toBe("created"); + expect(result.current.isAscending).toBe(true); + expect(result.current.selectedSortOption).toBeUndefined(); + act(() => result.current.setSortBy("unknown")); + rerender({ sortOptions }); + expect(result.current.sortBy).toBe("unknown"); + act(() => result.current.setSortBy(null)); + expect(result.current.sortBy).toBeNull(); + }); + + it("retains direction through non-directional fields and guards every direction operation", () => { + const onSortDirectionChange = vi.fn(); + const { result } = renderHook(() => useSorting({ + sortOptions, defaultSortBy: "created", defaultIsAscending: true, onSortDirectionChange, + })); + act(() => result.current.setSortBy("relevant")); + expect(result.current.directionToggleDisabled).toBe(true); + act(() => result.current.setIsAscending(false)); + act(() => result.current.toggleSortDirection()); + expect(result.current.isAscending).toBe(true); + expect(onSortDirectionChange).not.toHaveBeenCalled(); + act(() => result.current.setSortBy("updated")); + expect(result.current.directionToggleDisabled).toBe(false); + act(() => result.current.toggleSortDirection()); + expect(result.current.isAscending).toBe(false); + expect(onSortDirectionChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("derives disabled state from the latest options without resetting controlled direction", () => { + const { result, rerender } = renderHook((options: UseSortingOptions) => useSorting(options), { + initialProps: { sortOptions, sortBy: "created", isAscending: true }, + }); + rerender({ sortOptions: [{ value: "created", text: "Created date", directionToggleDisabled: true }], + sortBy: "created", isAscending: false }); + expect(result.current.directionToggleDisabled).toBe(true); + expect(result.current.isAscending).toBe(false); + rerender({ sortOptions: [], sortBy: "created", isAscending: true }); + expect(result.current.directionToggleDisabled).toBe(false); + expect(result.current.sortBy).toBe("created"); + }); + + it("distinguishes numbers from strings with the same characters", () => { + const { result } = renderHook(() => useSorting({ + sortOptions: [{ value: 0, text: "Number" }, { value: "0", text: "String" }], defaultSortBy: 0, + })); + expect(result.current.selectedSortOption?.text).toBe("Number"); + act(() => result.current.setSortBy("0")); + expect(result.current.selectedSortOption?.text).toBe("String"); + }); + + it("emits each change exactly once in StrictMode", () => { + const onSortByChange = vi.fn(); + const onSortDirectionChange = vi.fn(); + const wrapper = ({ children }: PropsWithChildren) => {children}; + const { result } = renderHook(() => useSorting({ + sortOptions, defaultSortBy: "created", onSortByChange, onSortDirectionChange, + }), { wrapper }); + expect(onSortByChange).not.toHaveBeenCalled(); + expect(onSortDirectionChange).not.toHaveBeenCalled(); + act(() => result.current.setSortBy("updated")); + act(() => result.current.toggleSortDirection()); + act(() => result.current.setSortBy("updated")); + act(() => result.current.setIsAscending(true)); + expect(onSortByChange).toHaveBeenCalledExactlyOnceWith("updated"); + expect(onSortDirectionChange).toHaveBeenCalledExactlyOnceWith(true); + }); +}); diff --git a/packages/ui/src/base/sorting/sorting.tsx b/packages/ui/src/base/sorting/sorting.tsx new file mode 100644 index 0000000..7b4bab1 --- /dev/null +++ b/packages/ui/src/base/sorting/sorting.tsx @@ -0,0 +1,123 @@ +/** + * Ported from GitLab UI: + * packages/gitlab-ui/src/components/base/sorting/sorting.vue + */ + +import { forwardRef, useId, type HTMLAttributes } from "react"; +import { cva } from "class-variance-authority"; +import GlButton from "../button/button.js"; +import GlButtonGroup from "../button-group/button-group.js"; +import GlListbox, { + GlListboxContent, + GlListboxItem, + GlListboxTrigger, +} from "../listbox/listbox.js"; +import GlTooltip, { + GlTooltipContent, + GlTooltipTrigger, +} from "../tooltip/tooltip.js"; +import { useSorting, type UseSortingOptions } from "./use-sorting.js"; + +type SortingElementProps = Omit< + HTMLAttributes, + "children" | "defaultValue" | "onChange" | "role" +>; + +export type GlSortingProps = SortingElementProps & UseSortingOptions & { + /** Overrides the selected option's text when non-empty. */ + text?: string | null; + /** Expands the dropdown trigger to its container width. Defaults to true. */ + block?: boolean; + /** Screen-reader prefix for the selected field. Defaults to "Sort by:". */ + sortByLabel?: string; + /** Overrides both the direction tooltip and accessible name when non-empty. */ + sortDirectionTooltip?: string | null; + dropdownClassName?: string; + dropdownToggleClassName?: string; + sortDirectionToggleClassName?: string; +}; + +const sortingVariants = cva(["gl-sorting", "gl-flex"]); +const dropdownVariants = cva("gl-w-full"); +const directionVariants = cva("sorting-direction-button"); + +const GlSorting = forwardRef(function GlSorting({ + sortOptions, + sortBy, + defaultSortBy, + isAscending, + defaultIsAscending, + onSortByChange, + onSortDirectionChange, + text = "", + block = true, + sortByLabel = "Sort by:", + sortDirectionTooltip, + className, + dropdownClassName, + dropdownToggleClassName, + sortDirectionToggleClassName, + ...elementProps +}, forwardedRef) { + const sorting = useSorting({ + sortOptions, + sortBy, + defaultSortBy, + isAscending, + defaultIsAscending, + onSortByChange, + onSortDirectionChange, + }); + const labelId = useId(); + const textId = useId(); + const selectedText = sorting.selectedSortOption?.text ?? ""; + const directionText = sortDirectionTooltip || ( + sorting.directionToggleDisabled + ? selectedText + ? `Sort direction unavailable for ${selectedText}` + : "Sort direction unavailable" + : sorting.isAscending + ? "Sort direction: ascending" + : "Sort direction: descending" + ); + + return ( + + + + {text || selectedText} + + + {sorting.sortingProps.sortOptions.map((option) => ( + + {option.text} + + ))} + + + {sortByLabel} + + + + + {directionText} + + + ); +}); + +export default GlSorting; diff --git a/packages/ui/src/base/sorting/use-sorting.ts b/packages/ui/src/base/sorting/use-sorting.ts new file mode 100644 index 0000000..546d0b9 --- /dev/null +++ b/packages/ui/src/base/sorting/use-sorting.ts @@ -0,0 +1,100 @@ +import { useCallback, useMemo, useState } from "react"; + +export type GlSortingSortBy = string | number | null; + +export type GlSortingOption = { + value: string | number; + text: string; + /** Disables direction changes for fields such as "Most relevant". */ + directionToggleDisabled?: boolean; +}; + +export type UseSortingOptions = { + sortOptions?: readonly GlSortingOption[]; + sortBy?: GlSortingSortBy; + defaultSortBy?: GlSortingSortBy; + isAscending?: boolean; + defaultIsAscending?: boolean; + onSortByChange?: (sortBy: GlSortingSortBy) => void; + onSortDirectionChange?: (isAscending: boolean) => void; +}; + +export type UseSortingResult = { + sortBy: GlSortingSortBy; + isAscending: boolean; + selectedSortOption: GlSortingOption | undefined; + directionToggleDisabled: boolean; + setSortBy: (sortBy: GlSortingSortBy) => void; + setIsAscending: (isAscending: boolean) => void; + toggleSortDirection: () => void; + sortingProps: { + sortOptions: readonly GlSortingOption[]; + sortBy: GlSortingSortBy; + isAscending: boolean; + onSortByChange: (sortBy: GlSortingSortBy) => void; + onSortDirectionChange: (isAscending: boolean) => void; + }; +}; + +const EMPTY_SORT_OPTIONS: readonly GlSortingOption[] = []; + +/** + * Manages independent controlled or uncontrolled sort field and direction. + * Data sorting, persistence, and fetching belong to the caller. + */ +export function useSorting({ + sortOptions = EMPTY_SORT_OPTIONS, + sortBy: controlledSortBy, + defaultSortBy = null, + isAscending: controlledIsAscending, + defaultIsAscending = false, + onSortByChange, + onSortDirectionChange, +}: UseSortingOptions = {}): UseSortingResult { + const [uncontrolledSortBy, setUncontrolledSortBy] = useState(defaultSortBy); + const [uncontrolledIsAscending, setUncontrolledIsAscending] = useState(defaultIsAscending); + const sortBy = controlledSortBy === undefined ? uncontrolledSortBy : controlledSortBy; + const isAscending = controlledIsAscending === undefined + ? uncontrolledIsAscending + : controlledIsAscending; + const selectedSortOption = useMemo( + () => sortOptions.find((option) => option.value === sortBy), + [sortBy, sortOptions], + ); + const directionToggleDisabled = Boolean(selectedSortOption?.directionToggleDisabled); + + const setSortBy = useCallback((nextSortBy: GlSortingSortBy) => { + if(nextSortBy === sortBy) return; + if(controlledSortBy === undefined) setUncontrolledSortBy(nextSortBy); + onSortByChange?.(nextSortBy); + }, [controlledSortBy, onSortByChange, sortBy]); + + const setIsAscending = useCallback((nextIsAscending: boolean) => { + if(directionToggleDisabled || nextIsAscending === isAscending) return; + if(controlledIsAscending === undefined) setUncontrolledIsAscending(nextIsAscending); + onSortDirectionChange?.(nextIsAscending); + }, [controlledIsAscending, directionToggleDisabled, isAscending, onSortDirectionChange]); + + const toggleSortDirection = useCallback(() => { + setIsAscending(!isAscending); + }, [isAscending, setIsAscending]); + + const sortingProps = useMemo(() => ({ + sortOptions, + sortBy, + isAscending, + onSortByChange: setSortBy, + onSortDirectionChange: setIsAscending, + }), [isAscending, setIsAscending, setSortBy, sortBy, sortOptions]); + + return { + sortBy, + isAscending, + selectedSortOption, + directionToggleDisabled, + setSortBy, + setIsAscending, + toggleSortDirection, + sortingProps, + }; +} diff --git a/packages/ui/src/internal/trigger/trigger-composition.tsx b/packages/ui/src/internal/trigger/trigger-composition.tsx index 3a17402..096195e 100644 --- a/packages/ui/src/internal/trigger/trigger-composition.tsx +++ b/packages/ui/src/internal/trigger/trigger-composition.tsx @@ -7,6 +7,7 @@ import { } from "react"; import GlButton, { type GlButtonCategory, + type GlButtonProps, type GlButtonSize, type GlButtonVariant, } from "../../base/button/button.js"; @@ -44,7 +45,7 @@ export type GlTriggerAsChildProps = { export type GlTriggerButtonOptions = Pick< GlTriggerDefaultProps, "block" | "category" | "disabled" | "icon" | "loading" | "size" | "variant" ->; +> & Pick; function resolveAsChildElement(componentName: string, children: ReactNode): ReactElement { if(!isValidElement(children) || children.type === Fragment) {