Skip to content

Commit

Permalink
WB-1779: Add startIcon prop to Combobox (#2364)
Browse files Browse the repository at this point in the history
## Summary:

Adds a new `startIcon` prop (optional) to the Combobox component. This prop
will allow to optinally add an icon to the left of the input field.

This use case will be helpful for the AI tools case where we want to use a
search icon.


Issue: https://khanacademy.atlassian.net/browse/WB-1779

## Test plan:

Verify that the `Start icon` docs make sense and that the example works as
expected.

/?path=/docs/packages-dropdown-combobox--docs#start%20icon

<img width="1076" alt="Screenshot 2024-11-18 at 3 21 00 PM" src="https://github.com/user-attachments/assets/00e70b53-92cf-4b35-8cbd-30695e2a7a13">

Author: jandrade

Reviewers: beaesguerra, jandrade, marcysutton

Required Reviewers:

Approved By: beaesguerra, marcysutton

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: #2364
  • Loading branch information
jandrade authored Nov 22, 2024
1 parent 951105d commit c512e76
Show file tree
Hide file tree
Showing 4 changed files with 169 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/old-pears-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/wonder-blocks-dropdown": minor
---

Add `startIcon` prop to Combobox
87 changes: 85 additions & 2 deletions __docs__/wonder-blocks-dropdown/combobox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import {Meta, StoryObj} from "@storybook/react";
import {expect, userEvent, within} from "@storybook/test";
import {StyleSheet} from "aphrodite";
import * as React from "react";
import {LabelLarge} from "@khanacademy/wonder-blocks-typography";
import {color, spacing} from "@khanacademy/wonder-blocks-tokens";
import magnifyingGlassIcon from "@phosphor-icons/core/bold/magnifying-glass-bold.svg";

import {LabelLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography";
import {color, semanticColor, spacing} from "@khanacademy/wonder-blocks-tokens";
import {Checkbox} from "@khanacademy/wonder-blocks-form";
import {Combobox, OptionItem} from "@khanacademy/wonder-blocks-dropdown";
import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
import {PropsFor, View} from "@khanacademy/wonder-blocks-core";
import {allProfilesWithPictures} from "./option-item-examples";

Expand Down Expand Up @@ -428,3 +431,83 @@ export const Error: Story = {
error: true,
},
};

/**
* With `startIcon`, you can customize the icon that appears at the beginning of
* the Combobox. This is useful when you want to add a custom icon to the
* component.
*
* **NOTE:** When `startIcon` is set, we set some default values for the icon:
* - `size`: "small"
* - `color`: `semanticColor.icon.default`
*
* You can customize the size and color of the icon by passing the `size` and
* `color` props to the `PhosphorIcon` component.
*/
export const StartIcon: Story = {
render: function Render(args: PropsFor<typeof Combobox>) {
const [_, updateArgs] = useArgs();

return (
<View style={{gap: spacing.medium_16}}>
<LabelMedium>With default size and color:</LabelMedium>
<Combobox
{...args}
startIcon={<PhosphorIcon icon={magnifyingGlassIcon} />}
onChange={(newValue) => {
updateArgs({value: newValue});
action("onChange")(newValue);
}}
/>
<LabelMedium>With custom size:</LabelMedium>
<Combobox
{...args}
startIcon={
<PhosphorIcon
icon={magnifyingGlassIcon}
size="medium"
/>
}
onChange={(newValue) => {
updateArgs({value: newValue});
action("onChange")(newValue);
}}
/>
<LabelMedium>With custom color:</LabelMedium>
<Combobox
{...args}
startIcon={
<PhosphorIcon
icon={magnifyingGlassIcon}
size="small"
color={semanticColor.icon.action}
/>
}
onChange={(newValue) => {
updateArgs({value: newValue});
action("onChange")(newValue);
}}
/>
<LabelMedium>Disabled (overrides color prop):</LabelMedium>
<Combobox
{...args}
startIcon={
<PhosphorIcon
icon={magnifyingGlassIcon}
size="small"
color={semanticColor.icon.action}
/>
}
disabled={true}
onChange={(newValue) => {
updateArgs({value: newValue});
action("onChange")(newValue);
}}
/>
</View>
);
},
args: {
children: items,
},
};
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import * as React from "react";
import {render, screen, waitFor} from "@testing-library/react";
import {RenderStateRoot} from "@khanacademy/wonder-blocks-core";
import {render, screen, waitFor} from "@testing-library/react";
import * as React from "react";
import magnifyingGlassIcon from "@phosphor-icons/core/regular/magnifying-glass.svg";

import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
import {PointerEventsCheckLevel, userEvent} from "@testing-library/user-event";
import Combobox from "../combobox";
import OptionItem from "../option-item";
import {defaultComboboxLabels} from "../../util/constants";
import {MaybeValueOrValues} from "../../util/types";
import Combobox from "../combobox";
import OptionItem from "../option-item";

const doRender = (element: React.ReactElement) => {
render(element, {wrapper: RenderStateRoot});
Expand Down Expand Up @@ -305,6 +307,31 @@ describe("Combobox", () => {
expect(screen.getByRole("combobox")).not.toHaveFocus();
});

it("should include an icon at the beginning of the combobox", () => {
// Arrange

// Act
doRender(
<Combobox
selectionType="single"
value=""
startIcon={
<PhosphorIcon
icon={magnifyingGlassIcon}
testId="start-icon"
/>
}
>
<OptionItem label="option 1" value="option1" />
<OptionItem label="option 2" value="option2" />
<OptionItem label="option 3" value="option3" />
</Combobox>,
);

// Assert
expect(screen.getByTestId("start-icon")).toBeInTheDocument();
});

describe("dismiss button", () => {
it("should clear the value when the user presses the clear button (x) via Mouse", async () => {
// Arrange
Expand Down
49 changes: 48 additions & 1 deletion packages/wonder-blocks-dropdown/src/components/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ import {
} from "@khanacademy/wonder-blocks-core";
import {TextField} from "@khanacademy/wonder-blocks-form";
import IconButton from "@khanacademy/wonder-blocks-icon-button";
import {border, color, spacing} from "@khanacademy/wonder-blocks-tokens";
import {
border,
color,
semanticColor,
spacing,
} from "@khanacademy/wonder-blocks-tokens";

import {DetailCell} from "@khanacademy/wonder-blocks-cell";
import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon";
import {useListbox} from "../hooks/use-listbox";
import {useMultipleSelection} from "../hooks/use-multiple-selection";
import {
Expand Down Expand Up @@ -132,6 +138,13 @@ type Props = {
*/
// TODO(WB-1740): Add support to `inline` and `both` values.
autoComplete?: "none" | "list" | undefined;

/**
* An optional decorative icon to display at the start of the combobox.
*/
startIcon?: React.ReactElement<
React.ComponentProps<typeof PhosphorIcon>
> | null;
};

/**
Expand All @@ -158,6 +171,7 @@ export default function Combobox({
opened,
placeholder,
selectionType = "single",
startIcon,
testId,
value = "",
}: Props) {
Expand Down Expand Up @@ -477,6 +491,30 @@ export default function Combobox({
return [labelFromSelected];
}, [children, labelFromSelected, selected]);

/**
* Renders the start icon if provided.
*/
const maybeRenderStartIcon = () => {
if (!startIcon) {
return null;
}

const startIconElement = React.cloneElement(startIcon, {
// Provide a default size for the icon that can be overridden by
// the consumer.
size: "small",
...startIcon.props,
// Override the disabled state of the icon to match the combobox
// state.
color: disabled
? color.offBlack32
: // Use the color passed in, otherwise use the default color.
startIcon.props.color ?? semanticColor.icon.primary,
} as Partial<React.ReactElement<React.ComponentProps<typeof PhosphorIcon>>>);

return <View style={styles.iconWrapper}>{startIconElement}</View>;
};

const pillIdPrefix = id ? `${id}-pill-` : ids.get("pill");

const currentActiveDescendant = !openState
Expand Down Expand Up @@ -534,6 +572,8 @@ export default function Combobox({
removeSelectedLabel={labels.removeSelected}
/>
)}
{maybeRenderStartIcon()}

<TextField
id={ids.get("input")}
testId={testId}
Expand Down Expand Up @@ -739,4 +779,11 @@ const styles = StyleSheet.create({
// This is calculated based on the padding + width of the arrow button.
right: spacing.xLarge_32 + spacing.xSmall_8,
},
iconWrapper: {
padding: spacing.xxxSmall_4,
// View has a default minWidth of 0, which causes the label text
// to encroach on the icon when it needs to truncate. We can fix
// this by setting the minWidth to auto.
minWidth: "auto",
},
});

0 comments on commit c512e76

Please sign in to comment.