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('', () => {
it('should render dynamically colored icons before option', async () => {
const renderBeforeLabel = (props: any) => {
@@ -338,4 +376,148 @@ 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('', () => {
expect(inputRef).toHaveBeenCalledWith(input)
})
})
+
+ it("should not render option's before content in input field when isOptionContentAppliedToInput is set to false ", async () => {
+ const { container } = render(
+
+ )
+
+ 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 = [