diff --git a/cypress/component/Select.cy.tsx b/cypress/component/Select.cy.tsx index 1cb75283d8..efb7ba6d87 100644 --- a/cypress/component/Select.cy.tsx +++ b/cypress/component/Select.cy.tsx @@ -22,8 +22,8 @@ * SOFTWARE. */ import React from 'react' -import { Select } from '../../packages/ui' -import { IconCheckSolid } from '../../packages/ui-icons' +import { Select } from '@instructure/ui' +import { IconCheckSolid, IconEyeSolid } from '@instructure/ui-icons' import 'cypress-real-events' import '../support/component' @@ -45,6 +45,44 @@ const getOptions = ( )) +type ExampleOptionWithContent = 'opt1' | 'op2' | 'opt3' + +const optionsWithBeforeContent = [ + { + id: 'opt1', + label: 'Text', + renderBeforeLabel: 'XY' + }, + { + id: 'opt2', + label: 'Icon', + renderBeforeLabel: 'YY' + }, + { + id: 'opt3', + label: 'Colored Icon', + renderBeforeLabel: + } +] + +const optionsWithAfterContent = [ + { + id: 'opt1', + label: 'Text', + renderAfterLabel: 'XY' + }, + { + id: 'opt2', + label: 'Icon', + renderAfterLabel: 'YY' + }, + { + id: 'opt3', + label: 'Colored Icon', + renderAfterLabel: + } +] + describe('', () => { cy.wrap(onRequestHighlightOption).should('have.been.calledOnce') cy.wrap(onKeyDown).should('have.been.calledTwice') }) + + it("should render the selected option's before content in the input field", async () => { + const MyTestComponent = () => { + const [selectedOptionId, setSelectedOptionId] = React.useState( + optionsWithBeforeContent[0].id + ) + const [isShowingOptions, setIsShowingOptions] = React.useState(false) + const [inputValue, setInputValue] = React.useState( + optionsWithBeforeContent[0].label + ) + + const getOptionById = (selectedOptionId) => { + return optionsWithBeforeContent.find( + ({ id }) => id === selectedOptionId + ) + } + + const handleShowOptions = () => { + setIsShowingOptions(true) + } + + const handleHideOptions = (options) => { + const option = getOptionById(selectedOptionId)?.label + setIsShowingOptions(false) + setInputValue(selectedOptionId ? option : '') + } + + const handleSelectOption = (event, data: { id?: string }) => { + const option = data.id ? getOptionById(data.id)?.label : undefined + if (data.id) { + setSelectedOptionId(data.id) + setInputValue(option || '') + } + setIsShowingOptions(false) + } + + return ( + + ) + } + cy.mount() + + cy.get('span[class$="-textInput__beforeElement"]').should( + 'contain.text', + 'XY' + ) + cy.get('input').click() + cy.get('ul[role="listbox"]').as('listbox') + cy.get('@listbox') + cy.get('@listbox').find('li').eq(1).click() + cy.get('span[class$="-textInput__beforeElement"]').should( + 'contain.text', + 'YY' + ) + }) + + it("should render the selected option's after content in the input field", async () => { + const MyTestComponent = () => { + const [selectedOptionId, setSelectedOptionId] = React.useState( + optionsWithAfterContent[0].id + ) + const [isShowingOptions, setIsShowingOptions] = React.useState(false) + const [inputValue, setInputValue] = React.useState( + optionsWithAfterContent[0].label + ) + + const getOptionById = (selectedOptionId) => { + return optionsWithAfterContent.find(({ id }) => id === selectedOptionId) + } + + const handleShowOptions = () => { + setIsShowingOptions(true) + } + + const handleHideOptions = (options) => { + const option = getOptionById(selectedOptionId)?.label + setIsShowingOptions(false) + setInputValue(selectedOptionId ? option : '') + } + + const handleSelectOption = (event, data: { id?: string }) => { + const option = data.id ? getOptionById(data.id)?.label : undefined + if (data.id) { + setSelectedOptionId(data.id) + setInputValue(option || '') + } + setIsShowingOptions(false) + } + + return ( + + ) + } + cy.mount() + + cy.get('span[class$="-textInput__afterElement"]').should( + 'contain.text', + 'XY' + ) + cy.get('input').click() + cy.get('ul[role="listbox"]').as('listbox') + cy.get('@listbox') + cy.get('@listbox').find('li').eq(1).click() + cy.get('span[class$="-textInput__afterElement"]').should( + 'contain.text', + 'YY' + ) + }) }) diff --git a/packages/ui-select/src/Select/__new-tests__/Select.test.tsx b/packages/ui-select/src/Select/__new-tests__/Select.test.tsx index 3784585a43..a6cb61f23e 100644 --- a/packages/ui-select/src/Select/__new-tests__/Select.test.tsx +++ b/packages/ui-select/src/Select/__new-tests__/Select.test.tsx @@ -34,6 +34,12 @@ import { runAxeCheck } from '@instructure/ui-axe-check' import Select from '../index' import SelectExamples from '../__examples__/Select.examples' import * as utils from '@instructure/ui-utils' +import { + IconAddLine, + IconCheckSolid, + IconDownloadSolid, + IconEyeSolid +} from '@instructure/ui-icons' type ExampleOption = 'foo' | 'bar' | 'baz' const defaultOptions: ExampleOption[] = ['foo', 'bar', 'baz'] @@ -55,6 +61,241 @@ const getOptions = ( )) +type ExampleOptionWithContent = 'opt1' | 'op2' | 'opt3' +type GroupExampleOptionWithContent = + | 'opt1' + | 'op2' + | 'opt3' + | 'opt4' + | 'op5' + | 'opt6' + +type groupOptionWithBeforeContent = { + id: string + label: string + renderBeforeLabel: string | React.ReactNode +} +type groupOptionWithAfterContent = { + id: string + label: string + renderAfterLabel: string | React.ReactNode +} + +const optionsWithBeforeContent = [ + { + id: 'opt1', + label: 'Text', + renderBeforeLabel: 'XY' + }, + { + id: 'opt2', + label: 'Icon', + renderBeforeLabel: + }, + { + id: 'opt3', + label: 'Colored Icon', + renderBeforeLabel: + } +] + +const groupOptionsWithBeforeContent: { + [key: string]: groupOptionWithBeforeContent[] +} = { + Options1: [ + { + id: 'opt1', + label: 'Text1', + renderBeforeLabel: 'XY' + }, + { + id: 'opt2', + label: 'Icon1', + renderBeforeLabel: + }, + { + id: 'opt3', + label: 'Colored Icon1', + renderBeforeLabel: + } + ], + Options2: [ + { + id: 'opt4', + label: 'Text2', + renderBeforeLabel: 'AB' + }, + { + id: 'opt5', + label: 'Icon2', + renderBeforeLabel: + }, + { + id: 'opt6', + label: 'Colored Icon2', + renderBeforeLabel: + } + ] +} + +const optionsWithAfterContent = [ + { + id: 'opt1', + label: 'Text', + renderAfterLabel: 'XY' + }, + { + id: 'opt2', + label: 'Icon', + renderAfterLabel: + }, + { + id: 'opt3', + label: 'Colored Icon', + renderAfterLabel: + } +] + +const groupOptionsWithAfterContent: { + [key: string]: groupOptionWithAfterContent[] +} = { + Options1: [ + { + id: 'opt1', + label: 'Text1', + renderAfterLabel: 'XY' + }, + { + id: 'opt2', + label: 'Icon1', + renderAfterLabel: + }, + { + id: 'opt3', + label: 'Colored Icon1', + renderAfterLabel: + } + ], + Options2: [ + { + id: 'opt4', + label: 'Text2', + renderAfterLabel: 'AB' + }, + { + id: 'opt5', + label: 'Icon2', + renderAfterLabel: + }, + { + id: 'opt6', + label: 'Colored Icon2', + renderAfterLabel: + } + ] +} + +const optionsWithBeforeAndAfterContent = [ + { + id: 'opt1', + label: 'Text', + renderBeforeLabel: 'XY', + renderAfterLabel: 'ZZ' + }, + { + id: 'opt2', + label: 'Icon', + renderAfterLabel: , + renderBeforeLabel: + }, + { + id: 'opt3', + label: 'Colored Icon', + renderBeforeLabel: , + renderAfterLabel: + } +] + +const getOptionsWithBeforeContent = (selected: ExampleOptionWithContent) => + optionsWithBeforeContent.map((opt) => ( + + )) + +const getGroupOptionsWithBeforeContent = ( + selected: GroupExampleOptionWithContent +) => { + return Object.keys(groupOptionsWithBeforeContent).map((key, index) => { + return ( + + {groupOptionsWithBeforeContent[key].map( + (opt: groupOptionWithBeforeContent) => ( + + ) + )} + + ) + }) +} + +const getOptionsWithAfterContent = (selected: ExampleOptionWithContent) => + optionsWithAfterContent.map((opt) => ( + + )) + +const getGroupOptionsWithAfterContent = ( + selected: GroupExampleOptionWithContent +) => { + return Object.keys(groupOptionsWithAfterContent).map((key, index) => { + return ( + + {groupOptionsWithAfterContent[key].map( + (opt: groupOptionWithAfterContent) => ( + + ) + )} + + ) + }) +} + +const getOptionsWithBeforeAndAfterContent = ( + selected: ExampleOptionWithContent +) => + optionsWithBeforeAndAfterContent.map((opt) => ( + + )) + vi.mock('@instructure/ui-utils', async (importOriginal) => { const originalModule = (await importOriginal()) as any return { @@ -382,6 +623,237 @@ describe(' + {getOptionsWithBeforeContent('opt1')} + + ) + + const beforeContent = container.querySelector( + 'span[class$="-textInput__beforeElement"]' + ) + + expect(beforeContent).not.toBeInTheDocument() + }) + + it('should render arrow in input field when isOptionContentAppliedToInput is set to false', async () => { + const { container } = render( + + ) + + const spanElement = container.querySelector( + 'span[class$="-textInput__afterElement"]' + ) + const svgElement = spanElement!.querySelector( + 'svg[name="IconArrowOpenDown"]' + ) + expect(svgElement).toBeInTheDocument() + }) + + it("should render option's before content in input field when isOptionContentAppliedToInput is set to true", async () => { + const { container } = render( + + ) + + const beforeContent = container.querySelector( + 'span[class$="-textInput__beforeElement"]' + ) + expect(beforeContent).toHaveTextContent('XY') + }) + + it("should render option's after content in input field when isOptionContentAppliedToInput is set to true", async () => { + const { container } = render( + + ) + + const beforeContent = container.querySelector( + 'span[class$="-textInput__afterElement"]' + ) + expect(beforeContent).toHaveTextContent('XY') + }) + + it("should render option's before content in input field when isOptionContentAppliedToInput is set to true but renderBeforeInput is also set", async () => { + const { container } = render( + + ) + + const beforeContent = container.querySelector( + 'span[class$="-textInput__beforeElement"]' + ) + expect(beforeContent).toHaveTextContent('XY') + }) + + it("should render option's after content in input field when isOptionContentAppliedToInput is set to true but renderAfterInput is also set", async () => { + const { container } = render( + + ) + + const afterContent = container.querySelector( + 'span[class$="-textInput__afterElement"]' + ) + expect(afterContent).toHaveTextContent('XY') + }) + + it("should not render option's before content in input field when isOptionContentAppliedToInput is set to true but inputValue is not set", async () => { + const { container } = render( + + ) + + const beforeContent = container.querySelector( + 'span[class$="-textInput__beforeElement"]' + ) + expect(beforeContent).not.toBeInTheDocument() + }) + + it("should render option's before content in input field when isOptionContentAppliedToInput is set to true and both optionBeforeContent and optionAfterContent are provided", async () => { + const { container } = render( + + ) + + const beforeContent = container.querySelector( + 'span[class$="-textInput__beforeElement"]' + ) + expect(beforeContent).toHaveTextContent('XY') + }) + + it("should render option's after content in input field when isOptionContentAppliedToInput is set to true and both optionBeforeContent and optionAfterContent are provided", async () => { + const { container } = render( + + ) + + const afterContent = container.querySelector( + 'span[class$="-textInput__afterElement"]' + ) + expect(afterContent).toHaveTextContent('ZZ') + }) + + it('should render arrow in input field when isOptionContentAppliedToInput is set to true but inputValue is not set', async () => { + const { container } = render( + + ) + + const spanElement = container.querySelector( + 'span[class$="-textInput__afterElement"]' + ) + const svgElement = spanElement!.querySelector( + 'svg[name="IconArrowOpenDown"]' + ) + + expect(svgElement).toBeInTheDocument() + }) + + it("should not render option's after content in input field when isOptionContentAppliedToInput is set to true but inputValue is not set", async () => { + const { container } = render( + + ) + + const afterContent = container.querySelector( + 'span[class$="-textInput__afterElement"]' + ) + + expect(afterContent).not.toHaveTextContent('XY') + }) + + it("should render option's before content input field when isOptionContentAppliedToInput is set to true with group options", async () => { + const { container } = render( + + ) + + const beforeContent = container.querySelector( + 'span[class$="-textInput__beforeElement"]' + ) + + expect(beforeContent).toHaveTextContent('XY') + }) + + it("should render option's after content input field when isOptionContentAppliedToInput is set to true with group options", async () => { + const { container } = render( + + ) + + const afterContent = container.querySelector( + 'span[class$="-textInput__afterElement"]' + ) + + expect(afterContent).toHaveTextContent('AB') + }) }) describe('list', () => { diff --git a/packages/ui-select/src/Select/index.tsx b/packages/ui-select/src/Select/index.tsx index 9429a35415..b4c1e5bb5a 100644 --- a/packages/ui-select/src/Select/index.tsx +++ b/packages/ui-select/src/Select/index.tsx @@ -72,6 +72,7 @@ import type { SelectOptionProps, RenderSelectOptionLabel } from './Option/props' import type { SelectProps } from './props' import { allowedProps, propTypes } from './props' +import { Renderable } from '@instructure/shared-types' type GroupChild = React.ComponentElement type OptionChild = React.ComponentElement @@ -149,7 +150,8 @@ class Select extends Component { placement: 'bottom stretch', constrain: 'window', shouldNotWrap: false, - scrollToHighlightedOption: true + scrollToHighlightedOption: true, + isOptionContentAppliedToInput: false } static Option = Option @@ -576,6 +578,82 @@ class Select extends Component { ) } + renderContentBeforeOrAfterInput(position: string) { + for (const child of this.childrenArray) { + if (matchComponentTypes(child, [Group])) { + // Group found + const options = this.getGroupChildrenArray(child) + for (const option of options) { + if (option.props.isSelected) { + return position === 'before' + ? option.props.renderBeforeLabel + : option.props.renderAfterLabel + } + } + } else { + // Ungrouped option found + if (child.props.isSelected) { + return position === 'before' + ? child.props.renderBeforeLabel + : child.props.renderAfterLabel + ? child.props.renderAfterLabel + : this.renderIcon() + } + } + } + return null + } + + handleInputContentRender( + renderLabelInput: Renderable, + inputValue: string | undefined, + isOptionContentAppliedToInput: boolean, + position: 'before' | 'after', + defaultReturn: Renderable + ): Renderable { + const isInputValueEmpty = inputValue === '' + if (renderLabelInput && isOptionContentAppliedToInput) { + if (!isInputValueEmpty) { + return this.renderContentBeforeOrAfterInput(position) as Renderable + } + return renderLabelInput + } + if (isOptionContentAppliedToInput) { + if (isInputValueEmpty) { + return defaultReturn + } + return this.renderContentBeforeOrAfterInput(position) as Renderable + } + if (renderLabelInput) { + return renderLabelInput + } + return defaultReturn + } + + handleRenderBeforeInput() { + const { renderBeforeInput, inputValue, isOptionContentAppliedToInput } = + this.props + return this.handleInputContentRender( + renderBeforeInput, + inputValue, + isOptionContentAppliedToInput!, + 'before', + null // default for before + ) + } + + handleRenderAfterInput() { + const { renderAfterInput, inputValue, isOptionContentAppliedToInput } = + this.props + return this.handleInputContentRender( + renderAfterInput, + inputValue, + isOptionContentAppliedToInput!, + 'after', + this.renderIcon() // default for after + ) + } + renderInput( data: Pick ) { @@ -642,8 +720,8 @@ class Select extends Component { isRequired, shouldNotWrap, display: isInline ? 'inline-block' : 'block', - renderBeforeInput, - renderAfterInput: renderAfterInput || this.renderIcon(), + renderBeforeInput: this.handleRenderBeforeInput(), + renderAfterInput: this.handleRenderAfterInput(), // If `inputValue` is provided, we need to pass a default onChange handler, // because TextInput `value` is a controlled prop, diff --git a/packages/ui-select/src/Select/props.ts b/packages/ui-select/src/Select/props.ts index 1ed6d73f4c..84618ed206 100644 --- a/packages/ui-select/src/Select/props.ts +++ b/packages/ui-select/src/Select/props.ts @@ -78,6 +78,17 @@ type SelectOwnProps = { */ visibleOptionsCount?: number + /** + * Whether or not the before and after content of the selected option appear in the input field. + * If the selected option has both before and after content, both will be displayed in the input field. + * One of the Select.Options isSelected prop should be true in order to display the content in the input field. + * Before and after content will not be displayed, if Select's inputValue is an empty value. + * If true and the selected option has a renderAfterInput value, it will replace the default arrow icon. + * If true and Select's ownrenderBeforeInput or renderAfterInput prop is set, it will display the selected option's content instead of Select's renderBeforeInput or renderAfterInput value. + * If the selected option's renderAfterInput value is empty, default arrow icon will be rendered. + */ + isOptionContentAppliedToInput?: boolean + /** * The max height the options list can be before having to scroll. If * set, it will __override__ the `visibleOptionsCount` prop. @@ -288,6 +299,7 @@ const propTypes: PropValidators = { width: PropTypes.string, htmlSize: PropTypes.number, visibleOptionsCount: PropTypes.number, + isOptionContentAppliedToInput: PropTypes.bool, optionsMaxHeight: PropTypes.string, optionsMaxWidth: PropTypes.string, messages: PropTypes.arrayOf(FormPropTypes.message), @@ -325,6 +337,7 @@ const allowedProps: AllowedPropKeys = [ 'width', 'htmlSize', 'visibleOptionsCount', + 'isOptionContentAppliedToInput', 'optionsMaxHeight', 'optionsMaxWidth', 'messages', diff --git a/packages/ui-simple-select/src/SimpleSelect/index.tsx b/packages/ui-simple-select/src/SimpleSelect/index.tsx index 707effe50c..f63d32cd71 100644 --- a/packages/ui-simple-select/src/SimpleSelect/index.tsx +++ b/packages/ui-simple-select/src/SimpleSelect/index.tsx @@ -80,7 +80,8 @@ class SimpleSelect extends Component { visibleOptionsCount: 8, placement: 'bottom stretch', constrain: 'window', - renderEmptyOption: '---' + renderEmptyOption: '---', + isOptionContentAppliedToInput: false } ref: Select | null = null @@ -450,6 +451,7 @@ class SimpleSelect extends Component { onRequestHideOptions={this.handleHideOptions} onRequestHighlightOption={this.handleHighlightOption} onRequestSelectOption={this.handleSelectOption} + isOptionContentAppliedToInput={this.props.isOptionContentAppliedToInput} {...passthroughProps(rest)} > {this.renderChildren()} diff --git a/packages/ui-simple-select/src/SimpleSelect/props.ts b/packages/ui-simple-select/src/SimpleSelect/props.ts index 7693001ba4..5506e1663f 100644 --- a/packages/ui-simple-select/src/SimpleSelect/props.ts +++ b/packages/ui-simple-select/src/SimpleSelect/props.ts @@ -218,6 +218,15 @@ type PropsPassedToSelect = { * Callback fired when text input loses focus. */ onBlur?: (event: React.FocusEvent) => void + /** + * Whether or not the before and after content of the selected option appear in the input field. + * If the selected option has both before and after content, both will be displayed in the input field. + * Before and after content will not be displayed, if the selected SimpleSelect.Option display text is empty. + * If true and the selected option has a renderAfterInput value, it will replace the default arrow icon. + * If true and SimpleSelect's renderBeforeInput or renderAfterInput prop is set, it will display the selected option's content instead of SimpleSelect's own renderBeforeInput or renderAfterInput value. + * If the selected option's renderAfterInput value is empty, default arrow icon will be rendered. + */ + isOptionContentAppliedToInput?: boolean } type PropKeys = keyof SimpleSelectOwnProps @@ -279,7 +288,8 @@ const propTypes: PropValidators = { renderEmptyOption: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), renderBeforeInput: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), renderAfterInput: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), - children: ChildrenPropTypes.oneOf([Group, Option]) + children: ChildrenPropTypes.oneOf([Group, Option]), + isOptionContentAppliedToInput: PropTypes.bool } const allowedProps: AllowedPropKeys = [