GDGT-3241: SegmentedControl constrained text + icon props (#83336)

* GDGT-3241 Constrain SegmentedControl items to text and icon props

Items are now typed data (label, icon, ariaLabel) instead of arbitrary
label nodes, and the component owns icon size and spacing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Update Loki Snapshots

* set line height on SegmentedControl label

* add second click to assert handler doesnt refire on multiple clicks

* interface -> type

* add test

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Metabase Automation <github-automation@metabase.com>
This commit is contained in:
Will Fry
2026-10-06 16:42:18 +01:00
committed by GitHub
parent db875cbd2a
commit 01d4659a3e
19 changed files with 469 additions and 209 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -101,9 +101,10 @@ describe("scenarios > visualizer > snowplow tracking", () => {
triggered_from: "visualizer-modal",
});
// change the visualization type
// change the visualization type (the question is already a line chart,
// and re-selecting the current type isn't a change)
cy.log("change the visualization type");
H.selectVisualization("line");
H.selectVisualization("area");
H.expectUnstructuredSnowplowEvent({
event: "visualizer_data_changed",
event_detail: "visualizer_viz_type_changed",

View File

@@ -4,7 +4,7 @@ import { t } from "ttag";
import { FormField } from "metabase/common/components/FormField";
import { useUniqueId } from "metabase/common/hooks/use-unique-id";
import { Center, Icon, SegmentedControl } from "metabase/ui";
import { SegmentedControl } from "metabase/ui";
import { OFFICIAL_COLLECTION, REGULAR_COLLECTION } from "../constants";
@@ -23,7 +23,6 @@ const getOptions = () => [
label: OFFICIAL_COLLECTION.name,
value: OFFICIAL_COLLECTION.type,
icon: OFFICIAL_COLLECTION.icon,
selectedColor: OFFICIAL_COLLECTION.color,
},
];
@@ -54,12 +53,8 @@ export function FormCollectionAuthorityLevel({
onChange={handleChange}
data={getOptions().map((option) => ({
value: String(option.value),
label: (
<Center style={{ gap: 10 }}>
<Icon name={option.icon} />
{option.label}
</Center>
),
label: option.label,
icon: option.icon,
}))}
/>
</FormField>

View File

@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
import type { SegmentedControlItem } from "metabase/ui";
import type { DependencySortColumn, SortDirection } from "metabase-types/api";
export type SortColumnItem = {
@@ -7,7 +6,4 @@ export type SortColumnItem = {
label: string;
};
export type SortDirectionItem = {
value: SortDirection;
label: ReactNode;
};
export type SortDirectionItem = SegmentedControlItem<SortDirection>;

View File

@@ -1,6 +1,5 @@
import { t } from "ttag";
import { FixedSizeIcon } from "metabase/ui";
import type { DependencySortColumn } from "metabase-types/api";
import type { SortColumnItem, SortDirectionItem } from "./types";
@@ -26,11 +25,13 @@ export function getSortDirectionItems(): SortDirectionItem[] {
return [
{
value: "asc",
label: <FixedSizeIcon name="arrow_up" aria-label={t`Ascending`} />,
ariaLabel: t`Ascending`,
icon: "arrow_up",
},
{
value: "desc",
label: <FixedSizeIcon name="arrow_down" aria-label={t`Descending`} />,
ariaLabel: t`Descending`,
icon: "arrow_down",
},
];
}

View File

@@ -1,35 +1,14 @@
import { t } from "ttag";
import { Box, Group, Icon, SegmentedControl } from "metabase/ui";
import type { IconName } from "metabase-types/api";
import { SegmentedControl, type SegmentedControlItem } from "metabase/ui";
import type { ActivePreviewPane } from "./types";
const ControlOptionItem = ({
label,
iconName,
}: {
label: string;
iconName: IconName;
}) => (
<Group wrap="nowrap" px="sm" gap="xxs">
<Icon name={iconName} />
<Box>{label}</Box>
</Group>
);
const getCodePreviewControlOptions = () => [
{
label: <ControlOptionItem label={t`Code`} iconName="embed" />,
// Unjustified type cast. FIXME
value: "code" as ActivePreviewPane,
},
{
label: <ControlOptionItem label={t`Preview`} iconName="eye_filled" />,
// Unjustified type cast. FIXME
value: "preview" as ActivePreviewPane,
},
];
const getCodePreviewControlOptions =
(): SegmentedControlItem<ActivePreviewPane>[] => [
{ label: t`Code`, icon: "embed", value: "code" },
{ label: t`Preview`, icon: "eye_filled", value: "preview" },
];
interface PreviewModeSelectorProps {
value: ActivePreviewPane;

View File

@@ -23,9 +23,9 @@ import {
type IconProps,
Menu,
SegmentedControl,
type SegmentedControlItem,
Stack,
Text,
Tooltip,
} from "metabase/ui";
import type { Exploration, ExplorationPageNodeId } from "metabase-types/api";
@@ -278,10 +278,7 @@ export function ExplorationSidebar({
}
}}
data={Object.values(explorationSidebarTabsInfo).map(
({ value, label }) => ({
value,
label: <SidebarTabLabel tab={value} label={label} />,
}),
getSidebarTabItem,
)}
/>
</Box>
@@ -302,24 +299,19 @@ const TAB_ICON: Partial<Record<ExplorationSidebarTab, IconProps["name"]>> = {
discussions: "comment",
};
function SidebarTabLabel({
tab,
function getSidebarTabItem({
value,
label,
}: {
tab: ExplorationSidebarTab;
label: string;
}) {
const iconName = TAB_ICON[tab];
}: ExplorationSidebarTabsInfo[ExplorationSidebarTab]): SegmentedControlItem<ExplorationSidebarTab> {
const iconName = TAB_ICON[value];
return iconName ? (
<Tooltip label={label}>
<Center component="span" aria-label={label}>
<Icon name={iconName} />
</Center>
</Tooltip>
) : (
label
);
return iconName
? {
value,
ariaLabel: label,
icon: <Icon name={iconName} tooltip={label} />,
}
: { value, label };
}
function SidebarShowFilterMenu({

View File

@@ -1,18 +1,21 @@
import { useEffect, useMemo, useState } from "react";
import { t } from "ttag";
import { SegmentedControl, type SegmentedControlProps } from "metabase/ui";
import type { TableFieldOrder } from "metabase-types/api";
import {
Icon,
SegmentedControl,
type SegmentedControlItem,
type SegmentedControlProps,
} from "metabase/ui";
import type { IconName, TableFieldOrder } from "metabase-types/api";
import { Label } from "./Label";
interface Props extends Omit<
type Props = Omit<
SegmentedControlProps<TableFieldOrder>,
"data" | "value" | "onChange"
> {
> & {
value: TableFieldOrder;
onChange: (value: TableFieldOrder) => void;
}
};
export const FieldOrderPicker = ({ value, onChange, ...props }: Props) => {
const data = useMemo(() => getData(), []);
@@ -40,23 +43,45 @@ export const FieldOrderPicker = ({ value, onChange, ...props }: Props) => {
);
};
function getData() {
function getData(): SegmentedControlItem<TableFieldOrder>[] {
return [
{
value: "smart" as const,
label: <Label icon="sparkles" tooltip={t`Auto order`} />,
},
{
value: "database" as const,
label: <Label icon="database" tooltip={t`Database order`} />,
},
{
value: "alphabetical" as const,
label: <Label icon="string" tooltip={t`Alphabetical order`} />,
},
{
value: "custom" as const,
label: <Label icon="palette" tooltip={t`Custom order`} />,
},
getIconOnlyItem({
value: "smart",
iconName: "sparkles",
label: t`Auto order`,
}),
getIconOnlyItem({
value: "database",
iconName: "database",
label: t`Database order`,
}),
getIconOnlyItem({
value: "alphabetical",
iconName: "string",
label: t`Alphabetical order`,
}),
getIconOnlyItem({
value: "custom",
iconName: "palette",
label: t`Custom order`,
}),
];
}
type IconOnlyItemOpts = {
value: TableFieldOrder;
iconName: IconName;
label: string;
};
function getIconOnlyItem({
value,
iconName,
label,
}: IconOnlyItemOpts): SegmentedControlItem<TableFieldOrder> {
return {
value,
ariaLabel: label,
icon: <Icon name={iconName} tooltip={label} />,
};
}

View File

@@ -1,6 +1,6 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import { renderWithProviders, screen, waitFor, within } from "__support__/ui";
import type { TableFieldOrder } from "metabase-types/api";
import { FieldOrderPicker } from "./FieldOrderPicker";
@@ -38,8 +38,8 @@ describe("FieldOrderPicker", () => {
setup();
for (const label of TOOLTIP_LABELS) {
expect(screen.getByLabelText(label)).toBeInTheDocument();
await userEvent.hover(screen.getByLabelText(label));
const option = screen.getByLabelText(label);
await userEvent.hover(within(option).getByRole("img"));
await waitFor(() => {
expect(

View File

@@ -1,14 +0,0 @@
import { Flex, Icon, Tooltip } from "metabase/ui";
import type { IconName } from "metabase-types/api";
interface Props {
icon: IconName;
tooltip: string;
}
export const Label = ({ icon, tooltip }: Props) => (
<Tooltip label={tooltip}>
<Flex aria-label={tooltip} align="center" justify="center" w={24}>
<Icon name={icon} />
</Flex>
</Tooltip>
);

View File

@@ -1,7 +1,7 @@
import { t } from "ttag";
import { useRegisterShortcut } from "metabase/palette/hooks/useRegisterShortcut";
import { Icon, SegmentedControl } from "metabase/ui";
import { SegmentedControl } from "metabase/ui";
export interface QuestionDisplayToggleProps {
className?: string;
@@ -37,19 +37,13 @@ export const QuestionDisplayToggle = ({
data={[
{
value: "data",
label: (
<Icon size={16} name="table2" aria-label={t`Switch to data`} />
),
ariaLabel: t`Switch to data`,
icon: "table2",
},
{
value: "visualization",
label: (
<Icon
size={16}
name="lineandbar"
aria-label={t`Switch to visualization`}
/>
),
ariaLabel: t`Switch to visualization`,
icon: "lineandbar",
},
]}
/>

View File

@@ -11,6 +11,24 @@ Our themed wrapper around [Mantine SegmentedControl](https://mantine.dev/core/se
- [Mantine SegmentedControl Docs](https://mantine.dev/core/segmented-control/)
## Items
Each item in `data` is text only, icon + text, or icon only. The component owns the icon size and spacing, so labels are plain data rather than custom JSX.
```tsx
<SegmentedControl
data={[
{ value: "code", label: "Code" },
{ value: "preview", label: "Preview", icon: "eye_filled" },
{ value: "settings", ariaLabel: "Settings", icon: "gear" },
]}
/>
```
- `label` is the visible text. Add `icon` to show an icon before it.
- Leave out `label` and pass `ariaLabel` with `icon` for an icon-only item. `ariaLabel` is required so the item has an accessible name.
- `icon` is usually an icon name. Pass an element only when the icon can't be expressed as a name; the element is then responsible for its own size.
## Examples
### Text only

View File

@@ -18,6 +18,7 @@
font-weight: bold;
padding: var(--mantine-spacing-xxs) var(--mantine-spacing-md);
border-radius: var(--mantine-radius-xs);
line-height: normal;
&:hover:not([data-active], [data-disabled]) {
color: var(--mb-color-text-primary);
@@ -45,9 +46,14 @@
display: flex;
align-items: center;
justify-content: center;
gap: var(--mantine-spacing-xs);
min-height: 1rem;
}
.SegmentedControlIconElement {
display: flex;
}
.SegmentedControlIndicator {
padding: var(--mantine-spacing-xxs) var(--mantine-spacing-md);
background-color: var(--mb-color-segmented_control-selected);

View File

@@ -3,7 +3,6 @@ import { Fragment } from "react";
import {
Box,
Group,
Icon,
SegmentedControl,
type SegmentedControlItem,
@@ -21,33 +20,22 @@ import S from "./SegmentedControl.module.css";
const TEXT_DATA = [
{ label: "Code", value: "code" },
{ label: "Preview", value: "preview" },
];
] satisfies SegmentedControlItem<string>[];
const TEXT_AND_ICON_DATA = [
{
label: (
<Group gap="xs" wrap="nowrap">
<Icon name="embed" />
Code
</Group>
),
value: "code",
},
{
label: (
<Group gap="xs" wrap="nowrap">
<Icon name="eye_filled" />
Preview
</Group>
),
value: "preview",
},
];
{ label: "Code", icon: "embed", value: "code" },
{ label: "Preview", icon: "eye_filled", value: "preview" },
] satisfies SegmentedControlItem<string>[];
const ICON_DATA = [
{ label: <Icon name="embed" aria-label="Code" />, value: "code" },
{ label: <Icon name="eye_filled" aria-label="Preview" />, value: "preview" },
];
{ ariaLabel: "Code", icon: "embed", value: "code" },
{ ariaLabel: "Preview", icon: "eye_filled", value: "preview" },
] satisfies SegmentedControlItem<string>[];
const ICON_ELEMENT_DATA = [
{ ariaLabel: "Code", icon: <Icon name="embed" />, value: "code" },
{ ariaLabel: "Preview", icon: <Icon name="eye_filled" />, value: "preview" },
] satisfies SegmentedControlItem<string>[];
export default {
title: "Components/Inputs/SegmentedControl",
@@ -87,6 +75,7 @@ const CONTENT_KINDS: {
{ id: "text", label: "Text only", data: TEXT_DATA },
{ id: "text-and-icon", label: "Text + icon", data: TEXT_AND_ICON_DATA },
{ id: "icon", label: "Icon only", data: ICON_DATA },
{ id: "icon-element", label: "Icon element", data: ICON_ELEMENT_DATA },
];
type OverviewState = {

View File

@@ -0,0 +1,146 @@
import userEvent from "@testing-library/user-event";
import { render, screen, within } from "__support__/ui";
import { Icon } from "../../icons";
import { SegmentedControl, type SegmentedControlItem } from "./index";
type Value = "code" | "preview";
interface SetupOpts {
data: SegmentedControlItem<Value>[];
value?: Value;
}
const setup = ({ data, value = "code" }: SetupOpts) => {
const onChange = jest.fn();
render(<SegmentedControl data={data} value={value} onChange={onChange} />);
return { onChange };
};
describe("SegmentedControl", () => {
it("should render text-only items", () => {
setup({
data: [
{ value: "code", label: "Code" },
{ value: "preview", label: "Preview" },
],
});
expect(screen.getByRole("radio", { name: "Code" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Preview" })).not.toBeChecked();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("should render a decorative icon next to the label", () => {
setup({
data: [
{ value: "code", label: "Code", icon: "embed" },
{ value: "preview", label: "Preview", icon: "eye_filled" },
],
});
const label = screen.getByText("Code");
const icon = within(label).getByRole("img", { hidden: true });
expect(icon).toHaveClass("Icon-embed");
expect(icon).toHaveAttribute("aria-hidden", "true");
expect(icon).toHaveAttribute("width", "12");
expect(screen.getByRole("radio", { name: "Code" })).toBeInTheDocument();
});
it("should name icon-only items by their ariaLabel", () => {
setup({
data: [
{ value: "code", ariaLabel: "Code", icon: "embed" },
{ value: "preview", ariaLabel: "Preview", icon: "eye_filled" },
],
});
const icon = screen.getByRole("img", { name: "Code" });
expect(icon).toHaveClass("Icon-embed");
expect(icon).toHaveAttribute("width", "16");
expect(screen.getByRole("radio", { name: "Code" })).toBeInTheDocument();
expect(screen.queryByText("Code")).not.toBeInTheDocument();
});
it("should name icon-only items with a custom icon element by their ariaLabel", () => {
setup({
data: [
{
value: "code",
ariaLabel: "Code",
icon: <Icon name="embed" data-testid="custom-icon" />,
},
{ value: "preview", ariaLabel: "Preview", icon: "eye_filled" },
],
});
expect(
within(screen.getByLabelText("Code")).getByTestId("custom-icon"),
).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Code" })).toBeInTheDocument();
});
it("should render a custom icon element next to the label", () => {
setup({
data: [
{
value: "code",
label: "Code",
icon: <Icon name="embed" data-testid="custom-icon" />,
},
{ value: "preview", label: "Preview" },
],
});
expect(
within(screen.getByText("Code")).getByTestId("custom-icon"),
).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Code" })).toBeInTheDocument();
});
it("should call onChange with the value of the clicked item", async () => {
const { onChange } = setup({
data: [
{ value: "code", label: "Code" },
{ value: "preview", ariaLabel: "Preview", icon: "eye_filled" },
],
});
await userEvent.click(screen.getByLabelText("Preview"));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("preview");
});
it("doesn't call onChange when clicking the selected item", async () => {
const { onChange } = setup({
value: "preview",
data: [
{ value: "code", label: "Code" },
{ value: "preview", ariaLabel: "Preview", icon: "eye_filled" },
],
});
await userEvent.click(screen.getByLabelText("Preview"));
expect(onChange).not.toHaveBeenCalled();
});
it("should disable individual items", () => {
setup({
data: [
{ value: "code", label: "Code" },
{ value: "preview", label: "Preview", disabled: true },
],
});
expect(screen.getByRole("radio", { name: "Code" })).toBeEnabled();
expect(screen.getByRole("radio", { name: "Preview" })).toBeDisabled();
});
});

View File

@@ -1,18 +1,30 @@
import type {
SegmentedControlItem as MantineSegmentedControlItem,
SegmentedControlProps as MantineSegmentedControlProps,
} from "@mantine/core";
import type { SegmentedControlProps as MantineSegmentedControlProps } from "@mantine/core";
import { SegmentedControl as MantineSegmentedControl } from "@mantine/core";
import type { ReactElement } from "react";
import type { IconName } from "metabase-types/api";
import { Icon } from "../../icons";
import S from "./SegmentedControl.module.css";
export { segmentedControlOverrides } from "./SegmentedControl.config";
export interface SegmentedControlItem<Value extends string> extends Omit<
MantineSegmentedControlItem,
"value"
> {
value: Value;
}
const LABELLED_ICON_SIZE = 12;
const ICON_ONLY_SIZE = 16;
export interface SegmentedControlProps<Value extends string> extends Omit<
type SegmentedControlIcon = IconName | ReactElement;
type SegmentedControlItemContent =
| { label: string; icon?: SegmentedControlIcon; ariaLabel?: never }
| { ariaLabel: string; icon: SegmentedControlIcon; label?: never };
export type SegmentedControlItem<Value extends string> = {
value: Value;
disabled?: boolean;
} & SegmentedControlItemContent;
export type SegmentedControlProps<Value extends string> = Omit<
MantineSegmentedControlProps,
| "data"
| "value"
@@ -24,17 +36,101 @@ export interface SegmentedControlProps<Value extends string> extends Omit<
| "bg"
| "variant"
| "autoContrast"
> {
data: SegmentedControlItem<Value>[] | Value[];
> & {
data: readonly SegmentedControlItem<Value>[];
value?: Value;
onChange?: (value: Value) => void;
}
};
export function SegmentedControl<Value extends string = string>({
data,
onChange,
...props
}: SegmentedControlProps<Value>) {
const handleChange = (newValue: string) => {
const item = data.find((item) => item.value === newValue);
if (item) {
onChange?.(item.value);
}
};
export function SegmentedControl<Value extends string = string>(
props: SegmentedControlProps<Value>,
) {
return (
// @ts-expect-error -- our tighter types are better
<MantineSegmentedControl {...props} />
<MantineSegmentedControl
{...props}
data={data.map((item) => ({
value: item.value,
disabled: item.disabled,
label: <SegmentedControlItemLabel content={item} />,
}))}
onChange={handleChange}
/>
);
}
type SegmentedControlItemLabelProps = {
content: SegmentedControlItemContent;
};
function SegmentedControlItemLabel({
content,
}: SegmentedControlItemLabelProps) {
if (content.label === undefined) {
return (
<SegmentedControlItemIcon
icon={content.icon}
size={ICON_ONLY_SIZE}
ariaLabel={content.ariaLabel}
/>
);
}
return (
<>
{content.icon !== undefined && (
<SegmentedControlItemIcon
icon={content.icon}
size={LABELLED_ICON_SIZE}
/>
)}
{content.label}
</>
);
}
type SegmentedControlItemIconProps = {
icon: SegmentedControlIcon;
size: number;
ariaLabel?: string;
};
function SegmentedControlItemIcon({
icon,
size,
ariaLabel,
}: SegmentedControlItemIconProps) {
// Next to a visible label the icon is decorative: the label names the item.
const isDecorative = ariaLabel === undefined;
const ariaHidden = isDecorative ? true : undefined;
if (typeof icon === "string") {
return (
<Icon
name={icon}
size={size}
aria-label={ariaLabel}
aria-hidden={ariaHidden}
/>
);
}
return (
<span
className={S.SegmentedControlIconElement}
role={isDecorative ? undefined : "img"}
aria-label={ariaLabel}
aria-hidden={ariaHidden}
>
{icon}
</span>
);
}

View File

@@ -1,18 +0,0 @@
.ListItem[aria-selected="true"] {
background-color: var(--mb-color-core-brand);
color: var(--mb-color-text-primary-inverse);
.ListItemLabel {
color: var(--mb-color-text-primary-inverse);
}
&:hover {
.ListItemLabel {
color: var(--mb-color-text-primary);
}
}
}
.SegmentedControlLabel {
padding: 0;
}

View File

@@ -1,14 +1,12 @@
import { useMemo } from "react";
import { EntityIcon } from "metabase/common/components/EntityIcon";
import { Center, SegmentedControl } from "metabase/ui";
import { SegmentedControl } from "metabase/ui";
import { visualizations } from "metabase/viz-core";
import type { VisualizationDisplay } from "metabase-types/api";
import { trackVisualizerDataChanged } from "../analytics";
import S from "./VisualizationPicker.module.css";
interface VisualizationPickerProps {
value: VisualizationDisplay | null;
onChange: (vizType: string) => void;
@@ -30,38 +28,25 @@ export function VisualizationPicker({
});
}, []);
const selectedOption = useMemo(
() => options.find((option) => option.value === value),
[value, options],
);
return (
<>
<SegmentedControl
classNames={{
label: S.SegmentedControlLabel,
}}
value={selectedOption?.value}
data={options.map((o, i) => ({
value={value ?? undefined}
data={options.map((o) => ({
value: o.value,
label: (
<Center
key={i}
onClick={() => {
trackVisualizerDataChanged("visualizer_viz_type_changed");
onChange(o.value);
}}
p="sm"
>
<EntityIcon
data-testid={o.value}
name={o.icon}
iconUrl={o.iconUrl}
/>
</Center>
ariaLabel: o.label,
icon: (
<EntityIcon
data-testid={o.value}
name={o.icon}
iconUrl={o.iconUrl}
/>
),
}))}
onChange={(vizType) => {
trackVisualizerDataChanged("visualizer_viz_type_changed");
onChange(vizType);
}}
data-testid="viz-picker-main"
/>
</>

View File

@@ -0,0 +1,69 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "__support__/ui";
import { registerVisualizations } from "metabase/visualizations/register";
import type { VisualizationDisplay } from "metabase-types/api";
import { trackVisualizerDataChanged } from "../analytics";
import { VisualizationPicker } from "./VisualizationPicker";
jest.mock("../analytics", () => ({
trackVisualizerDataChanged: jest.fn(),
}));
registerVisualizations();
interface SetupOpts {
value: VisualizationDisplay | null;
}
const setup = ({ value }: SetupOpts) => {
const onChange = jest.fn();
renderWithProviders(
<VisualizationPicker value={value} onChange={onChange} />,
);
return { onChange };
};
describe("VisualizationPicker", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should only list visualizations the visualizer supports", () => {
setup({ value: "bar" });
expect(screen.getByRole("radio", { name: "Bar" })).toBeChecked();
expect(screen.getByRole("radio", { name: "Pie" })).not.toBeChecked();
expect(
screen.queryByRole("radio", { name: "Table" }),
).not.toBeInTheDocument();
});
it("should change the visualization and track the change", async () => {
const { onChange } = setup({ value: "bar" });
await userEvent.click(screen.getByTestId("pie"));
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith("pie");
expect(trackVisualizerDataChanged).toHaveBeenCalledWith(
"visualizer_viz_type_changed",
);
});
it("should select nothing when the value is not a listed visualization", async () => {
const { onChange } = setup({ value: "table" });
expect(
screen.queryByRole("radio", { checked: true }),
).not.toBeInTheDocument();
await userEvent.click(screen.getAllByRole("radio")[0]);
expect(onChange).toHaveBeenCalledTimes(1);
});
});