Skip to content

Commit

Permalink
Update label in SingleSelect and MultiSelect (#2354)
Browse files Browse the repository at this point in the history
## Summary:
When you pass in a JSX Element as a label to `OptionItem`, the SelectOpener is labeled with an empty string. This PR updates SelectOpener in the `SingleSelect` and `MultiSelect` components to return the JSX as a label in that case. 

This change is being made to unblock supporting TEX in the Perseus Dropdown widget. 

Issue: LIT-1425


## Test plan:
- Added new stories
  - https://5e1bf4b385e3fb0020b7073c-xhxyrfwkfd.chromatic.com/?path=/story/packages-dropdown-singleselect--custom-option-item-with-node-label
  - https://5e1bf4b385e3fb0020b7073c-xhxyrfwkfd.chromatic.com/?path=/story/packages-dropdown-multiselect--custom-option-items-with-node-label
- Unit tests pass
- Installed npm snapshot and tested against my branch in Perseus ([PR here](Khan/perseus#1810))
- Test in webapp and storybook to ensure no regressions

Author: daniellewhyte

Reviewers: marcysutton, daniellewhyte, beaesguerra, jandrade

Required Reviewers:

Approved By: beaesguerra, jandrade

Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 20.x), ✅ Test / Test (ubuntu-latest, 20.x, 2/2), ✅ Test / Test (ubuntu-latest, 20.x, 1/2), ✅ Lint / Lint (ubuntu-latest, 20.x), ✅ Check build sizes (ubuntu-latest, 20.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 20.x), ✅ Publish npm snapshot (ubuntu-latest, 20.x), ⏭️  Chromatic - Skip on Release PR (changesets), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ gerald, ⏭️  dependabot

Pull Request URL: #2354
  • Loading branch information
daniellewhyte authored Dec 2, 2024
1 parent 1ce2a2e commit 2b8424c
Show file tree
Hide file tree
Showing 12 changed files with 306 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/thirty-shirts-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-dropdown": minor
---

Allow use of JSX Element as label in SingleSelect and MultiSelect
12 changes: 12 additions & 0 deletions __docs__/wonder-blocks-dropdown/multi-select.argtypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ const argTypes: ArgTypes = {
type: {summary: "Labels"},
},
},
showOpenerLabelAsText: {
control: {type: "boolean"},
description: `When false, the SelectOpener can show a Node as a label. When true, the
SelectOpener will use a string as a label. If using custom OptionItems, a
plain text label can be provided with the \`labelAsText\` prop.
Defaults to true.`,

table: {
type: {summary: "boolean"},
defaultValue: {summary: "true"},
},
},
};

export default argTypes;
58 changes: 57 additions & 1 deletion __docs__/wonder-blocks-dropdown/multi-select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import ComponentInfo from "../../.storybook/components/component-info";
import packageConfig from "../../packages/wonder-blocks-dropdown/package.json";
import multiSelectArgtypes from "./multi-select.argtypes";
import {defaultLabels} from "../../packages/wonder-blocks-dropdown/src/util/constants";
import {allCountries, allProfilesWithPictures} from "./option-item-examples";
import {
allCountries,
allProfilesWithPictures,
locales,
chatIcon,
} from "./option-item-examples";
import {OpenerProps} from "../../packages/wonder-blocks-dropdown/src/util/types";
import Strut from "../../packages/wonder-blocks-layout/src/components/strut";

Expand Down Expand Up @@ -650,3 +655,54 @@ export const CustomOptionItems: StoryComponentType = {
),
],
};

