Skip to content

Commit

Permalink
feat: update combobox, listbox
Browse files Browse the repository at this point in the history
  • Loading branch information
timrbula committed Jul 26, 2022
1 parent b3f5e16 commit 7767f51
Show file tree
Hide file tree
Showing 16 changed files with 278 additions and 131 deletions.
6 changes: 3 additions & 3 deletions .storybook/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@use "../src/styles/themes/boomerang";
@use '@carbon/react/scss/compat/themes' as compat;
@use '@carbon/react/scss/compat/theme' with (
$theme: map.merge(compat.$white, boomerang.$theme)
$theme: map.merge(compat.$white, compat.$white)
);
@use '@carbon/react/scss/fonts';
@use '@carbon/react/scss/grid';
Expand All @@ -17,10 +17,10 @@
@use '@carbon/react/scss/components/notification/tokens' as notification;
@use '@carbon/react/scss/components/tag/tokens' as tag;
@use '@carbon/react/scss/components';
@use '../src/styles/index' with ($use-theme-boomerang: true);
@use '../src/styles/index' with ($use-theme-boomerang: false);

[data-carbon-theme="boomerang"] {
@include theme.add-component-tokens(map.merge(button.$button-tokens, boomerang.$v11-button-tokens));
@include theme.add-component-tokens(map.merge(button.$button-tokens, button.$button-tokens));
@include theme.add-component-tokens(notification.$notification-tokens);
@include theme.add-component-tokens(tag.$tag-tokens);
@include theme.theme();
Expand Down
128 changes: 114 additions & 14 deletions src/components/ComboBox/ComboBox.jsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,25 @@
import React from "react";
import PropTypes from "prop-types";
import cx from "classnames";
import TooltipHover from "../TooltipHover";
import { Information } from "@carbon/react/icons";
import { prefix } from "../../internal/settings";

import { ComboBox } from "@carbon/react";

const defaultShouldFilterItem = ({ item, inputValue }) => {
if (typeof item === "string") {
return item.toLowerCase().includes(inputValue?.toLowerCase());
}
if (item && item.label) {
return item.label.toLowerCase().includes(inputValue?.toLowerCase());
}
return item;
};

ComboBoxComponent.propTypes = {
...ComboBox.propTypes,
disableClear: PropTypes.bool,
id: PropTypes.string.isRequired,
labelText: PropTypes.node,
label: PropTypes.node,
shouldFilterItem: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]),
titleText: PropTypes.node,
tooltipClassName: PropTypes.string,
tooltipContent: PropTypes.any,
tooltipProps: PropTypes.object,
};

