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:
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 84 KiB |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,34 +1,13 @@
|
||||
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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
ariaLabel: o.label,
|
||||
icon: (
|
||||
<EntityIcon
|
||||
data-testid={o.value}
|
||||
name={o.icon}
|
||||
iconUrl={o.iconUrl}
|
||||
/>
|
||||
</Center>
|
||||
),
|
||||
}))}
|
||||
onChange={(vizType) => {
|
||||
trackVisualizerDataChanged("visualizer_viz_type_changed");
|
||||
onChange(vizType);
|
||||
}}
|
||||
data-testid="viz-picker-main"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user