/**
* This example illustrates how a JSX Element can appear as the label by setting
* `showOpenerLabelAsText` to false. Note that in this example, we define
* `labelAsText` on the OptionItems to ensure that filtering works correctly.
*/
export const CustomOptionItemsWithNodeLabel: StoryComponentType = {
render: function Render() {
const [opened, setOpened] = React.useState(true);
const [selectedValues, setSelectedValues] = React.useState<
Array<string>
>([]);

const handleChange = (selectedValues: Array<string>) => {
setSelectedValues(selectedValues);
};

const handleToggle = (opened: boolean) => {
setOpened(opened);
};

return (
<MultiSelect
onChange={handleChange}
selectedValues={selectedValues}
onToggle={handleToggle}
opened={opened}
showOpenerLabelAsText={false}
isFilterable={true}
>
{locales.map((locale, index) => (
<OptionItem
key={index}
value={String(index)}
label={
<span>
{chatIcon} {locale}
</span>
}
labelAsText={locale}
/>
))}
</MultiSelect>
);
},
decorators: [
(Story): React.ReactElement<React.ComponentProps<typeof View>> => (
<View style={styles.wrapper}>{Story()}</View>
),
],
};
25 changes: 25 additions & 0 deletions __docs__/wonder-blocks-dropdown/option-item-examples.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as React from "react";
import userCircleIcon from "@phosphor-icons/core/duotone/user-circle-duotone.svg";
import chatBubbleIcon from "@phosphor-icons/core/regular/chats.svg";
import bitcoinIcon from "@phosphor-icons/core/regular/currency-btc.svg";
import euroIcon from "@phosphor-icons/core/regular/currency-eur.svg";
import dollarIcon from "@phosphor-icons/core/regular/currency-dollar.svg";
import yenIcon from "@phosphor-icons/core/regular/currency-jpy.svg";
import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";

export const allCountries = [
Expand Down Expand Up @@ -301,3 +306,23 @@ export const allProfilesWithPictures = [
picture: icon,
},
];

export const currencies = [
{name: "Bitcoin", icon: bitcoinIcon},
{name: "Dollars", icon: dollarIcon},
{name: "Yen", icon: yenIcon},
{name: "Euros", icon: euroIcon},
];

export const locales = [
"অসমীয়া",
"Azərbaycanca",
"čeština",
"dansk",
"Ελληνικά",
"ગુજરાતી",
"magyar",
"Bahasa Indonesia",
];

export const chatIcon = <PhosphorIcon icon={chatBubbleIcon} size={"small"} />;
12 changes: 12 additions & 0 deletions __docs__/wonder-blocks-dropdown/single-select.argtypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ const argTypes: ArgTypes = {
type: {summary: "Labels"},
},
},
showOpenerLabelAsText: {
control: {type: "boolean"},
description: `When false, the SelectOpener can show a Node as a label. When true, the
SelectOpener will use a string as a label. If using custom OptionItems, a
plain text label can be provided with the \`labelAsText\` prop.
Defaults to true.`,

table: {
type: {summary: "boolean"},
defaultValue: {summary: "true"},
},
},
};

export default argTypes;
58 changes: 57 additions & 1 deletion __docs__/wonder-blocks-dropdown/single-select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ import ComponentInfo from "../../.storybook/components/component-info";
import singleSelectArgtypes from "./single-select.argtypes";
import {IconMappings} from "../wonder-blocks-icon/phosphor-icon.argtypes";
import {defaultLabels} from "../../packages/wonder-blocks-dropdown/src/util/constants";
import {allCountries, allProfilesWithPictures} from "./option-item-examples";
import {
allCountries,
allProfilesWithPictures,
currencies,
} from "./option-item-examples";
import {OpenerProps} from "../../packages/wonder-blocks-dropdown/src/util/types";

type StoryComponentType = StoryObj<typeof SingleSelect>;
Expand Down Expand Up @@ -884,6 +888,58 @@ export const CustomOptionItems: StoryComponentType = {
},
};

/**
* This example illustrates how a JSX Element can appear as the label if
* `labelAsText` is undefined. Note that in this example, we define `labelAsText`
* on the OptionItems to ensure that filtering works correctly.
*/
export const CustomOptionItemWithNodeLabel: StoryComponentType = {
render: function Render() {
const [opened, setOpened] = React.useState(true);
const [selectedValue, setSelectedValue] = React.useState("");

const handleChange = (selectedValue: string) => {
setSelectedValue(selectedValue);
};

const handleToggle = (opened: boolean) => {
setOpened(opened);
};

return (
<View style={styles.wrapper}>
<SingleSelect
placeholder="Select your currency"
onChange={handleChange}
selectedValue={selectedValue}
onToggle={handleToggle}
opened={opened}
showOpenerLabelAsText={false}
isFilterable={true}
>
{currencies.map((currency, index) => (
<OptionItem
key={index}
value={String(index)}
horizontalRule="full-width"
label={
<span>
<PhosphorIcon
icon={currency.icon}
size={"small"}
/>
{currency.name}
</span>
}
labelAsText={currency.name}
/>
))}
</SingleSelect>
</View>
);
},
};