ComboBoxComponent.defaultProps = {
shouldFilterItem: defaultShouldFilterItem,
disableClear: false,
tooltipClassName: `${prefix}--bmrg-select__tooltip`,
tooltipProps: { direction: "top" },
Expand All @@ -44,9 +34,116 @@ function ComboBoxComponent({
tooltipClassName,
tooltipContent,
tooltipProps,
...comboBoxProps
onChange,
onInputChange,
shouldFilterItem,
...restComboBoxProps
}) {
// Set the initial selected item to the label or single value passed
const selectedItemRef = React.useRef(
restComboBoxProps.initialSelectedItem?.label ?? restComboBoxProps.initialSelectedItem
);
const queryRef = React.useRef(selectedItemRef.current);
const [hasQuery, setHasQuery] = React.useState(false);

// Support several props for the label text
const labelValue = titleText || label || labelText;

/**
* The following three functions are to support a better ComboBox filtering experience
* than the default or passing a `shouldFilterItem` function. With the latter, if you have a selected item
* only that will be displayed if you do a naive filtering of the input.
* We want to:
* 1. Filter options based on the input text and plain value or label of the item
* 2. After selecting a value, show all options when opening the combobox with having to clear the selection
* 3. Filter the values when you enter a query with an item selected
*/

/**
* Keep track of the selected value with a ref so it doesn' re-render and to ensure that
* onInputChange has a fresh value. `onChange` is called, then `onInputChange` when selecting an item
*/
const defaultOnChange = React.useCallback(
({ selectedItem }) => {
if (!selectedItem) {
selectedItemRef.current = selectedItem;
}

if (typeof selectedItem === "string" || typeof selectedItem === "number") {
selectedItemRef.current = selectedItem;
} else {
selectedItemRef.current = selectedItem?.label;
}

// Additional check if the onInputChange function is not called
// Isn't triggered if the query value matches the selected one
if (queryRef.current === selectedItemRef.current) {
setHasQuery(false);
}

// Call consumer
if (onChange) {
onChange({ selectedItem });
}
},
[onChange]
);

/**
* When an item is selected, the `onInputChange` handler is called with the value selected
* so it is difficult to disambiguate between a keydown event and a select event
* Take a simple approach here. If the selectedItem and input values match, there isn't a query
* they don't, there is. We use this to determine if we should filter the values
*/
const defaultInputChange = (input) => {
queryRef.current = input;
if (input !== selectedItemRef.current) {
setHasQuery(true);
} else {
setHasQuery(false);
}
// Call consumer
if (onInputChange) {
onInputChange(input);
}
};

/**
* Determine if I should filter the items or not
* Selected value and no query means show everything, otherwise filter based on the input
* No point in optimizing this because re-renders will only occur on query changes
* and we need fresh values on those events to determine how to filter
*/
const defaultShouldFilterItem = ({ item, inputValue }) => {
if (selectedItemRef.current && !hasQuery) {
return true;
}

if (typeof item === "string" || typeof item === "number") {
return item.toLowerCase().includes(inputValue?.toLowerCase());
}

if (item && item.label) {
return item.label.toLowerCase().includes(inputValue?.toLowerCase());
}

return item;
};

/**
* If a function is passed, use that
* If a false or null value is explicitely passed, then use default filtering behavior in component
* Otherwise use our filtering logic as the new default
*/
let finalShouldFilterItem;
if (typeof shouldFilterItem === "function") {
finalShouldFilterItem = shouldFilterItem;
} else if (shouldFilterItem === false || shouldFilterItem === null) {
finalShouldFilterItem = undefined;
} else {
finalShouldFilterItem = defaultShouldFilterItem;
}

return (
<div key={id} className={cx(`${prefix}--bmrg-select`, { "--disableClear": disableClear })}>
<ComboBox
Expand All @@ -65,7 +162,10 @@ function ComboBoxComponent({
</div>
)
}
{...comboBoxProps}
onChange={defaultOnChange}
onInputChange={defaultInputChange}
shouldFilterItem={finalShouldFilterItem}
{...restComboBoxProps}
/>
</div>
);
Expand Down
77 changes: 46 additions & 31 deletions src/components/ComboBox/ComboBox.stories.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React from "react";
import { action } from "@storybook/addon-actions";
import ComboBox from "./index";

export default {
title: "Inputs/ComboBox",
component: ComboBox,
argTypes: { onChange: { action: "clicked" } },
};

const items = [
{ label: "Caribou", value: "caribou", isDisabled: true },
{ label: "Caribou", value: "caribou", disabled: true },
{ label: "Cat", value: "cat" },
{ label: "Catfish", value: "catfish" },
{ label: "Cheetah", value: "cheetah" },
Expand All @@ -22,28 +22,41 @@ const items = [
{ label: "Penguin", value: "penguin" },
];

const ComboBoxExternallyControlled = () => {
const [selectedItem, setSelectedItem] = React.useState([]);
const singleItems = [
"Caribou",
"Cat",
"Catfish",
"Cheetah",
"Chipmunk",
"Dog",
"Dolphin",
"Dove",
"Panda",
"Parrot",
"Peacock",
"Penguin",
];

export const Default = () => {
return (
<ComboBox
onChange={() => setSelectedItem({ label: "Penguin", value: "penguin" })}
id="select-default"
items={items}
placeholder="Search for something"
titleText="Should always select penguin"
selectedItem={selectedItem}
/>
<div style={{ width: "25rem" }}>
<ComboBox
id="select-default"
items={items}
placeholder="Search for something"
titleText="Should filter item"
helperText="Default behavior"
/>
</div>
);
};

export const Default = () => {
export const SingleItems = () => {
return (
<div style={{ width: "25rem" }}>
<ComboBox
onChange={action("select change")}
id="select-default"
items={items}
items={singleItems}
placeholder="Search for something"
titleText="Should filter item"
helperText="Default behavior"
Expand All @@ -56,11 +69,10 @@ export const WithoutFilter = () => {
return (
<div style={{ width: "25rem" }}>
<ComboBox
onChange={action("select change")}
id="select-filter"
items={items}
placeholder="Select something"
titleText="Should filter item"
titleText="Should not filter, only highlight"
shouldFilterItem={false}
/>
</div>
Expand All @@ -72,21 +84,12 @@ export const ItemToElement = () => {
<div style={{ width: "25rem" }}>
<ComboBox
disableClear
onChange={action("select change")}
id="select-tooltip-helper"
items={items}
placeholder="Search for something"
titleText="Should filter item"
helperText="My items are filtered internally"
shouldFilterItem={({ item, inputValue }) => item.label.toLowerCase().includes(inputValue.toLowerCase())}
itemToElement={(item) => (
<button
style={{ height: "100%", width: "100%", outline: "none", background: "none", border: "none" }}
disabled
>
{item.value + " test"}
</button>
)}
itemToElement={(item) => item.value + " 😊"}
tooltipContent="Tooltip for select"
tooltipProps={{ direction: "top" }}
/>
Expand All @@ -96,9 +99,8 @@ export const ItemToElement = () => {

export const MenuOpenUpwards = () => {
return (
<div style={{ width: "25rem", height: "30rem", display: "flex", alignItems: "flex-end" }}>
<div style={{ width: "25rem", height: "15rem", display: "flex", alignItems: "flex-end" }}>
<ComboBox
onChange={action("select change")}
id="select-default"
items={items}
placeholder="Search for something"
Expand All @@ -109,6 +111,21 @@ export const MenuOpenUpwards = () => {
);
};

const ComboBoxExternallyControlled = () => {
const [selectedItem, setSelectedItem] = React.useState([]);

return (
<ComboBox
onChange={({ selectedItem }) => (selectedItem ? setSelectedItem({ label: "Penguin", value: "penguin" }) : null)}
id="select-default"
items={items}
placeholder="Search for something"
titleText="Always getting pengiun"
selectedItem={selectedItem}
/>
);
};

export const ExternalControl = () => {
return (
<div style={{ width: "25rem" }}>
Expand All @@ -122,13 +139,11 @@ export const KitchenSink = () => {
<div style={{ width: "25rem" }}>
<ComboBox
disableClear
onChange={action("select change")}
id="select-tooltip-helper"
items={items}
placeholder="Search for something"
titleText="Should filter item"
helperText="My items are filtered internally"
shouldFilterItem={({ item, inputValue }) => item.label.toLowerCase().includes(inputValue.toLowerCase())}
tooltipContent="Tooltip for select"
tooltipProps={{ direction: "top" }}
/>
Expand Down
17 changes: 13 additions & 4 deletions src/internal/ListBox/ListBox.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
import cx from "classnames";
import React from "react";
import PropTypes from "prop-types";
import { prefix } from "../../internal/settings";
import { ListBoxType, ListBoxSize } from "./ListBoxPropTypes";


import ListBoxField from "./ListBoxField";
import ListBoxMenu from "./ListBoxMenu";
import ListBoxMenuIcon from "./ListBoxMenuIcon";
import ListBoxMenuItem from "./ListBoxMenuItem";
import ListBoxSelection from "./ListBoxSelection";
import { prefix } from "../settings";

const handleOnKeyDown = (event) => {
if (event.keyCode === 27) {
Expand Down Expand Up @@ -109,7 +112,7 @@ ListBox.propTypes = {
isOpen: PropTypes.bool,

/**
* `true` to use the light version. For use on theme.$layer-01 backgrounds only.
* `true` to use the light version. For use on $ui-01 backgrounds only.
* Don't use this to make tile background color same as container background color.
*/
light: PropTypes.bool,
Expand Down Expand Up @@ -141,4 +144,10 @@ ListBox.defaultProps = {
type: "default",
};

ListBox.Field = ListBoxField;
ListBox.Menu = ListBoxMenu;
ListBox.MenuIcon = ListBoxMenuIcon;
ListBox.MenuItem = ListBoxMenuItem;
ListBox.Selection = ListBoxSelection;

export default ListBox;
5 changes: 1 addition & 4 deletions src/internal/ListBox/ListBoxField.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/


import { prefix } from "../../internal/settings";
import PropTypes from "prop-types";


import { prefix } from "../settings";

// No longer used, left export for backward-compatibility
export const translationIds = {};
Expand Down
Loading

0 comments on commit 7767f51

Please sign in to comment.