diff --git a/packages/ui-color-picker/src/ColorPicker/v2/index.tsx b/packages/ui-color-picker/src/ColorPicker/v2/index.tsx index 68f31e8147..3f9feeddb8 100644 --- a/packages/ui-color-picker/src/ColorPicker/v2/index.tsx +++ b/packages/ui-color-picker/src/ColorPicker/v2/index.tsx @@ -710,7 +710,11 @@ class ColorPicker extends Component { display="inline-block" width={width} placeholder={placeholderText} - themeOverride={{ padding: '' }} + themeOverride={{ + paddingHorizontalSm: '', + paddingHorizontalMd: '', + paddingHorizontalLg: '' + }} renderAfterInput={this.renderAfterInput()} renderBeforeInput={this.renderBeforeInput()} inputContainerRef={this.handleInputContainerRef} diff --git a/packages/ui-options/src/Options/v2/Item/styles.ts b/packages/ui-options/src/Options/v2/Item/styles.ts index b9db9de4ff..a385efbe66 100644 --- a/packages/ui-options/src/Options/v2/Item/styles.ts +++ b/packages/ui-options/src/Options/v2/Item/styles.ts @@ -71,7 +71,7 @@ const generateStyle = ( }, 'selected-highlighted': { background: componentTheme.selectedHighlightedBackground, - color: componentTheme.highlightedLabelColor + color: componentTheme.selectedLabelColor }, default: { transition: 'background 200ms' diff --git a/packages/ui-popover/src/Popover/v2/index.tsx b/packages/ui-popover/src/Popover/v2/index.tsx index 49edef212d..f5b7a60806 100644 --- a/packages/ui-popover/src/Popover/v2/index.tsx +++ b/packages/ui-popover/src/Popover/v2/index.tsx @@ -178,6 +178,7 @@ class Popover extends Component { } componentDidMount() { + this.props.makeStyles?.() if (this.isTooltip) { // if popover is being used as a tooltip with no focusable content // manage its FocusRegion internally rather than registering it with @@ -213,6 +214,7 @@ class Popover extends Component { } componentDidUpdate(prevProps: PopoverProps, prevState: PopoverState) { + this.props.makeStyles?.() if (this._focusRegion && this.isTooltip) { // if focus region exists, popover is acting as a tooltip // so we manually activate and deactivate the region when showing/hiding diff --git a/packages/ui-select/package.json b/packages/ui-select/package.json index cf38faf3e8..de9e4d5cae 100644 --- a/packages/ui-select/package.json +++ b/packages/ui-select/package.json @@ -77,18 +77,18 @@ "default": "./es/exports/a.js" }, "./v11_7": { - "src": "./src/exports/a.ts", - "types": "./types/exports/a.d.ts", - "import": "./es/exports/a.js", - "require": "./lib/exports/a.js", - "default": "./es/exports/a.js" + "src": "./src/exports/b.ts", + "types": "./types/exports/b.d.ts", + "import": "./es/exports/b.js", + "require": "./lib/exports/b.js", + "default": "./es/exports/b.js" }, "./latest": { - "src": "./src/exports/a.ts", - "types": "./types/exports/a.d.ts", - "import": "./es/exports/a.js", - "require": "./lib/exports/a.js", - "default": "./es/exports/a.js" + "src": "./src/exports/b.ts", + "types": "./types/exports/b.d.ts", + "import": "./es/exports/b.js", + "require": "./lib/exports/b.js", + "default": "./es/exports/b.js" } } } diff --git a/packages/ui-select/src/Select/v2/Group/index.tsx b/packages/ui-select/src/Select/v2/Group/index.tsx new file mode 100644 index 0000000000..9a1206847a --- /dev/null +++ b/packages/ui-select/src/Select/v2/Group/index.tsx @@ -0,0 +1,52 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' +import type { SelectGroupProps } from './props' +import { allowedProps } from './props' + +/** +--- +parent: Select +id: Select.Group +--- +@module Group +**/ +class Group extends Component { + static readonly componentId = 'Select.Group' + + static allowedProps = allowedProps + + static defaultProps = {} + + /* istanbul ignore next */ + render() { + // this component is only used for prop validation. Select.Group children + // are parsed in Select and rendered as Options components + return null + } +} + +export default Group +export { Group } diff --git a/packages/ui-select/src/Select/v2/Group/props.ts b/packages/ui-select/src/Select/v2/Group/props.ts new file mode 100644 index 0000000000..6ed1d1376d --- /dev/null +++ b/packages/ui-select/src/Select/v2/Group/props.ts @@ -0,0 +1,48 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React from 'react' + +import type { OtherHTMLAttributes, Renderable } from '@instructure/shared-types' + +type SelectGroupOwnProps = { + /** + * The label associated with the group options. + */ + renderLabel: Renderable + /** + * Children of type `` that will be considered part of the group. + */ + children?: React.ReactNode // TODO: ChildrenPropTypes.oneOf([Option]) +} + +type PropKeys = keyof SelectGroupOwnProps + +type AllowedPropKeys = Readonly> + +type SelectGroupProps = SelectGroupOwnProps & + OtherHTMLAttributes +const allowedProps: AllowedPropKeys = ['renderLabel', 'children'] + +export type { SelectGroupProps } +export { allowedProps } diff --git a/packages/ui-select/src/Select/v2/Option/index.tsx b/packages/ui-select/src/Select/v2/Option/index.tsx new file mode 100644 index 0000000000..c66af88996 --- /dev/null +++ b/packages/ui-select/src/Select/v2/Option/index.tsx @@ -0,0 +1,56 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' +import type { SelectOptionProps } from './props' +import { allowedProps } from './props' + +/** +--- +parent: Select +id: Select.Option +--- +@module Option +**/ +class Option extends Component { + static readonly componentId = 'Select.Option' + + static allowedProps = allowedProps + + static defaultProps = { + isHighlighted: false, + isSelected: false, + isDisabled: false + } + + /* istanbul ignore next */ + render() { + // this component is only used for prop validation. Select.Option children + // are parsed in Select and rendered as Options.Item components + return null + } +} + +export default Option +export { Option } diff --git a/packages/ui-select/src/Select/v2/Option/props.ts b/packages/ui-select/src/Select/v2/Option/props.ts new file mode 100644 index 0000000000..5ddc9f7f91 --- /dev/null +++ b/packages/ui-select/src/Select/v2/Option/props.ts @@ -0,0 +1,82 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react' +import type { OtherHTMLAttributes, Renderable } from '@instructure/shared-types' + +type OptionProps = { + /** + * The id for the option. **Must be globally unique**, it will be translated + * to an `id` prop in the DOM. + */ + id: string + /** + * Whether or not this option is highlighted. + */ + isHighlighted?: boolean + /** + * Whether or not this option is selected. + */ + isSelected?: boolean + /** + * Whether or not this option is disabled. + */ + isDisabled?: boolean + /** + * Content to display as the option label. + */ + children?: React.ReactNode +} + +type RenderSelectOptionLabel = Renderable + +type SelectOptionOwnProps = OptionProps & { + /** + * Content to display before the option label, such as an icon. + */ + renderBeforeLabel?: RenderSelectOptionLabel + /** + * Content to display after the option label, such as an icon. + */ + renderAfterLabel?: RenderSelectOptionLabel +} + +type PropKeys = keyof SelectOptionOwnProps + +type AllowedPropKeys = Readonly> + +type SelectOptionProps = SelectOptionOwnProps & + OtherHTMLAttributes +const allowedProps: AllowedPropKeys = [ + 'id', + 'isHighlighted', + 'isSelected', + 'isDisabled', + 'renderBeforeLabel', + 'renderAfterLabel', + 'children' +] + +export type { SelectOptionProps, RenderSelectOptionLabel } +export { allowedProps } diff --git a/packages/ui-select/src/Select/v2/README.md b/packages/ui-select/src/Select/v2/README.md new file mode 100644 index 0000000000..78992236fe --- /dev/null +++ b/packages/ui-select/src/Select/v2/README.md @@ -0,0 +1,1342 @@ +--- +describes: Select +--- + +`Select` is an accessible, custom styled combobox component for inputting a variety of data types. + +- It behaves similar to [Popover](Popover) but provides additional semantic markup and focus behavior as a form input. +- It should not be used for navigation or as a list of actions/functions. (see [Menu](Menu)). +- It can behave like a ` { + inputRef.current = el + }} + > + {options.map((option) => { + return ( + + {option.label} + + ) + })} + + + ) + } + render( + + + + ) +``` + +#### Providing autocomplete behavior + +It's best practice to always provide autocomplete functionality to help users make a selection. The example below demonstrates one method of filtering options based on user input, but this logic should be customized to what works best for the application. + +> Note: Select makes some conditional assumptions about keyboard behavior. For example, if the list is NOT showing, up/down arrow keys and the space key, will show the list. Otherwise, the arrows will navigate options and the space key will type a space character. + +```js +--- +type: example +--- + const AutocompleteExample = ({ options }) => { + const [inputValue, setInputValue] = useState('') + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(null) + const [filteredOptions, setFilteredOptions] = useState(options) + const [announcement, setAnnouncement] = useState(null) + const inputRef = useRef() + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.blur() + inputRef.current.focus() + } + } + + const getOptionById = (queryId) => { + return options.find(({ id }) => id === queryId) + } + + const getOptionsChangedMessage = (newOptions) => { + let message = + newOptions.length !== filteredOptions.length + ? `${newOptions.length} options available.` // options changed, announce new total + : null // options haven't changed, don't announce + if (message && newOptions.length > 0) { + // options still available + if (highlightedOptionId !== newOptions[0].id) { + // highlighted option hasn't been announced + const option = getOptionById(newOptions[0].id).label + message = `${option}. ${message}` + } + } + return message + } + + const filterOptions = (value) => { + return options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } + + const matchValue = () => { + // an option matching user input exists + if (filteredOptions.length === 1) { + const onlyOption = filteredOptions[0] + // automatically select the matching option + if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { + setInputValue(onlyOption.label) + setSelectedOptionId(onlyOption.id) + setFilteredOptions(filterOptions('')) + } + } + // allow user to return to empty input and no selection + else if (inputValue.length === 0) { + setSelectedOptionId(null) + } + // no match found, return selected option label to input + else if (selectedOptionId) { + const selectedOption = getOptionById(selectedOptionId) + setInputValue(selectedOption.label) + } + // input value is from highlighted option, not user input + // clear input, reset options + else if (highlightedOptionId) { + if (inputValue === getOptionById(highlightedOptionId).label) { + setInputValue('') + setFilteredOptions(filterOptions('')) + } + } + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + setAnnouncement( + `List expanded. ${filteredOptions.length} options available.` + ) + if (inputValue || selectedOptionId || options.length === 0) return + + if ('key' in event) { + switch (event.key) { + case 'ArrowDown': + return handleHighlightOption(event, { id: options[0].id }) + case 'ArrowUp': + return handleHighlightOption(event, { + id: options[options.length - 1].id + }) + } + } + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + setHighlightedOptionId(false) + setAnnouncement('List collapsed.') + matchValue() + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const option = getOptionById(id) + if (!option) return // prevent highlighting of empty option + setHighlightedOptionId(id) + setInputValue(inputValue) + setAnnouncement(option.label) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id) + if (!option) return // prevent selecting of empty option + focusInput() + setSelectedOptionId(id) + setInputValue(option.label) + setIsShowingOptions(false) + setFilteredOptions(options) + setAnnouncement(`${option.label} selected. List collapsed.`) + } + + const handleInputChange = (event) => { + const value = event.target.value + const newOptions = filterOptions(value) + setInputValue(value) + setFilteredOptions(newOptions) + setHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null) + setIsShowingOptions(true) + setSelectedOptionId(value === '' ? null : selectedOptionId) + setAnnouncement(getOptionsChangedMessage(newOptions)) + } + + return ( +
+ +
+ ) + } + + render( + + + + ) +``` + +#### Highlighting and selecting options + +To mark an option as "highlighted", use the option's `isHighlighted` prop. Note that only one highlighted option is permitted. Similarly, use `isSelected` to mark an option or multiple options as "selected". When allowing multiple selections, it's best to render a [Tag](Tag) with [AccessibleContent](AccessibleContent) for each selected option via the `renderBeforeInput` prop. + +```js +--- +type: example +--- + const MultipleSelectExample = ({ options }) => { + const [inputValue, setInputValue] = useState('') + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(['opt1', 'opt6']) + const [filteredOptions, setFilteredOptions] = useState(options) + const [announcement, setAnnouncement] = useState(null) + const inputRef = useRef() + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.blur() + inputRef.current.focus() + } + } + + const getOptionById = (queryId) => { + return options.find(({ id }) => id === queryId) + } + + const getOptionsChangedMessage = (newOptions) => { + let message = + newOptions.length !== filteredOptions.length + ? `${newOptions.length} options available.` // options changed, announce new total + : null // options haven't changed, don't announce + if (message && newOptions.length > 0) { + // options still available + if (highlightedOptionId !== newOptions[0].id) { + // highlighted option hasn't been announced + const option = getOptionById(newOptions[0].id).label + message = `${option}. ${message}` + } + } + return message + } + + const filterOptions = (value) => { + return options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } + + const matchValue = () => { + // an option matching user input exists + if (filteredOptions.length === 1) { + const onlyOption = filteredOptions[0] + // automatically select the matching option + if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { + setInputValue('') + setSelectedOptionId([...selectedOptionId, onlyOption.id]) + setFilteredOptions(filterOptions('')) + } + } + // input value is from highlighted option, not user input + // clear input, reset options + else if (highlightedOptionId) { + if (inputValue === getOptionById(highlightedOptionId).label) { + setInputValue('') + setFilteredOptions(filterOptions('')) + } + } + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + + if (inputValue || options.length === 0) return + + if ('key' in event) { + switch (event.key) { + case 'ArrowDown': + return handleHighlightOption(event, { + id: options.find((option) => !selectedOptionId.includes(option.id)) + .id + }) + case 'ArrowUp': + // Highlight last non-selected option + return handleHighlightOption(event, { + id: options[ + options.findLastIndex( + (option) => !selectedOptionId.includes(option.id) + ) + ].id + }) + } + } + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + matchValue() + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const option = getOptionById(id) + if (!option) return // prevent highlighting empty option + setHighlightedOptionId(id) + setInputValue(inputValue) + setAnnouncement(option.label) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id) + if (!option) return // prevent selecting of empty option + focusInput() + setSelectedOptionId([...selectedOptionId, id]) + setHighlightedOptionId(null) + setFilteredOptions(filterOptions('')) + setInputValue('') + setIsShowingOptions(false) + setAnnouncement(`${option.label} selected. List collapsed.`) + } + + const handleInputChange = (event) => { + const value = event.target.value + const newOptions = filterOptions(value) + setInputValue(value) + setFilteredOptions(newOptions) + setHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null) + setIsShowingOptions(true) + setAnnouncement(getOptionsChangedMessage(newOptions)) + } + + const handleKeyDown = (event) => { + if ('keyCode' in event && event.keyCode === 8) { + // when backspace key is pressed + if (inputValue === '' && selectedOptionId.length > 0) { + // remove last selected option, if input has no entered text + setHighlightedOptionId(null) + setSelectedOptionId(selectedOptionId.slice(0, -1)) + } + } + } + + // remove a selected option tag + const dismissTag = (e, tag) => { + // prevent closing of list + e.stopPropagation() + e.preventDefault() + + const newSelection = selectedOptionId.filter((id) => id !== tag) + + setSelectedOptionId(newSelection) + setHighlightedOptionId(null) + setAnnouncement(`${getOptionById(tag).label} removed`) + + inputRef.current.focus() + } + + const renderTags = () => { + return selectedOptionId.map((id, index) => ( + + {getOptionById(id).label} + + } + margin={ + index > 0 ? 'xxx-small xx-small xxx-small 0' : '0 xx-small 0 0' + } + onClick={(e) => dismissTag(e, id)} + /> + )) + } + + return ( +
+ +
+ ) + } + + render( + + + + ) +``` + +#### Composing option groups + +In addition to `` Select also accepts `` as children. This is meant to serve the same purpose as `` elements. Group only requires you provide a label via its `renderLabel` prop. Groups and their associated options also accept icons or other stylistic additions if needed. + +```js +--- +type: example +--- +const GroupSelectExample = ({ options }) => { + const [inputValue, setInputValue] = useState(options['Western'][0].label) + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState( + options['Western'][0].id + ) + const [announcement, setAnnouncement] = useState(null) + const inputRef = useRef() + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.blur() + inputRef.current.focus() + } + } + + const getOptionById = (id) => { + let match = null + Object.keys(options).forEach((key, index) => { + for (let i = 0; i < options[key].length; i++) { + const option = options[key][i] + if (id === option.id) { + // return group property with the object just to make it easier + // to check which group the option belongs to + match = { ...option, group: key } + break + } + } + }) + return match + } + + const getGroupChangedMessage = (newOption) => { + const currentOption = getOptionById(highlightedOptionId) + const isNewGroup = + !currentOption || currentOption.group !== newOption.group + let message = isNewGroup ? `Group ${newOption.group} entered. ` : '' + message += newOption.label + return message + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + setHighlightedOptionId(null) + if (inputValue || selectedOptionId || Object.keys(options).length === 0) return + + if ('key' in event) { + switch (event.key) { + case 'ArrowDown': + return handleHighlightOption(event, { + id: options[Object.keys(options)[0]][0].id + }) + case 'ArrowUp': + return handleHighlightOption(event, { + id: Object.values(options).at(-1)?.at(-1)?.id + }) + } + } + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + setHighlightedOptionId(null) + setInputValue(getOptionById(selectedOptionId)?.label) + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const newOption = getOptionById(id) + setHighlightedOptionId(id) + setInputValue(inputValue) + setAnnouncement(getGroupChangedMessage(newOption)) + } + + const handleSelectOption = (event, { id }) => { + focusInput() + setSelectedOptionId(id) + setInputValue(getOptionById(id).label) + setIsShowingOptions(false) + setAnnouncement(`${getOptionById(id).label} selected.`) + } + + const renderLabel = (text, variant) => { + return ( + + + {text} + + ) + } + + const renderGroup = () => { + return Object.keys(options).map((key, index) => { + const badgeVariant = key === 'Eastern' ? 'success' : 'primary' + return ( + + {options[key].map((option) => ( + + {option.label} + + ))} + + ) + }) + } + + return ( +
+ +
+ ) +} + +render( + + + +) +``` + +##### Using groups with autocomplete on Safari + +Due to a WebKit bug if you are using `Select.Group` with autocomplete, the screenreader won't announce highlight/selection changes. This only seems to be an issue in Safari. Here is an example how you can work around that: + +```js +--- +type: example +--- +const GroupSelectAutocompleteExample = ({ options }) => { + const [inputValue, setInputValue] = useState('') + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(null) + const [filteredOptions, setFilteredOptions] = useState(options) + const [announcement, setAnnouncement] = useState(null) + const inputRef = useRef() + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.blur() + inputRef.current.focus() + } + } + + const getOptionById = (id) => { + return Object.values(options) + .flat() + .find((o) => o?.id === id) + } + + const filterOptions = (value, options) => { + const filteredOptions = {} + Object.keys(options).forEach((key) => { + filteredOptions[key] = options[key]?.filter((option) => + option.label.toLowerCase().includes(value.toLowerCase()) + ) + }) + const optionsWithoutEmptyKeys = Object.keys(filteredOptions) + .filter((k) => filteredOptions[k].length > 0) + .reduce((a, k) => ({ ...a, [k]: filteredOptions[k] }), {}) + return optionsWithoutEmptyKeys + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + setHighlightedOptionId(null) + + if (inputValue || selectedOptionId || Object.keys(options).length === 0) return + + if ('key' in event) { + switch (event.key) { + case 'ArrowDown': + return handleHighlightOption(event, { + id: options[Object.keys(options)[0]][0].id + }) + case 'ArrowUp': + return handleHighlightOption(event, { + id: Object.values(options).at(-1)?.at(-1)?.id + }) + } + } + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + setHighlightedOptionId(null) + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const option = getOptionById(id) + setTimeout(() => { + setAnnouncement(option.label) + }, 0) + setHighlightedOptionId(id) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id) + if (!option) return // prevent selecting of empty option + focusInput() + setSelectedOptionId(id) + setInputValue(option.label) + setIsShowingOptions(false) + setFilteredOptions(options) + setAnnouncement(option.label) + } + + const handleInputChange = (event) => { + const value = event.target.value + const newOptions = filterOptions(value, options) + setInputValue(value) + setFilteredOptions(newOptions) + setHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null) + setIsShowingOptions(true) + setSelectedOptionId(value === '' ? null : selectedOptionId) + } + + const renderGroup = () => { + return Object.keys(filteredOptions).map((key, index) => { + return ( + + {filteredOptions[key].map((option) => ( + + {option.label} + + ))} + + ) + }) + } + + const renderScreenReaderHelper = () => { + return ( + window.safari && ( + + + {announcement} + + + ) + ) + } + + return ( +
+ + {renderScreenReaderHelper()} +
+ ) +} + +render( + + + +) +``` + +#### Asynchronous option loading + +If no results match the user's search, it's recommended to leave `isShowingOptions` as `true` and to display an "empty option" as a way of communicating that there are no matches. Similarly, it's helpful to display a [Spinner](Spinner) in an empty option while options load. + +```js +--- +type: example +--- +const AsyncExample = ({ options }) => { + const [inputValue, setInputValue] = useState('') + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(null) + const [selectedOptionLabel, setSelectedOptionLabel] = useState('') + const [filteredOptions, setFilteredOptions] = useState([]) + const [announcement, setAnnouncement] = useState(null) + const inputRef = useRef() + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.blur() + inputRef.current.focus() + } + } + + let timeoutId = null + + const getOptionById = (queryId) => { + return filteredOptions.find(({ id }) => id === queryId) + } + + const filterOptions = (value) => { + return options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } + + const matchValue = () => { + // an option matching user input exists + if (filteredOptions.length === 1) { + const onlyOption = filteredOptions[0] + // automatically select the matching option + if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { + setInputValue(onlyOption.label) + setSelectedOptionId(onlyOption.id) + return + } + } + // allow user to return to empty input and no selection + if (inputValue.length === 0) { + setSelectedOptionId(null) + setFilteredOptions([]) + return + } + // no match found, return selected option label to input + if (selectedOptionId) { + setInputValue(selectedOptionLabel) + return + } + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + setHighlightedOptionId(null) + setAnnouncement('List collapsed.') + matchValue() + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const option = getOptionById(id) + if (!option) return // prevent highlighting of empty option + + setHighlightedOptionId(id) + setInputValue(inputValue) + setAnnouncement(option.label) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id) + if (!option) return // prevent selecting of empty option + focusInput() + setSelectedOptionId(id) + setSelectedOptionLabel(option.label) + setInputValue(option.label) + setIsShowingOptions(false) + setAnnouncement(`${option.label} selected. List collapsed.`) + setFilteredOptions([getOptionById(id)]) + } + + const handleInputChange = (event) => { + const value = event.target.value + clearTimeout(timeoutId) + + if (!value || value === '') { + setIsLoading(false) + setInputValue(value) + setIsShowingOptions(true) + setSelectedOptionId(null) + setSelectedOptionLabel(null) + setFilteredOptions([]) + } else { + setIsLoading(true) + setInputValue(value) + setIsShowingOptions(true) + setFilteredOptions([]) + setHighlightedOptionId(null) + setAnnouncement('Loading options.') + + timeoutId = setTimeout(() => { + const newOptions = filterOptions(value) + setFilteredOptions(newOptions) + setIsLoading(false) + setAnnouncement(`${newOptions.length} options available.`) + }, 1500) + } + } + + return ( +
+ +
+ ) +} + +render( + + + +) +``` + +### Icons + +To display icons (or other elements) before or after an option, pass it via the `renderBeforeLabel` and `renderAfterLabel` prop to `Select.Option`. You can pass a function as well, which will have a `props` parameter, so you can access the properties of that `Select.Option` (e.g. if it is currently `isHighlighted`). The available props are: `[ id, isDisabled, isSelected, isHighlighted, children ]`. + +```js +--- +type: example +--- +const SingleSelectExample = ({ options }) => { + const [inputValue, setInputValue] = useState(options[0].label) + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(options[0].id) + const [announcement, setAnnouncement] = useState(null) + const inputRef = useRef() + + const focusInput = () => { + if (inputRef.current) { + inputRef.current.blur() + inputRef.current.focus() + } + } + + const getOptionById = (queryId) => { + return options.find(({ id }) => id === queryId) + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + + if (inputValue || selectedOptionId || options.length === 0) return + + if ('key' in event) { + switch (event.key) { + case 'ArrowDown': + return handleHighlightOption(event, { id: options[0].id }) + case 'ArrowUp': + return handleHighlightOption(event, { + id: options[options.length - 1].id + }) + } + } + } + + const handleHideOptions = (event) => { + const option = getOptionById(selectedOptionId)?.label + setIsShowingOptions(false) + setHighlightedOptionId(null) + setInputValue(selectedOptionId ? option : '') + setAnnouncement('List collapsed.') + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const optionsAvailable = `${options.length} options available.` + const nowOpen = !isShowingOptions + ? `List expanded. ${optionsAvailable}` + : '' + const option = getOptionById(id).label + setHighlightedOptionId(id) + setInputValue(inputValue) + setAnnouncement(`${option} ${nowOpen}`) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id).label + focusInput() + setSelectedOptionId(id) + setInputValue(option) + setIsShowingOptions(false) + setAnnouncement(`"${option}" selected. List collapsed.`) + } + + return ( +
+ +
+ ) +} + +render( + + + }, + { + id: 'opt3', + label: 'Colored Icon', + renderBeforeLabel: (props) => { + let color = 'infoColor' + if (props.isHighlighted) color = 'baseColor' + if (props.isSelected) color = 'inverseColor' + if (props.isDisabled) color = 'disabledBaseColor' + return + } + } + ]} + /> + +) +``` + +#### Providing assistive text for screen readers + +It's important to ensure screen reader users receive instruction and feedback while interacting with a `Select`, but screen reader support for the `combobox` role varies. The `assistiveText` prop should always be used to explain how a keyboard user can make a selection. Additionally, a live region should be updated with feedback as the component is interacted with, such as when options are filtered or highlighted. Using an [Alert](Alert) with the `screenReaderOnly` prop is the easiest way to do this. + +> Note: This component uses a native `input` field to render the selected value. When it's included in a native HTML `form`, the text value will be sent to the backend instead of anything specified in the `value` field of the `Select.Option`-s. We do not recommend to use this component this way, rather write your own code that collects information and sends it to the backend. + +```js +--- +type: embed +--- + +
+ To ensure Select is accessible for iOS VoiceOver users, the input field’s focus must be blurred and then reapplied after selecting an option and closing the listbox. The examples above demonstrate this behavior. + + If no option is selected initially, pressing the down arrow should open the listbox and move focus to the first option, while pressing up should move focus to the last item. You can see this behavior in the examples above. + +
+
+``` diff --git a/packages/ui-select/src/Select/v1/__tests__/Select.test.tsx b/packages/ui-select/src/Select/v2/__tests__/Select.test.tsx similarity index 96% rename from packages/ui-select/src/Select/v1/__tests__/Select.test.tsx rename to packages/ui-select/src/Select/v2/__tests__/Select.test.tsx index 751e6deca1..98702ab021 100644 --- a/packages/ui-select/src/Select/v1/__tests__/Select.test.tsx +++ b/packages/ui-select/src/Select/v2/__tests__/Select.test.tsx @@ -32,10 +32,10 @@ import Select from '../index' import * as utils from '@instructure/ui-utils' import { - IconAddLine, - IconCheckSolid, - IconDownloadSolid, - IconEyeSolid + CheckInstUIIcon, + DownloadInstUIIcon, + EyeInstUIIcon, + PlusInstUIIcon } from '@instructure/ui-icons' type ExampleOption = 'foo' | 'bar' | 'baz' @@ -87,12 +87,12 @@ const optionsWithBeforeContent = [ { id: 'opt2', label: 'Icon', - renderBeforeLabel: + renderBeforeLabel: }, { id: 'opt3', label: 'Colored Icon', - renderBeforeLabel: + renderBeforeLabel: } ] @@ -108,12 +108,12 @@ const groupOptionsWithBeforeContent: { { id: 'opt2', label: 'Icon1', - renderBeforeLabel: + renderBeforeLabel: }, { id: 'opt3', label: 'Colored Icon1', - renderBeforeLabel: + renderBeforeLabel: } ], Options2: [ @@ -125,12 +125,12 @@ const groupOptionsWithBeforeContent: { { id: 'opt5', label: 'Icon2', - renderBeforeLabel: + renderBeforeLabel: }, { id: 'opt6', label: 'Colored Icon2', - renderBeforeLabel: + renderBeforeLabel: } ] } @@ -144,12 +144,12 @@ const optionsWithAfterContent = [ { id: 'opt2', label: 'Icon', - renderAfterLabel: + renderAfterLabel: }, { id: 'opt3', label: 'Colored Icon', - renderAfterLabel: + renderAfterLabel: } ] @@ -165,12 +165,12 @@ const groupOptionsWithAfterContent: { { id: 'opt2', label: 'Icon1', - renderAfterLabel: + renderAfterLabel: }, { id: 'opt3', label: 'Colored Icon1', - renderAfterLabel: + renderAfterLabel: } ], Options2: [ @@ -182,12 +182,12 @@ const groupOptionsWithAfterContent: { { id: 'opt5', label: 'Icon2', - renderAfterLabel: + renderAfterLabel: }, { id: 'opt6', label: 'Colored Icon2', - renderAfterLabel: + renderAfterLabel: } ] } @@ -202,14 +202,14 @@ const optionsWithBeforeAndAfterContent = [ { id: 'opt2', label: 'Icon', - renderAfterLabel: , - renderBeforeLabel: + renderAfterLabel: , + renderBeforeLabel: }, { id: 'opt3', label: 'Colored Icon', - renderBeforeLabel: , - renderAfterLabel: + renderBeforeLabel: , + renderAfterLabel: } ] @@ -374,7 +374,7 @@ describe(' ) - const select = container.querySelector('span[class$="-select"]') + const select = screen.getByTestId('subidubi') const label = screen.getByLabelText('Choose an option') const input = container.querySelector('input[id^="Select_"]') const list = screen.getByRole('listbox') @@ -661,9 +661,7 @@ describe('', () => { const spanElement = container.querySelector( 'span[class$="-textInput__afterElement"]' ) - const svgElement = spanElement!.querySelector( - 'svg[name="IconArrowOpenDown"]' - ) + const svgElement = spanElement!.querySelector('svg[name="ChevronDown"]') expect(svgElement).toBeInTheDocument() }) @@ -824,9 +820,7 @@ describe('', () => { const spanElement = container.querySelector( 'span[class$="-textInput__afterElement"]' ) - const svgElement = spanElement!.querySelector( - 'svg[name="IconArrowOpenDown"]' - ) + const svgElement = spanElement!.querySelector('svg[name="ChevronDown"]') expect(svgElement).toBeInTheDocument() }) @@ -976,7 +968,7 @@ describe(' ) - const icon = container.querySelector('svg[name="IconArrowOpenDown"]') + const icon = container.querySelector('svg[name="ChevronDown"]') const label = screen.getByText('Choose an option') expect(icon).toBeInTheDocument() @@ -1117,7 +1109,7 @@ describe(' ) - const icon = container.querySelector('svg[name="IconArrowOpenUp"]') + const icon = container.querySelector('svg[name="ChevronUp"]') const label = screen.getByText('Choose an option') expect(icon).toBeInTheDocument() diff --git a/packages/ui-select/src/Select/v2/index.tsx b/packages/ui-select/src/Select/v2/index.tsx new file mode 100644 index 0000000000..59e087ca8d --- /dev/null +++ b/packages/ui-select/src/Select/v2/index.tsx @@ -0,0 +1,889 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ComponentElement, Children, Component, memo, ReactNode } from 'react' + +import * as utils from '@instructure/ui-utils' +import { combineDataCid } from '@instructure/ui-utils' +import { + matchComponentTypes, + omitProps, + getInteraction, + withDeterministicId +} from '@instructure/ui-react-utils' +import { + getBoundingClientRect, + isActiveElement +} from '@instructure/ui-dom-utils' + +import { View } from '@instructure/ui-view/latest' +import { Selectable } from '@instructure/ui-selectable' +import { Popover } from '@instructure/ui-popover/latest' +import { TextInput } from '@instructure/ui-text-input/latest' +import { Options } from '@instructure/ui-options/latest' +import { + ChevronDownInstUIIcon, + ChevronUpInstUIIcon +} from '@instructure/ui-icons' + +import type { ViewProps } from '@instructure/ui-view/latest' +import type { TextInputProps } from '@instructure/ui-text-input/latest' +import type { + OptionsItemProps, + OptionsSeparatorProps, + OptionsItemRenderProps +} from '@instructure/ui-options/latest' +import type { + SelectableProps, + SelectableRender +} from '@instructure/ui-selectable' + +import { withStyle, BorderWidth } from '@instructure/emotion' + +import generateStyle from './styles' + +import { Group } from './Group' +import type { SelectGroupProps } from './Group/props' +import { Option } from './Option' +import type { SelectOptionProps, RenderSelectOptionLabel } from './Option/props' + +import type { SelectProps } from './props' +import { allowedProps } from './props' +import { Renderable } from '@instructure/shared-types' + +const selectSizeToIconSize: Record< + NonNullable, + 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' +> = { + small: 'sm', + medium: 'md', + large: 'lg' +} + +type GroupChild = ComponentElement +type OptionChild = ComponentElement +type SelectChildren = (GroupChild | OptionChild)[] + +type MemoedOptionProps = React.PropsWithChildren<{ + selectOption: OptionChild + optionsItemProps: OptionsItemProps +}> + +// This memoed Option component is used to prevent unnecessary re-renders of +// Options.Item when the Select component is re-rendered. This is necessary +// because the Select component is re-rendered on every prop change of the +// and with a large amount of options, this can cause a lot of unnecessary re-renders. +const MemoedOption = memo( + function Opt(props: MemoedOptionProps) { + const { optionsItemProps, children } = props + + return ( + // The main that renders this is always an "ul" + + {children} + + ) + }, + // This is a custom equality function that checks if the props of the + // have changed. If they haven't, then the Options.Item + // doesn't need to be re-rendered. + (prevProps, nextProps) => { + return ( + prevProps.selectOption.props.isHighlighted === + nextProps.selectOption.props.isHighlighted && + prevProps.selectOption.props.isSelected === + nextProps.selectOption.props.isSelected && + prevProps.selectOption.props.isDisabled === + nextProps.selectOption.props.isDisabled && + prevProps.selectOption.props.children === + nextProps.selectOption.props.children && + prevProps.selectOption.props.id === nextProps.selectOption.props.id && + prevProps.selectOption.props.renderBeforeLabel === + nextProps.selectOption.props.renderBeforeLabel && + prevProps.selectOption.props.renderAfterLabel === + nextProps.selectOption.props.renderAfterLabel && + prevProps.children === nextProps.children + ) + } +) +// This is needed so the propTypes in check are correct +MemoedOption.displayName = 'Item' + +/** +--- +category: components +tags: autocomplete, typeahead, combobox, dropdown, search, form +--- +**/ +@withDeterministicId() +@withStyle(generateStyle) +class Select extends Component { + static readonly componentId = 'Select' + private readonly SCROLL_TOLERANCE = 0.5 + + static allowedProps = allowedProps + + static defaultProps = { + inputValue: '', + isShowingOptions: false, + size: 'medium', + // Leave interaction default undefined so that `disabled` and `readOnly` can also be supplied + interaction: undefined, + isRequired: false, + isInline: false, + visibleOptionsCount: 8, + placement: 'bottom stretch', + constrain: 'window', + shouldNotWrap: false, + scrollToHighlightedOption: true, + isOptionContentAppliedToInput: false + } + + static Option = Option + static Group = Group + + componentDidMount() { + this.props.makeStyles?.() + } + + componentDidUpdate() { + this.props.makeStyles?.() + + if (this.props.scrollToHighlightedOption) { + // scroll option into view if needed + requestAnimationFrame(() => this.scrollToOption(this.highlightedOptionId)) + } + } + + state = { + hasInputRef: false + } + ref: HTMLSpanElement | null = null + _input: HTMLInputElement | null = null + private _defaultId = this.props.deterministicId!() + private _inputContainer: HTMLSpanElement | null = null + private _listView: Element | null = null + // temporarily stores actionable options + private _optionIds: string[] = [] + // best guess for first calculation of list height + private _optionHeight = 36 + + focus() { + this._input && this._input.focus() + } + + blur() { + this._input && this._input.blur() + } + + get childrenArray() { + return Children.toArray(this.props.children) as SelectChildren + } + + getGroupChildrenArray(group: GroupChild) { + return Children.toArray(group.props.children) as OptionChild[] + } + + get focused() { + return this._input ? isActiveElement(this._input) : false + } + + get id() { + return this.props.id || this._defaultId + } + + get width() { + return this._inputContainer ? this._inputContainer.offsetWidth : undefined + } + + get interaction() { + return getInteraction({ props: this.props }) + } + + get highlightedOptionId(): string | undefined { + let highlightedOptionId: string | undefined + + this.childrenArray.forEach((child) => { + if (matchComponentTypes(child, [Group])) { + // group found + this.getGroupChildrenArray(child).forEach((option) => { + // check options in group + if (option.props.isHighlighted) { + highlightedOptionId = option.props.id + } + }) + } else { + // ungrouped option found + if (child.props.isHighlighted) { + highlightedOptionId = child.props.id + } + } + }) + + return highlightedOptionId + } + + get selectedOptionId() { + const selectedOptionId: string[] = [] + + this.childrenArray.forEach((child) => { + if (matchComponentTypes(child, [Group])) { + // group found + this.getGroupChildrenArray(child).forEach((option) => { + // check options in group + if (option.props.isSelected) { + selectedOptionId.push(option.props.id) + } + }) + } else { + // ungrouped option found + if (child.props.isSelected) { + selectedOptionId.push(child.props.id) + } + } + }) + + if (selectedOptionId.length === 1) { + return selectedOptionId[0] + } + if (selectedOptionId.length === 0) { + return undefined + } + return selectedOptionId + } + + handleInputRef = (node: HTMLInputElement | null) => { + // ensures list is positioned with respect to input if list is open on mount + if (!this.state.hasInputRef) { + this.setState({ hasInputRef: true }) + } + this._input = node + this.props.inputRef?.(node) + } + + handleListRef = (node: HTMLUListElement | null) => { + this.props.listRef?.(node) + + // store option height to calculate list maxHeight + if (node && node.querySelector('[role="option"]')) { + this._optionHeight = ( + node.querySelector('[role="option"]') as HTMLElement + ).offsetHeight + } + } + + handleInputContainerRef = (node: HTMLSpanElement | null) => { + this._inputContainer = node + } + + scrollToOption(id?: string) { + if (!this._listView || !id) return + const option = this._listView.querySelector(`[id="${CSS.escape(id)}"]`) + if (!option) return + + const listItem = option.parentNode + const parentTop = getBoundingClientRect(this._listView).top + const elemTop = getBoundingClientRect(listItem).top + const parentBottom = parentTop + this._listView.clientHeight + const elemBottom = + elemTop + (listItem ? (listItem as Element).clientHeight : 0) + + if (elemBottom > parentBottom) { + this._listView.scrollTop += elemBottom - parentBottom + } else if (elemTop < parentTop) { + this._listView.scrollTop -= parentTop - elemTop + } + } + + highlightOption(event: React.KeyboardEvent | React.MouseEvent, id: string) { + const { onRequestHighlightOption } = this.props + if (id) { + onRequestHighlightOption?.(event, { id }) + } + } + + getEventHandlers(): Partial { + const { + isShowingOptions, + onRequestShowOptions, + onRequestHideOptions, + onRequestSelectOption + } = this.props + + return this.interaction === 'enabled' + ? { + onRequestShowOptions: (event) => { + onRequestShowOptions?.(event) + const selectedOptionId = this.selectedOptionId + + if (selectedOptionId && !Array.isArray(selectedOptionId)) { + // highlight selected option on show + this.highlightOption(event, selectedOptionId) + } + }, + onRequestHideOptions: (event) => { + onRequestHideOptions?.(event) + }, + onRequestHighlightOption: ( + event, + { id, direction }: { id?: string; direction?: number } + ) => { + if (!isShowingOptions) return + + const highlightedOptionId = this.highlightedOptionId + // if id exists, use that + let highlightId = this._optionIds.indexOf(id!) > -1 ? id : undefined + if (!highlightId) { + if (!highlightedOptionId) { + // nothing highlighted yet, highlight first option + highlightId = this._optionIds[0] + } else { + // find next id based on direction + const index = this._optionIds.indexOf(highlightedOptionId) + highlightId = + index > -1 ? this._optionIds[index + direction!] : undefined + } + } + if (highlightId) { + // only highlight if id exists as a valid option + this.highlightOption(event, highlightId) + } + }, + onRequestHighlightFirstOption: (event) => { + this.highlightOption(event, this._optionIds[0]) + }, + onRequestHighlightLastOption: (event) => { + this.highlightOption( + event, + this._optionIds[this._optionIds.length - 1] + ) + }, + onRequestSelectOption: (event, { id }) => { + if (id && this._optionIds.indexOf(id) !== -1) { + // only select if id exists as a valid option + onRequestSelectOption?.(event, { id }) + } + } + } + : {} + } + + renderOption( + option: OptionChild, + data: Pick + ) { + const { getOptionProps, getDisabledOptionProps } = data + const { + id, + isDisabled, + isHighlighted, + isSelected, + renderBeforeLabel, + renderAfterLabel, + children + } = option.props + + const getRenderOptionLabel = ( + renderOptionLabel: RenderSelectOptionLabel + ): + | React.ReactNode + | ((_args: OptionsItemRenderProps) => React.ReactNode) => { + return typeof renderOptionLabel === 'function' && + !renderOptionLabel?.prototype?.isReactComponent + ? (renderOptionLabel as any).bind(null, { + id, + isDisabled, + isSelected, + isHighlighted, + children + }) + : (renderOptionLabel as React.ReactNode) + } + + let optionProps: Partial = { + // passthrough props + ...omitProps(option.props, [ + ...Option.allowedProps, + ...Options.Item.allowedProps + ]), + // props from selectable + ...getOptionProps({ id }), + // Options.Item props + renderBeforeLabel: getRenderOptionLabel(renderBeforeLabel), + renderAfterLabel: getRenderOptionLabel(renderAfterLabel) + } + // should option be treated as highlighted or selected + if (isSelected && isHighlighted) { + optionProps.variant = 'selected-highlighted' + optionProps.isSelected = true + } else if (isSelected) { + optionProps.variant = 'selected' + optionProps.isSelected = true + } else if (isHighlighted) { + optionProps.variant = 'highlighted' + } + // should option be treated as disabled + if (isDisabled) { + optionProps.variant = 'disabled' + optionProps = { ...optionProps, ...getDisabledOptionProps() } + } else { + // track as valid option if not disabled + this._optionIds.push(id) + } + + return ( + + {children} + + ) + } + + renderGroup( + group: GroupChild, + data: Pick< + SelectableRender, + 'getOptionProps' | 'getDisabledOptionProps' + > & { + isFirstChild: boolean + isLastChild: boolean + afterGroup: boolean + } + ) { + const { + getOptionProps, + getDisabledOptionProps, + isFirstChild, + isLastChild, + afterGroup + } = data + const { id, renderLabel, children, ...rest } = group.props + const groupChildren: ( + | React.ReactElement + | React.ReactElement + )[] = [] + // add a separator above + if (!isFirstChild && !afterGroup) { + groupChildren.push() + } + // create a sublist as a group + // a wrapping listitem will be created by Options + groupChildren.push( + + {Children.map(children as OptionChild[], (child) => { + return this.renderOption(child, { + getOptionProps, + getDisabledOptionProps + }) + })} + + ) + // add a separator below + if (!isLastChild) { + groupChildren.push() + } + + return groupChildren + } + + renderList( + data: Pick< + SelectableRender, + 'getListProps' | 'getOptionProps' | 'getDisabledOptionProps' + > + ) { + const { getListProps, getOptionProps, getDisabledOptionProps } = data + const { + isShowingOptions, + optionsMaxWidth, + optionsMaxHeight, + visibleOptionsCount, + children + } = this.props + + let lastWasGroup = false + + const viewProps: Partial = isShowingOptions + ? { + display: 'block', + overflowY: 'auto', + maxHeight: + optionsMaxHeight || + this._optionHeight * visibleOptionsCount! - + // in Chrome, we need to prevent scrolling when the bottom area of last item is hovered + (utils.isChromium() ? this.SCROLL_TOLERANCE : 0), + maxWidth: optionsMaxWidth || this.width, + background: 'primary', + elementRef: (node: Element | null) => (this._listView = node), + borderRadius: 'inherit' + } + : { maxHeight: 0 } + + return ( + + + {isShowingOptions + ? Children.map(children as SelectChildren, (child, index) => { + if (!child || !matchComponentTypes(child, [Group, Option])) { + return // ignore invalid children + } + if (matchComponentTypes(child, [Option])) { + lastWasGroup = false + return this.renderOption(child, { + getOptionProps, + getDisabledOptionProps + }) + } + if (matchComponentTypes(child, [Group])) { + const afterGroup = lastWasGroup + lastWasGroup = true + return this.renderGroup(child, { + getOptionProps, + getDisabledOptionProps, + // for rendering separators appropriately + isFirstChild: index === 0, + isLastChild: index === Children.count(children) - 1, + afterGroup + }) + } + return + }) + : null} + + + ) + } + + renderIcon() { + const { isShowingOptions, size = 'medium' } = this.props + const iconSize = selectSizeToIconSize[size] + return ( + + {isShowingOptions ? ( + + ) : ( + + )} + + ) + } + + 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 + ? option.props.renderAfterLabel + : this.renderIcon() + } + } + } else { + // Ungrouped option found + if (child.props.isSelected) { + return position === 'before' + ? child.props.renderBeforeLabel + : child.props.renderAfterLabel + ? child.props.renderAfterLabel + : this.renderIcon() + } + } + } + // if no option with isSelected is found + if (position === 'after') { + return this.renderIcon() + } + return console.warn( + "isOptionContentAppliedToInput is set but no option has an isSelected='true' prop so desired content cannot be displayed in input filed" + ) + } + + handleInputContentRender( + renderLabelInput: Renderable, + inputValue: string | undefined, + isOptionContentAppliedToInput: boolean, + position: 'before' | 'after', + defaultReturn: Renderable + ): Renderable { + const isInputValueEmpty = !inputValue || 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 + ) { + const { getInputProps, getTriggerProps } = data + const { + renderLabel, + inputValue, + placeholder, + isRequired, + shouldNotWrap, + size, + isInline, + width, + htmlSize, + messages, + renderBeforeInput, + renderAfterInput, + onFocus, + onBlur, + onInputChange, + onRequestHideOptions, + layout, + ...rest + } = this.props + + const { interaction } = this + const passthroughProps = omitProps(rest, Select.allowedProps) + const { ref, ...triggerProps } = getTriggerProps({ ...passthroughProps }) + const isEditable = typeof onInputChange !== 'undefined' + + // props to ensure screen readers treat uneditable selects as accessible + // popup buttons rather than comboboxes. + const overrideProps: Partial = !isEditable + ? { + // We need role="combobox" for the 'open list' button shortcut to work + // with desktop screenreaders. + // But desktop Safari with Voiceover does not support proper combobox + // handling, a 'button' role is set as a workaround. + // See https://bugs.webkit.org/show_bug.cgi?id=236881 + // Also on iOS Chrome with role='combobox' it announces unnecessarily + // that its 'read-only' and that this is a 'textfield', see INSTUI-4500 + role: + utils.isSafari() || + utils.isAndroidOrIOS() || + (interaction === 'disabled' && utils.isChromium()) + ? 'button' + : 'combobox', + title: inputValue, + 'aria-autocomplete': undefined, + 'aria-readonly': true + } + : interaction === 'disabled' && utils.isChromium() + ? { role: 'button' } + : {} + + // backdoor to autocomplete attr to work around chrome autofill issues + if (passthroughProps['autoComplete']) { + overrideProps.autoComplete = passthroughProps['autoComplete'] + } + + const inputProps: Partial = { + id: this.id, + renderLabel, + placeholder, + size, + width, + htmlSize, + messages, + value: inputValue, + inputRef: utils.createChainedFunction(ref, this.handleInputRef), + inputContainerRef: this.handleInputContainerRef, + interaction: + interaction === 'enabled' && !isEditable + ? 'readonly' // prevent keyboard cursor + : interaction, + isRequired, + shouldNotWrap, + layout, + display: isInline ? 'inline-block' : 'block', + renderBeforeInput: this.handleRenderBeforeInput(), + // On iOS VoiceOver, if there is a custom element instead of the changing up and down arrow button + // the listbox closes on a swipe, so a DOM change is enforced by the key change + // that seems to inform VoiceOver to behave the correct way + renderAfterInput: + utils.isAndroidOrIOS() && renderAfterInput !== undefined ? ( + + {this.handleRenderAfterInput() as ReactNode} + + ) : ( + this.handleRenderAfterInput() + ), + + // If `inputValue` is provided, we need to pass a default onChange handler, + // because TextInput `value` is a controlled prop, + // and onChange is not required for Select + // (before it was handled by TextInput's defaultProp) + onChange: + typeof onInputChange === 'function' + ? onInputChange + : inputValue + ? () => {} + : undefined, + + onFocus, + onBlur: utils.createChainedFunction(onBlur, onRequestHideOptions), + ...overrideProps + } + // suppressHydrationWarning is needed because `role` depends on the browser type + return ( + ({ + backgroundReadonlyColor: componentTheme.backgroundColor + }) + })} + /> + ) + } + + render() { + const { + constrain, + placement, + mountNode, + assistiveText, + isShowingOptions, + styles + } = this.props + // clear temporary option store + this._optionIds = [] + + const highlightedOptionId = this.highlightedOptionId + const selectedOptionId = this.selectedOptionId + + return ( + + {({ + getRootProps, + getInputProps, + getTriggerProps, + getListProps, + getOptionProps, + getDisabledOptionProps, + getDescriptionProps + }) => ( + { + this.ref = el + }} + data-cid={combineDataCid('Select', this.props)} + > + {this.renderInput({ getInputProps, getTriggerProps })} + + {assistiveText} + + + {this.renderList({ + getListProps, + getOptionProps, + getDisabledOptionProps + })} + + + )} + + ) + } +} + +export default Select +export { Select } diff --git a/packages/ui-select/src/Select/v2/props.ts b/packages/ui-select/src/Select/v2/props.ts new file mode 100644 index 0000000000..a77615d93c --- /dev/null +++ b/packages/ui-select/src/Select/v2/props.ts @@ -0,0 +1,335 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { InputHTMLAttributes } from 'react' + +import type { + OtherHTMLAttributes, + SelectTheme +} from '@instructure/shared-types' +import type { FormMessage } from '@instructure/ui-form-field' +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' +import type { + PlacementPropValues, + PositionConstraint, + PositionMountNode +} from '@instructure/ui-position' +import type { WithDeterministicIdProps } from '@instructure/ui-react-utils' +import { Renderable } from '@instructure/shared-types' + +type SelectOwnProps = { + /** + * The id of the text input. One is generated if not supplied. + */ + id?: string + + /** + * Additional helpful text to provide to screen readers about the operation + * of the component. + */ + assistiveText?: string + + /** + * Specifies if interaction with the input is enabled, disabled, or readonly. + * When "disabled", the input changes visibly to indicate that it cannot + * receive user interactions. When "readonly" the input still cannot receive + * user interactions but it keeps the same styles as if it were enabled. + */ + interaction?: 'enabled' | 'disabled' | 'readonly' + + /** + * Whether the input is rendered inline with other elements or if it + * is rendered as a block level element. + */ + isInline?: boolean + + /** + * The number of options that should be visible before having to scroll. Works best when the options are the same height. + */ + visibleOptionsCount?: number + + /** + * Whether or not the content of the selected `Select.Option`'s `renderBeforeLabel` and `renderAfterLabel` appear in the input field. + * + * If the selected `Select.Option` has both `renderBeforeLabel` and `renderAfterLabel` content, both will be displayed in the input field. + * + * One of the `Select.Option`'s `isSelected` prop should be `true` in order to display the content in the input field. + * + * `Select.Option`'s `renderBeforeLabel` and `renderAfterLabel` content will not be displayed, if `Select`'s `inputValue` is an empty value, null or undefined. + * + * If `true` and the selected `Select.Option` has a `renderAfterLabel` value, it will replace the default arrow icon. + * + * If `true` and `Select`'s `renderBeforeInput` or `renderAfterInput` prop is set, it will display the selected `Select.Option`'s `renderBeforeLabel` and `renderAfterLabel` instead of `Select`'s `renderBeforeInput` or `renderAfterInput` value. + * + * If the selected `Select.Option`'s `renderAfterLabel` 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. + */ + optionsMaxHeight?: string + + /** + * The max width the options list can be before option text wraps. If not + * set, the list will only display as wide as the text input. + */ + optionsMaxWidth?: string + + // Passed directly to TextInput as `value` + /** + * The value to display in the text input. + */ + inputValue?: string + + // Passed directly to TextInput as `onChange` + /** + * Callback fired when text input value changes. + */ + onInputChange?: ( + event: React.ChangeEvent, + value: string + ) => void + + /** + * A ref to the html `ul` element. + */ + listRef?: (listElement: HTMLUListElement | null) => void + + /** + * Enable/disable auto scroll to the highlighted option on every re-render + */ + scrollToHighlightedOption?: boolean + + /** + * Children of type `` or ``. + */ + children?: React.ReactNode // TODO: ChildrenPropTypes.oneOf([Group, Option]) +} & PropsFromSelectable & + PropsFromTextInput & + PropsFromPopover + +// These props are directly passed to Selectable +// TODO: import these from Selectable once TS types can be imported +type PropsFromSelectable = { + /** + * Whether or not to show the options list. + */ + isShowingOptions?: boolean + + /** + * Callback fired requesting that the options list be shown. + */ + onRequestShowOptions?: (event: React.KeyboardEvent | React.MouseEvent) => void + + /** + * Callback fired requesting that the options list be hidden. + */ + onRequestHideOptions?: ( + event: React.KeyboardEvent | React.MouseEvent | React.FocusEvent + ) => void + + /** + * Callback fired requesting a particular option be highlighted. + */ + onRequestHighlightOption?: ( + event: React.KeyboardEvent | React.MouseEvent, + data: { id?: string; direction?: 1 | -1 } + ) => void + + /** + * Callback fired requesting a particular option be selected. + */ + onRequestSelectOption?: ( + event: React.KeyboardEvent | React.MouseEvent, + data: { id?: string } + ) => void +} + +// These props are directly passed to TextInput +// TODO: import these from TextInput once TS types can be imported +type PropsFromTextInput = { + /** + * The form field label. + */ + renderLabel: Renderable + + /** + * The size of the text input. + */ + size?: 'small' | 'medium' | 'large' + + /** + * Html placeholder text to display when the input has no value. This should + * be hint text, not a label replacement. + */ + placeholder?: string + + /** + * Whether or not the text input is required. + */ + isRequired?: boolean + + /** + * The width of the text input. + */ + width?: string + + /** + * The width of the input (integer value 0 or higher), if a width is not explicitly + * provided via the `width` prop. + * + * Only applicable if `isInline={true}`. + * + * For more see https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/size + */ + htmlSize?: number + + /** + * Displays messages and validation for the input. It should be an object + * with the following shape: + * `{ + * text: React.ReactNode, + * type: One of: ['newError', 'error', 'hint', 'success', 'screenreader-only'] + * }` + */ + messages?: FormMessage[] + + /** + * Callback fired when text input receives focus. + */ + onFocus?: (event: React.FocusEvent) => void + + /** + * Callback fired when text input loses focus. + */ + onBlur?: (event: React.FocusEvent) => void + + /** + * A ref to the html `input` element. + */ + inputRef?: (inputElement: HTMLInputElement | null) => void + + /** + * Content to display before the text input. This will commonly be an icon or + * tags to show multiple selections. + */ + renderBeforeInput?: Renderable + + /** + * Content to display after the text input. This content will replace the + * default arrow icons. + */ + renderAfterInput?: Renderable + + /** + * Prevents the default behavior of wrapping the input and rendered content + * when available space is exceeded. + */ + shouldNotWrap?: boolean + + /** + * In `stacked` mode the input is below the label. + * + * In `inline` mode the input is to the right/left (depending on text direction) of the label, + * and the layout will look like `stacked` for small screens. + */ + layout?: 'stacked' | 'inline' +} + +// These props are directly passed to Popover +// TODO: import these from Popover once TS types can be imported +type PropsFromPopover = { + /** + * The placement of the options list. + */ + placement?: PlacementPropValues + + /** + * The parent in which to constrain the placement. + */ + constrain?: PositionConstraint + + /** + * An element or a function returning an element to use mount the options + * list to in the DOM (defaults to `document.body`) + */ + mountNode?: PositionMountNode +} + +type PropKeys = keyof SelectOwnProps + +type AllowedPropKeys = Readonly> + +type SelectProps = SelectOwnProps & + WithStyleProps & + OtherHTMLAttributes< + SelectOwnProps, + InputHTMLAttributes + > & + WithDeterministicIdProps + +type SelectStyle = ComponentStyle<'assistiveText' | 'popoverBorderWidth'> + +const allowedProps: AllowedPropKeys = [ + 'renderLabel', + 'inputValue', + 'isShowingOptions', + 'id', + 'size', + 'assistiveText', + 'placeholder', + 'interaction', + 'isRequired', + 'isInline', + 'width', + 'htmlSize', + 'visibleOptionsCount', + 'isOptionContentAppliedToInput', + 'optionsMaxHeight', + 'optionsMaxWidth', + 'messages', + 'placement', + 'constrain', + 'mountNode', + 'onFocus', + 'onBlur', + 'onInputChange', + 'onRequestShowOptions', + 'onRequestHideOptions', + 'onRequestHighlightOption', + 'onRequestSelectOption', + 'inputRef', + 'listRef', + 'renderBeforeInput', + 'renderAfterInput', + 'children', + 'shouldNotWrap', + 'scrollToHighlightedOption', + 'layout' +] + +export type { SelectProps, SelectOwnProps, SelectStyle } +export { allowedProps } diff --git a/packages/ui-select/src/Select/v2/styles.ts b/packages/ui-select/src/Select/v2/styles.ts new file mode 100644 index 0000000000..f3f241259f --- /dev/null +++ b/packages/ui-select/src/Select/v2/styles.ts @@ -0,0 +1,48 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { NewComponentTypes } from '@instructure/ui-themes' +import type { SelectProps, SelectStyle } from './props' + +/** + * Generates the style object from the theme and provided additional information + * @param {Object} componentTheme The theme variable object. + * @param {Object} props the props of the component, the style is applied to + * @param {Object} state the state of the component, the style is applied to + * @return {Object} The final style object, which will be used in the component + */ +const generateStyle = ( + componentTheme: NewComponentTypes['Select'], + _props: SelectProps +): SelectStyle => { + return { + assistiveText: { + label: 'select__assistiveText', + display: 'none' + }, + popoverBorderWidth: componentTheme.popoverBorderWidth + } +} + +export default generateStyle diff --git a/packages/ui-select/src/exports/b.ts b/packages/ui-select/src/exports/b.ts new file mode 100644 index 0000000000..942d052e0b --- /dev/null +++ b/packages/ui-select/src/exports/b.ts @@ -0,0 +1,31 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export { Select } from '../Select/v2' +export { Group as SelectGroup } from '../Select/v2/Group' +export { Option as SelectOption } from '../Select/v2/Option' + +export type { SelectProps, SelectOwnProps } from '../Select/v2/props' +export type { SelectGroupProps } from '../Select/v2/Group/props' +export type { SelectOptionProps } from '../Select/v2/Option/props' diff --git a/packages/ui-simple-select/package.json b/packages/ui-simple-select/package.json index 2d15dfa62b..882dce3ae3 100644 --- a/packages/ui-simple-select/package.json +++ b/packages/ui-simple-select/package.json @@ -69,18 +69,18 @@ "default": "./es/exports/a.js" }, "./v11_7": { - "src": "./src/exports/a.ts", - "types": "./types/exports/a.d.ts", - "import": "./es/exports/a.js", - "require": "./lib/exports/a.js", - "default": "./es/exports/a.js" + "src": "./src/exports/b.ts", + "types": "./types/exports/b.d.ts", + "import": "./es/exports/b.js", + "require": "./lib/exports/b.js", + "default": "./es/exports/b.js" }, "./latest": { - "src": "./src/exports/a.ts", - "types": "./types/exports/a.d.ts", - "import": "./es/exports/a.js", - "require": "./lib/exports/a.js", - "default": "./es/exports/a.js" + "src": "./src/exports/b.ts", + "types": "./types/exports/b.d.ts", + "import": "./es/exports/b.js", + "require": "./lib/exports/b.js", + "default": "./es/exports/b.js" } } } diff --git a/packages/ui-simple-select/src/SimpleSelect/v2/Group/index.tsx b/packages/ui-simple-select/src/SimpleSelect/v2/Group/index.tsx new file mode 100644 index 0000000000..57b632f430 --- /dev/null +++ b/packages/ui-simple-select/src/SimpleSelect/v2/Group/index.tsx @@ -0,0 +1,51 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' + +import type { SimpleSelectGroupProps } from './props' +import { allowedProps } from './props' + +/** +--- +parent: SimpleSelect +id: SimpleSelect.Group +--- +**/ +class Group extends Component { + static readonly componentId = 'SimpleSelect.Group' + + static allowedProps = allowedProps + static defaultProps = {} + + /* istanbul ignore next */ + render() { + // this component is only used for prop validation. Select.Group children + // are parsed in Select and rendered as Options components + return null + } +} + +export default Group +export { Group } diff --git a/packages/ui-simple-select/src/SimpleSelect/v2/Group/props.ts b/packages/ui-simple-select/src/SimpleSelect/v2/Group/props.ts new file mode 100644 index 0000000000..91227c7bb2 --- /dev/null +++ b/packages/ui-simple-select/src/SimpleSelect/v2/Group/props.ts @@ -0,0 +1,49 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react' + +import type { OtherHTMLAttributes } from '@instructure/shared-types' + +type SimpleSelectGroupOwnProps = { + /** + * The label associated with the group options. + */ + renderLabel: React.ReactNode | (() => React.ReactNode) + /** + * Children of type `` that will be considered part of the group. + */ + children?: React.ReactNode // TODO: ChildrenPropTypes.oneOf([Option]) +} + +type PropKeys = keyof SimpleSelectGroupOwnProps + +type AllowedPropKeys = Readonly> + +type SimpleSelectGroupProps = SimpleSelectGroupOwnProps & + OtherHTMLAttributes +const allowedProps: AllowedPropKeys = ['renderLabel', 'children'] + +export type { SimpleSelectGroupProps } +export { allowedProps } diff --git a/packages/ui-simple-select/src/SimpleSelect/v2/Option/index.tsx b/packages/ui-simple-select/src/SimpleSelect/v2/Option/index.tsx new file mode 100644 index 0000000000..31efa5c653 --- /dev/null +++ b/packages/ui-simple-select/src/SimpleSelect/v2/Option/index.tsx @@ -0,0 +1,52 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' +import type { SimpleSelectOptionProps } from './props' +import { allowedProps } from './props' + +/** +--- +parent: SimpleSelect +id: SimpleSelect.Option +--- +**/ +class Option extends Component { + static readonly componentId = 'SimpleSelect.Option' + + static allowedProps = allowedProps + static defaultProps = { + isDisabled: false + } + + /* istanbul ignore next */ + render() { + // this component is only used for prop validation. SimpleSelect.Option children + // are parsed in Select and rendered as Options.Item components + return null + } +} + +export default Option +export { Option } diff --git a/packages/ui-simple-select/src/SimpleSelect/v2/Option/props.ts b/packages/ui-simple-select/src/SimpleSelect/v2/Option/props.ts new file mode 100644 index 0000000000..532f9317ce --- /dev/null +++ b/packages/ui-simple-select/src/SimpleSelect/v2/Option/props.ts @@ -0,0 +1,83 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React from 'react' +import type { OtherHTMLAttributes } from '@instructure/shared-types' +import { Renderable } from '@instructure/shared-types' + +type OptionProps = { + id: SimpleSelectOptionOwnProps['id'] + isDisabled?: SimpleSelectOptionOwnProps['isDisabled'] + isSelected?: boolean + isHighlighted?: boolean + children?: React.ReactNode +} + +type RenderSimpleSelectOptionLabel = Renderable + +type SimpleSelectOptionOwnProps = { + /** + * The id for the option. **Must be globally unique**, it will be translated + * to an `id` prop in the DOM. + */ + id: string + /** + * The value for the option. + */ + value: string | number + /** + * Whether or not this option is disabled. + */ + isDisabled?: boolean + /** + * Content to display as the option label. + */ + children?: string + /** + * Content to display before the option label, such as an icon. + */ + renderBeforeLabel?: RenderSimpleSelectOptionLabel + /** + * Content to display after the option label, such as an icon. + */ + renderAfterLabel?: RenderSimpleSelectOptionLabel +} + +type PropKeys = keyof SimpleSelectOptionOwnProps + +type AllowedPropKeys = Readonly> + +type SimpleSelectOptionProps = SimpleSelectOptionOwnProps & + OtherHTMLAttributes +const allowedProps: AllowedPropKeys = [ + 'id', + 'value', + 'isDisabled', + 'renderBeforeLabel', + 'renderAfterLabel', + 'children' +] + +export type { SimpleSelectOptionProps, RenderSimpleSelectOptionLabel } +export { allowedProps } diff --git a/packages/ui-simple-select/src/SimpleSelect/v2/README.md b/packages/ui-simple-select/src/SimpleSelect/v2/README.md new file mode 100644 index 0000000000..b364d99f39 --- /dev/null +++ b/packages/ui-simple-select/src/SimpleSelect/v2/README.md @@ -0,0 +1,157 @@ +--- +describes: SimpleSelect +--- + +`SimpleSelect` is a higher level abstraction of [Select](Select) that closely parallels the functionality of standard HTML `` element, `SimpleSelect` supports option groups. `SimpleSelect.Group` only requires the `renderLabel` prop be provided. + +```javascript +--- +type: example +--- + + + + Option one + + + + + Option two + + + Option three + + + Option four + + + +``` + +### Icons + +To display icons (or other elements) before or after an option, pass it via the `renderBeforeLabel` and `renderAfterLabel` prop to `SimpleSelect.Option`. You can pass a function as well, which will have a `props` parameter, so you can access the properties of that `SimpleSelect.Option` (e.g. if it is currently `isHighlighted`). The available props are: `[ id, isDisabled, isSelected, isHighlighted, children ]` (same as for `Select.Option`). + +```javascript +--- +type: example +--- + + + Text + + } + > + Icon + + { + let color = 'infoColor' + if (props.isHighlighted) color = 'baseColor' + if (props.isSelected) color = 'inverseColor' + if (props.isDisabled) color = 'disabledBaseColor' + return + }} + > + Colored Icon + + +``` + +> Note: This component uses a native `input` field to render the selected value. When it's included in a native HTML `form`, the text value will be sent to the backend instead of anything specified in the `value` field of the `SimpleSelect.Option`-s. We do not recommend to use this component this way, rather write your own code that collects information and sends it to the backend. diff --git a/packages/ui-simple-select/src/SimpleSelect/v1/__tests__/SimpleSelect.test.tsx b/packages/ui-simple-select/src/SimpleSelect/v2/__tests__/SimpleSelect.test.tsx similarity index 99% rename from packages/ui-simple-select/src/SimpleSelect/v1/__tests__/SimpleSelect.test.tsx rename to packages/ui-simple-select/src/SimpleSelect/v2/__tests__/SimpleSelect.test.tsx index c76a953252..1dc0ac0b09 100644 --- a/packages/ui-simple-select/src/SimpleSelect/v1/__tests__/SimpleSelect.test.tsx +++ b/packages/ui-simple-select/src/SimpleSelect/v2/__tests__/SimpleSelect.test.tsx @@ -26,7 +26,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { vi, MockInstance, it } from 'vitest' import userEvent from '@testing-library/user-event' import '@testing-library/jest-dom' -import { IconCheckSolid } from '@instructure/ui-icons' +import { CheckInstUIIcon } from '@instructure/ui-icons' import SimpleSelect from '../index' @@ -228,7 +228,7 @@ describe('', () => { it('should render icons before option and call renderBeforeLabel callback with necessary props', async () => { const renderBeforeLabel = vi.fn(() => ( - + )) render( diff --git a/packages/ui-simple-select/src/SimpleSelect/v2/index.tsx b/packages/ui-simple-select/src/SimpleSelect/v2/index.tsx new file mode 100644 index 0000000000..d9452719e2 --- /dev/null +++ b/packages/ui-simple-select/src/SimpleSelect/v2/index.tsx @@ -0,0 +1,559 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + isValidElement, + ComponentElement, + Component, + Children, + type ReactElement +} from 'react' + +import * as utils from '@instructure/ui-utils' +import { + matchComponentTypes, + passthroughProps, + callRenderProp, + getInteraction, + withDeterministicId +} from '@instructure/ui-react-utils' + +import { Select } from '@instructure/ui-select/latest' +import type { SelectProps } from '@instructure/ui-select/latest' + +import { Option } from './Option' +import type { + SimpleSelectOptionProps, + RenderSimpleSelectOptionLabel +} from './Option/props' + +import { Group } from './Group' +import type { SimpleSelectGroupProps } from './Group/props' + +import type { SimpleSelectProps } from './props' +import { allowedProps, SimpleSelectState } from './props' + +type OptionChild = ComponentElement +type GroupChild = ComponentElement + +type GetOption = ( + field: F, + value?: SimpleSelectOptionProps[F] +) => OptionChild | undefined + +/** +--- +category: components +tags: form, field, dropdown +--- +**/ +@withDeterministicId() +class SimpleSelect extends Component { + static readonly componentId = 'SimpleSelect' + + static Option = Option + static Group = Group + + static allowedProps = allowedProps + + static defaultProps = { + size: 'medium', + isRequired: false, + isInline: false, + visibleOptionsCount: 8, + placement: 'bottom stretch', + constrain: 'window', + renderEmptyOption: '---', + isOptionContentAppliedToInput: false + } + + ref: Select | null = null + + private readonly _emptyOptionId + + constructor(props: SimpleSelectProps) { + super(props) + + const option = this.getInitialOption(props) + + this.state = { + inputValue: option ? option.props.children : '', + isShowingOptions: false, + highlightedOptionId: undefined, + selectedOptionId: option ? option.props.id : undefined + } + + this._emptyOptionId = props.deterministicId!('Select-EmptyOption') + } + + get _select() { + console.warn( + '_select property is deprecated and will be removed in v9, please use ref instead' + ) + + return this.ref + } + + focus() { + this.ref && this.ref.focus() + } + + blur() { + this.ref && this.ref.blur() + } + + get focused() { + return this.ref ? this.ref.focused : false + } + + get id() { + return this.ref ? this.ref.id : undefined + } + + get isControlled() { + return typeof this.props.value !== 'undefined' + } + + get interaction() { + return getInteraction({ props: this.props }) + } + + hasOptionsChanged( + prevChildren: SimpleSelectProps['children'], + currentChildren: SimpleSelectProps['children'] + ) { + const getValues = (children: SimpleSelectProps['children']) => + Children.map(children, (child) => { + if (isValidElement(child)) { + return (child as ReactElement).props.value + } + return null + }) + + const prevValues = getValues(prevChildren) + const currentValues = getValues(currentChildren) + + return JSON.stringify(prevValues) !== JSON.stringify(currentValues) + } + + componentDidUpdate(prevProps: SimpleSelectProps) { + if (this.hasOptionsChanged(prevProps.children, this.props.children)) { + // Compare current input value to children's child prop, this is put into + // state.inputValue + const option = this.getOption('children', this.state.inputValue) + this.setState({ + inputValue: option ? option.props.children : undefined, + selectedOptionId: option ? option.props.id : '' + }) + } + if (this.props.value !== prevProps.value) { + // if value has changed externally try to find an option with the same value + // and select it + let option = this.getOption('value', this.props.value) + if (typeof this.props.value === 'undefined') { + // preserve current value when changing from controlled to uncontrolled + option = this.getOption('value', prevProps.value) + } + this.setState({ + inputValue: option ? option.props.children : '', + selectedOptionId: option ? option.props.id : '' + }) + } + } + + getInitialOption(props: SimpleSelectProps) { + const { value, defaultValue } = props + const initialValue = value || defaultValue + + if (typeof initialValue === 'string' || typeof initialValue === 'number') { + // get option based on value or defaultValue, if provided + return this.getOption('value', initialValue) + } + // otherwise get the first option + return this.getFirstOption() + } + + getOptionLabelById(id: string) { + const option = this.getOption('id', id) + return option ? option.props.children : '' + } + + getFirstOption() { + const children = Children.toArray(this.props.children) as ( + | OptionChild + | GroupChild + )[] + let match: OptionChild | undefined + + for (let i = 0; i < children.length; i++) { + const child = children[i] + if (matchComponentTypes(child, [Option])) { + match = child + } else if (matchComponentTypes(child, [Group])) { + // first child is a group, not an option, find first child in group + match = (Children.toArray(child.props.children) as OptionChild[])[0] + } + if (match) { + break + } + } + return match + } + + getOption: GetOption = (field, value) => { + const children = Children.toArray(this.props.children) as ( + | OptionChild + | GroupChild + )[] + let match: OptionChild | undefined + + for (let i = 0; i < children.length; ++i) { + const child = children[i] + if (matchComponentTypes(child, [Option])) { + if (child.props[field] === value) { + match = child + } + } else if (matchComponentTypes(child, [Group])) { + const groupChildren = Children.toArray( + child.props.children + ) as OptionChild[] + for (let j = 0; j < groupChildren.length; ++j) { + const groupChild = groupChildren[j] + if (groupChild.props[field] === value) { + match = groupChild + break + } + } + } + if (match) break + } + return match + } + + getOptionByPosition(position: 'first' | 'last'): OptionChild | undefined { + const children = Children.toArray(this.props.children) + + // Determine where to start looking based on position + const index = position === 'first' ? 0 : children.length - 1 + + // Check if child is an option or group + const child = children[index] + if (!child) return undefined + + // If it's a regular option, return it + if (matchComponentTypes(child, [Option])) { + return child + } + + // If it's a group, get its options + if (matchComponentTypes(child, [Group])) { + const groupOptions = Children.toArray(child.props.children) + const groupIndex = position === 'first' ? 0 : groupOptions.length - 1 + return groupOptions[groupIndex] as OptionChild + } + + return undefined + } + + handleRef = (node: Select) => { + this.ref = node + } + + handleBlur: SelectProps['onBlur'] = (event) => { + this.setState({ highlightedOptionId: undefined }) + if (typeof this.props.onBlur === 'function') { + this.props.onBlur(event) + } + } + + handleShowOptions: SelectProps['onRequestShowOptions'] = (event) => { + this.setState({ isShowingOptions: true }) + if (typeof this.props.onShowOptions === 'function') { + this.props.onShowOptions(event) + } + + if (event.type.startsWith('key')) { + const keyboardEvent = event as React.KeyboardEvent + const children = Children.toArray(this.props.children) as ( + | OptionChild + | GroupChild + )[] + + if (!this.state.inputValue && children.length > 0) { + const position = + keyboardEvent.key === 'ArrowDown' + ? 'first' + : keyboardEvent.key === 'ArrowUp' + ? 'last' + : undefined + if (position) { + const optionId = this.getOptionByPosition(position)?.props.id + optionId && + this.setState({ + highlightedOptionId: optionId + }) + } + } + } + } + + handleHideOptions: SelectProps['onRequestHideOptions'] = (event) => { + this.setState((state) => { + const option = this.getOption('id', state.selectedOptionId) + return { + isShowingOptions: false, + highlightedOptionId: undefined, + inputValue: option ? option.props.children : '' + } + }) + if (typeof this.props.onHideOptions === 'function') { + this.props.onHideOptions(event) + } + } + + handleHighlightOption: SelectProps['onRequestHighlightOption'] = ( + _event, + { id } + ) => { + if (id === this._emptyOptionId) return + + this.setState({ + highlightedOptionId: id, + inputValue: this.state.inputValue + }) + } + + handleSelectOption: SelectProps['onRequestSelectOption'] = ( + event, + { id } + ) => { + if (id === this._emptyOptionId) { + // selected option is the empty option + this.setState({ isShowingOptions: false }) + return + } + + const option = this.getOption('id', id) + const value = option && option.props.value + + // Focus needs to be reapplied to input + // after selecting an item to make sure VoiceOver behaves correctly on iOS + if (utils.isAndroidOrIOS()) { + this.blur() + this.focus() + } + + if (this.isControlled) { + this.setState({ isShowingOptions: false }) + } else { + this.setState((state) => ({ + isShowingOptions: false, + selectedOptionId: id, + inputValue: option ? option.props.children : state.inputValue + })) + } + // fire onChange if selected option changed + if (option && typeof this.props.onChange === 'function') { + this.props.onChange(event, { value, id }) + } + // hide options list whenever selection is made + if (typeof this.props.onHideOptions === 'function') { + this.props.onHideOptions(event) + } + } + + renderChildren() { + let children = Children.toArray(this.props.children) as ( + | OptionChild + | GroupChild + )[] + children = Children.map(children, (child) => { + if (matchComponentTypes(child, [Option])) { + return this.renderOption(child) + } else if (matchComponentTypes(child, [Group])) { + return this.renderGroup(child) + } + return null + }).filter((child) => !!child) + + if (children.length === 0) { + // no valid children, render empty option + return this.renderEmptyOption() + } + + return children + } + + renderEmptyOption() { + return ( + + {callRenderProp(this.props.renderEmptyOption)} + + ) as OptionChild + } + + renderOption(option: OptionChild) { + const { + id, + value, + children, + renderBeforeLabel, + renderAfterLabel, + ...rest + } = option.props + + const isDisabled = option.props.isDisabled ?? false // after the react 19 upgrade `isDisabled` is undefined instead of defaulting to false if not specified (but only in vitest env for some reason) + const isSelected = id === this.state.selectedOptionId + const isHighlighted = id === this.state.highlightedOptionId + + const getRenderLabel = (renderLabel: RenderSimpleSelectOptionLabel) => { + if ( + typeof renderLabel === 'function' && + !renderLabel?.prototype?.isReactComponent + ) { + return (renderLabel as any).bind(null, { + id, + isDisabled, + isSelected, + isHighlighted, + children + }) + } + return renderLabel + } + + return ( + + {children} + + ) as OptionChild + } + + renderGroup(group: GroupChild) { + const { id, renderLabel, children, ...rest } = group.props + return ( + + {Children.map(children as OptionChild[], (child) => + this.renderOption(child) + )} + + ) as GroupChild + } + + render() { + const { + renderLabel, + value, + defaultValue, + id, + size, + assistiveText, + placeholder, + interaction, + isRequired, + isInline, + width, + optionsMaxWidth, + optionsMaxHeight, + visibleOptionsCount, + messages, + placement, + constrain, + mountNode, + inputRef, + listRef, + renderEmptyOption, + renderBeforeInput, + renderAfterInput, + onFocus, + onBlur, + onShowOptions, + onHideOptions, + children, + layout, + ...rest + } = this.props + + return ( + + ) + } +} + +export { SimpleSelect } +export default SimpleSelect diff --git a/packages/ui-simple-select/src/SimpleSelect/v2/props.ts b/packages/ui-simple-select/src/SimpleSelect/v2/props.ts new file mode 100644 index 0000000000..0dad4e852a --- /dev/null +++ b/packages/ui-simple-select/src/SimpleSelect/v2/props.ts @@ -0,0 +1,300 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { InputHTMLAttributes } from 'react' + +import type { FormMessage } from '@instructure/ui-form-field/latest' +import type { + OtherHTMLAttributes, + PickPropsWithExceptions +} from '@instructure/shared-types' +import type { + PlacementPropValues, + PositionConstraint, + PositionMountNode +} from '@instructure/ui-position' +import type { SelectOwnProps } from '@instructure/ui-select/latest' +import type { WithDeterministicIdProps } from '@instructure/ui-react-utils' +import { Renderable } from '@instructure/shared-types' + +type SimpleSelectOwnProps = PropsPassedToSelect & { + /** + * The value corresponding to the value of the selected option. If defined, + * the component will act controlled and will not manage its own state. + */ + value?: string | number // TODO: it was using the "controllable" util, in the TS migration mimic that behaviour + + /** + * The value of the option to select by default, when uncontrolled. + */ + defaultValue?: string + + /** + * Callback fired when a new option is selected. + */ + onChange?: ( + event: React.SyntheticEvent, + data: { + value?: string | number + id?: string + } + ) => void + + // passed to Select as onRequestShowOptions + /** + * Callback fired when the options list is shown. + */ + onShowOptions?: (event: React.SyntheticEvent) => void + + // passed to Select as onRequestHideOptions + /** + * Callback fired when the options list is hidden. + */ + onHideOptions?: (event: React.SyntheticEvent) => void + + /** + * Content to display in the list when no options are available. + */ + renderEmptyOption?: Renderable + + /** + * Children of type `` or ``. + */ + children?: React.ReactNode // TODO: ChildrenPropTypes.oneOf([Group, Option]) +} + +type PropsPassedToSelect = { + /** + * The form field label. + */ + renderLabel: Renderable + + /** + * The id of the text input. One is generated if not supplied. + */ + id?: string + + /** + * The size of the text input. + */ + size?: 'small' | 'medium' | 'large' + + /** + * Additional helpful text to provide to screen readers about the operation + * of the component. Provided via aria-describedby. + */ + assistiveText?: string + + /** + * Html placeholder text to display when the input has no value. This should + * be hint text, not a label replacement. + */ + placeholder?: string + + /** + * Specifies if interaction with the input is enabled, disabled, or readonly. + * When "disabled", the input changes visibly to indicate that it cannot + * receive user interactions. When "readonly" the input still cannot receive + * user interactions but it keeps the same styles as if it were enabled. + */ + interaction?: 'enabled' | 'disabled' | 'readonly' + + /** + * Whether or not the text input is required. + */ + isRequired?: boolean + + /** + * Whether the input is rendered inline with other elements or if it + * is rendered as a block level element. + */ + isInline?: boolean + + /** + * The width of the text input. + */ + width?: string + + /** + * The number of options that should be visible before having to scroll. Works best when the options are the same height. + */ + visibleOptionsCount?: number + + /** + * The max height the options list can be before having to scroll. If + * set, it will __override__ the `visibleOptionsCount` prop. + */ + optionsMaxHeight?: string + + /** + * The max width the options list can be before option text wraps. If not + * set, the list will only display as wide as the text input. + */ + optionsMaxWidth?: string + + /** + * Displays messages and validation for the input. It should be an array of + * objects with the following shape: + * `{ + * text: ReactNode, + * type: One of: ['newError', 'error', 'hint', 'success', 'screenreader-only'] + * }` + */ + messages?: FormMessage[] + + /** + * The placement of the options list. + */ + placement?: PlacementPropValues + + /** + * The parent in which to constrain the placement. + */ + constrain?: PositionConstraint + + /** + * An element or a function returning an element to use mount the options + * list to in the DOM (defaults to `document.body`) + */ + mountNode?: PositionMountNode + + /** + * A ref to the html `input` element. + */ + inputRef?: (inputElement: HTMLInputElement | null) => void + + /** + * A ref to the html `ul` element. + */ + listRef?: (listElement: HTMLUListElement | null) => void + + /** + * Content to display before the text input. This will commonly be an icon. + */ + renderBeforeInput?: Renderable + + /** + * Content to display after the text input. This content will replace the + * default arrow icons. + */ + renderAfterInput?: Renderable + + /** + * Callback fired when text input receives focus. + */ + onFocus?: (event: React.FocusEvent) => void + + /** + * Callback fired when text input loses focus. + */ + onBlur?: (event: React.FocusEvent) => void + /** + * Whether or not the content of the selected `SimpleSelect.Option`'s `renderBeforeLabel` and `renderAfterLabel` appear in the input field. + * + * If the selected `SimpleSelect.Option` has both `renderBeforeLabel` and `renderAfterLabel` content, both will be displayed in the input field. + * + * `SimpleSelect.Option`'s `renderBeforeLabel` and `renderAfterLabel` content will not be displayed, if `SimpleSelect`'s `inputValue` is an empty value, null or undefined. + * + * If `true` and the selected `SimpleSelect.Option` has a `renderAfterLabel` value, it will replace the default arrow icon. + * + * If `true` and `SimpleSelect`'s `renderBeforeInput` or `renderAfterInput` prop is set, it will display the selected `SimpleSelect.Option`'s `renderBeforeLabel` and `renderAfterLabel` instead of `SimpleSelect`'s `renderBeforeInput` or `renderAfterInput` value. + * + * If the selected `SimpleSelect.Option`'s `renderAfterLabel` value is empty, default arrow icon will be rendered. + */ + isOptionContentAppliedToInput?: boolean + + /** + * In `stacked` mode the input is below the label. + * + * In `inline` mode the input is to the right/left (depending on text direction) of the label, + * and the layout will look like `stacked` for small screens. + */ + layout?: 'stacked' | 'inline' +} + +type PropKeys = keyof SimpleSelectOwnProps + +type AllowedPropKeys = Readonly> + +type SimpleSelectProps = PickPropsWithExceptions< + SelectOwnProps, + | keyof PropsPassedToSelect + | 'children' + | 'onRequestShowOptions' + | 'onRequestHideOptions' + | 'onRequestHighlightOption' + | 'onRequestSelectOption' + | 'inputValue' + | 'isShowingOptions' + | 'layout' +> & + SimpleSelectOwnProps & + OtherHTMLAttributes< + SimpleSelectOwnProps, + InputHTMLAttributes + > & + WithDeterministicIdProps + +type SimpleSelectState = { + inputValue?: string + isShowingOptions: boolean + highlightedOptionId?: string + selectedOptionId?: string +} + +const allowedProps: AllowedPropKeys = [ + 'renderLabel', + 'value', + 'defaultValue', + 'id', + 'size', + 'assistiveText', + 'placeholder', + 'interaction', + 'isRequired', + 'isInline', + 'width', + 'visibleOptionsCount', + 'optionsMaxHeight', + 'optionsMaxWidth', + 'messages', + 'placement', + 'constrain', + 'mountNode', + 'onChange', + 'onFocus', + 'onBlur', + 'onShowOptions', + 'onHideOptions', + 'inputRef', + 'listRef', + 'renderEmptyOption', + 'renderBeforeInput', + 'renderAfterInput', + 'children', + 'layout' +] + +export type { SimpleSelectProps, SimpleSelectState } +export { allowedProps } diff --git a/packages/ui-simple-select/src/exports/b.ts b/packages/ui-simple-select/src/exports/b.ts new file mode 100644 index 0000000000..cbafda86c3 --- /dev/null +++ b/packages/ui-simple-select/src/exports/b.ts @@ -0,0 +1,31 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export { SimpleSelect } from '../SimpleSelect/v2' +export { Group as SimpleSelectGroup } from '../SimpleSelect/v2/Group' +export { Option as SimpleSelectOption } from '../SimpleSelect/v2/Option' + +export type { SimpleSelectProps } from '../SimpleSelect/v2/props' +export type { SimpleSelectGroupProps } from '../SimpleSelect/v2/Group/props' +export type { SimpleSelectOptionProps } from '../SimpleSelect/v2/Option/props' diff --git a/packages/ui-text-input/src/TextInput/v2/index.tsx b/packages/ui-text-input/src/TextInput/v2/index.tsx index 2984c18e0f..a9464a9dd6 100644 --- a/packages/ui-text-input/src/TextInput/v2/index.tsx +++ b/packages/ui-text-input/src/TextInput/v2/index.tsx @@ -297,7 +297,9 @@ class TextInput extends Component { {renderBeforeOrAfter ? ( - {beforeElement} + {beforeElement && ( + {beforeElement} + )} {/* The input and content after input should not wrap, so they're in their own flex container */} diff --git a/packages/ui-text-input/src/TextInput/v2/props.ts b/packages/ui-text-input/src/TextInput/v2/props.ts index 8cb0cbb87d..572b53732c 100644 --- a/packages/ui-text-input/src/TextInput/v2/props.ts +++ b/packages/ui-text-input/src/TextInput/v2/props.ts @@ -24,16 +24,17 @@ import { InputHTMLAttributes } from 'react' -import type { FormFieldProps, FormMessage } from '@instructure/ui-form-field/latest' import type { - OtherHTMLAttributes, - TextInputTheme -} from '@instructure/shared-types' + FormFieldProps, + FormMessage +} from '@instructure/ui-form-field/latest' +import type { OtherHTMLAttributes } from '@instructure/shared-types' import type { WithStyleProps, ComponentStyle, Spacing } from '@instructure/emotion' +import type { NewComponentTypes } from '@instructure/ui-themes' import type { InteractionType, WithDeterministicIdProps @@ -183,7 +184,7 @@ type PropKeys = keyof TextInputOwnProps type AllowedPropKeys = Readonly> type TextInputProps = TextInputOwnProps & - WithStyleProps & + WithStyleProps & OtherHTMLAttributes< TextInputOwnProps, InputHTMLAttributes @@ -194,7 +195,12 @@ type TextInputProps = TextInputOwnProps & WithDeterministicIdProps type TextInputStyle = ComponentStyle< - 'textInput' | 'facade' | 'layout' | 'afterElement' | 'inputLayout' + | 'textInput' + | 'facade' + | 'layout' + | 'beforeElement' + | 'afterElement' + | 'inputLayout' > const allowedProps: AllowedPropKeys = [ 'renderLabel', diff --git a/packages/ui-text-input/src/TextInput/v2/styles.ts b/packages/ui-text-input/src/TextInput/v2/styles.ts index cf73522270..0464dc4068 100644 --- a/packages/ui-text-input/src/TextInput/v2/styles.ts +++ b/packages/ui-text-input/src/TextInput/v2/styles.ts @@ -205,7 +205,12 @@ const generateStyle = ( justifyContent: 'flex-start', flexDirection: 'row' }, + beforeElement: { + ...(interaction === 'disabled' && { opacity: 0.5 }), + label: 'textInput__beforeElement' + }, afterElement: { + ...(interaction === 'disabled' && { opacity: 0.5 }), display: 'flex', alignItems: 'center', paddingInlineEnd: paddingHorizontalVariants[size!], diff --git a/packages/ui-time-select/package.json b/packages/ui-time-select/package.json index 8006c748b8..d5f3512e22 100644 --- a/packages/ui-time-select/package.json +++ b/packages/ui-time-select/package.json @@ -68,18 +68,18 @@ "default": "./es/exports/a.js" }, "./v11_7": { - "src": "./src/exports/a.ts", - "types": "./types/exports/a.d.ts", - "import": "./es/exports/a.js", - "require": "./lib/exports/a.js", - "default": "./es/exports/a.js" + "src": "./src/exports/b.ts", + "types": "./types/exports/b.d.ts", + "import": "./es/exports/b.js", + "require": "./lib/exports/b.js", + "default": "./es/exports/b.js" }, "./latest": { - "src": "./src/exports/a.ts", - "types": "./types/exports/a.d.ts", - "import": "./es/exports/a.js", - "require": "./lib/exports/a.js", - "default": "./es/exports/a.js" + "src": "./src/exports/b.ts", + "types": "./types/exports/b.d.ts", + "import": "./es/exports/b.js", + "require": "./lib/exports/b.js", + "default": "./es/exports/b.js" } } } diff --git a/packages/ui-time-select/src/TimeSelect/v2/README.md b/packages/ui-time-select/src/TimeSelect/v2/README.md new file mode 100644 index 0000000000..11bdb8414c --- /dev/null +++ b/packages/ui-time-select/src/TimeSelect/v2/README.md @@ -0,0 +1,85 @@ +--- +describes: TimeSelect +--- + +`TimeSelect` component is a higher level abstraction of [Select](Select) specifically for selecting time values. The list of possible values can be configured via the component's props. + +### Uncontrolled + +For the most basic implementations, `TimeSelect` can be uncontrolled. If desired, the `defaultValue` prop can be used to set the initial selection. + +```javascript +--- +type: example +--- + console.log(value)} + onHideOptions={(e)=> console.log("hide opts")} + // defaultValue={new Date().toISOString()} +/> +``` + +### Controlled + +To use `TimeSelect` controlled, simply provide the `value` prop an ISO string. The `onChange` callback provides the ISO value of the corresponding option that was selected. Use this value to update the state. + +```js +--- +type: example +--- +const Example = () => { + const [value, setValue] = useState('2020-05-18T23:59:00') + + const handleChange = (e, { value }) => { + setValue(value) + } + + return ( + + ) +} + +render() +``` + +### Freeform input + +By default, the user can only set a value that is divisible by `step` (although the component allows to set any valid time value programmatically). You can allow the user to set any valid value with typing in by setting `allowNonStepInput` to `true`. You can use the `onInputChange` event to see whether the current input is valid and its current value. +Note that the exact value needed to be typed by the user depends on their `locale`: + +```javascript +--- +type: example +--- + console.log("change",value)} + onInputChange={(e, value, isoValue)=> console.log("inputChange", value, isoValue)} + defaultValue="2022-05-12T05:30:00.000Z" + locale="en_AU" + timezone='US/Eastern' + allowNonStepInput +/> +``` + +### Guidelines + +```js +--- +type: embed +--- + +
+ Use a default value of 11:59 pm for implementations that have to do with due dates + Respect the user's `locale` and `timezone` browser settings (the component does this by itself when not setting `locale` or `timezone`). +
+
+``` diff --git a/packages/ui-time-select/src/TimeSelect/v1/__tests__/TimeSelect.test.tsx b/packages/ui-time-select/src/TimeSelect/v2/__tests__/TimeSelect.test.tsx similarity index 100% rename from packages/ui-time-select/src/TimeSelect/v1/__tests__/TimeSelect.test.tsx rename to packages/ui-time-select/src/TimeSelect/v2/__tests__/TimeSelect.test.tsx diff --git a/packages/ui-time-select/src/TimeSelect/v2/index.tsx b/packages/ui-time-select/src/TimeSelect/v2/index.tsx new file mode 100644 index 0000000000..9fa0987932 --- /dev/null +++ b/packages/ui-time-select/src/TimeSelect/v2/index.tsx @@ -0,0 +1,645 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Component } from 'react' +import type { Moment } from 'moment-timezone' +import { ApplyLocaleContext, Locale, DateTime } from '@instructure/ui-i18n' +import { + getInteraction, + passthroughProps, + callRenderProp, + withDeterministicId +} from '@instructure/ui-react-utils' +import { Select } from '@instructure/ui-select/latest' +import * as utils from '@instructure/ui-utils' + +import type { SelectProps } from '@instructure/ui-select/latest' +import type { + TimeSelectProps, + TimeSelectState, + TimeSelectOptions +} from './props' + +import { allowedProps } from './props' + +type GetOption = ( + field: F, + value?: TimeSelectOptions[F], + options?: TimeSelectOptions[] +) => TimeSelectOptions | undefined + +/** +--- +category: components +--- + +A component used to select a time value. + **/ +@withDeterministicId() +class TimeSelect extends Component { + declare context: React.ContextType + + static readonly componentId = 'TimeSelect' + static allowedProps = allowedProps + static defaultProps = { + defaultToFirstOption: false, + format: 'LT', // see https://momentjs.com/docs/#/displaying/ + step: 30, + isRequired: false, + isInline: false, + visibleOptionsCount: 8, + placement: 'bottom stretch', + constrain: 'window', + renderEmptyOption: '---', + allowNonStepInput: false, + allowClearingSelection: false + } + static contextType = ApplyLocaleContext + + ref: Select | null = null + + private readonly _emptyOptionId = + this.props.deterministicId!('Select-EmptyOption') + + constructor(props: TimeSelectProps) { + super(props) + this.state = this.getInitialState() + } + + componentDidMount() { + // we'll need to recalculate the state because the context value is + // set at this point (and it might change locale & timezone) + this.setState(this.getInitialState()) + } + + focus() { + this.ref?.focus() + } + + blur() { + this.ref && this.ref.blur() + } + + get _select() { + console.warn( + '_select property is deprecated and will be removed in v9, please use ref instead' + ) + return this.ref + } + + get isControlled() { + return typeof this.props.value !== 'undefined' + } + + get interaction() { + return getInteraction({ props: this.props }) + } + + get focused() { + return this.ref && this.ref.focused + } + + get id() { + return this.ref && this.ref.id + } + + locale() { + if (this.props.locale) { + return this.props.locale + } else if (this.context && this.context.locale) { + return this.context.locale + } + return Locale.browserLocale() + } + + timezone() { + if (this.props.timezone) { + return this.props.timezone + } else if (this.context && this.context.timezone) { + return this.context.timezone + } + return DateTime.browserTimeZone() + } + + componentDidUpdate(prevProps: TimeSelectProps) { + if ( + this.props.step !== prevProps.step || + this.props.format !== prevProps.format || + this.props.locale !== prevProps.locale || + this.props.timezone !== prevProps.timezone || + this.props.allowNonStepInput !== prevProps.allowNonStepInput + ) { + // options change, reset everything + // when controlled, selection will be preserved + // when uncontrolled, selection will be lost + this.setState(this.getInitialState()) + } + if (this.props.value !== prevProps.value) { + let newValue: Moment | undefined + if (this.props.value) { + newValue = DateTime.parse( + this.props.value, + this.locale(), + this.timezone() + ) + } + // value changed + const initState = this.getInitialState() + this.setState(initState) + // options need to be passed because state is not set immediately + let option + if (!this.isControlled) { + // preserve current value when changing from controlled to uncontrolled + if (prevProps.value) { + option = this.getOption( + 'id', + this.getFormattedId( + DateTime.parse(prevProps.value, this.locale(), this.timezone()) + ) + ) + } + } else if (newValue) { + option = this.getOption( + 'id', + this.getFormattedId(newValue), + initState.options + ) + } + const outsideVal = this.props.value ? this.props.value : '' + // value does not match an existing option + const date = DateTime.parse(outsideVal, this.locale(), this.timezone()) + let label = '' + if (date.isValid()) { + label = this.props.format + ? date.format(this.props.format) + : date.toISOString() + } + this.setState({ + inputValue: option ? option.label : label, + selectedOptionId: option ? option.id : undefined + }) + } + } + + getFormattedId(date: Moment) { + // ISO8601 strings may contain a space. Remove any spaces before using the + // date as the id. + return date.toISOString().replace(/\s/g, '') + } + + getInitialState(): TimeSelectState { + const initialOptions = this.generateOptions() + const initialSelection = this.getInitialOption(initialOptions) + return { + inputValue: initialSelection ? initialSelection.label : '', + options: initialOptions, + // 288 = 5 min step + filteredOptions: + initialOptions.length > 288 + ? initialOptions.filter( + (opt) => opt.value.minute() % this.props.step! === 0 + ) + : initialOptions, + isShowingOptions: false, + highlightedOptionId: initialSelection ? initialSelection.id : undefined, + selectedOptionId: initialSelection ? initialSelection.id : undefined, + isInputCleared: false + } + } + + getInitialOption(options: TimeSelectOptions[]) { + const { value, defaultValue, defaultToFirstOption, format } = this.props + const initialValue = value || defaultValue + if (typeof initialValue === 'string') { + const date = DateTime.parse(initialValue, this.locale(), this.timezone()) + // get option based on value or defaultValue, if provided + const option = this.getOption('value', date, options) + if (option) { + // value matches an existing option + return option + } + // value does not match an existing option + return { + id: this.getFormattedId(date), + label: format ? date.format(format) : date.toISOString(), + value: date + } as TimeSelectOptions + } + // otherwise, return first option, if desired + if (defaultToFirstOption) { + return options[0] + } + return undefined + } + + getOption: GetOption = (field, value, options = this.state.options) => { + return options.find((option) => option[field] === value) + } + + getBaseDate() { + let baseDate + const baseValue = this.props.value || this.props.defaultValue + if (baseValue) { + baseDate = DateTime.parse(baseValue, this.locale(), this.timezone()) + } else { + baseDate = DateTime.now(this.locale(), this.timezone()) + } + return baseDate.set({ second: 0, millisecond: 0 }).clone() + } + + generateOptions(): TimeSelectOptions[] { + const date = this.getBaseDate() + const options = [] + const step = this.props.step ? this.props.step : 30 + const maxMinute = this.props.allowNonStepInput ? 60 : 60 / step + const minuteStep = this.props.allowNonStepInput ? 1 : step + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < maxMinute; minute++) { + const minutes = minute * minuteStep + const newDate = date.set({ hour: hour, minute: minutes }) + // store time options + options.push({ + id: this.getFormattedId(newDate), + value: newDate.clone(), + label: this.props.format + ? newDate.format(this.props.format) + : newDate.toISOString() + }) + } + } + return options + } + + filterOptions(inputValue: string) { + let inputNoSeconds = inputValue + // if the input contains seconds disregard them (e.g. if format = LTS) + if (inputValue.length > 5) { + // e.g. "5:34:" + const input = this.parseInputText(inputValue) + if (input.isValid()) { + input.set({ second: 0 }) + inputNoSeconds = input.format(this.props.format) + } + } + + if (this.props.allowNonStepInput && inputNoSeconds.length < 3) { + // could show too many results, show only step values + return this.state?.options.filter((option: TimeSelectOptions) => { + return ( + option.label.toLowerCase().startsWith(inputNoSeconds.toLowerCase()) && + option.value.minute() % this.props.step! == 0 + ) + }) + } + return this.state?.options.filter((option: TimeSelectOptions) => + option.label.toLowerCase().startsWith(inputNoSeconds.toLowerCase()) + ) + } + + handleRef = (node: Select) => { + this.ref = node + } + + handleBlur = (event: React.FocusEvent) => { + this.props.onBlur?.(event) + } + + handleInputChange = (event: React.ChangeEvent) => { + const value = event.target.value + const newOptions = this.filterOptions(value) + if (newOptions?.length == 1) { + // if there is only 1 option, it will be automatically selected except + // if in controlled mode (it would commit this change) + if (!this.isControlled) { + this.setState({ selectedOptionId: newOptions[0].id }) + } + this.setState({ fireChangeOnBlur: newOptions[0] }) + } else { + this.setState({ + fireChangeOnBlur: undefined, + isInputCleared: this.props.allowClearingSelection! && value === '' + }) + } + this.setState({ + inputValue: value, + filteredOptions: newOptions, + highlightedOptionId: newOptions.length > 0 ? newOptions[0].id : undefined, + isShowingOptions: true + }) + if (!this.state.isShowingOptions) { + this.props.onShowOptions?.(event) + } + const inputAsDate = this.parseInputText(value) + this.props.onInputChange?.( + event, + value, + inputAsDate.isValid() ? inputAsDate.toISOString() : undefined + ) + } + + onKeyDown = (event: React.KeyboardEvent) => { + const input = this.parseInputText(this.state.inputValue) + if ( + event.key === 'Enter' && + this.props.allowNonStepInput && + input.isValid() + ) { + this.setState(() => ({ + isShowingOptions: false, + highlightedOptionId: undefined + })) + // others are set in handleBlurOrEsc + } + this.props.onKeyDown?.(event) + } + + handleShowOptions = (event: React.SyntheticEvent) => { + this.setState({ + isShowingOptions: true, + highlightedOptionId: this.state.selectedOptionId + }) + this.props.onShowOptions?.(event) + + if (event.type.startsWith('key')) { + const keyboardEvent = event as React.KeyboardEvent + const children = this.state.filteredOptions + if ( + !this.state.inputValue && + children.length > 0 && + !this.props.allowClearingSelection + ) { + const optionId = + keyboardEvent.key === 'ArrowDown' + ? children[0].id + : keyboardEvent.key === 'ArrowUp' + ? children[children.length - 1].id + : undefined + optionId && + this.setState({ + highlightedOptionId: optionId + }) + } + } + } + + // Called when the input is blurred (=when clicking outside, tabbing away), + // when pressing ESC. NOT called when an item is selected via Enter/click, + // (but in this case it will be called later when the input is blurred.) + handleBlurOrEsc: SelectProps['onRequestHideOptions'] = (event) => { + const { selectedOptionId, inputValue, isInputCleared } = this.state + let defaultValue = '' + if (this.props.defaultValue) { + const date = DateTime.parse( + this.props.defaultValue, + this.locale(), + this.timezone() + ) + defaultValue = this.props.format + ? date.format(this.props.format) + : date.toISOString() + } + const selectedOption = this.getOption('id', selectedOptionId) + let newInputValue = defaultValue + // if input was completely cleared, ensure it stays clear + // e.g. defaultValue defined, but no selection yet made + if (inputValue === '' && this.props.allowClearingSelection) { + newInputValue = '' + } else if (selectedOption) { + // If there is a selected option use its value in the input field. + newInputValue = selectedOption.label + } else if (this.props.value) { + // If controlled and input is cleared and blurred after the first render, it should revert to value + const date = DateTime.parse( + this.props.value, + this.locale(), + this.timezone() + ) + newInputValue = this.props.format + ? date.format(this.props.format) + : date.toISOString() + } + this.setState(() => ({ + isShowingOptions: false, + highlightedOptionId: undefined, + inputValue: newInputValue, + filteredOptions: this.filterOptions('') + })) + if (this.state.fireChangeOnBlur && (event as any).key !== 'Escape') { + this.setState(() => ({ fireChangeOnBlur: undefined })) + this.props.onChange?.(event, { + value: this.state.fireChangeOnBlur.value.toISOString(), + inputText: this.state.fireChangeOnBlur.label + }) + } else if ( + isInputCleared && + (event as any).key !== 'Escape' && + this.props.allowClearingSelection + ) { + this.setState(() => ({ isInputCleared: false })) + this.props.onChange?.(event, { + value: '', + inputText: '' + }) + } + // TODO only fire this if handleSelectOption was not called before. + this.props.onHideOptions?.(event) + } + + // Called when an option is selected via mouse click or Enter. + handleSelectOption: SelectProps['onRequestSelectOption'] = (event, data) => { + if (data.id === this._emptyOptionId) { + this.setState({ isShowingOptions: false }) + return + } + const selectedOption = this.getOption('id', data.id) + let newInputValue: string + const currentSelectedOptionId = this.state.selectedOptionId + + // Focus needs to be reapplied to input + // after selecting an item to make sure VoiceOver behaves correctly on iOS + if (utils.isAndroidOrIOS()) { + this.blur() + this.focus() + } + + if (this.isControlled) { + // in controlled mode we leave to the user to set the value of the + // component e.g. in the onChange event handler. + // This is accomplished by not setting a selectedOptionId + const prev = this.getOption('id', this.state.selectedOptionId) + newInputValue = prev ? prev.label : '' + this.setState({ + isShowingOptions: false + }) + } else { + newInputValue = selectedOption!.label + this.setState({ + isShowingOptions: false, + selectedOptionId: data.id, + inputValue: newInputValue + }) + } + if (data.id !== currentSelectedOptionId) { + this.props.onChange?.(event, { + value: selectedOption!.value.toISOString(), + inputText: newInputValue + }) + } + this.props.onHideOptions?.(event) + } + + handleHighlightOption: SelectProps['onRequestHighlightOption'] = ( + _event, + data + ) => { + if (data.id === this._emptyOptionId) return + + this.setState((state) => ({ + highlightedOptionId: data.id, + inputValue: state.inputValue + })) + } + + renderOptions() { + const { filteredOptions, highlightedOptionId, selectedOptionId } = + this.state + + if (filteredOptions.length < 1) { + return this.renderEmptyOption() + } + + return filteredOptions.map((option: TimeSelectOptions) => { + const { id, label } = option + return ( + + {label} + + ) + }) + } + + renderEmptyOption() { + return ( + + {callRenderProp(this.props.renderEmptyOption)} + + ) + } + + parseInputText = (inputValue: string) => { + const input = DateTime.parse( + inputValue, + this.locale(), + this.timezone(), + [this.props.format!], + true + ) + const baseDate = this.getBaseDate() + input.year(baseDate.year()) + input.month(baseDate.month()) + input.date(baseDate.date()) + return input + } + + render() { + const { + value, + defaultValue, + placeholder, + renderLabel, + inputRef, + id, + listRef, + renderBeforeInput, + renderAfterInput, + isRequired, + isInline, + width, + format, + step, + optionsMaxWidth, + visibleOptionsCount, + messages, + placement, + constrain, + onFocus, + onShowOptions, + onHideOptions, + onInputChange, + onKeyDown, + mountNode, + ...rest + } = this.props + + const { inputValue, isShowingOptions } = this.state + return ( + + ) + } +} + +export { TimeSelect } +export default TimeSelect diff --git a/packages/ui-time-select/src/TimeSelect/v2/props.ts b/packages/ui-time-select/src/TimeSelect/v2/props.ts new file mode 100644 index 0000000000..2c2a0ae313 --- /dev/null +++ b/packages/ui-time-select/src/TimeSelect/v2/props.ts @@ -0,0 +1,327 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { InputHTMLAttributes } from 'react' + +import type { FormMessage } from '@instructure/ui-form-field/latest' +import type { OtherHTMLAttributes } from '@instructure/shared-types' +import type { + PlacementPropValues, + PositionConstraint, + PositionMountNode +} from '@instructure/ui-position' +import type { WithDeterministicIdProps } from '@instructure/ui-react-utils' +import { Renderable } from '@instructure/shared-types' +import type { Moment } from 'moment-timezone' + +type PropKeys = keyof TimeSelectOwnProps + +type AllowedPropKeys = Readonly> + +type TimeSelectProps = TimeSelectOwnProps & + OtherHTMLAttributes< + TimeSelectOwnProps, + InputHTMLAttributes + > & + WithDeterministicIdProps + +type TimeSelectOwnProps = { + /** + * The form field label. + */ + renderLabel: Renderable + /** + * Whether to default to the first option when `defaultValue` hasn't been specified. + */ + defaultToFirstOption?: boolean + /** + * An ISO 8601 formatted date string representing the current selected value. If defined, + * the component will act controlled and will not manage its own state. + */ + value?: string // TODO: controllable(I18nPropTypes.iso8601, 'onChange'), + /** + * An ISO 8601 formatted date string to use if `value` isn't provided. + */ + defaultValue?: string + /** + * The id of the text input. One is generated if not supplied. + */ + id?: string + /** + * The format to use when displaying the possible and currently selected options. + * This component currently rounds seconds down to the minute. + * Defaults to `LT`, which is localized time without seconds, e.g. "16:45" or + * "4:45 PM" + * + * See [moment](https://momentjs.com/docs/#/displaying/format/) for the list + * of available formats. + */ + format?: string + /** + * The number of minutes to increment by when generating the allowable options. + */ + step?: 5 | 10 | 15 | 20 | 30 | 60 + /** + * Specifies if interaction with the input is enabled, disabled, or readonly. + * When "disabled", the input changes visibly to indicate that it cannot + * receive user interactions. When "readonly" the input still cannot receive + * user interactions, but it keeps the same styles as if it were enabled. + */ + interaction?: 'enabled' | 'disabled' | 'readonly' + /** + * Html placeholder text to display when the input has no value. This should + * be hint text, not a label replacement. + */ + placeholder?: string + isRequired?: boolean + /** + * Whether the input is rendered inline with other elements or if it + * is rendered as a block level element. + */ + isInline?: boolean + /** + * The width of the text input. + */ + width?: string + /** + * The max width the options list can be before option text wraps. If not + * set, the list will only display as wide as the text input. + */ + optionsMaxWidth?: string + /** + * The number of options that should be visible before having to scroll. + */ + visibleOptionsCount?: number + /** + * Displays messages and validation for the input. It should be an array of + * objects with the following shape: + * `{ + * text: ReactNode, + * type: One of: ['newError', 'error', 'hint', 'success', 'screenreader-only'] + * }` + */ + messages?: FormMessage[] + /** + * The placement of the options list. + */ + placement?: PlacementPropValues + /** + * The parent in which to constrain the placement. + */ + constrain?: PositionConstraint + /** + * An element or a function returning an element to use mount the options + * list to in the DOM (defaults to `document.body`) + */ + mountNode?: PositionMountNode + /** + * Callback fired when a new option is selected. This can happen in the + * following ways: + * 1. User clicks/presses enter on an option in the dropdown and focuses away + * 2. User enters a valid time manually and focuses away + * @param event - the event object + * @param data - additional data containing the value and the input string + */ + onChange?: ( + event: React.SyntheticEvent, + data: { value: string; inputText: string } + ) => void + /** + * Callback fired when text input receives focus. + */ + onFocus?: (event: React.FocusEvent) => void + /** + * Callback fired when text input loses focus. + */ + onBlur?: (event: React.FocusEvent) => void + /** + * Callback fired when the options list is shown. + */ + onShowOptions?: (event: React.SyntheticEvent) => void + /** + * Callback fired when the options list is hidden. + */ + onHideOptions?: (event: React.SyntheticEvent) => void + /** + * A ref to the html `input` element. + */ + inputRef?: (inputElement: HTMLInputElement | null) => void + /** + * A ref to the html `ul` element. + */ + listRef?: (listElement: HTMLUListElement | null) => void + /** + * Content to display in the list when no options are available. + */ + renderEmptyOption?: Renderable + /** + * Content to display before the text input. This will commonly be an icon. + */ + renderBeforeInput?: Renderable + /** + * Content to display after the text input. This content will replace the + * default arrow icons. + */ + renderAfterInput?: Renderable + /** + * A standard language identifier. + * + * See [moment.js i18n](https://momentjs.com/docs/#/i18n/) for more details. + * + * This property can also be set via a context property and if both are set then the component property takes + * precedence over the context property. + * + * The web browser's locale will be used if no value is set via a component property or a context + * property. + */ + locale?: string + /** + * A timezone identifier in the format: Area/Location + * + * See [List of tz database time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for the list + * of possible options. + * + * This property can also be set via a context property and if both are set then the component property takes + * precedence over the context property. + * + * The web browser's timezone will be used if no value is set via a component property or a context + * property. + */ + timezone?: string + /** + * Whether to allow the user to enter non-step divisible values in the input field. + * Note that even if this is set to `false` one can enter non-step divisible values programatically. + * The user will need to enter the value exactly (except for lower/uppercase) as specified by the `format` prop for + * it to be accepted. + * + * Default is `false` + */ + allowNonStepInput?: boolean + /** + * Callback fired when text input value changes. + */ + onInputChange?: ( + /** + * The raw HTML input event + */ + event: React.ChangeEvent, + /** + * The text value in the input field. + */ + value: string, + /** + * Current value as ISO datetime string, undefined it its a non-valid value. + */ + valueAsISOString?: string + ) => void + /** + * Whether to allow for the user to clear the selected option in the input field. + * If `false`, the input field will return the last selected option after the input is cleared and loses focus. + */ + allowClearingSelection?: boolean +} +const allowedProps: AllowedPropKeys = [ + 'renderLabel', + 'defaultToFirstOption', + 'value', + 'defaultValue', + 'id', + 'format', + 'step', + 'interaction', + 'placeholder', + 'isRequired', + 'isInline', + 'width', + 'optionsMaxWidth', + 'mountNode', + 'visibleOptionsCount', + 'messages', + 'placement', + 'constrain', + 'onChange', + 'onFocus', + 'onBlur', + 'onShowOptions', + 'onHideOptions', + 'inputRef', + 'listRef', + 'renderEmptyOption', + 'renderBeforeInput', + 'renderAfterInput', + 'locale', + 'timezone', + 'allowNonStepInput', + 'onInputChange', + 'allowClearingSelection' +] + +type TimeSelectOptions = { + // the ID of this option, ISO date without spaces + id: string + // the actual date value + value: Moment + // the label shown to the user + label: string +} + +type TimeSelectState = { + /** + * The current value in the input field, not necessarily a valid time + */ + inputValue: string + /** + * All possible options. Filtered down because if `allowNonStepInput` is true + * it'd be 24*60 options and filtered by user input. + */ + options: TimeSelectOptions[] + /** + * The options shown in the options list. + */ + filteredOptions: TimeSelectOptions[] + /** + * Whether to show the options list. + */ + isShowingOptions: boolean + /** + * The highlighted option in the dropdown e.g. by hovering, + * not necessarily selected + */ + highlightedOptionId?: string + /** + * The ID of the selected option in the options list dropdown + */ + selectedOptionId?: string + /** + * fire onChange event when the popup closes? + */ + fireChangeOnBlur?: TimeSelectOptions + /** + * Whether to selected option is cleared + */ + isInputCleared: boolean +} + +export type { TimeSelectProps, TimeSelectState, TimeSelectOptions } +export { allowedProps } diff --git a/packages/ui-time-select/src/exports/b.ts b/packages/ui-time-select/src/exports/b.ts new file mode 100644 index 0000000000..9b5eddd17b --- /dev/null +++ b/packages/ui-time-select/src/exports/b.ts @@ -0,0 +1,26 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +export { TimeSelect } from '../TimeSelect/v2' +export type { TimeSelectProps } from '../TimeSelect/v2/props'