/**
* This example illustrates how you can use the `OptionItem` component to
* display a virtualized list with custom option items. Note that in this
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/* eslint-disable no-constant-condition */
/* eslint-disable max-lines */
import * as React from "react";
import {fireEvent, render, screen, waitFor} from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import {
userEvent as ue,
PointerEventsCheckLevel,
Expand Down Expand Up @@ -264,6 +270,28 @@ describe("MultiSelect", () => {
// Assert
expect(opener).toHaveAttribute("data-testid", "some-test-id");
});

it("can render a Node as a label", async () => {
// Arrange
doRender(
<MultiSelect
onChange={onChange}
selectedValues={["1"]}
showOpenerLabelAsText={false}
>
<OptionItem label={<div>custom item 1</div>} value="1" />
<OptionItem label={<div>custom item 2</div>} value="2" />
<OptionItem label={<div>custom item 3</div>} value="3" />
</MultiSelect>,
);

// Act
const opener = await screen.findByRole("button");
const menuLabel = within(opener).getByText("custom item 1");

// Assert
expect(menuLabel).toBeVisible();
});
});

describe("Controlled component", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable max-lines */
import * as React from "react";
import {fireEvent, render, screen} from "@testing-library/react";
import {fireEvent, render, screen, within} from "@testing-library/react";
import {
userEvent as ue,
PointerEventsCheckLevel,
Expand Down Expand Up @@ -135,6 +135,35 @@ describe("SingleSelect", () => {
// Assert
expect(opener).toHaveTextContent("Plain Toggle A");
});
it("can render a Node as a label", async () => {
// Arrange
doRender(
<SingleSelect
placeholder="Default placeholder"
onChange={jest.fn()}
selectedValue="toggle_a"
showOpenerLabelAsText={false}
>
<OptionItem
label={<div>custom item A</div>}
value="toggle_a"
labelAsText="Plain Toggle A"
/>
<OptionItem
label={<div>custom item B</div>}
value="toggle_b"
labelAsText="Plain Toggle B"
/>
</SingleSelect>,
);

// Act
const opener = await screen.findByRole("button");
const menuLabel = within(opener).getByText("custom item A");

// Assert
expect(menuLabel).toBeVisible();
});
});

describe("mouse", () => {
Expand Down
20 changes: 16 additions & 4 deletions packages/wonder-blocks-dropdown/src/components/multi-select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type {
OptionItemComponent,
OptionItemComponentArray,
} from "../util/types";
import {getLabel} from "../util/helpers";
import {getLabel, getSelectOpenerLabel} from "../util/helpers";

export type Labels = {
/**
Expand Down Expand Up @@ -91,6 +91,13 @@ type DefaultProps = Readonly<{
* Whether to display shortcuts for Select All and Select None.
*/
shortcuts: boolean;
/**
* When false, the SelectOpener can show a Node as a label. When true, the
* SelectOpener will use a string as a label. If using custom OptionItems, a
* plain text label can be provided with the `labelAsText` prop.
* Defaults to true.
*/
showOpenerLabelAsText: boolean;
}>;

type Props = AriaProps &
Expand Down Expand Up @@ -227,6 +234,7 @@ export default class MultiSelect extends React.Component<Props, State> {
light: false,
shortcuts: false,
selectedValues: [],
showOpenerLabelAsText: true,
};

constructor(props: Props) {
Expand Down Expand Up @@ -315,8 +323,9 @@ export default class MultiSelect extends React.Component<Props, State> {
onChange([]);
};

getMenuText(children: OptionItemComponentArray): string {
const {implicitAllEnabled, selectedValues} = this.props;
getMenuText(children: OptionItemComponentArray): string | JSX.Element {
const {implicitAllEnabled, selectedValues, showOpenerLabelAsText} =
this.props;
const {noneSelected, someSelected, allSelected} = this.state.labels;
const numSelectedAll = children.filter(
(option) => !option.props.disabled,
Expand All @@ -338,7 +347,10 @@ export default class MultiSelect extends React.Component<Props, State> {
);

if (selectedItem) {
const selectedLabel = getLabel(selectedItem?.props);
const selectedLabel = getSelectOpenerLabel(
showOpenerLabelAsText,
selectedItem?.props,
);
if (selectedLabel) {
return selectedLabel;
// If the label is a ReactNode and `labelAsText` is not set,
Expand Down
Loading

0 comments on commit 2b8424c

Please sign in to comment.