From 56a896c6b6b1169360ad1b6e63638a8cc1d8a593 Mon Sep 17 00:00:00 2001 From: Juan Andrade Date: Thu, 14 Dec 2023 12:57:51 -0500 Subject: [PATCH] WB-1645: Allow custom option item elements (#2139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: 1. Allow custom `OptionItem` components by using `DetailCell` internally. - Removed `ClickableBehavior` from `OptionItem` and replaced it with `DetailCell` (which internally uses `Clickable`). - Added stories and included docs for `OptionItem`. 2. Modified Cell to support a new prop required for the `Optionitem` changes: - `aria-selected` is used to allow the `OptionItem` to be selectable (via aria attributes). Issue: WB-1645 ## Test plan: ### SingleSelect: 1. Navigate to http://localhost:6061/?path=/docs/dropdown-singleselect--docs#custom-option-items 2. Verify that the custom option items are rendered as expected. 3. Verify that the custom option items are clickable and that clicking on them selects them. https://github.com/Khan/wonder-blocks/assets/843075/6d171969-cc1e-4922-bbcf-16e23cb8e4d4 ### MultiSelect: 1. Navigate to http://localhost:6061/?path=/docs/dropdown-multiselect--docs#custom-option-items 2. Verify that the custom option items are rendered as expected. 3. Verify that the custom option items are clickable and that clicking on them selects them. https://github.com/Khan/wonder-blocks/assets/843075/e60716a7-533c-45c4-b239-c282de7dbaf9 ### OptionItem docs: 1. Navigate to http://localhost:6061/?path=/docs/dropdown-optionitem--docs 2. Verify that the documentation for `OptionItem` is correct and up to date. Screenshot 2023-12-13 at 12 07 11 PM Author: jandrade Reviewers: jandrade, jeresig Required Reviewers: Approved By: jeresig Checks: ✅ Chromatic - Get results on regular PRs (ubuntu-latest, 16.x), ✅ codecov/project, ✅ Test (ubuntu-latest, 16.x, 2/2), ✅ Lint (ubuntu-latest, 16.x), ✅ Test (ubuntu-latest, 16.x, 1/2), ✅ Check build sizes (ubuntu-latest, 16.x), ✅ Publish npm snapshot (ubuntu-latest, 16.x), ✅ Chromatic - Build on regular PRs / chromatic (ubuntu-latest, 16.x), ⏭ Chromatic - Skip on Release PR (changesets), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 16.x), ⏭ dependabot, ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 16.x), ✅ gerald Pull Request URL: https://github.com/Khan/wonder-blocks/pull/2139 --- .changeset/fuzzy-roses-crash.md | 5 + .changeset/smooth-icons-whisper.md | 5 + .../compact-cell.argtypes.tsx | 13 + .../multi-select.stories.tsx | 62 ++++ .../option-item-examples.tsx | 303 ++++++++++++++++++ .../option-item.argtypes.tsx | 124 +++++++ .../option-item.stories.tsx | 144 +++++++++ .../single-select.stories.tsx | 119 +++++++ .../__tests__/clickables.test.tsx | 2 - .../src/components/internal/cell-core.tsx | 2 + packages/wonder-blocks-cell/src/util/types.ts | 6 +- .../__tests__/multi-select.test.tsx | 43 ++- .../components/__tests__/option-item.test.tsx | 86 +++++ .../__tests__/single-select.test.tsx | 4 +- .../src/components/check.tsx | 25 +- .../src/components/checkbox.tsx | 40 +-- .../src/components/dropdown-core.tsx | 19 +- .../src/components/dropdown-opener.tsx | 4 +- .../src/components/multi-select.tsx | 21 +- .../src/components/option-item.tsx | 251 +++++++++++---- .../src/components/select-opener.tsx | 3 +- .../src/components/single-select.tsx | 7 +- .../{helpers.test.ts => helpers.test.tsx} | 51 ++- .../src/util/constants.ts | 2 +- .../src/util/helpers.ts | 27 ++ .../wonder-blocks-dropdown/src/util/types.ts | 14 +- 26 files changed, 1239 insertions(+), 143 deletions(-) create mode 100644 .changeset/fuzzy-roses-crash.md create mode 100644 .changeset/smooth-icons-whisper.md create mode 100644 __docs__/wonder-blocks-dropdown/option-item-examples.tsx create mode 100644 __docs__/wonder-blocks-dropdown/option-item.argtypes.tsx create mode 100644 __docs__/wonder-blocks-dropdown/option-item.stories.tsx create mode 100644 packages/wonder-blocks-dropdown/src/components/__tests__/option-item.test.tsx rename packages/wonder-blocks-dropdown/src/util/__tests__/{helpers.test.ts => helpers.test.tsx} (54%) diff --git a/.changeset/fuzzy-roses-crash.md b/.changeset/fuzzy-roses-crash.md new file mode 100644 index 000000000..2bdc7f9ea --- /dev/null +++ b/.changeset/fuzzy-roses-crash.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-cell": minor +--- + +Add `aria-selected` support to Cell so it can be used in dropdown options diff --git a/.changeset/smooth-icons-whisper.md b/.changeset/smooth-icons-whisper.md new file mode 100644 index 000000000..06f85ce8d --- /dev/null +++ b/.changeset/smooth-icons-whisper.md @@ -0,0 +1,5 @@ +--- +"@khanacademy/wonder-blocks-dropdown": major +--- + +Allow custom OptionItem elements (internally using cell components) diff --git a/__docs__/wonder-blocks-cell/compact-cell.argtypes.tsx b/__docs__/wonder-blocks-cell/compact-cell.argtypes.tsx index e9a3f4c6f..9d4cb124c 100644 --- a/__docs__/wonder-blocks-cell/compact-cell.argtypes.tsx +++ b/__docs__/wonder-blocks-cell/compact-cell.argtypes.tsx @@ -209,6 +209,19 @@ export default { }, }, }, + ariaSelected: { + name: "aria-selected", + control: { + type: "string", + }, + description: " Used to indicate the current element is selected", + table: { + category: "Accessibility", + type: { + summary: "string", + }, + }, + }, role: { description: "The role of the Cell component, can be a role of type `ClickableRole`", diff --git a/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx b/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx index 2d8bd75c2..e20abf96a 100644 --- a/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/multi-select.stories.tsx @@ -11,12 +11,14 @@ import {OnePaneDialog, ModalLauncher} from "@khanacademy/wonder-blocks-modal"; import Spacing from "@khanacademy/wonder-blocks-spacing"; import {HeadingLarge, LabelMedium} from "@khanacademy/wonder-blocks-typography"; import {MultiSelect, OptionItem} from "@khanacademy/wonder-blocks-dropdown"; +import Pill from "@khanacademy/wonder-blocks-pill"; import type {Labels} from "@khanacademy/wonder-blocks-dropdown"; 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 {allProfilesWithPictures} from "./option-item-examples"; type StoryComponentType = StoryObj; @@ -565,3 +567,63 @@ export const CustomLabels: StoryComponentType = { ); }, }; + +/** + * Custom option items + */ + +/** + * This example illustrates how you can use the `OptionItem` component to + * display a list with custom option items. Note that in this example, we are + * using `leftAccessory` to display a custom icon for each option item, + * `subtitle1` to optionally display a pill and `subtitle2` to display the + * email. + * + * **Note:** As these are custom option items, we strongly recommend to pass the + * `labelAsText` prop to display a summarized label in the menu. + */ +export const CustomOptionItems: StoryComponentType = { + render: function Render() { + const [opened, setOpened] = React.useState(true); + const [selectedValues, setSelectedValues] = React.useState< + Array + >([]); + + const handleChange = (selectedValues: Array) => { + setSelectedValues(selectedValues); + }; + + const handleToggle = (opened: boolean) => { + setOpened(opened); + }; + + return ( + + {allProfilesWithPictures.map((user, index) => ( + New + ) : undefined + } + subtitle2={user.email} + /> + ))} + + ); + }, + decorators: [ + (Story): React.ReactElement> => ( + {Story()} + ), + ], +}; diff --git a/__docs__/wonder-blocks-dropdown/option-item-examples.tsx b/__docs__/wonder-blocks-dropdown/option-item-examples.tsx new file mode 100644 index 000000000..5b679945e --- /dev/null +++ b/__docs__/wonder-blocks-dropdown/option-item-examples.tsx @@ -0,0 +1,303 @@ +import * as React from "react"; +import userCircleIcon from "@phosphor-icons/core/duotone/user-circle-duotone.svg"; +import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; + +export const allCountries = [ + ["AF", "Afghanistan"], + ["AX", "Åland Islands"], + ["AL", "Albania"], + ["DZ", "Algeria"], + ["AS", "American Samoa"], + ["AD", "Andorra"], + ["AO", "Angola"], + ["AI", "Anguilla"], + ["AQ", "Antarctica"], + ["AG", "Antigua and Barbuda"], + ["AR", "Argentina"], + ["AM", "Armenia"], + ["AW", "Aruba"], + ["AU", "Australia"], + ["AT", "Austria"], + ["AZ", "Azerbaijan"], + ["BS", "Bahamas (the)"], + ["BH", "Bahrain"], + ["BD", "Bangladesh"], + ["BB", "Barbados"], + ["BY", "Belarus"], + ["BE", "Belgium"], + ["BZ", "Belize"], + ["BJ", "Benin"], + ["BM", "Bermuda"], + ["BT", "Bhutan"], + ["BO", "Bolivia (Plurinational State of)"], + ["BQ", "Bonaire, Sint Eustatius and Saba"], + ["BA", "Bosnia and Herzegovina"], + ["BW", "Botswana"], + ["BV", "Bouvet Island"], + ["BR", "Brazil"], + ["VG", "Virgin Islands (British)"], + ["IO", "British Indian Ocean Territory (the)"], + ["BN", "Brunei Darussalam"], + ["BG", "Bulgaria"], + ["BF", "Burkina Faso"], + ["BI", "Burundi"], + ["KH", "Cambodia"], + ["CM", "Cameroon"], + ["CA", "Canada"], + ["CV", "Cabo Verde"], + ["KY", "Cayman Islands (the)"], + ["CF", "Central African Republic (the)"], + ["TD", "Chad"], + ["CL", "Chile"], + ["CN", "China"], + ["HK", "Hong Kong"], + ["MO", "Macao"], + ["CX", "Christmas Island"], + ["CC", "Cocos (Keeling) Islands (the)"], + ["CO", "Colombia"], + ["KM", "Comoros (the)"], + ["CG", "Congo (the)"], + ["CD", "Congo (the Democratic Republic of the)"], + ["CK", "Cook Islands (the)"], + ["CR", "Costa Rica"], + ["CI", "Côte d'Ivoire"], + ["HR", "Croatia"], + ["CU", "Cuba"], + ["CW", "Curaçao"], + ["CY", "Cyprus"], + ["CZ", "Czechia"], + ["DK", "Denmark"], + ["DJ", "Djibouti"], + ["DM", "Dominica"], + ["DO", "Dominican Republic (the)"], + ["EC", "Ecuador"], + ["EG", "Egypt"], + ["SV", "El Salvador"], + ["GQ", "Equatorial Guinea"], + ["ER", "Eritrea"], + ["EE", "Estonia"], + ["ET", "Ethiopia"], + ["FK", "Falkland Islands (the) [Malvinas]"], + ["FO", "Faroe Islands (the)"], + ["FJ", "Fiji"], + ["FI", "Finland"], + ["FR", "France"], + ["GF", "French Guiana"], + ["PF", "French Polynesia"], + ["TF", "French Southern Territories (the)"], + ["GA", "Gabon"], + ["GM", "Gambia (the)"], + ["GE", "Georgia"], + ["DE", "Germany"], + ["GH", "Ghana"], + ["GI", "Gibraltar"], + ["GR", "Greece"], + ["GL", "Greenland"], + ["GD", "Grenada"], + ["GP", "Guadeloupe"], + ["GU", "Guam"], + ["GT", "Guatemala"], + ["GG", "Guernsey"], + ["GN", "Guinea"], + ["GW", "Guinea-Bissau"], + ["GY", "Guyana"], + ["HT", "Haiti"], + ["HM", "Heard Island and McDonald Islands"], + ["VA", "Holy See (the)"], + ["HN", "Honduras"], + ["HU", "Hungary"], + ["IS", "Iceland"], + ["IN", "India"], + ["ID", "Indonesia"], + ["IR", "Iran (Islamic Republic of)"], + ["IQ", "Iraq"], + ["IE", "Ireland"], + ["IM", "Isle of Man"], + ["IL", "Israel"], + ["IT", "Italy"], + ["JM", "Jamaica"], + ["JP", "Japan"], + ["JE", "Jersey"], + ["JO", "Jordan"], + ["KZ", "Kazakhstan"], + ["KE", "Kenya"], + ["KI", "Kiribati"], + ["KP", "Korea (the Democratic People's Republic of)"], + ["KR", "Korea (the Republic of)"], + ["KW", "Kuwait"], + ["KG", "Kyrgyzstan"], + ["LA", "Lao People's Democratic Republic (the)"], + ["LV", "Latvia"], + ["LB", "Lebanon"], + ["LS", "Lesotho"], + ["LR", "Liberia"], + ["LY", "Libya"], + ["LI", "Liechtenstein"], + ["LT", "Lithuania"], + ["LU", "Luxembourg"], + ["MK", "North Macedonia"], + ["MG", "Madagascar"], + ["MW", "Malawi"], + ["MY", "Malaysia"], + ["MV", "Maldives"], + ["ML", "Mali"], + ["MT", "Malta"], + ["MH", "Marshall Islands (the)"], + ["MQ", "Martinique"], + ["MR", "Mauritania"], + ["MU", "Mauritius"], + ["YT", "Mayotte"], + ["MX", "Mexico"], + ["FM", "Micronesia (Federated States of)"], + ["MD", "Moldova (the Republic of)"], + ["MC", "Monaco"], + ["MN", "Mongolia"], + ["ME", "Montenegro"], + ["MS", "Montserrat"], + ["MA", "Morocco"], + ["MZ", "Mozambique"], + ["MM", "Myanmar"], + ["NA", "Namibia"], + ["NR", "Nauru"], + ["NP", "Nepal"], + ["NL", "Netherlands (the)"], + ["NC", "New Caledonia"], + ["NZ", "New Zealand"], + ["NI", "Nicaragua"], + ["NE", "Niger (the)"], + ["NG", "Nigeria"], + ["NU", "Niue"], + ["NF", "Norfolk Island"], + ["MP", "Northern Mariana Islands (the)"], + ["NO", "Norway"], + ["OM", "Oman"], + ["PK", "Pakistan"], + ["PW", "Palau"], + ["PS", "Palestine, State of"], + ["PA", "Panama"], + ["PG", "Papua New Guinea"], + ["PY", "Paraguay"], + ["PE", "Peru"], + ["PH", "Philippines (the)"], + ["PN", "Pitcairn"], + ["PL", "Poland"], + ["PT", "Portugal"], + ["PR", "Puerto Rico"], + ["QA", "Qatar"], + ["RE", "Réunion"], + ["RO", "Romania"], + ["RU", "Russian Federation (the)"], + ["RW", "Rwanda"], + ["BL", "Saint Barthélemy"], + ["SH", "Saint Helena, Ascension and Tristan da Cunha"], + ["KN", "Saint Kitts and Nevis"], + ["LC", "Saint Lucia"], + ["MF", "Saint Martin (French part)"], + ["PM", "Saint Pierre and Miquelon"], + ["VC", "Saint Vincent and the Grenadines"], + ["WS", "Samoa"], + ["SM", "San Marino"], + ["ST", "Sao Tome and Principe"], + ["SA", "Saudi Arabia"], + ["SN", "Senegal"], + ["RS", "Serbia"], + ["SC", "Seychelles"], + ["SL", "Sierra Leone"], + ["SG", "Singapore"], + ["SX", "Sint Maarten (Dutch part)"], + ["SK", "Slovakia"], + ["SI", "Slovenia"], + ["SB", "Solomon Islands"], + ["SO", "Somalia"], + ["ZA", "South Africa"], + ["GS", "South Georgia and the South Sandwich Islands"], + ["SS", "South Sudan"], + ["ES", "Spain"], + ["LK", "Sri Lanka"], + ["SD", "Sudan (the)"], + ["SR", "Suriname"], + ["SJ", "Svalbard and Jan Mayen"], + ["SZ", "Eswatini"], + ["SE", "Sweden"], + ["CH", "Switzerland"], + ["SY", "Syrian Arab Republic (the)"], + ["TW", "Taiwan (Province of China)"], + ["TJ", "Tajikistan"], + ["TZ", "Tanzania, the United Republic of"], + ["TH", "Thailand"], + ["TL", "Timor-Leste"], + ["TG", "Togo"], + ["TK", "Tokelau"], + ["TO", "Tonga"], + ["TT", "Trinidad and Tobago"], + ["TN", "Tunisia"], + ["TR", "Turkey"], + ["TM", "Turkmenistan"], + ["TC", "Turks and Caicos Islands (the)"], + ["TV", "Tuvalu"], + ["UG", "Uganda"], + ["UA", "Ukraine"], + ["AE", "United Arab Emirates (the)"], + ["GB", "United Kingdom of Great Britain and Northern Ireland (the)"], + ["US", "United States of America (the)"], + ["UM", "United States Minor Outlying Islands (the)"], + ["UY", "Uruguay"], + ["UZ", "Uzbekistan"], + ["VU", "Vanuatu"], + ["VE", "Venezuela (Bolivarian Republic of)"], + ["VN", "Viet Nam"], + ["VI", "Virgin Islands (U.S.)"], + ["WF", "Wallis and Futuna"], + ["EH", "Western Sahara"], + ["YE", "Yemen"], + ["ZM", "Zambia"], + ["ZW", "Zimbabwe"], +]; + +const icon = ( + +); + +export const allProfilesWithPictures = [ + { + id: "1", + name: "John Doe 1", + email: "john.doe@wonder-blocks.com", + picture: icon, + }, + { + id: "2", + name: "Jane Doe 1", + email: "jane.doe@wonder-blocks.com", + picture: icon, + }, + { + id: "3", + name: "John Smith 1", + email: "john.smith@wonder-blocks.com", + picture: icon, + }, + { + id: "4", + name: "Jane Smith 1", + email: "jane.smith@wonder-blocks.com", + picture: icon, + }, + { + id: "5", + name: "John Doe 2", + email: "john.doe@wonder-blocks.com", + picture: icon, + }, + { + id: "6", + name: "Jane Doe 2", + email: "jane.doe@wonder-blocks.com", + picture: icon, + }, +]; diff --git a/__docs__/wonder-blocks-dropdown/option-item.argtypes.tsx b/__docs__/wonder-blocks-dropdown/option-item.argtypes.tsx new file mode 100644 index 000000000..782edd3b6 --- /dev/null +++ b/__docs__/wonder-blocks-dropdown/option-item.argtypes.tsx @@ -0,0 +1,124 @@ +import * as React from "react"; +import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; +import Pill from "@khanacademy/wonder-blocks-pill"; +import {IconMappings} from "../wonder-blocks-icon/phosphor-icon.argtypes"; + +export const AccessoryMappings = { + none: null, + icon: , + pill: New, +}; + +export default { + label: { + control: {type: "text"}, + description: "Display text of the option item.", + table: { + category: "Labelling", + type: {summary: "string"}, + }, + type: {name: "string", required: true}, + }, + labelAsText: { + control: {type: "text"}, + description: `Optional text to use as the label. If not provided, label + will be used. This is useful for cases where the label is a complex + component and you want to display a simpler string in the menu.`, + table: { + category: "Labelling", + type: {summary: "string"}, + }, + type: {name: "string", required: false}, + }, + value: { + control: {type: "text"}, + description: `Value of the item, used as a key of sorts for the parent + to manage its items, because label/display text may be identical + for some selects. This is the value passed back when the item is + selected.`, + table: { + type: {summary: "string"}, + }, + type: {name: "string", required: true}, + }, + disabled: { + control: {type: "boolean"}, + description: "Whether or not the option item is disabled.", + table: { + defaultValue: {summary: false}, + type: {summary: "boolean"}, + }, + type: {name: "boolean", required: true}, + }, + onClick: { + control: {type: null}, + description: `Optional user-supplied callback when this item is called.`, + table: { + type: {summary: "() => unknown"}, + }, + type: {name: "function", required: false}, + }, + testId: { + control: {type: "text"}, + description: "Test ID used for e2e testing.", + table: { + type: {summary: "string"}, + }, + type: {name: "string", required: false}, + }, + horizontalRule: { + options: ["none", "inset", "full-width"], + control: {type: "select"}, + description: "Adds a horizontal rule at the bottom of the action item.", + defaultValue: "none", + table: { + defaultValue: {summary: "none"}, + type: {summary: `"none" | "inset" | "full-width"`}, + }, + type: {name: `"none" | "inset" | "full-width"`, required: false}, + }, + leftAccessory: { + options: Object.keys(AccessoryMappings) as Array, + mapping: AccessoryMappings, + control: {type: "select"}, + description: "Adds an accessory to the left of the action item.", + table: { + type: {summary: "React.Node"}, + }, + type: {name: "React.Node", required: false}, + }, + rightAccessory: { + options: Object.keys(AccessoryMappings) as Array, + mapping: AccessoryMappings, + control: {type: "select"}, + description: "Adds an accessory to the right of the action item.", + table: { + type: {summary: "React.Node"}, + }, + type: {name: "React.Node", required: false}, + }, + subtitle1: { + control: { + type: "text", + }, + description: "Optional subtitle to display before the label.", + table: { + category: "Layout", + type: { + detail: "string | React.Element", + }, + }, + }, + subtitle2: { + control: { + type: "text", + }, + description: "Optional subtitle to display after the label.", + table: { + category: "Layout", + type: { + detail: "string | React.Element", + }, + }, + }, +}; diff --git a/__docs__/wonder-blocks-dropdown/option-item.stories.tsx b/__docs__/wonder-blocks-dropdown/option-item.stories.tsx new file mode 100644 index 000000000..13c8a89c9 --- /dev/null +++ b/__docs__/wonder-blocks-dropdown/option-item.stories.tsx @@ -0,0 +1,144 @@ +import {Meta} from "@storybook/react"; +import * as React from "react"; +import {StyleSheet} from "aphrodite"; +import Color from "@khanacademy/wonder-blocks-color"; +import {PropsFor, View} from "@khanacademy/wonder-blocks-core"; +import {OptionItem} from "@khanacademy/wonder-blocks-dropdown"; +import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; +import Spacing from "@khanacademy/wonder-blocks-spacing"; + +import ComponentInfo from "../../.storybook/components/component-info"; +import packageConfig from "../../packages/wonder-blocks-dropdown/package.json"; +import {IconMappings} from "../wonder-blocks-icon/phosphor-icon.argtypes"; +import optionItemArgtypes, {AccessoryMappings} from "./option-item.argtypes"; + +const defaultArgs = { + label: "Option Item", + onClick: () => {}, + disabled: false, + testId: "", + horizontalRule: "none", + leftAccessory: null, + rightAccessory: null, +}; + +const styles = StyleSheet.create({ + example: { + background: Color.offWhite, + padding: Spacing.medium_16, + width: 300, + }, + items: { + background: Color.white, + }, +}); + +/** + * For option items that can be selected in a dropdown, selection denoted either + * with a check ✔️ or a checkbox ☑️. Use as children in `SingleSelect` or + * `MultiSelect`. + * + * ### Usage + * + * ```tsx + * import {OptionItem, SingleSelect} from "@khanacademy/wonder-blocks-dropdown"; + * + * + * {}} /> + * + * ``` + */ +export default { + title: "Dropdown/OptionItem", + component: OptionItem, + argTypes: optionItemArgtypes, + args: defaultArgs, + decorators: [ + (Story): React.ReactElement> => ( + + + + ), + ], + parameters: { + componentSubtitle: ( + + ), + }, +} as Meta; + +/** + * The default option item with a `label` and an `onClick` handler. This is used + * to trigger actions (if needed). + */ +export const Default = { + args: { + label: "Option Item", + onClick: () => {}, + }, +}; + +/** + * OptionItem can be `disabled`. This is used to indicate that the Option is not + * available. + */ +export const Disabled = { + args: { + label: "Option Item", + onClick: () => {}, + disabled: true, + }, +}; + +/** + * OptionItem can have more complex content, such as icons. + * + * This can be done by passing in a `leftAccessory` and/or `rightAccessory` + * prop. These can be any React node, and internally use the WB DetailCell + * component to render. + * + * If you need more control over the content, you can also use `subtitle1` and + * `subtitle2` props. These can be any React node, and internally use the WB + * `LabelSmall` component to render. + */ +export const CustomOptionItem = { + args: { + label: "Option Item", + onClick: () => {}, + subtitle1: AccessoryMappings.pill, + subtitle2: "Subtitle 2", + leftAccessory: ( + + ), + rightAccessory: ( + + ), + }, +}; + +/** + * `horizontalRule` can be used to separate items within + * SingleSelect/MultiSelect instances. It defaults to `none`, but can be set to + * `inset` or `full-width` to add a horizontal rule at the bottom of the cell. + */ +export const HorizontalRule = { + args: { + label: "Option Item", + onClick: () => {}, + }, + render: (args: PropsFor): React.ReactNode => ( + + + + + + + ), +}; diff --git a/__docs__/wonder-blocks-dropdown/single-select.stories.tsx b/__docs__/wonder-blocks-dropdown/single-select.stories.tsx index bbb0093fc..2f00104a6 100644 --- a/__docs__/wonder-blocks-dropdown/single-select.stories.tsx +++ b/__docs__/wonder-blocks-dropdown/single-select.stories.tsx @@ -1,7 +1,9 @@ import * as React from "react"; import {StyleSheet} from "aphrodite"; +import planetIcon from "@phosphor-icons/core/regular/planet.svg"; import type {Meta, StoryObj} from "@storybook/react"; + import Button from "@khanacademy/wonder-blocks-button"; import Color from "@khanacademy/wonder-blocks-color"; import {View} from "@khanacademy/wonder-blocks-core"; @@ -9,6 +11,7 @@ import {TextField} from "@khanacademy/wonder-blocks-form"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; import {Strut} from "@khanacademy/wonder-blocks-layout"; import {OnePaneDialog, ModalLauncher} from "@khanacademy/wonder-blocks-modal"; +import Pill from "@khanacademy/wonder-blocks-pill"; import Spacing from "@khanacademy/wonder-blocks-spacing"; import { Body, @@ -28,6 +31,7 @@ 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"; type StoryComponentType = StoryObj; type SingleSelectArgs = Partial; @@ -774,3 +778,118 @@ export const AutoFocusDisabled: StoryComponentType = { }, }, }; + +/** + * Custom option items + */ + +/** + * This example illustrates how you can use the `OptionItem` component to + * display a list with custom option items. Note that in this example, we are + * using `leftAccessory` to display a custom icon for each option item, + * `subtitle1` to optionally display a pill and `subtitle2` to display the + * email. + * + * **Note:** As these are custom option items, we strongly recommend to pass the + * `labelAsText` prop to display a summarized label in the menu. + */ +export const CustomOptionItems: 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 ( + + + {allProfilesWithPictures.map((user, index) => ( + New + ) : undefined + } + subtitle2={user.email} + /> + ))} + + + ); + }, +}; + +/** + * This example illustrates how you can use the `OptionItem` component to + * display a virtualized list with custom option items. Note that in this + * example, we are using `leftAccessory` to display a custom icon for each + * option item. + * + * **Note:** The virtualized version doesn't support custom option items with + * multiple lines at the moment. This is a known issue and we are working on + * fixing it. + */ +export const CustomOptionItemsVirtualized: StoryComponentType = { + name: "Custom option items (virtualized)", + render: function Render() { + const [opened, setOpened] = React.useState(true); + const [selectedValue, setSelectedValue] = React.useState( + allCountries[0][0], + ); + + const handleToggle = (opened: boolean) => { + setOpened(opened); + }; + + const handleChange = (selectedValue: string) => { + setSelectedValue(selectedValue); + }; + + return ( + + {allCountries.map(([code, translatedName]) => ( + + } + /> + ))} + + ); + }, + decorators: [ + (Story): React.ReactElement> => ( + {Story()} + ), + ], +}; diff --git a/consistency-tests/__tests__/clickables.test.tsx b/consistency-tests/__tests__/clickables.test.tsx index 2aa4085b5..93700c517 100644 --- a/consistency-tests/__tests__/clickables.test.tsx +++ b/consistency-tests/__tests__/clickables.test.tsx @@ -10,7 +10,6 @@ import {render, screen} from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import plus from "@phosphor-icons/core/regular/plus.svg"; -import {OptionItem} from "@khanacademy/wonder-blocks-dropdown"; import Button from "@khanacademy/wonder-blocks-button"; import Clickable from "@khanacademy/wonder-blocks-clickable"; import { @@ -175,7 +174,6 @@ describe.each` ${DetailCell} | ${"DetailCell"} | ${false} ${IconButtonWrapper} | ${"IconButton"} | ${false} ${Link} | ${"Link"} | ${false} - ${OptionItem} | ${"OptionItem"} | ${true} `("$name", ({Component, name, hasTabIndex}: any) => { test("has expected existence of tabIndex", () => { // Arrange diff --git a/packages/wonder-blocks-cell/src/components/internal/cell-core.tsx b/packages/wonder-blocks-cell/src/components/internal/cell-core.tsx index b269b1a22..897e4fd62 100644 --- a/packages/wonder-blocks-cell/src/components/internal/cell-core.tsx +++ b/packages/wonder-blocks-cell/src/components/internal/cell-core.tsx @@ -172,6 +172,7 @@ const CellCore = (props: CellCoreProps): React.ReactElement => { href, onClick, "aria-label": ariaLabel, + "aria-selected": ariaSelected, target, role, rootStyle, @@ -187,6 +188,7 @@ const CellCore = (props: CellCoreProps): React.ReactElement => { href={href} hideDefaultFocusRing={true} aria-label={ariaLabel ? ariaLabel : undefined} + aria-selected={ariaSelected ? ariaSelected : undefined} role={role} target={target} style={[ diff --git a/packages/wonder-blocks-cell/src/util/types.ts b/packages/wonder-blocks-cell/src/util/types.ts index 6e8a881dc..d98607ad0 100644 --- a/packages/wonder-blocks-cell/src/util/types.ts +++ b/packages/wonder-blocks-cell/src/util/types.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import type {StyleType} from "@khanacademy/wonder-blocks-core"; +import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core"; import type {Typography} from "@khanacademy/wonder-blocks-typography"; import {ClickableRole} from "@khanacademy/wonder-blocks-clickable"; @@ -127,6 +127,10 @@ export type CellProps = { * Used to announce the cell's content to screen readers. */ "aria-label"?: string; + /** + * Used to indicate the current element is selected. + */ + "aria-selected"?: AriaProps["aria-selected"]; /** * Optinal href which Cell should direct to, uses client-side routing * by default if react-router is present. diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx index 65bde925a..39fdc750c 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/multi-select.test.tsx @@ -16,7 +16,8 @@ const defaultLabels: Labels = { ...builtinLabels, selectAllLabel: (numOptions: any) => `Sellect all (${numOptions})`, noneSelected: "Choose", - someSelected: (numSelectedValues: any) => `${numSelectedValues} students`, + someSelected: (numSelectedValues: any) => + numSelectedValues > 1 ? `${numSelectedValues} students` : "1 student", allSelected: "All students", }; @@ -108,6 +109,46 @@ describe("MultiSelect", () => { expect(screen.getByRole("button")).toHaveTextContent("item 1"); }); + it("displays correct text for opener when there's one custom item selected", () => { + // Arrange + + // Act + render( + + custom item 1} value="1" /> + custom item 2} value="2" /> + custom item 3} value="3" /> + , + ); + + // Assert + expect(screen.getByRole("button")).toHaveTextContent("1 student"); + }); + + it("displays correct text for opener when an invalid selection is provided", () => { + // Arrange + + // Act + render( + + + + + , + ); + + // Assert + expect(screen.getByRole("button")).toHaveTextContent("Choose"); + }); + it("displays correct text for opener when there's more than one item selected", () => { // Arrange diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/option-item.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/option-item.test.tsx new file mode 100644 index 000000000..1b545b684 --- /dev/null +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/option-item.test.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; +import {render, screen} from "@testing-library/react"; + +import {HeadingSmall} from "@khanacademy/wonder-blocks-typography"; +import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; + +import plusIcon from "@phosphor-icons/core/regular/plus.svg"; +import OptionItem from "../option-item"; + +describe("OptionItem", () => { + it("should render with disabled styles", () => { + // Arrange + + // Act + render(); + + // Assert + expect(screen.getByRole("option")).toHaveAttribute( + "aria-disabled", + "true", + ); + }); + + it("should allow passing an accessory", () => { + // Arrange + + // Act + render( + } + />, + ); + + // Assert + expect(screen.getByRole("img")).toBeInTheDocument(); + }); + + it("should allow passing a custom label", () => { + // Arrange + + // Act + render( + A heading as an item} + />, + ); + + // Assert + expect(screen.getByRole("heading")).toBeInTheDocument(); + }); + + it("should allow passing subtitle1", () => { + // Arrange + + // Act + render( + , + ); + + // Assert + expect(screen.getByText("Subtitle 1")).toBeInTheDocument(); + }); + + it("should allow passing subtitle2", () => { + // Arrange + + // Act + render( + , + ); + + // Assert + expect(screen.getByText("Subtitle 2")).toBeInTheDocument(); + }); +}); diff --git a/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx b/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx index a17e25f6c..82d9f182b 100644 --- a/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx +++ b/packages/wonder-blocks-dropdown/src/components/__tests__/single-select.test.tsx @@ -773,9 +773,7 @@ describe("SingleSelect", () => { "dropdown-live-region", ).textContent; - // TODO(WB-1318): Change this assertion to `1 item` after adding the - // `labels` prop to the component. - expect(liveRegionText).toEqual("1 items"); + expect(liveRegionText).toEqual("1 item"); }); }); diff --git a/packages/wonder-blocks-dropdown/src/components/check.tsx b/packages/wonder-blocks-dropdown/src/components/check.tsx index 9f2f9a805..426653081 100644 --- a/packages/wonder-blocks-dropdown/src/components/check.tsx +++ b/packages/wonder-blocks-dropdown/src/components/check.tsx @@ -1,12 +1,10 @@ import * as React from "react"; import {StyleSheet} from "aphrodite"; -import Color from "@khanacademy/wonder-blocks-color"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; +import Spacing from "@khanacademy/wonder-blocks-spacing"; import checkIcon from "@phosphor-icons/core/bold/check-bold.svg"; -const {offBlack, offBlack32, white} = Color; - /** * Props describing the state of the OptionItem, shared by the checkbox * component, @@ -16,30 +14,17 @@ type CheckProps = { disabled: boolean; /** Whether option item is selected. */ selected: boolean; - /** Whether option item is pressed. */ - pressed: boolean; - /** Whether option item is hovered. */ - hovered: boolean; - /** Whether option item is focused. */ - focused: boolean; }; /** * The check component used by OptionItem. */ const Check = function (props: CheckProps): React.ReactElement { - const {disabled, selected, pressed, hovered, focused} = props; + const {selected} = props; return ( ); @@ -49,9 +34,11 @@ export default Check; const styles = StyleSheet.create({ bounds: { + alignSelf: "center", + height: Spacing.medium_16, // Semantically, this are the constants for a small-sized icon - minHeight: 16, - minWidth: 16, + minHeight: Spacing.medium_16, + minWidth: Spacing.medium_16, }, hide: { diff --git a/packages/wonder-blocks-dropdown/src/components/checkbox.tsx b/packages/wonder-blocks-dropdown/src/components/checkbox.tsx index 18c68851c..7f82a3c03 100644 --- a/packages/wonder-blocks-dropdown/src/components/checkbox.tsx +++ b/packages/wonder-blocks-dropdown/src/components/checkbox.tsx @@ -1,13 +1,13 @@ import * as React from "react"; import {StyleSheet} from "aphrodite"; -import Color, {mix} from "@khanacademy/wonder-blocks-color"; +import Color from "@khanacademy/wonder-blocks-color"; import {View} from "@khanacademy/wonder-blocks-core"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; import Spacing from "@khanacademy/wonder-blocks-spacing"; import checkIcon from "@phosphor-icons/core/bold/check-bold.svg"; -const {blue, white, offBlack16, offBlack32, offBlack50, offWhite} = Color; +const {offBlack16, offBlack50, offWhite} = Color; /** * Props describing the state of the OptionItem, shared by the check @@ -18,50 +18,28 @@ type CheckProps = { disabled: boolean; /** Whether option item is selected. */ selected: boolean; - /** Whether option item is pressed. */ - pressed: boolean; - /** Whether option item is hovered. */ - hovered: boolean; - /** Whether option item is focused. */ - focused: boolean; }; /** * The checkbox component used by OptionItem. */ const Checkbox = function (props: CheckProps): React.ReactElement { - const {disabled, selected, pressed, hovered, focused} = props; - const activeBlue = mix(offBlack32, blue); - const clickInteraction = pressed || hovered || focused; - - const bgColor = disabled - ? offWhite - : selected && !clickInteraction - ? blue - : white; - const checkColor = disabled - ? offBlack32 - : clickInteraction - ? pressed - ? activeBlue - : blue - : white; + const {disabled, selected} = props; return ( {selected && ( { return false; } - // TypeScript doesn't know that the component is an OptionItem - // @ts-expect-error [FEI-5019] - TS2339 - Property 'label' does not exist on type '{}'. - const label = component.props?.label.toLowerCase(); + if (OptionItem.isClassOf(component)) { + const optionItemProps = component.props as PropsFor< + typeof OptionItem + >; - return label.startsWith(key.toLowerCase()); + return getLabel(optionItemProps) + .toLowerCase() + .startsWith(key.toLowerCase()); + } + + return false; }); if (foundIndex >= 0) { diff --git a/packages/wonder-blocks-dropdown/src/components/dropdown-opener.tsx b/packages/wonder-blocks-dropdown/src/components/dropdown-opener.tsx index 6ce7eca65..b84bdbb6a 100644 --- a/packages/wonder-blocks-dropdown/src/components/dropdown-opener.tsx +++ b/packages/wonder-blocks-dropdown/src/components/dropdown-opener.tsx @@ -8,7 +8,7 @@ import type { ClickableState, } from "@khanacademy/wonder-blocks-clickable"; -import type {OpenerProps} from "../util/types"; +import type {OpenerProps, OptionLabel} from "../util/types"; type Props = Partial> & { /** @@ -34,7 +34,7 @@ type Props = Partial> & { /** * Text for the opener that can be passed to the child as an argument. */ - text: string; + text: OptionLabel; }; type DefaultProps = { diff --git a/packages/wonder-blocks-dropdown/src/components/multi-select.tsx b/packages/wonder-blocks-dropdown/src/components/multi-select.tsx index 33e66bccc..6fd6a56b5 100644 --- a/packages/wonder-blocks-dropdown/src/components/multi-select.tsx +++ b/packages/wonder-blocks-dropdown/src/components/multi-select.tsx @@ -20,6 +20,7 @@ import type { OpenerProps, OptionItemComponentArray, } from "../util/types"; +import {getLabel} from "../util/helpers"; export type Labels = { /** @@ -312,9 +313,20 @@ export default class MultiSelect extends React.Component { const selectedItem = children.find( (option) => option.props.value === selectedValues[0], ); - return selectedItem - ? selectedItem.props.label - : noSelectionText; + + if (selectedItem) { + const selectedLabel = getLabel(selectedItem?.props); + if (selectedLabel) { + return selectedLabel; + // If the label is a ReactNode and `labelAsText` is not set, + // we fallback to, the default label for the case where only + // one item is selected. + } else { + return someSelected(1); + } + } + + return noSelectionText; case children.length: return allSelected; default: @@ -384,7 +396,8 @@ export default class MultiSelect extends React.Component { const filteredChildren = children.filter( ({props}) => !searchText || - props.label.toLowerCase().indexOf(lowercasedSearchText) > -1, + getLabel(props).toLowerCase().indexOf(lowercasedSearchText) > + -1, ); const lastSelectedChildren: React.ReactElement< diff --git a/packages/wonder-blocks-dropdown/src/components/option-item.tsx b/packages/wonder-blocks-dropdown/src/components/option-item.tsx index b15f3e672..a104cd2b1 100644 --- a/packages/wonder-blocks-dropdown/src/components/option-item.tsx +++ b/packages/wonder-blocks-dropdown/src/components/option-item.tsx @@ -1,23 +1,31 @@ import * as React from "react"; import {StyleSheet} from "aphrodite"; +import {DetailCell} from "@khanacademy/wonder-blocks-cell"; import Color, {mix, fade} from "@khanacademy/wonder-blocks-color"; import Spacing from "@khanacademy/wonder-blocks-spacing"; -import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; -import {View} from "@khanacademy/wonder-blocks-core"; -import {getClickableBehavior} from "@khanacademy/wonder-blocks-clickable"; +import {LabelMedium, LabelSmall} from "@khanacademy/wonder-blocks-typography"; -import type {AriaProps, StyleType} from "@khanacademy/wonder-blocks-core"; +import {AriaProps, StyleType, View} from "@khanacademy/wonder-blocks-core"; -import {DROPDOWN_ITEM_HEIGHT} from "../util/constants"; +import {Strut} from "@khanacademy/wonder-blocks-layout"; import Check from "./check"; import Checkbox from "./checkbox"; +import {CellProps, OptionLabel} from "../util/types"; type OptionProps = AriaProps & { /** * Display text of the option item. */ - label: string; + label: OptionLabel; + + /** + * Optional text to use as the label. If not provided, label will be used. + * This is useful for cases where the label is a complex component and you + * want to display a simpler string in the menu. + */ + labelAsText?: string; + /** * Value of the item, used as a key of sorts for the parent to manage its * items, because label/display text may be identical for some selects. This @@ -63,10 +71,41 @@ type OptionProps = AriaProps & { * @ignore */ style?: StyleType; + + /** + * Inherited from WB Cell. + */ + + /** + * Adds a horizontal rule at the bottom of the cell that can be used to + * separate items within ActionMenu instances. Defaults to `none`. + */ + horizontalRule: CellProps["horizontalRule"]; + + /** + * Optional left accessory to display in the `OptionItem` element. + */ + leftAccessory?: CellProps["leftAccessory"]; + + /** + * Optional right accessory to display in the `OptionItem` element. + */ + rightAccessory?: CellProps["rightAccessory"]; + + /** + * Optional subtitle to display before the label. + */ + subtitle1?: CellProps["subtitle1"]; + + /** + * Optional subtitle to display after the label. + */ + subtitle2?: CellProps["subtitle2"]; }; type DefaultProps = { disabled: OptionProps["disabled"]; + horizontalRule: OptionProps["horizontalRule"]; onToggle: OptionProps["onToggle"]; role: OptionProps["role"]; selected: OptionProps["selected"]; @@ -84,6 +123,7 @@ export default class OptionItem extends React.Component { } static defaultProps: DefaultProps = { disabled: false, + horizontalRule: "none", onToggle: () => void 0, role: "option", selected: false, @@ -114,6 +154,11 @@ export default class OptionItem extends React.Component { selected, testId, style, + leftAccessory, + horizontalRule, + rightAccessory, + subtitle1, + subtitle2, // eslint-disable-next-line @typescript-eslint/no-unused-vars value, /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -124,93 +169,161 @@ export default class OptionItem extends React.Component { ...sharedProps } = this.props; - const ClickableBehavior = getClickableBehavior(); const CheckComponent = this.getCheckComponent(); + const defaultStyle = [ + styles.item, + // pass optional styles from react-window (if applies) + style, + ]; + return ( - - {(state, childrenProps) => { - const {pressed, hovered, focused} = state; - - const defaultStyle = [ - styles.itemContainer, - pressed - ? styles.active - : (hovered || focused) && styles.focus, - disabled && styles.disabled, - // pass optional styles from react-window (if applies) - style, - ]; - - return ( - + testId={testId} + leftAccessory={ + <> + {leftAccessory ? ( + + + + {leftAccessory} + + ) : ( - - {label} - - - ); - }} - + )} + + } + rightAccessory={rightAccessory} + subtitle1={ + subtitle1 ? ( + + {subtitle1} + + ) : undefined + } + title={{label}} + subtitle2={ + subtitle2 ? ( + + {subtitle2} + + ) : undefined + } + onClick={this.handleClick} + {...sharedProps} + /> ); } } const {blue, white, offBlack, offBlack32} = Color; +const activeBlue = mix(offBlack32, blue); + const styles = StyleSheet.create({ - itemContainer: { - flexDirection: "row", - background: white, - color: offBlack, - alignItems: "center", - height: DROPDOWN_ITEM_HEIGHT, - minHeight: DROPDOWN_ITEM_HEIGHT, - border: 0, - outline: 0, - paddingLeft: Spacing.xSmall_8, - paddingRight: Spacing.medium_16, - whiteSpace: "nowrap", - cursor: "default", - }, + item: { + // Reset the default styles for the cell element so it can grow + // vertically. + minHeight: "unset", + // Make sure that the item is always at least as tall as 40px. + paddingBlock: Spacing.xxxxSmall_2, - focus: { - color: white, - background: blue, - }, + /** + * States + */ + ":focus": { + // Override the default focus state for the cell element, so that it + // can be added programmatically to the button element. + borderRadius: Spacing.xxxSmall_4, + outline: `${Spacing.xxxxSmall_2}px solid ${Color.blue}`, + outlineOffset: -Spacing.xxxxSmall_2, + }, - active: { - color: mix(fade(blue, 0.32), white), - background: mix(offBlack32, blue), - }, + ":focus-visible": { + // Override the default focus-visible state for the cell element, so + // that it allows the button to grow vertically with the popover + // height. + overflow: "visible", + }, - disabled: { - color: offBlack32, - background: white, + // Overrides the default cell state for the button element. + [":hover[aria-disabled=false]" as any]: { + color: white, + background: blue, + }, + + // Allow hover styles on non-touch devices only. This prevents an + // issue with hover being sticky on touch devices (e.g. mobile). + ["@media not (hover: hover)" as any]: { + // Revert the hover styles to the default/resting state (mobile + // only). + [":hover[aria-disabled=false]" as any]: { + color: white, + background: offBlack, + }, + }, + + // active and pressed states + [":active[aria-disabled=false]" as any]: { + color: mix(fade(blue, 0.32), white), + background: activeBlue, + }, + + // checkbox states (see checkbox.tsx) + [":hover[aria-disabled=false] .checkbox" as any]: { + background: white, + }, + [":hover[aria-disabled=false] .check" as any]: { + color: blue, + }, + [":active[aria-disabled=false] .check" as any]: { + color: activeBlue, + }, + + [":is([aria-selected=true]) .checkbox" as any]: { + background: blue, + }, + + [":is([aria-selected=true]) .check" as any]: { + color: white, + }, + + /** + * Cell states + */ + [":is([aria-disabled=false]) .subtitle" as any]: { + color: Color.offBlack64, + }, + + [":hover[aria-disabled=false] .subtitle" as any]: { + color: Color.offWhite, + }, + [":active[aria-disabled=false] .subtitle" as any]: { + color: mix(fade(blue, 0.16), white), + }, + }, + itemContainer: { + minHeight: "unset", + padding: Spacing.xSmall_8, + paddingRight: Spacing.medium_16, + whiteSpace: "nowrap", }, label: { whiteSpace: "nowrap", userSelect: "none", - marginLeft: Spacing.xSmall_8, // added to truncate strings that are longer than expected overflow: "hidden", textOverflow: "ellipsis", diff --git a/packages/wonder-blocks-dropdown/src/components/select-opener.tsx b/packages/wonder-blocks-dropdown/src/components/select-opener.tsx index be38102c4..c337c36e7 100644 --- a/packages/wonder-blocks-dropdown/src/components/select-opener.tsx +++ b/packages/wonder-blocks-dropdown/src/components/select-opener.tsx @@ -12,6 +12,7 @@ import {LabelMedium} from "@khanacademy/wonder-blocks-typography"; import {tokens} from "@khanacademy/wonder-blocks-theming"; import caretDownIcon from "@phosphor-icons/core/bold/caret-down-bold.svg"; import {DROPDOWN_ITEM_HEIGHT} from "../util/constants"; +import {OptionLabel} from "../util/types"; const StyledButton = addStyle("button"); @@ -19,7 +20,7 @@ type SelectOpenerProps = AriaProps & { /** * Display text in the SelectOpener. */ - children: string; + children: OptionLabel; /** * Whether the SelectOpener is disabled. If disabled, disallows interaction. * Default false. diff --git a/packages/wonder-blocks-dropdown/src/components/single-select.tsx b/packages/wonder-blocks-dropdown/src/components/single-select.tsx index f71cfa259..8dace0b24 100644 --- a/packages/wonder-blocks-dropdown/src/components/single-select.tsx +++ b/packages/wonder-blocks-dropdown/src/components/single-select.tsx @@ -18,6 +18,7 @@ import type { OpenerProps, OptionItemComponentArray, } from "../util/types"; +import {getLabel} from "../util/helpers"; export type SingleSelectLabels = { /** @@ -330,7 +331,7 @@ export default class SingleSelect extends React.Component { return children.filter( ({props}) => !searchText || - props.label.toLowerCase().indexOf(lowercasedSearchText) > -1, + getLabel(props).indexOf(lowercasedSearchText) > -1, ); } @@ -399,7 +400,9 @@ export default class SingleSelect extends React.Component { ); // If nothing is selected, or if the selectedValue doesn't match any // item in the menu, use the placeholder. - const menuText = selectedItem ? selectedItem.props.label : placeholder; + const menuText = selectedItem + ? getLabel(selectedItem.props) || defaultLabels.someSelected(1) + : placeholder; const dropdownOpener = opener ? ( { it("should get a valid string", () => { @@ -70,3 +73,49 @@ describe("debounce", () => { expect(callbackFnMock).toHaveBeenCalledWith("abc"); }); }); + +describe("getLabel", () => { + it("should return the label if it is a string", () => { + // Arrange + const props: PropsFor = { + label: "label", + value: "foo", + }; + + // Act + const label = getLabel(props); + + // Assert + expect(label).toBe("label"); + }); + + it("should return the value of labelAsText if `label` is a Node", () => { + // Arrange + const props: PropsFor = { + label:
a custom node
, + labelAsText: "plain text", + value: "foo", + }; + + // Act + const label = getLabel(props); + + // Assert + expect(label).toBe("plain text"); + }); + + it("should return empty if `label` is a Node and `labelAsText` is not defined", () => { + // Arrange + const props: PropsFor = { + label:
a custom node
, + labelAsText: undefined, + value: "foo", + }; + + // Act + const label = getLabel(props); + + // Assert + expect(label).toBe(""); + }); +}); diff --git a/packages/wonder-blocks-dropdown/src/util/constants.ts b/packages/wonder-blocks-dropdown/src/util/constants.ts index 982f0c8c4..d2536c6e9 100644 --- a/packages/wonder-blocks-dropdown/src/util/constants.ts +++ b/packages/wonder-blocks-dropdown/src/util/constants.ts @@ -42,6 +42,6 @@ export const defaultLabels = { `Select all (${numOptions})`, noneSelected: "0 items", someSelected: (numSelectedValues: number): string => - `${numSelectedValues} items`, + numSelectedValues === 1 ? "1 item" : `${numSelectedValues} items`, allSelected: "All items", } as const; diff --git a/packages/wonder-blocks-dropdown/src/util/helpers.ts b/packages/wonder-blocks-dropdown/src/util/helpers.ts index dc82f5da3..3a0742505 100644 --- a/packages/wonder-blocks-dropdown/src/util/helpers.ts +++ b/packages/wonder-blocks-dropdown/src/util/helpers.ts @@ -1,3 +1,6 @@ +import {PropsFor} from "@khanacademy/wonder-blocks-core"; +import OptionItem from "../components/option-item"; + /** * Checks if a given key is a valid ASCII value. * @@ -43,3 +46,27 @@ export function debounce( timeout = setTimeout(later, wait); }; } + +/** + * Type guard for strings. + */ +function isString(x: any): x is string { + return typeof x === "string"; +} + +type OptionItemProps = PropsFor; + +/** + * Returns a valid label for the given props. + */ +export function getLabel(props: OptionItemProps): string { + if (isString(props.label)) { + return props.label; + } + + if (isString(props.labelAsText)) { + return props.labelAsText; + } + + return ""; +} diff --git a/packages/wonder-blocks-dropdown/src/util/types.ts b/packages/wonder-blocks-dropdown/src/util/types.ts index 9916dc42e..208ce5f04 100644 --- a/packages/wonder-blocks-dropdown/src/util/types.ts +++ b/packages/wonder-blocks-dropdown/src/util/types.ts @@ -1,6 +1,8 @@ import * as React from "react"; import type {ClickableState} from "@khanacademy/wonder-blocks-clickable"; +import {DetailCell} from "@khanacademy/wonder-blocks-cell"; +import {PropsFor} from "@khanacademy/wonder-blocks-core"; import ActionItem from "../components/action-item"; import OptionItem from "../components/option-item"; import SeparatorItem from "../components/separator-item"; @@ -28,9 +30,19 @@ export type DropdownItem = { role?: string; }; +/** + * Used to extend the option items with some of the DetailCell props. + */ +export type CellProps = PropsFor; + +/** + * The allowed types for the label of an option item. + */ +export type OptionLabel = string | CellProps["title"]; + // Custom opener arguments export type OpenerProps = ClickableState & { - text: string; + text: OptionLabel; }; export type OptionItemComponentArray = React.ReactElement<