diff --git a/packages/__docs__/buildScripts/DataTypes.mts b/packages/__docs__/buildScripts/DataTypes.mts index c0cedeac3d..7aa7f89b97 100644 --- a/packages/__docs__/buildScripts/DataTypes.mts +++ b/packages/__docs__/buildScripts/DataTypes.mts @@ -60,6 +60,9 @@ type YamlMetaInfo = { // if true it won't be included in the docs private: boolean tags?: string + // points to another component's theme ID to display its theme variables + // (e.g. Drilldown.Group uses Options theme, so themeId: "Options") + themeId?: string } type JsDocResult = { diff --git a/packages/__docs__/src/Document/index.tsx b/packages/__docs__/src/Document/index.tsx index 5167f7bdea..4fd73fc22c 100644 --- a/packages/__docs__/src/Document/index.tsx +++ b/packages/__docs__/src/Document/index.tsx @@ -78,10 +78,18 @@ class Document extends Component { // use PascalCase without dots (e.g. "MenuItem"). // New-theme entries are in themeVariables.newTheme.components. const selectedId = this.state.selectedDetailsTabId - const themeKey = selectedId?.replace(/\./g, '') + const childDoc = + selectedId !== doc.id + ? doc?.children?.find((value) => value.id === selectedId) + : null + // in case of some components, we need to display the theme variables of other components based on themeId (like displaying the theme variables of Options in Drillsdown.Group) + const themeKey = + childDoc?.themeId || selectedId?.replace(/\./g, '') // @ts-ignore todo type const newThemeEntry = themeVariables?.newTheme?.components?.[themeKey] - if (newThemeEntry) { + const componentInstance = + selectedId === doc.id ? doc?.componentInstance : childDoc?.componentInstance + if (newThemeEntry && typeof componentInstance?.generateComponentTheme !== 'function') { // new theme - use pre-computed theme object directly this.setState({ componentTheme: newThemeEntry }) return @@ -90,9 +98,7 @@ class Document extends Component { if (selectedId === doc.id) { generateTheme = doc?.componentInstance?.generateComponentTheme } else { - generateTheme = doc?.children?.find( - (value) => value.id === selectedId - )?.componentInstance?.generateComponentTheme + generateTheme = childDoc?.componentInstance?.generateComponentTheme } if (typeof generateTheme === 'function' && themeVariables) { // @ts-ignore todo type diff --git a/packages/ui-drilldown/package.json b/packages/ui-drilldown/package.json index c05ede30b8..a045327fa1 100644 --- a/packages/ui-drilldown/package.json +++ b/packages/ui-drilldown/package.json @@ -76,18 +76,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-drilldown/src/Drilldown/v1/DrilldownGroup/__tests__/DrilldownGroup.test.tsx b/packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/__tests__/DrilldownGroup.test.tsx similarity index 99% rename from packages/ui-drilldown/src/Drilldown/v1/DrilldownGroup/__tests__/DrilldownGroup.test.tsx rename to packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/__tests__/DrilldownGroup.test.tsx index 008d5b5f7d..fdcaf3de11 100644 --- a/packages/ui-drilldown/src/Drilldown/v1/DrilldownGroup/__tests__/DrilldownGroup.test.tsx +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/__tests__/DrilldownGroup.test.tsx @@ -363,7 +363,7 @@ describe('', () => { ) - const icon = container.querySelector('svg[name="IconCheck"]') + const icon = container.querySelector('svg[name="Check"]') const groupOption = container.querySelector('#groupOption01') expect(icon).not.toBeInTheDocument() @@ -380,7 +380,7 @@ describe('', () => { ) - const icon = container.querySelector('svg[name="IconCheck"]') + const icon = container.querySelector('svg[name="Check"]') const groupOption = container.querySelector('#groupOption01') expect(icon).toBeInTheDocument() @@ -397,7 +397,7 @@ describe('', () => { ) - const icon = container.querySelector('svg[name="IconCheck"]') + const icon = container.querySelector('svg[name="Check"]') const groupOption = container.querySelector('#groupOption01') expect(icon).toBeInTheDocument() @@ -451,7 +451,7 @@ describe('', () => { ) - const icons = container.querySelectorAll('svg[name="IconCheck"]') + const icons = container.querySelectorAll('svg[name="Check"]') expect(icons.length).toBe(3) @@ -486,7 +486,7 @@ describe('', () => { ) const options = screen.getAllByRole('menuitemcheckbox') - const icons = container.querySelectorAll('svg') + const icons = container.querySelectorAll('svg[name="Check"]') expect(options[0]).toHaveAttribute('aria-checked', 'true') expect(options[1]).toHaveAttribute('aria-checked', 'false') diff --git a/packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/index.tsx b/packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/index.tsx new file mode 100644 index 0000000000..e2793194c5 --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/index.tsx @@ -0,0 +1,64 @@ +/* + * 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 { withStyle } from '@instructure/emotion' + +import { allowedProps } from './props' +import type { DrilldownGroupProps } from './props' +import { isMac, isFirefox } from '@instructure/ui-utils' + +/** +--- +parent: Drilldown +id: Drilldown.Group +themeId: Options +--- +@module DrilldownGroup +**/ +// needed for listing the available theme variables on docs page, +// we pass the themeOverrides to Options +@withStyle(null) +class DrilldownGroup extends Component { + static readonly componentId = 'Drilldown.Group' + + static allowedProps = allowedProps + static defaultProps = { + disabled: false, + withoutSeparators: false, + // Firefox with NVDA does not read Drilldown.Group with role="group" correctly + // but setting role="menu" on all other platforms results in Drilldown.Group label not being read + role: !isMac() && isFirefox() ? 'menu' : 'group' + } + + render() { + // this component is only used for prop validation. + // Drilldown.Group is parsed in Drilldown as an Options component. + return null + } +} + +export default DrilldownGroup +export { DrilldownGroup } diff --git a/packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/props.ts b/packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/props.ts new file mode 100644 index 0000000000..61f33fbf0d --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownGroup/props.ts @@ -0,0 +1,134 @@ +/* + * 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, + OptionsTheme, + AsElementType +} from '@instructure/shared-types' +import type { WithStyleProps } from '@instructure/emotion' + +import Drilldown from '../index' +import type { DrilldownOptionValue } from '../DrilldownOption/props' +import type { OptionChild, SeparatorChild } from '../props' + +type GroupChildren = OptionChild | SeparatorChild + +type DrilldownGroupOwnProps = { + id: string + + /** + * Children of type: + * ``, `` + */ + children?: GroupChildren | GroupChildren[] // TODO: type Children.oneOf([DrilldownOption, DrilldownSeparator]) + + /** + * The label of the option group. + */ + renderGroupTitle?: React.ReactNode | (() => React.ReactNode) + + /** + * Hides the separators around the group. + */ + withoutSeparators?: boolean + + /** + * Is the option group disabled. + */ + disabled?: boolean + + /** + * The ARIA role of the element. + */ + role?: string + + /** + * Element type to render as. By default, it inherits Drilldown's `as` prop. + */ + as?: AsElementType + + /** + * Provides a reference to the underlying html root element + */ + elementRef?: (element: Element | null) => void + + // selection props + /** + * Makes the option group selectable (with "check" icon indicators). + * Can be set to a single-select (radio) or a multi-select (checkbox) group. + */ + selectableType?: 'single' | 'multiple' + + /** + * An array of the values for the selected items on initial render. Works only with "selectableType" set. If "selectableType" is "single", the array has to have 1 item. + */ + defaultSelected?: DrilldownOptionValue[] + + /** + * An array of the values for the selected items. If defined, the component will act controlled and will not manage its own state. Works only with "selectableType" set. + * If "selectableType" is "single", the array has to have 1 item. + */ + selectedOptions?: DrilldownOptionValue[] + + /** + * Callback fired when an option within the `` is selected + */ + onSelect?: ( + event: React.SyntheticEvent, + args: { + value: DrilldownOptionValue[] + isSelected: boolean + selectedOption: OptionChild + drilldown: Drilldown + } + ) => void +} + +type PropKeys = keyof DrilldownGroupOwnProps + +type AllowedPropKeys = Readonly> + +type DrilldownGroupProps = DrilldownGroupOwnProps & + WithStyleProps & + OtherHTMLAttributes +const allowedProps: AllowedPropKeys = [ + 'id', + 'children', + 'renderGroupTitle', + 'withoutSeparators', + 'disabled', + 'role', + 'as', + 'elementRef', + 'selectableType', + 'defaultSelected', + 'selectedOptions', + 'onSelect' +] + +export type { DrilldownGroupProps, GroupChildren } +export { allowedProps } diff --git a/packages/ui-drilldown/src/Drilldown/v1/DrilldownOption/__tests__/DrilldownOption.test.tsx b/packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/__tests__/DrilldownOption.test.tsx similarity index 98% rename from packages/ui-drilldown/src/Drilldown/v1/DrilldownOption/__tests__/DrilldownOption.test.tsx rename to packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/__tests__/DrilldownOption.test.tsx index 8329221721..196e472f75 100644 --- a/packages/ui-drilldown/src/Drilldown/v1/DrilldownOption/__tests__/DrilldownOption.test.tsx +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/__tests__/DrilldownOption.test.tsx @@ -27,7 +27,7 @@ import { vi } 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 { Drilldown } from '../../index' @@ -196,7 +196,7 @@ describe('', () => { const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('name', 'IconArrowOpenEnd') + expect(icon).toHaveAttribute('name', 'ChevronRight') }) it('should indicate subpage fo SR', async () => { @@ -448,7 +448,7 @@ describe('', () => { } + renderBeforeLabel={} > Option @@ -458,11 +458,11 @@ describe('', () => { const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('name', 'IconCheck') + expect(icon).toHaveAttribute('name', 'Check') }) it('as function should have option props as params', async () => { - const beforeLabelFunction = vi.fn(() => ) + const beforeLabelFunction = vi.fn(() => ) render( @@ -517,7 +517,7 @@ describe('', () => { } + renderAfterLabel={} > Option @@ -527,11 +527,11 @@ describe('', () => { const icon = container.querySelector('svg') expect(icon).toBeInTheDocument() - expect(icon).toHaveAttribute('name', 'IconCheck') + expect(icon).toHaveAttribute('name', 'Check') }) it('as function should have option props as params', async () => { - const beforeLabelFunction = vi.fn(() => ) + const beforeLabelFunction = vi.fn(() => ) render( diff --git a/packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/index.tsx b/packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/index.tsx new file mode 100644 index 0000000000..083317da90 --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/index.tsx @@ -0,0 +1,65 @@ +/* + * 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 { withStyle } from '@instructure/emotion' + +import { allowedProps } from './props' +import type { DrilldownOptionProps } from './props' + +/** +--- +parent: Drilldown +id: Drilldown.Option +themeId: OptionsItem +--- +@module DrilldownOption +**/ +// needed for listing the available theme variables on docs page, +// we pass the themeOverrides to Options.Item +@withStyle(null) +class DrilldownOption extends Component { + static readonly componentId = 'Drilldown.Option' + + static allowedProps = allowedProps + + static defaultProps = { + disabled: false, + beforeLabelContentVAlign: 'start', + afterLabelContentVAlign: 'start', + as: 'li', + role: 'menuitem', + shouldCloseOnClick: 'auto' + } + + render() { + // this component is only used for prop validation. Drilldown.Option children + // are parsed in Drilldown and rendered as Options.Item components + return null + } +} + +export default DrilldownOption +export { DrilldownOption } diff --git a/packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/props.ts b/packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/props.ts new file mode 100644 index 0000000000..be7c1d7ad6 --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownOption/props.ts @@ -0,0 +1,211 @@ +/* + * 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, + OptionsItemTheme, + AsElementType +} from '@instructure/shared-types' +import type { WithStyleProps } from '@instructure/emotion' +import type { + OptionsItemRenderProps, + OptionsItemProps +} from '@instructure/ui-options' + +import Drilldown from '../index' +import { Renderable } from '@instructure/shared-types' + +type DrilldownOptionValue = string | number | undefined + +type RenderContentVAlign = 'start' | 'center' | 'end' + +type ShouldCloseOnClick = 'auto' | 'always' | 'never' + +type DrilldownOptionVariant = Exclude + +type RenderContentProps = { + as: DrilldownOptionOwnProps['as'] + role: DrilldownOptionOwnProps['role'] + variant: DrilldownOptionVariant + vAlign?: RenderContentVAlign + isSelected: boolean +} + +type DrilldownOptionOwnProps = { + id: string + + /** + * Label of the Drilldown option. + */ + children?: + | React.ReactNode + | ((props: { + id: string + variant: DrilldownOptionVariant + isSelected: boolean + }) => React.ReactNode) + + /** + * The id of the sub-page the option navigates to. + */ + subPageId?: string + + /** + * Is the option disabled. + */ + disabled?: boolean + + /** + * Whether the option is selected or not. (Setting this property assumes controlled behaviour) + */ + selected?: boolean + + /** + * The value of the option. Should be set for options in selectable groups. + */ + value?: DrilldownOptionValue + + /** + * Providing href will render the option as ``. Doesn't work, if subPageId is provided or is in a selectable group. + */ + href?: string + + /** + * Element type to render as. Will be set to `` if href is provided. If the parent is "ul" or "ol", the option is forced to be "li" element. *Important*: `Drilldown` is rendered as `ul` by default so you *have to* change that as well if you want to use this prop. + */ + as?: AsElementType + + /** + * The ARIA role of the element + */ + role?: string + + /** + * Info content to render after the label. + * + * If a function is provided, it has a `props` parameter. + */ + renderLabelInfo?: Renderable + + /** + * Content to render before the label. + * + * If a function is provided, it has a `props` parameter. + */ + renderBeforeLabel?: Renderable + + /** + * Content to render after the label. + * + * If a function is provided, it has a `props` parameter. + */ + renderAfterLabel?: Renderable + + /** + * Sets the vAlign of renderBeforeLabel content + */ + beforeLabelContentVAlign?: RenderContentVAlign + + /** + * Sets the vAlign of renderAfterLabel content and renderLabelInfo + */ + afterLabelContentVAlign?: RenderContentVAlign + + /** + * Additional "secondary" description text + */ + description?: React.ReactNode | (() => React.ReactNode) + + /** + * The ARIA role of the description element + */ + descriptionRole?: string + + /** + * Callback fired when the option is clicked. + */ + onOptionClick?: ( + event: React.SyntheticEvent, + args: { + optionId: string + drilldown: Drilldown + pageHistory: string[] + goToPage: ( + pageId: string + ) => { prevPageId: string; newPageId: string } | undefined + goToPreviousPage: () => + | { prevPageId: string; newPageId: string } + | undefined + } + ) => void + + /** + * Whether the option is selected by default. + */ + defaultSelected?: boolean + + /** + * Provides a reference to the underlying html root element + */ + elementRef?: (element: Element | null) => void + + /** + * Should close the container menu component, if clicked on the option marked with this prop + */ + shouldCloseOnClick?: ShouldCloseOnClick +} + +type PropKeys = keyof DrilldownOptionOwnProps + +type AllowedPropKeys = Readonly> + +type DrilldownOptionProps = DrilldownOptionOwnProps & + WithStyleProps & + OtherHTMLAttributes +const allowedProps: AllowedPropKeys = [ + 'id', + 'children', + 'subPageId', + 'disabled', + 'selected', + 'value', + 'href', + 'as', + 'role', + 'renderLabelInfo', + 'renderBeforeLabel', + 'renderAfterLabel', + 'beforeLabelContentVAlign', + 'afterLabelContentVAlign', + 'description', + 'descriptionRole', + 'onOptionClick', + 'defaultSelected', + 'elementRef', + 'shouldCloseOnClick' +] + +export type { DrilldownOptionProps, DrilldownOptionValue } +export { allowedProps } diff --git a/packages/ui-drilldown/src/Drilldown/v1/DrilldownPage/__tests__/DrilldownPage.test.tsx b/packages/ui-drilldown/src/Drilldown/v2/DrilldownPage/__tests__/DrilldownPage.test.tsx similarity index 100% rename from packages/ui-drilldown/src/Drilldown/v1/DrilldownPage/__tests__/DrilldownPage.test.tsx rename to packages/ui-drilldown/src/Drilldown/v2/DrilldownPage/__tests__/DrilldownPage.test.tsx diff --git a/packages/ui-options/src/Options/v2/Separator/theme.ts b/packages/ui-drilldown/src/Drilldown/v2/DrilldownPage/index.tsx similarity index 60% rename from packages/ui-options/src/Options/v2/Separator/theme.ts rename to packages/ui-drilldown/src/Drilldown/v2/DrilldownPage/index.tsx index 9429a0be48..3ce83dd0bd 100644 --- a/packages/ui-options/src/Options/v2/Separator/theme.ts +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownPage/index.tsx @@ -22,27 +22,34 @@ * SOFTWARE. */ -import type { Theme } from '@instructure/ui-themes' -import { OptionsSeparatorTheme } from '@instructure/shared-types' +import { Component } from 'react' + +import { allowedProps } from './props' +import type { DrilldownPageProps } from './props' /** - * Generates the theme object for the component from the theme and provided additional information - * @param {Object} theme The actual theme object. - * @return {Object} The final theme object with the overrides and component variables - */ -const generateComponentTheme = (theme: Theme): OptionsSeparatorTheme => { - const { borders, colors, spacing } = theme +--- +parent: Drilldown +id: Drilldown.Page +--- +@module DrilldownPage +**/ +class DrilldownPage extends Component { + static readonly componentId = 'Drilldown.Page' - const componentVariables: OptionsSeparatorTheme = { - background: colors?.contrasts?.grey3045, - height: borders?.widthSmall, - margin: `0 ${spacing?.small}` + static allowedProps = allowedProps + static defaultProps = { + renderBackButtonLabel: 'Back', + disabled: false, + withoutHeaderSeparator: false } - return { - ...componentVariables + render() { + // this component is only used for prop validation. + // Drilldown.Page children are parsed in Drilldown. + return null } } -export { generateComponentTheme as optionsSeparatorThemeGenerator } -export default generateComponentTheme +export default DrilldownPage +export { DrilldownPage } diff --git a/packages/ui-drilldown/src/Drilldown/v2/DrilldownPage/props.ts b/packages/ui-drilldown/src/Drilldown/v2/DrilldownPage/props.ts new file mode 100644 index 0000000000..29ff7e2d10 --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownPage/props.ts @@ -0,0 +1,102 @@ +/* + * 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 type { OptionChild, SeparatorChild, GroupChild } from '../props' +import { Renderable } from '@instructure/shared-types' + +type PageChildren = GroupChild | OptionChild | SeparatorChild | null | false + +type DrilldownPageOwnProps = { + id: string + + /** + * Children of type: + * ``, ``, `` + */ + children?: PageChildren | PageChildren[] // TODO: type Children.oneOf([DrilldownOption, DrilldownSeparator, DrilldownGroup ]) + + /** + * The title of the page displayed in the header + */ + renderTitle?: Renderable + + /** + * Label for the optional "action" option in the header (e.g.: "Select all") + */ + renderActionLabel?: React.ReactNode | (() => React.ReactNode) + + /** + * Label for the "back" navigation in the header. + * + * If a function is provided, the first parameter of the function + * is the title of the previous page. + */ + renderBackButtonLabel?: + | React.ReactNode + | ((prevPageTitle?: React.ReactNode) => React.ReactNode) + + /** + * Callback fired when the "action" option is clicked in the header + */ + onHeaderActionClicked?: (event: React.SyntheticEvent) => void + + /** + * Callback fired when the "back" navigation option is clicked in the header + */ + onBackButtonClicked?: (newPageId: string, prevPageId: string) => void + + /** + * Hides the separator under the page header + */ + withoutHeaderSeparator?: boolean + + /** + * Is the page disabled + */ + disabled?: boolean +} + +type PropKeys = keyof DrilldownPageOwnProps + +type AllowedPropKeys = Readonly> + +type DrilldownPageProps = DrilldownPageOwnProps & + OtherHTMLAttributes +const allowedProps: AllowedPropKeys = [ + 'id', + 'children', + 'renderTitle', + 'renderActionLabel', + 'renderBackButtonLabel', + 'onHeaderActionClicked', + 'onBackButtonClicked', + 'withoutHeaderSeparator', + 'disabled' +] + +export type { DrilldownPageProps, PageChildren } +export { allowedProps } diff --git a/packages/ui-drilldown/src/Drilldown/v1/DrilldownSeparator/__tests__/DrilldownSeparator.test.tsx b/packages/ui-drilldown/src/Drilldown/v2/DrilldownSeparator/__tests__/DrilldownSeparator.test.tsx similarity index 100% rename from packages/ui-drilldown/src/Drilldown/v1/DrilldownSeparator/__tests__/DrilldownSeparator.test.tsx rename to packages/ui-drilldown/src/Drilldown/v2/DrilldownSeparator/__tests__/DrilldownSeparator.test.tsx diff --git a/packages/ui-options/src/Options/v2/theme.ts b/packages/ui-drilldown/src/Drilldown/v2/DrilldownSeparator/index.tsx similarity index 56% rename from packages/ui-options/src/Options/v2/theme.ts rename to packages/ui-drilldown/src/Drilldown/v2/DrilldownSeparator/index.tsx index b632af489a..841df824f0 100644 --- a/packages/ui-options/src/Options/v2/theme.ts +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownSeparator/index.tsx @@ -22,31 +22,36 @@ * SOFTWARE. */ -import type { Theme } from '@instructure/ui-themes' -import type { OptionsTheme } from '@instructure/shared-types' +import { Component } from 'react' -/** - * Generates the theme object for the component from the theme and provided additional information - * @param {Object} theme The actual theme object. - * @return {Object} The final theme object with the overrides and component variables - */ -const generateComponentTheme = (theme: Theme): OptionsTheme => { - const { colors, typography, spacing } = theme +import { withStyle } from '@instructure/emotion' - const componentVariables: OptionsTheme = { - labelFontWeight: typography?.fontWeightBold, +import { allowedProps } from './props' +import type { DrilldownSeparatorProps } from './props' - background: colors?.contrasts?.white1010, - labelColor: colors?.contrasts?.grey125125, +/** +--- +parent: Drilldown +id: Drilldown.Separator +themeId: OptionsSeparator +--- +@module DrilldownSeparator +**/ +// needed for listing the available theme variables on docs page, +// we pass the themeOverrides to Options.Separator +@withStyle(null) +class DrilldownSeparator extends Component { + static readonly componentId = 'Drilldown.Separator' - labelPadding: `${spacing?.xSmall} 0`, - nestedLabelPadding: `${spacing?.xSmall} ${spacing?.small}` - } + static allowedProps = allowedProps + static defaultProps = {} - return { - ...componentVariables + render() { + // this component is only used for prop validation. Drilldown.Separator children + // are parsed in Drilldown and rendered as Options.Separator components + return null } } -export { generateComponentTheme as optionsThemeGenerator } -export default generateComponentTheme +export default DrilldownSeparator +export { DrilldownSeparator } diff --git a/packages/ui-drilldown/src/Drilldown/v2/DrilldownSeparator/props.ts b/packages/ui-drilldown/src/Drilldown/v2/DrilldownSeparator/props.ts new file mode 100644 index 0000000000..823d4f83e6 --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/DrilldownSeparator/props.ts @@ -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 type { + OtherHTMLAttributes, + PickPropsWithExceptions, + AsElementType, + OptionsSeparatorTheme +} from '@instructure/shared-types' +import type { WithStyleProps } from '@instructure/emotion' +import type { OptionsSeparatorProps } from '@instructure/ui-options' + +type DrilldownSeparatorOwnProps = { + id: string + + /** + * Element type to render as + */ + as?: AsElementType +} + +type PropKeys = keyof DrilldownSeparatorOwnProps + +type AllowedPropKeys = Readonly> + +type DrilldownSeparatorProps = + // we are passing all props to Options.Separator + PickPropsWithExceptions & + DrilldownSeparatorOwnProps & + WithStyleProps & + OtherHTMLAttributes +const allowedProps: AllowedPropKeys = ['id', 'as'] + +export type { DrilldownSeparatorProps } +export { allowedProps } diff --git a/packages/ui-drilldown/src/Drilldown/v2/README.md b/packages/ui-drilldown/src/Drilldown/v2/README.md new file mode 100644 index 0000000000..08bae6fd23 --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/README.md @@ -0,0 +1,1445 @@ +--- +describes: Drilldown +--- + +`Drilldown` is a diverse component that displays hierarchical data in a fashion that allows the users to "drill down" and dig deeper into the layers (pages) of the data structure. It has similar look and features to the [Menu](Menu), [Select](Select) and [TreeBrowser](TreeBrowser) components. + +The `Drilldown` component exists to support navigating and managing tree structures in compact spaces. With WCAG 2.1 requirements around small viewports, and also with general responsiveness standards the classic tree-navigation and flyout menu patterns got outdated. + +Some of Drilldown's features include: + +- options that navigate (drill) down in the structure is marked with an arrow; +- options that navigate back in the structure is always a “back” option at the top of the list; +- option groups can have titles that have the standard InstUI menu group title style; +- groups are divided by separator lines; +- items in groups have no indent unless the group is a checkbox or radio option group, in which case the selected items are marked with a "check" icon, all unselected items have an indent; +- secondary information blocks can be displayed both in-line with the option, or below the option with a dedicated text and color style; +- the component can be rendered both in-line and in a popover. + +### Pages + +The main building blocks of Drilldown are the `Drilldown.Pages`. These represent the layers of the structure and can contain Options, Separators and Groups. Each page has a "header" that can contain a page title, the back navigation and a "page action" option (see [Page header section](/#Drilldown/#page-header)). + +Each page needs an `id` prop that is used in the navigation. Options point to pages with their `subPageId` prop, and the Drilldown itself needs a `rootPageId` that indicates the first root level page, which renders first. + +```js +--- +type: example +--- + + + + Produce + + + Grains and Bread + + + + + + Fruits + + + Vegtables + + + + + {['Pasta', 'Rice', 'Bread', 'Flour', 'Cereal', 'Oats'].map( + item => {item} + )} + + + + {['Apple', 'Orange', 'Cherry', 'Grapefruit', 'Mango', 'Banana', 'Strawberry'].map( + item => {item} + )} + + + + {['Tomato', 'Cucumber', 'Eggplant', 'Lettuce', 'Garlic', 'Onion', 'Corn', 'Carrot', 'Bell pepper'].map( + item => {item} + )} + + +``` + +### Options + +`Drilldown.Option` is the main child component of Drilldown. The content can be a ReactNode or a function returning a ReactNode. The function has an object as its parameter, containing the option's `id`, `variant` and `isSelected` state. + +> Note: Drilldown is based on the [Options](Options) component, so the Drilldown.Options are rendered as `Options.Item` components under the hood. This is why the `variant` parameter has the values of Options.Item's `variant` prop. + +```js +--- +type: example +--- + + + + Option + + + {(props) => `Option ${props.variant === 'highlighted' + ? 'is highlighted' + : 'is not highlighted'}`} + + + Pill Option + + + Option + + + +``` + +Options can be links too. If the `href` prop is set, the option renders as an `` element. + +```js +--- +type: example +--- + + + + Options component + + + Menu component + + + Options component + + + SimpleSelect component + + + TreeBrowser component + + + +``` + +Just like `Options.Item`, `Drilldown.Option` can render a description under the label and icons before or after the label. + +```js +--- +type: example +--- + + + } + renderAfterLabel={} + beforeLabelContentVAlign="start" + afterLabelContentVAlign="start" + > + Option + + } + renderAfterLabel={} + beforeLabelContentVAlign="center" + afterLabelContentVAlign="center" + > + Option + + } + renderAfterLabel={} + beforeLabelContentVAlign="end" + afterLabelContentVAlign="end" + > + Option + + + +``` + +Additionally, the `renderLabelInfo` prop can render text or other elements next to the label. + +```js +--- +type: example +--- +const VideoSettingsExample = (props) => { + const [selectedCaption, setSelectedCaption] = useState('English') + const [selectedSpeed, setSelectedSpeed] = useState('Normal') + const [selectedQuality, setSelectedQuality] = useState('720p') + const [isCommentsOn, setIsCommentsOn] = useState(true) + + const renderTrigger = () => { + return + } + + const renderSelected = (props, value) => { + return ( + + {value} + + ) + } + + const renderSelectGroup = (options, selectedState, setSelectedState) => { + return ( + { + setSelectedState(value[0]) + }} + > + {options.map((option, idx) => ( + { + goToPreviousPage() + }} + > + {option} + + ))} + + ) + } + + return ( + { + shown && goToPage('videoSettings') + }} + > + + renderSelected(props, selectedCaption)} + > + Captions + + renderSelected(props, selectedSpeed)} + > + Speed + + renderSelected(props, selectedQuality)} + > + Quality + + { + setIsCommentsOn((state) => !state) + }} + role="checkbox" + aria-checked={isCommentsOn ? 'true' : 'false'} + // prevents reading the label of the checkbox too (duplicated) + aria-describedby={['']} + renderLabelInfo={ + Comments} + variant="toggle" + readOnly + checked={isCommentsOn} + onChange={() => { + // needed for controlled Checkbox, + // but the state is handled on the Drilldown.Option + }} + labelPlacement="start" + tabIndex={-1} + /> + } + > + Comments + + + + + {renderSelectGroup( + props.captionOptions, + selectedCaption, + setSelectedCaption + )} + + + + {renderSelectGroup( + props.speedOptions, + selectedSpeed, + setSelectedSpeed + )} + + + + {renderSelectGroup( + props.qualityOptions, + selectedQuality, + setSelectedQuality + )} + + + ) +} + +render( + +) +``` + +### Displaying Drilldown in a Popover + +Just like [Menu](Menu), Drilldown accepts a `trigger` property: it will render a toggle button which, when clicked, shows or hides the Drilldown in a [Popover](Popover). Drilldown passes many of its props to Popover in this case (`mountNode`, `shouldContainFocus`, `withArrow`, etc.). + +```js +--- +type: example +--- +const SelectContactsExample = (props) => { + const [selected, setSelected] = useState([]) + + const getCategoryById = (id) => { + return props.contactsData.find((cat) => cat.id === id) + } + + const selectContacts = (values) => { + setSelected(values) + } + + const renderContacts = (contacts) => { + return contacts.map((contact, idx) => { + const { id, name, email } = contact + + return ( + + #{idx + 1} | {email} + + } + onOptionClick={() => { + selectContacts([contact]) + }} + > + {name} + + ) + }) + } + + const renderSubCategoryOptions = (subCategories = []) => { + return subCategories.map((subCatId, idx) => { + const category = getCategoryById(subCatId) + const { id, title } = category + + return ( + + {title} + + ) + }) + } + + const renderPage = (category, key) => { + const { id, title, subCategories = [], options = [] } = category + + const allContacts = getAllContactsFromCategory(category) + + return ( + { + selectContacts(allContacts) + }} + > + {[ + ...renderSubCategoryOptions(subCategories), + ...renderContacts(options) + ]} + + ) + } + + const getAllContactsFromCategory = (category) => { + let allContacts = [] + + const addOptions = (cat) => { + const { subCategories = [], options = [] } = cat + allContacts.push(...options) + + subCategories.forEach((subCatId) => { + addOptions(getCategoryById(subCatId)) + }) + } + + addOptions(category) + return allContacts + } + + return ( + + + Select Contacts} + onToggle={(_event, args) => { + args.shown && selectContacts([]) + }} + > + {props.contactsData.map((page, idx) => renderPage(page, idx))} + + + + + +
+ + Selected ({selected ? selected.length : 0}): + +
+
{selected.map((a) => a.name).join(', ')}
+
+
+
+ ) +} + +const generateCategory = (name, count) => { + return Array(count) + .fill(name) + .map((category, idx) => { + const id = category.replace(/(-|\s)/g, '').toLowerCase() + return { + id: `${id}${idx + 1}`, + category, + name: `${category} ${idx + 1}`, + email: `${id}${idx + 1}@randommail.com` + } + }) +} + +const contactsData = [ + { + id: 'contacts', + title: 'Contacts', + subCategories: ['administrators', 'teachers', 'students'] + }, + { + id: 'administrators', + title: 'Administrators', + subCategories: ['accountAdmins', 'subAccountAdmins'] + }, + { + id: 'accountAdmins', + title: 'Account Admins', + options: generateCategory('Account Admin', 8) + }, + { + id: 'subAccountAdmins', + title: 'Sub-Account Admins', + options: generateCategory('Sub-Account Admin', 12) + }, + { + id: 'teachers', + title: 'Teachers', + options: generateCategory('Teacher', 23) + }, + { + id: 'students', + title: 'Students', + options: generateCategory('Student', 34) + } +] + +render() +``` + +### Page header + +Each page can have a "header" with three optional items. The header and the Drilldown content are separated with a `` (can be hidden with the `withoutHeaderSeparator` prop.) + +##### Title + +The `renderTitle` prop sets the title of the Page. + +##### Action + +The `renderActionLabel` displays an "action" option that can be used as e.g.: a "Select all" function. The action itself can be set with the `onHeaderActionClicked` prop. + +##### Back navigation + +On every page (except the root page) a "Back" navigation option is displayed with an arrow. + +The label can be changed with `renderBackButtonLabel` prop (defaults to "Back"). If a function is passed, it has a `prevPageTitle` parameter. + +This option will always display on the page when needed and cannot be disabled. + +```js +--- +type: example +--- +const BackNavigationExample = () => { + const [showTitle, setShowTitle] = useState(true) + const [showAction, setShowAction] = useState(true) + const [showAlternativeBackLabel, setShowAlternativeBackLabel] = + useState(false) + + return ( + + + + + + Option + + + Option + + + Option + + + + + prevPageTitle ? `Back to ${prevPageTitle}` : 'Back' + : undefined + } + > + + Option + + + Option + + + Option + + + + + + + + { + setShowAlternativeBackLabel(!showAlternativeBackLabel) + }} + /> + { + setShowTitle(!showTitle) + }} + /> + { + setShowAction(!showAction) + }} + /> + + + + ) +} + +render() +``` + +### Option Groups + +Wrapping the Options in `` will separate these options with separators. These separators can be hidden, and you can provide a label with the `renderGroupTitle` prop. + +```js +--- +type: example +--- +const GroupsExample = () => { + const [showSeparators, setShowSeparators] = useState(true) + const [showTitles, setShowTitles] = useState(true) + + return ( + + + + + + Milano + Florence + + + + Lyon + Bordeaux + + + + Frankfurt + Dortmund + + + + + + + + { + setShowSeparators(!showSeparators) + }} + /> + { + setShowTitles(!showTitles) + }} + /> + + + + ) +} + +render() +``` + +#### Selectable Groups + +The `selectableType` prop makes a group either a single-select (radio) or multi-select (checkbox) group. Selected options are indicated with a "check" icon. + +It is recommended to set the `value` prop of the options in a group, because the `defaultSelected` and `onSelect` props are based on these values. + +##### Single-select Group + +```js +--- +type: example +--- + + + + + Strongly agree + + + Somewhat agree + + + Neither agree nor disagree + + + Somewhat disagree + + + Strongly disagree + + + + +``` + +##### Multi-select Group + +```js +--- +type: example +--- +const SelectMembersExample = (props) => { + const [selected, setSelected] = useState({}) + + const selectMembers = (groupValues) => { + setSelected((selected) => ({ + ...selected, + ...groupValues + })) + } + + const renderGroups = (groups) => { + return groups.map((group, idx) => { + const { id, label, members, selectableType } = group + + return ( + { + selectMembers({ [id]: value }) + }} + > + {members.map((member, idx) => { + const { id, name } = member + return ( + + {name} + + ) + })} + + ) + }) + } + + const renderSubPageOptions = (subPages = []) => { + return subPages.map((subPage, idx) => { + const { id, label } = subPage + + return ( + + {label} + + ) + }) + } + + const renderPage = (category, key) => { + const { id, subPages = [], groups = [] } = category + + return ( + + {subPages.length > 0 && renderSubPageOptions(subPages)} + {groups.length > 0 && renderGroups(groups)} + + ) + } + + return ( + + + Select Members} + shouldHideOnSelect={false} + onToggle={(_event, { shown }) => { + shown && selectMembers({}) + }} + > + {props.pages.map((page, idx) => renderPage(page, idx))} + + + + + +
+ Selected members: +
+
+ + {Object.entries(selected).map(([groupId, values], idx) => { + return values.length > 0 ? ( + + {groupId}: {values.join(', ')} + + ) : null + })} + +
+
+
+
+ ) +} + +const pages = [ + { + id: 'root', + subPages: [ + { id: 'courses', label: 'Courses' }, + { id: 'groups', label: 'Groups' }, + { id: 'consortiums', label: 'Consortiums' } + ] + }, + { + id: 'courses', + groups: [ + { + id: 'course1', + label: 'Course 1', + selectableType: 'multiple', + members: [ + { id: 'course1_1', name: 'Hanna Septimus' }, + { id: 'course1_2', name: 'Kadin Press' }, + { id: 'course1_3', name: 'Kaiya Botosh' } + ] + }, + { + id: 'course2', + label: 'Course 2', + selectableType: 'multiple', + members: [ + { id: 'course2_1', name: 'Leo Calzoni' }, + { id: 'course2_2', name: 'Gretchen Gouse' } + ] + } + ] + }, + { + id: 'groups', + groups: [ + { + id: 'group1', + label: 'Group 1', + selectableType: 'multiple', + members: [ + { id: 'groups1_1', name: 'Penka Okabe' }, + { id: 'groups1_2', name: 'Ausma Meggyesfalvi' }, + { id: 'groups1_3', name: 'Endrit Höfler' }, + { id: 'groups1_4', name: 'Ryuu Carey' }, + { id: 'groups1_5', name: 'Daphne Dioli' } + ] + } + ] + }, + { + id: 'consortiums', + groups: [ + { + id: 'consortium1', + label: 'Consortium 1', + selectableType: 'multiple', + members: [ + { id: 'consortium1_1', name: 'Brahim Gustavsson' }, + { id: 'consortium1_2', name: 'Elodia Dreschner' } + ] + }, + { + id: 'consortium2', + label: 'Consortium 2', + selectableType: 'multiple', + members: [ + { id: 'consortium2_1', name: 'Darwin Peter' }, + { id: 'consortium2_2', name: 'Sukhrab Burnham' } + ] + }, + { + id: 'consortium3', + label: 'Consortium 3', + selectableType: 'multiple', + members: [ + { id: 'consortium3_1', name: 'Jeffry Antonise' }, + { id: 'consortium3_2', name: 'Bia Regenbogen' } + ] + } + ] + } +] + +render() +``` + +### Disabled prop + +You can disable the whole Drilldown or its sub-components with the `disabled` prop. The only option that can not be disabled is the Back navigation. + +```js +--- +type: example +--- +const DisabledExample = () => { + const [disabled, setDisabled] = useState('None') + const [withTrigger, setWithTrigger] = useState(false) + + const disabledDrilldown = disabled === 'Drilldown' + const disabledPages = disabled === 'Pages' + const disabledGroups = disabled === 'Groups' + const disabledOptions = disabled === 'Options' + + return ( + + + + Toggle button} + disabled={disabledDrilldown} + > + + + Option with subPage navigation + + + Option + + + {['Apple', 'Orange', 'Banana', 'Strawberry'].map((item) => ( + + {item} + + ))} + + + + + {['Option 1', 'Option 2', 'Option 3', 'Option 4'].map( + (item) => ( + + {item} + + ) + )} + + + + + + + Settings} + colSpacing="medium" + layout="columns" + vAlign="top" + > + { + setDisabled(value) + }} + defaultValue="None" + name="disabledPart" + description="Select disabled" + > + {['None', 'Drilldown', 'Pages', 'Groups', 'Options'].map( + (part) => ( + + ) + )} + + + { + setWithTrigger(!withTrigger) + }} + /> + + + + ) +} + +render() +``` + +### shouldHideOnSelect + +By default, if the Drilldown is in a Popover, it will hide if an option is selected (except on page navigation). You can disable this behavior by setting `shouldHideOnSelect={false}`. + +You can still manually close the Popover on option click or select, calling the `.hide()` method on the `drilldown` parameter of the `onOptionClick` and `onSelect` callbacks. + +```js +--- +type: example +--- +Toggle button} + shouldHideOnSelect={false} +> + + + + Option 1 + + + Option 2 + + + Option 3 + + + } + onOptionClick={(_event, data) => { + data.drilldown.hide() + }} + themeOverride={(_componentTheme, currentTheme) => { + return { color: currentTheme.colors.textDanger } + }} + > + Close Popover + + + +``` + +### Page navigation + +The recommended way to navigate between pages is to add the `subaPageId` prop to the ``-s. This will make an arrow display on the option to indicate that it navigates to another page. + +If you have to manually navigate to another page on click or on Popover toggle, the `onOptionClick` and `onToggle` callbacks expose methods for page navigation and the current page history. + +- `pageHistory`: An array of the Page Id-s in "breadcrumbs" fashion. +- `goToPreviousPage`: A method that navigates to the previous page (if not on root page). +- `goToPage`: A method that navigates to a page, defined by the page id. If that page is already in the page history, it goes back to that level. If it is not in the history and the page exists, it adds it to the page history and goes there. + +These two navigation methods are also available as public methods on the Drilldown component. + +```js +--- +type: example +--- + + + + Go to Page Two + + + Go to Page Three + + { + console.log(args.pageHistory) + const nav = args.goToPage('Page Two') + console.log(`Navigated from "${nav.prevPageId}" to "${nav.newPageId}"`) + }} + description='Navigates to Page Two on click and logs the pageHistory on the console.' + > + Manual navigation to Page Two + + + + + + Go to Page Three + + { + console.log(args.pageHistory) + const nav = args.goToPreviousPage() + console.log(`Navigated back from "${nav.prevPageId}" to previous "${nav.newPageId}"`) + }} + description='Navigates to the previous page on click and logs the pageHistory on the console.' + > + Manual "Back" navigation + + { + console.log(args.pageHistory) + const nav = args.goToPage('Page Three') + console.log(`Navigated from "${nav.prevPageId}" to "${nav.newPageId}"`) + }} + description='Navigates to Page Two on click and logs the pageHistory on the console.' + > + Manual navigation to Page Three + + + + + { + console.log(args.pageHistory) + const nav = args.goToPage(args.pageHistory[0]) + console.log(`Navigated from "${nav.prevPageId}" to "${nav.newPageId}"`) + }} + description='Navigates to the first page on click and logs the pageHistory on the console.' + > + Manual "Back" navigation + + { + console.log(args.pageHistory) + const nav = args.goToPreviousPage() + console.log(`Navigated back from "${nav.prevPageId}" to previous "${nav.newPageId}"`) + }} + description='Navigates to the previous page on click and logs the pageHistory on the console.' + > + Manual "Back" navigation + + + +``` + +### Editable structures + +The following example showcases an editable drilldown that can add or delete options. + +```js +--- +type: example +--- + const EditableStructureExample = () => { + const [districtsData, setDistrictsData] = useState({ + d1: { + label: 'District 1', + schools: ['s1'] + } + }) + const [schoolsData, setSchoolsData] = useState({ + s1: { + label: 'School 1', + districtId: 'd1', + isSelected: false + } + }) + + const [districtCounter, setDistrictCounter] = useState( + Object.keys(districtsData).length + ) + const [schoolCounter, setSchoolCounter] = useState( + Object.keys(schoolsData).length + ) + + const districts = Object.entries(districtsData) + const schools = Object.entries(schoolsData) + + const toggleSelectedSchool = (id, school) => { + setSchoolsData({ + ...schoolsData, + [id]: { ...school, isSelected: !school.isSelected } + }) + } + + const renderSelectedIcon = (isSelected) => { + return + } + + const renderAddAction = (label) => { + return ( + + + + New {label} + + + ) + } + + const addDistrict = () => { + const newDistrictCounter = districtCounter + 1 + const districtNumber = newDistrictCounter + const districtId = `d${newDistrictCounter}` + setDistrictCounter(newDistrictCounter) + + setDistrictsData((districtsData) => ({ + ...districtsData, + [districtId]: { + label: `District ${districtNumber}`, + schools: [] + } + })) + } + + const addSchool = (districtId) => { + const newSchoolCounter = schoolCounter + 1 + const district = districtsData[districtId] + const schoolNumber = newSchoolCounter + const schoolId = `s${newSchoolCounter}` + setSchoolCounter(newSchoolCounter) + + setDistrictsData((districtsData) => ({ + ...districtsData, + [districtId]: { + ...districtsData[districtId], + schools: [...district.schools, schoolId] + } + })) + + setSchoolsData((schoolsData) => ({ + ...schoolsData, + [schoolId]: { + label: `School ${schoolNumber}`, + districtId, + isSelected: false + } + })) + } + + const renderDeleteOption = (type, label, idToDelete) => { + const id = type === 'school' ? 'deleteSchool' : 'deleteDistrict' + const callback = type === 'school' ? deleteSchool : deleteDistrict + const separatorId = `${idToDelete}__separator` + + return [ + , + { + callback(idToDelete) + }} + themeOverride={(_componentTheme, currentTheme) => { + return { color: currentTheme.colors.textDanger } + }} + > + + + Delete {label} + + + ] + } + + const deleteSchool = (id) => { + const { [id]: school, ...restSchools } = schoolsData + const { districtId } = school + const district = districtsData[districtId] + + setSchoolsData(restSchools) + setDistrictsData({ + ...districtsData, + [districtId]: { + ...district, + schools: district.schools.filter((schoolId) => schoolId !== id) + } + }) + } + + const deleteDistrict = (id) => { + const { [id]: district, ...restDistricts } = districtsData + + const filteredSchools = {} + + Object.entries(schoolsData).forEach(([schoolId, school]) => { + if (school.districtId !== id) { + filteredSchools[schoolId] = school + } + }) + + setDistrictsData(restDistricts) + setSchoolsData(filteredSchools) + } + + return ( + + { + addDistrict() + }} + > + {districts.map(([id, district]) => { + const { label } = district + return ( + + {label} + + ) + })} + + + {districts.map(([districtId, district]) => { + const { label, schools } = district + return ( + { + addSchool(districtId) + }} + > + {schools.map((id) => { + const { label, isSelected } = schoolsData[id] + return ( + + {label} + + ) + })} + {renderDeleteOption('district', label, districtId)} + + ) + })} + + {schools.map(([id, school]) => { + const { label, isSelected } = school + return ( + + { + toggleSelectedSchool(id, school) + }} + > + {isSelected ? 'Deselect' : 'Select'} {label} + + {renderDeleteOption('school', label, id)} + + ) + })} + + ) + } + + render() +``` + +### Guidelines + +```js +--- +type: embed +--- + +
+ + Use to display tree structures (instead of the TreeBrowser) + + + Use instead of flyout menus + + + Use for radio or checkbox type interactions + + + Make the text within Drilldown options direct and self-evident so users can quickly decide on an action + +
+
+ + Include complex content + + + Use content that is not a Drilldown.Option (eg. buttons) + + + Use the “back” option to display group names + +
+
+``` diff --git a/packages/ui-drilldown/src/Drilldown/v1/__tests__/Drilldown.test.tsx b/packages/ui-drilldown/src/Drilldown/v2/__tests__/Drilldown.test.tsx similarity index 98% rename from packages/ui-drilldown/src/Drilldown/v1/__tests__/Drilldown.test.tsx rename to packages/ui-drilldown/src/Drilldown/v2/__tests__/Drilldown.test.tsx index 17927dda86..1ceb4822cd 100644 --- a/packages/ui-drilldown/src/Drilldown/v1/__tests__/Drilldown.test.tsx +++ b/packages/ui-drilldown/src/Drilldown/v2/__tests__/Drilldown.test.tsx @@ -28,8 +28,8 @@ import userEvent from '@testing-library/user-event' import '@testing-library/jest-dom' import { runAxeCheck } from '@instructure/ui-axe-check' -import { IconCheckSolid } from '@instructure/ui-icons' -import { Popover } from '@instructure/ui-popover/v11_6' +import { CheckInstUIIcon } from '@instructure/ui-icons' +import { Popover } from '@instructure/ui-popover/latest' import { Drilldown } from '../index' @@ -212,19 +212,17 @@ describe('', () => { Go to Disabled Page
- + +
) + // 1. Navigate to the disabled page await userEvent.click(screen.getByText('Go to Disabled Page')) expect(screen.getByText('Disabled Page')).toBeInTheDocument() - + await userEvent.click(screen.getByText('Back')) - + // 4. Verify we have successfully navigated back expect(screen.getByText('First Page')).toBeInTheDocument() }) @@ -950,11 +948,11 @@ describe('', () => {
} + renderBeforeLabel={} > Item7 - }> + }> Item8 diff --git a/packages/ui-drilldown/src/Drilldown/v2/index.tsx b/packages/ui-drilldown/src/Drilldown/v2/index.tsx new file mode 100644 index 0000000000..2aab9cee96 --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/index.tsx @@ -0,0 +1,1681 @@ +/* + * 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 { Children, Component, ReactElement, ReactNode } from 'react' + +import { warn, error } from '@instructure/console' +import { contains, containsActiveElement } from '@instructure/ui-dom-utils' +import { deepEqual } from '@instructure/ui-utils' +import { + callRenderProp, + matchComponentTypes, + omitProps, + safeCloneElement, + withDeterministicId +} from '@instructure/ui-react-utils' + +import { View } from '@instructure/ui-view/latest' +import { Options } from '@instructure/ui-options/latest' +import type { + OptionsProps, + OptionsItemProps, + OptionsSeparatorProps +} from '@instructure/ui-options/latest' +import { Popover } from '@instructure/ui-popover/latest' +import { Selectable } from '@instructure/ui-selectable' +import type { SelectableRender } from '@instructure/ui-selectable' +import { + ChevronLeftInstUIIcon, + ChevronRightInstUIIcon, + CheckInstUIIcon +} from '@instructure/ui-icons' + +import { withStyle } from '@instructure/emotion' + +import { DrilldownSeparator } from './DrilldownSeparator' +import { DrilldownOption } from './DrilldownOption' +import type { DrilldownOptionValue } from './DrilldownOption/props' +import { DrilldownGroup } from './DrilldownGroup' +import type { DrilldownGroupProps, GroupChildren } from './DrilldownGroup/props' +import { DrilldownPage } from './DrilldownPage' +import type { DrilldownPageProps, PageChildren } from './DrilldownPage/props' + +import generateStyle from './styles' + +import { allowedProps, SelectedGroupOptionsMap } from './props' + +import type { + DrilldownProps, + DrilldownStyleProps, + DrilldownState, + PageChild, + OptionChild, + GroupChild, + SeparatorChild +} from './props' + +// Additional data we need to track on the Options, +// but shouldn't be settable via props +type OptionData = { + groupProps?: DrilldownGroupProps +} + +// Contains the Option ComponentElement and the extra data we need track on it, +// but don't want to expose as props +type MappedOption = OptionChild & OptionData & { index: number } + +// Contains the props object of the Page +// with the `children` transformed into an array +type MappedPage = DrilldownPageProps & { + children: PageChildren[] +} + +// An object with the mapped Pages with their id as keys +type PageMap = Record + +/** +--- +category: components +--- +**/ +@withDeterministicId() +@withStyle(generateStyle) +class Drilldown extends Component { + static readonly componentId = 'Drilldown' + + static allowedProps = allowedProps + static defaultProps = { + disabled: false, + rotateFocus: true, + as: 'ul', + role: 'menu', + overflowX: 'auto', + overflowY: 'auto', + placement: 'bottom center', + defaultShow: false, + shouldHideOnSelect: true, + shouldContainFocus: false, + shouldReturnFocus: true, + withArrow: true, + offsetX: 0, + offsetY: 0, + shouldSetAriaExpanded: true + } + + static Group = DrilldownGroup + static Option = DrilldownOption + static Page = DrilldownPage + static Separator = DrilldownSeparator + + private _drilldownRef: HTMLDivElement | null = null + private _popover: Popover | null = null + private _trigger: (React.ReactInstance & { focus?: () => void }) | null = null + private _containerElement: HTMLDivElement | null = null + + readonly _id: string + readonly _triggerId: string + readonly _headerBackId: string + readonly _headerTitleId: string + readonly _headerTitleLabelId: string + readonly _headerActionId: string + + // Array of visited page ids in "breadcrumbs" fashion + private readonly _pageHistory: string[] + + // Map of the active options on the page (includes header options too) + _activeOptionsMap: { + [optionId: string]: MappedOption + } = {} + + ref: HTMLDivElement | Element | null = null + + handleRef = (el: HTMLDivElement | Element | null) => { + // ref and elementRef have to be set together for the same element + const { elementRef } = this.props + + this.ref = el as HTMLDivElement + + if (typeof elementRef === 'function') { + elementRef(el as HTMLDivElement) + } + } + + handleDrilldownRef = (el: Element | null) => { + const { drilldownRef } = this.props + + this._drilldownRef = el as HTMLDivElement + + if (typeof drilldownRef === 'function') { + drilldownRef(el as HTMLDivElement) + } + + // setting ref for "non-popover" version, the drilldown itself + // (if a trigger is provided, the Popover sets the ref) + if (!this.props.trigger) { + this.handleRef(el as HTMLDivElement) + } + } + + constructor(props: DrilldownProps) { + super(props) + + this.state = { + isShowingPopover: props.trigger ? !!props.show : false, + activePageId: props.rootPageId, + highlightedOptionId: undefined, + lastSelectedId: undefined, + selectedGroupOptionsMap: this.getSelectedGroupOptionsMap() + } + this._pageHistory = [props.rootPageId] + + this._id = props.id || props.deterministicId!() + this._triggerId = props.deterministicId!('Drilldown-Trigger') + this._headerBackId = props.deterministicId!('DrilldownHeader-Back') + this._headerTitleId = props.deterministicId!('DrilldownHeader-Title') + this._headerTitleLabelId = props.deterministicId!( + 'DrilldownHeader-Title-Label' + ) + this._headerActionId = props.deterministicId!('DrilldownHeader-Action') + } + + componentDidMount() { + this.props.makeStyles?.(this.makeStylesVariables) + } + + componentDidUpdate(_prevProps: DrilldownProps, prevState: DrilldownState) { + this.props.makeStyles?.(this.makeStylesVariables) + if (prevState.activePageId !== this.state.activePageId) { + // on page change with mouse navigation, some option can get rendered + // under the cursor and get focused, so we need to wait a tick to see + // if anything gets focused, otherwise we focus the whole drilldown + setTimeout(() => { + if (!this.focused()) { + this.focus() + } + }, 0) + } + + // if the current page was removed + if (!this.currentPage) { + if (this.previousPage) { + this.goToPreviousPage() + } else { + this.goToPage(this.props.rootPageId) + } + } + + if ( + this.state.highlightedOptionId && + !this.getPageChildById(this.state.highlightedOptionId) + ) { + this.setState({ + highlightedOptionId: undefined + }) + } + + // This block syncs the internal state if the selectedOptions prop changes + // selectedOptions prop values always win over internal state. + let shouldUpdateState = false + const nextSelectionMap: SelectedGroupOptionsMap = { + ...this.state.selectedGroupOptionsMap + } + + this.pages.forEach((page) => { + this.getChildrenArray(page.props.children).forEach((child) => { + if (matchComponentTypes(child, [DrilldownGroup])) { + const { + id: groupId, + selectableType, + selectedOptions, + children: groupChildren + } = child.props + + if (!selectableType || !Array.isArray(selectedOptions)) return + + const newGroupMap = new Map() + + this.getChildrenArray(groupChildren)?.forEach((groupChild) => { + if ( + matchComponentTypes(groupChild, [DrilldownOption]) + ) { + const { id: optionId, value: optionValue } = groupChild.props + if (selectedOptions.includes(optionValue)) { + newGroupMap.set(optionId, optionValue) + } + } + }) + + const currentGroupMapInState = + this.state.selectedGroupOptionsMap[groupId] || new Map() + + if ( + !deepEqual( + this.getNormalizedMap(newGroupMap), + this.getNormalizedMap(currentGroupMapInState) + ) + ) { + nextSelectionMap[groupId] = newGroupMap + shouldUpdateState = true + } + } + }) + }) + if (shouldUpdateState) { + this.setState({ selectedGroupOptionsMap: nextSelectionMap }) + } + } + + get makeStylesVariables(): DrilldownStyleProps { + return { + hasHighlightedOption: !!this.state.highlightedOptionId + } + } + + get activeOptionIds() { + const orderedKeys = Object.keys(this._activeOptionsMap).sort((a, b) => { + return this._activeOptionsMap[a].index - this._activeOptionsMap[b].index + }) + return orderedKeys + } + + get activeOptions() { + return Object.values(this._activeOptionsMap) + } + + get pages(): PageChild[] { + const { children } = this.props + return Children.toArray(children || []) as PageChild[] + } + + get pageMap(): PageMap | undefined { + const { children } = this.props + + if (!children) { + return undefined + } + + const map: PageMap | undefined = {} + + this.pages.forEach((page) => { + const { props } = page + const { children } = props + + map[props.id] = { + ...props, + children: Children.toArray(children || []) as PageChildren[] + } + }) + + return map + } + + get isOnRootPage() { + return this.state.activePageId === this.props.rootPageId + } + + get currentPage(): MappedPage | undefined { + return this.getPageById(this.state.activePageId) + } + + get previousPage(): MappedPage | undefined { + const previousPageId = this._pageHistory[this._pageHistory.length - 2] + return this.getPageById(previousPageId) + } + + // Returns the navigation methods and the page history. + // These are used in some callbacks to expose the navigation logic. + get exposedNavigationProps() { + const { goToPage, goToPreviousPage } = this + + // we make a copy of the array so the original history + // cannot be modified from the outside + const pageHistory = [...this._pageHistory] + + return { + pageHistory, + goToPage, + goToPreviousPage + } + } + + get currentPageAriaLabel() { + // return, if explicitly set + if (this.props['aria-labelledby']) { + return this.props['aria-labelledby'] + } + + // if it has title, point to the title label content + if (this.currentPage?.renderTitle) { + return this._headerTitleLabelId + } + + // if root page has no title, try the trigger, if exists + if (this.isOnRootPage && this.props.trigger) { + return this._triggerId + } + + return undefined + } + + getChildrenArray( + children?: C | C[] + ) { + if (!children) { + return [] + } + return Array.isArray(children) ? children : ([children] as C[]) + } + + getPageById(id?: string): MappedPage | undefined { + return this.pageMap && id ? this.pageMap[id] : undefined + } + + getPageChildById(id?: string) { + return id ? this._activeOptionsMap[id] : undefined + } + + getNormalizedMap(map: Map) { + return Array.from(map.entries()) + } + + /** + * Initializes the map of selected options for each group on initial render. + * + * This function establishes the selection state based on a clear precedence: + * 1. **Controlled Mode**: If a `Drilldown.Group` has the `selectedOptions` prop + * (as an array), it is treated as the absolute source of truth. + * + * 2. **Uncontrolled Mode**: If `selectedOptions` is not provided, the selection + * is determined by `defaultSelected` props, with the following priority: + * a. The `defaultSelected` boolean on an individual `Drilldown.Option`. + * b. The `defaultSelected` array of values on the `Drilldown.Group`. + * + * It also validates that 'single' selection groups do not mistakenly receive + * multiple selected or default values. + * + * @returns {SelectedGroupOptionsMap} An object where keys are group IDs and + * values are Maps of the selected { optionId: optionValue } pairs for that group. + */ + getSelectedGroupOptionsMap() { + const selectedGroupOptionsMap: SelectedGroupOptionsMap = {} + this.pages.forEach((page) => { + const { children } = page.props + + this.getChildrenArray(children).forEach((child) => { + if (matchComponentTypes(child, [DrilldownGroup])) { + const { + id: groupId, + selectableType, + selectedOptions, + defaultSelected = [], + children: groupChildren + } = child.props + + if (!selectableType) return + + selectedGroupOptionsMap[groupId] = new Map() + + if (selectableType === 'single') { + if (Array.isArray(selectedOptions) && selectedOptions.length > 1) { + error( + false, + `Radio type selectable groups can have only one item selected! Group "${groupId}" has multiple values: [${selectedOptions.join( + ', ' + )}]!` + ) + return + } + if (defaultSelected.length > 1) { + error( + false, + `Radio type selectable groups can have only one default item selected! Group "${groupId}" has multiple values: [${defaultSelected.join( + ', ' + )}]!` + ) + return + } + } + + this.getChildrenArray(groupChildren)?.forEach((groupChild) => { + if ( + matchComponentTypes(groupChild, [DrilldownOption]) + ) { + const { + id: optionId, + value: optionValue, + defaultSelected: optionDefaultSelected + } = groupChild.props + + if (optionValue == null) return + + let isSelected = false + // If the group is controlled via the `selectedOptions` prop, it is the source of truth. + // `defaultSelected` values are ignored. + if (Array.isArray(selectedOptions)) { + isSelected = selectedOptions.includes(optionValue) + // Second strongest is the `defaultSelected` value on the option itself. + } else if (optionDefaultSelected === false) { + isSelected = false + } else { + // Last is the `defaultSelected` array on the group. + const isGroupDefaultSelected = + defaultSelected.includes(optionValue) + isSelected = optionDefaultSelected || isGroupDefaultSelected + } + + if (isSelected) { + selectedGroupOptionsMap[groupId].set(optionId, optionValue) + } + } + }) + } + }) + }) + return selectedGroupOptionsMap + } + + get selectedGroupOptionIdsArray() { + return Object.values(this.state.selectedGroupOptionsMap) + .map((groupIdMap) => Array.from(groupIdMap.keys())) + .flat() + } + + get headerChildren() { + const { currentPage } = this + const { styles, deterministicId } = this.props + + const headerChildren: PageChildren[] = [] + + if (!currentPage) { + return headerChildren + } + + const { + children, + renderBackButtonLabel, + renderTitle, + renderActionLabel, + onHeaderActionClicked, + withoutHeaderSeparator + } = currentPage + + // Back navigation option + if (this.previousPage) { + const prevPageTitle = callRenderProp(this.previousPage.renderTitle) + + const backButtonLabel: React.ReactNode = callRenderProp( + renderBackButtonLabel, + prevPageTitle + ) + + headerChildren.push( + +
+ {backButtonLabel} +
+
+ ) + } + + // Header title + if (renderTitle) { + const title = callRenderProp(renderTitle) + + if (title) { + headerChildren.push( + + ) + } + } + + // Action label + if (renderActionLabel) { + const actionLabel = callRenderProp(renderActionLabel) + + if (actionLabel) { + headerChildren.push( + { + if (typeof onHeaderActionClicked === 'function') { + onHeaderActionClicked(event) + } + }} + > + {actionLabel} + + ) + } + } + + // header separator + if ( + headerChildren.length > 0 && + children.length > 0 && + !withoutHeaderSeparator + ) { + headerChildren.push( + + ) + } + + return headerChildren + } + + get shown() { + return this.props.trigger ? this.state.isShowingPopover : true + } + + containsDuplicateChild(children: PageChildren[]) { + let containsDuplicate = false + const childMap = new Map() + + for (const child of children) { + if (child && typeof child === 'object' && child.props?.id) { + if (!childMap.has(child.props.id)) { + childMap.set(child.props.id, true) + } else { + warn( + false, + `Duplicate id: "${child.props.id}"! Make sure all options have unique ids, otherwise they won't be rendered.` + ) + + return (containsDuplicate = true) + } + } + } + + return containsDuplicate + } + + show = (event: React.SyntheticEvent) => { + if (this._popover) { + this._popover.show(event as React.UIEvent | React.FocusEvent) + this.setState({ isShowingPopover: true }) + } + } + + hide = (event: React.SyntheticEvent) => { + if (this._popover) { + this._popover.hide(event as React.UIEvent | React.FocusEvent) + this.setState({ isShowingPopover: false }) + this.reset() + } + } + + reset() { + this._activeOptionsMap = {} + this.setState({ highlightedOptionId: undefined }) + } + + public focus() { + if (this.shown) { + error( + !!this._drilldownRef?.focus, + '[Drilldown] Could not focus the drilldown.' + ) + this._drilldownRef?.focus() + } else { + error(!!this._trigger?.focus, '[Drilldown] Could not focus the trigger.') + this._trigger!.focus!() + } + } + + public focused() { + if (this.shown) { + return containsActiveElement(this._drilldownRef) + } else { + return containsActiveElement(this._trigger) + } + } + + focusOption(id: string) { + const container = this._containerElement + const optionElement = container?.querySelector( + `[id="${CSS.escape(id)}"]` + ) as HTMLSpanElement + + optionElement?.focus() + } + + handleOptionHighlight = ( + _event: React.SyntheticEvent, + { id, direction }: { id?: string; direction?: -1 | 1 } + ) => { + const { rotateFocus } = this.props + const { highlightedOptionId } = this.state + + // if id exists, use that, or calculate from direction + let highlightId = this.getPageChildById(id) ? id : undefined + + if (!highlightId) { + if (!highlightedOptionId) { + // nothing highlighted yet, highlight first option + highlightId = this.activeOptionIds[0] + } else if (direction) { + // if it has direction, find next id based on it + const index = this.activeOptionIds.indexOf(highlightedOptionId) + const newIndex = index + direction + + highlightId = index > -1 ? this.activeOptionIds[newIndex] : undefined + + if (rotateFocus) { + const lastOptionsIndex = this.activeOptionIds.length - 1 + + if (newIndex < 0) { + highlightId = this.activeOptionIds[lastOptionsIndex] + } + if (newIndex > lastOptionsIndex) { + highlightId = this.activeOptionIds[0] + } + } + } + } + + if (highlightId) { + // only highlight if id exists as a valid option + this.setState({ highlightedOptionId: highlightId }, () => { + this.focusOption(highlightId!) + }) + } + } + + // Navigates to the page and also returns the new and old pageIds + public goToPage = (newPageId: string) => { + if (!newPageId) { + warn(false, `Cannot go to page because there was no page id provided.`) + return undefined + } + + // TS complains that it cannot be true, but since it is an exposed method, + // it is better if we provide a warning for this case too + if (typeof newPageId !== 'string') { + warn( + false, + `Cannot go to page because parameter newPageId has to be string (valid page id). Current newPageId is "${typeof newPageId}".` + ) + return undefined + } + + if (!this.pageMap?.[newPageId]) { + warn( + false, + `Cannot go to page because page with id: "${newPageId}" doesn't exist.` + ) + return undefined + } + + // the last page id in the history is the current one, + // it will become the "prevPage" + const prevPageId = this._pageHistory[this._pageHistory.length - 1] + const idxInHistory = this._pageHistory.indexOf(newPageId) + + if (idxInHistory < 0) { + // if it is not in the page history, we have to add it + this._pageHistory.push(newPageId) + } else { + // if it was already in the history, we go back to that page, + // and clear the rest from the history + this._pageHistory.splice(idxInHistory + 1, this._pageHistory.length - 1) + } + + this.setState({ + activePageId: newPageId, + highlightedOptionId: undefined + }) + + return { prevPageId, newPageId } + } + + public goToPreviousPage = () => { + if (!this.previousPage) { + warn( + false, + `There is no previous page to go to. The current page history is: [${this._pageHistory.join( + ', ' + )}].` + ) + return undefined + } + + // If there is a previous page, goToPage will succeed and return the data + const { newPageId, prevPageId } = this.goToPage(this.previousPage.id)! + return { newPageId, prevPageId } + } + + handleBackButtonClick = () => { + const { onBackButtonClicked } = this.currentPage! + + // we only display the back button when there is a page to go back to + const { newPageId, prevPageId } = this.goToPreviousPage()! + + if (typeof onBackButtonClicked === 'function') { + onBackButtonClicked(newPageId, prevPageId) + } + } + + handleGroupOptionSelected( + event: React.SyntheticEvent, + selectedOption: MappedOption + ) { + this.setState( + (oldState) => { + const { id: optionId, value } = selectedOption.props + const { id: groupId, selectableType } = selectedOption.groupProps! + + let newState = new Map(oldState.selectedGroupOptionsMap[groupId]) + + if ( + selectableType === 'multiple' && + Boolean(oldState.selectedGroupOptionsMap[groupId]?.has(optionId)) + ) { + // toggle off, if already selected + newState.delete(optionId) + } else { + if (selectableType === 'multiple') { + // "checkbox" + newState.set(optionId, value) + } else if (selectableType === 'single') { + // "radio" + newState = new Map() + newState.set(optionId, value) + } + } + return { + ...oldState, + selectedGroupOptionsMap: { + ...oldState.selectedGroupOptionsMap, + [groupId]: newState + } + } + }, + () => { + const { value } = selectedOption.props + const { id: groupId, onSelect: groupOnSelect } = + selectedOption.groupProps! + + const { onSelect } = this.props + const { groupProps, ...option } = selectedOption + const selectedOptionValuesInGroup = [ + ...this.state.selectedGroupOptionsMap[groupId].values() + ] + + if (typeof groupOnSelect === 'function') { + groupOnSelect(event, { + value: selectedOptionValuesInGroup, + isSelected: selectedOptionValuesInGroup.includes(value), + selectedOption: option, + drilldown: this + }) + } + + if (typeof onSelect === 'function') { + onSelect(event, { + value: selectedOptionValuesInGroup, + isSelected: selectedOptionValuesInGroup.includes(value), + selectedOption: option, + drilldown: this + }) + } + } + ) + } + + handleOptionSelect = ( + event: React.SyntheticEvent, + { id }: { id?: string } + ) => { + const selectedOption = this.getPageChildById(id) + + if (!id || !selectedOption) { + event.preventDefault() + event.stopPropagation() + return + } + + const isOptionDisabled = + id !== this._headerBackId && + (this.props.disabled || + this.currentPage?.disabled || + selectedOption.groupProps?.disabled || + selectedOption.props.disabled) + + if (isOptionDisabled) { + event.preventDefault() + event.stopPropagation() + return + } + + const { shouldHideOnSelect, onSelect } = this.props + + const { groupProps, ...selectedOptionChild } = selectedOption + const { subPageId, href, value, onOptionClick } = selectedOptionChild.props + + if (typeof onOptionClick === 'function') { + onOptionClick(event, { + optionId: id, + drilldown: this, + ...this.exposedNavigationProps + }) + } + + if (subPageId) { + this.goToPage(subPageId) + } + + if (event.type === 'keydown' && href) { + const optionEl = this._drilldownRef?.querySelector( + `#${CSS.escape(id)}` + ) as HTMLLinkElement + const isLink = optionEl.tagName.toLowerCase() === 'a' + + // we need this check because in some cases + // we ignore href prop in renderOption() + if (isLink && optionEl?.href) { + optionEl.click() + } + } + + if (groupProps?.selectableType) { + this.handleGroupOptionSelected(event, selectedOption) + } else { + // TODO workaround for react 19 default props + const optionWithDefaultProps = { + ...selectedOptionChild, + props: { ...selectedOptionChild.props, role: 'menuitem' } + } + if (typeof onSelect === 'function') { + onSelect(event, { + value, + isSelected: true, + selectedOption: optionWithDefaultProps, + drilldown: this + }) + } + } + + // should prevent closing on page navigation + if (shouldHideOnSelect && !subPageId && id !== this._headerBackId) { + this.hide(event as React.UIEvent) + } + } + + // Setting extra logic for keyboard navigation. + // Enter, Esc and up/down/home/end keys are handled by Selectable. + handleKeyDown = (event: React.KeyboardEvent) => { + const id = (event.target as HTMLElement).id + const option = this.getPageChildById(id) + + // On Space... + if ([' ', 'space', 'Space'].includes(event.key)) { + // we need to preventDefault so the page doesn't scroll on Space + event.preventDefault() + event.stopPropagation() + + // for options, Space it has to work as Enter (get selected) + if (option) { + this.handleOptionSelect(event, { id }) + } + } + + // On Right arrow... + if (event.key === 'ArrowRight') { + // if the option has subpage, we open it + if (option?.props.subPageId) { + this.handleOptionSelect(event, { id }) + } + } + + // On Left arrow... + if (event.key === 'ArrowLeft') { + // if it is possible, we go a level up in the history + if (this._pageHistory.length > 1) { + this.handleBackButtonClick() + } + + // if on root page and popover is open, we close it + if (this.isOnRootPage && this._popover?.shown) { + this._popover.hide(event) + } + } + } + + handleToggle = (event: React.UIEvent | React.FocusEvent, shown: boolean) => { + const { onToggle, trigger } = this.props + + if (trigger && shown && this.currentPage) { + const actionLabel = callRenderProp(this.currentPage.renderActionLabel) + // Use action ID if exists, otherwise first non-action option's ID + const targetId = actionLabel + ? this._headerActionId + : this.getFirstOption()?.props.id + setTimeout(() => { + this.setState({ + highlightedOptionId: targetId + }) + }, 10) + } + this.setState({ isShowingPopover: shown }) + + if (typeof onToggle === 'function') { + onToggle(event, { + shown, + drilldown: this, + ...this.exposedNavigationProps + }) + } + } + + getFirstOption = () => { + const children = Children.toArray(this.currentPage?.children) + + const child = children[0] + if (!child) return undefined + + // If it's a regular option, return it + if (matchComponentTypes(child, [DrilldownOption])) { + return child + } + // If it's a group, get its options + if (matchComponentTypes(child, [Drilldown.Group])) { + const groupOptions = Children.toArray(child.props.children) + return groupOptions[0] as OptionChild + } + return undefined + } + + renderList( + getOptionProps: SelectableRender['getOptionProps'], + getDisabledOptionProps: SelectableRender['getDisabledOptionProps'] + ) { + const { currentPage, headerChildren } = this + + if (!currentPage || this.containsDuplicateChild(currentPage.children)) { + return null + } + + const pageChildren: PageChildren[] = [ + ...headerChildren, + ...currentPage.children + ] + + // for tracking if the last item was a Separator or not + let lastItemWasSeparator = false + + return pageChildren.map((child, index) => { + /** + * --- RENDER GROUP --- + */ + if (matchComponentTypes(child, [DrilldownGroup])) { + const isFirstChild = index === 0 + const isLastChild = index === pageChildren.length - 1 + const afterSeparator = lastItemWasSeparator + + const needsFirstSeparator = + !child.props.withoutSeparators && !isFirstChild && !afterSeparator + const needsLastSeparator = + !child.props.withoutSeparators && !isLastChild + + lastItemWasSeparator = needsLastSeparator + + return this.renderGroup( + child, + getOptionProps, + getDisabledOptionProps, + // for rendering separators appropriately + needsFirstSeparator, + needsLastSeparator + ) + + /** + * --- RENDER SEPARATOR --- + */ + } else if ( + matchComponentTypes(child, [DrilldownSeparator]) + ) { + // if the last item was separator, we don't want to duplicate it + if (lastItemWasSeparator) { + return null + } + lastItemWasSeparator = true + return this.renderSeparator(child) + + /** + * --- RENDER OPTION --- + */ + } else if (matchComponentTypes(child, [DrilldownOption])) { + lastItemWasSeparator = false + return this.renderOption(child, getOptionProps, getDisabledOptionProps) + } else { + return null + } + }) + } + + renderSeparator(separator: SeparatorChild) { + const { id, themeOverride, ...props } = separator.props + return ( + + ) + } + + renderOption( + option: OptionChild, + getOptionProps: SelectableRender['getOptionProps'], + getDisabledOptionProps: SelectableRender['getDisabledOptionProps'], + groupProps?: DrilldownGroupProps + ) { + const { styles } = this.props + let isSelected = false + + const { + id, + children, + href, + as = 'li', // workaround after the react 19 upgrade, defaultProps doesn't work anymore + role = 'menuitem', // workaround after the react 19 upgrade, defaultProps doesn't work anymore + subPageId, + disabled = false, // workaround after the react 19 upgrade, defaultProps doesn't work anymore + renderLabelInfo, + renderBeforeLabel, + renderAfterLabel, + beforeLabelContentVAlign = 'start', // workaround after the react 19 upgrade, defaultProps doesn't work anymore + afterLabelContentVAlign = 'start', // workaround after the react 19 upgrade, defaultProps doesn't work anymore + description, + descriptionRole, + elementRef, + themeOverride + } = option.props + + if (!id) { + warn( + false, + `Drilldown.Option without id won't be rendered. It is needed to internally track the options.` + ) + return null + } + + // Props passed to the Option.Item + let optionProps: Partial = { + // passthrough props + ...omitProps(option.props, [ + ...DrilldownOption.allowedProps, + ...Options.Item.allowedProps + ]), + // props from selectable + ...getOptionProps({ + id, + // aria-selected is only valid for these roles, otherwise we need to unset it + ...(role && + ![ + 'gridcell', + 'option', + 'row', + 'tab', + 'columnheader', + 'rowheader', + 'treeitem' + ].includes(role) && { + 'aria-selected': undefined + }) + }), + // we pass the themeOverride to Options.Item + themeOverride, + // directly passed props + renderBeforeLabel, + renderAfterLabel, + beforeLabelContentVAlign, + afterLabelContentVAlign, + description, + descriptionRole, + as, + role, + elementRef, + variant: 'default', + tabIndex: -1 + } + + // extra data we need track on the option, + // but don't want to expose as props + const optionData: OptionData = { + groupProps: groupProps + } + + const isOptionDisabled = + id !== this._headerBackId && // Back nav is never disabled + (this.props.disabled || + this.currentPage?.disabled || + groupProps?.disabled || + disabled) + + // display option as disabled + if (isOptionDisabled) { + optionProps.variant = 'disabled' + optionProps = { ...optionProps, ...getDisabledOptionProps() } + } + + // track as valid active option if not the title and the map doesn't already contain the id + if (id !== this._headerTitleId && !this._activeOptionsMap[id]) { + // store index to know the order of ids later; js objects doesn't preserve order + this._activeOptionsMap[id] = { + ...option, + ...optionData, + index: Object.keys(this._activeOptionsMap).length + 1 + } + } + + const customRole = + role !== DrilldownOption.defaultProps.role ? role : undefined + + // BEFORE/AFTER elements: + // we set a few manually on Options.Item, + // the rest are passed directly + if (subPageId) { + optionProps.renderAfterLabel = + optionProps['aria-haspopup'] = true + optionProps.role = customRole || 'menuitem' + warn( + !renderAfterLabel, + `The prop "renderAfterLabel" is reserved on item with id: "${id}". When it has "subPageId" provided, a navigation arrow will render after the label.` + ) + } + if (id === this._headerBackId) { + optionProps.renderBeforeLabel = + } + const isOptionControlled = typeof option.props.selected === 'boolean' + if ((groupProps?.selectableType || isOptionControlled) && groupProps) { + if (!isOptionControlled) { + isSelected = Boolean( + this.state.selectedGroupOptionsMap[groupProps.id]?.has(id) + ) + } else { + isSelected = Boolean(option.props.selected) + } + optionProps['aria-checked'] = isSelected + + optionProps.renderBeforeLabel = ( + + + + ) + + warn( + !renderBeforeLabel, + `The prop "renderBeforeLabel" is reserved on item with id: "${id}". When this option is a selectable member of a Drilldown.Group, selection indicator will render before the label.` + ) + + // setting aria roles and attributes for selectable group items + if (groupProps.selectableType === 'single') { + optionProps.role = customRole || 'menuitemradio' + } + if (groupProps.selectableType === 'multiple') { + optionProps.role = customRole || 'menuitemcheckbox' + } + } + // display option as highlighted + if (id === this.state.highlightedOptionId) { + optionProps.variant = 'highlighted' + + if (isOptionDisabled) { + optionProps.variant = 'highlighted-disabled' + } + } + + if (href) { + if (subPageId) { + warn( + false, + `Drilldown.Option with id "${id}" has subPageId, so it will ignore the "href" property.` + ) + } else if (groupProps?.selectableType) { + warn( + false, + `Drilldown.Option with id "${id}" is in a selectable group, so it will ignore the "href" property.` + ) + } else { + optionProps.href = href + } + } + + const optionLabel = callRenderProp(children, { + id, + variant: optionProps.variant as Exclude< + OptionsItemProps['variant'], + 'selected' + >, + isSelected + }) + + if (!optionLabel) { + warn( + false, + `There are no "children" prop provided for option with id: "${id}", so it won't be rendered.` + ) + return null + } + + const renderLabelProps = { + variant: optionProps.variant as Exclude< + OptionsItemProps['variant'], + 'selected' + >, + vAlign: afterLabelContentVAlign, + as, + role: optionProps.role, + isSelected + } + + // we need to bind our own option props the render functions + if ( + typeof optionProps.renderBeforeLabel === 'function' && + !optionProps.renderBeforeLabel?.prototype?.isReactComponent + ) { + optionProps.renderBeforeLabel = ( + optionProps.renderBeforeLabel as (args: any) => ReactNode + ).bind(null, renderLabelProps) + } + if ( + typeof optionProps.renderAfterLabel === 'function' && + !optionProps.renderAfterLabel?.prototype?.isReactComponent + ) { + optionProps.renderAfterLabel = ( + optionProps.renderAfterLabel as (args: any) => ReactNode + ).bind(null, renderLabelProps) + } + + const labelInfo = + renderLabelInfo && callRenderProp(renderLabelInfo, renderLabelProps) + + const vAlignMap = { + start: 'flex-start', + center: 'center', + end: 'flex-end' + } + + const labelAriaId = `${id}__label` + const infoAriaId = `${id}__info` + + const labelledby = option.props['aria-labelledby'] || labelAriaId + const describedby = + option.props['aria-describedby'] || (labelInfo ? infoAriaId : undefined) + + return ( + +
+ + {optionLabel} + + + {labelInfo ? ( + + {/* this container span is needed for correct vAlign */} + {labelInfo} + + ) : null} +
+
+ ) + } + + renderGroup( + group: GroupChild, + getOptionProps: SelectableRender['getOptionProps'], + getDisabledOptionProps: SelectableRender['getDisabledOptionProps'], + needsFirstSeparator: boolean, + needsLastSeparator: boolean + ) { + const { + id, + children, + renderGroupTitle, + themeOverride, + role = 'group', // react 19 defaultProps workaround + as, + elementRef + } = group.props + + if (!children) { + return null + } + + const groupChildren: ( + | React.ReactElement + | React.ReactElement + )[] = [] + + // add a separator above + if (needsFirstSeparator) { + groupChildren.push( + + ) + } + + // create a sublist as a group + // (a wrapping list item will be created by Options) + groupChildren.push( + + {this.getChildrenArray(children).map((child) => { + if ( + matchComponentTypes(child, [DrilldownSeparator]) + ) { + return this.renderSeparator(child) + } else if ( + matchComponentTypes(child, [DrilldownOption]) + ) { + return this.renderOption( + child, + getOptionProps, + getDisabledOptionProps, + group.props + ) + } else { + return null + } + })} + + ) + + // add a separator below + if (needsLastSeparator) { + groupChildren.push( + + ) + } + + return groupChildren + } + + renderPage() { + const { + styles, + overflowY, + overflowX, + height, + width, + minHeight, + minWidth, + maxHeight, + maxWidth, + role, + as, + label, + trigger + } = this.props + + if (!this.currentPage) { + return null + } + + return ( + { + const firstOptionId = this.activeOptionIds[0] + this.handleOptionHighlight(event, { id: firstOptionId }) + }} + onRequestHighlightLastOption={(event) => { + const lastOptionId = + this.activeOptionIds[this.activeOptionIds.length - 1] + this.handleOptionHighlight(event, { id: lastOptionId }) + }} + > + {({ + // TODO: figure out what other Selectable props we need, if we want to add a Select version for drilldown: + // getRootProps, - we probably don't need this + // getLabelProps, - do we need label? + // getDescriptionProps, - might be nice for assistiveText like in Select + // getInputProps, - hidden input for a11y? role="combobox" might be needed + getTriggerProps, + getListProps, + getOptionProps, + getDisabledOptionProps + }) => ( + + // (because it has an arrow) + {...(trigger ? {} : { borderWidth: 'small' })} + as="div" + data-cid="Drilldown" + elementRef={this.handleDrilldownRef} + tabIndex={0} + css={styles?.drilldown} + position="relative" + borderRadius="small" + width={width} + minWidth={maxWidth} + maxWidth={maxWidth} + role={role} + aria-label={label} + aria-labelledby={this.currentPageAriaLabel} + {...getTriggerProps({ + id: this._id, + // We need to override these aria attributes added by Selectable, + // since Drilldown is not a combobox and has no popup + 'aria-haspopup': false, + 'aria-expanded': undefined, + onKeyDown: this.handleKeyDown, + onBlur: (event: React.FocusEvent) => { + const target = event.currentTarget + const related = event.relatedTarget + const containsRelated = contains( + target as Node | Window, + related as Node | Window + ) + + if ( + !related || + related === this._drilldownRef || + (related !== target && !containsRelated) + ) { + this.setState({ highlightedOptionId: undefined }) + } + }, + onMouseLeave: () => { + this.setState({ highlightedOptionId: undefined }) + } + })} + > + { + this._containerElement = element as HTMLDivElement + }} + > + + {this.renderList(getOptionProps, getDisabledOptionProps)} + + + + )} + + ) + } + + render() { + // clear temporary option store + this._activeOptionsMap = {} + + const { + show, + defaultShow, + placement, + withArrow, + shouldContainFocus, + shouldReturnFocus, + trigger, + mountNode, + constrain, + positionTarget, + positionContainerDisplay, + popoverRef, + disabled, + onDismiss, + onFocus, + onMouseOver, + offsetX, + offsetY, + shouldSetAriaExpanded, + styles + } = this.props + + const borderColor = (styles?.drilldown as { borderColor: string }) + ?.borderColor + return trigger ? ( + { + if (typeof onDismiss === 'function') { + onDismiss(event, documentClick) + } + this.reset() + this.handleToggle(event, false) + }} + onShowContent={(event) => this.handleToggle(event, true)} + mountNode={mountNode || this.ref} + placement={placement} + withArrow={withArrow} + positionTarget={positionTarget} + positionContainerDisplay={positionContainerDisplay} + constrain={constrain} + shouldContainFocus={shouldContainFocus} + shouldReturnFocus={shouldReturnFocus} + id={this._id} + on={['click']} + onFocus={onFocus} + onMouseOver={onMouseOver} + offsetX={offsetX} + offsetY={offsetY} + defaultFocusElement={() => { + if (!this.currentPage) return null + const actionLabel = callRenderProp(this.currentPage.renderActionLabel) + // Use action ID if exists, otherwise first non-action option's ID + const targetId = actionLabel + ? this._headerActionId + : this.getFirstOption()?.props.id + + if (!targetId) return null + return this._popover?._contentElement?.querySelector( + `#${CSS.escape(targetId)}` + ) + }} + elementRef={(element) => { + // setting ref for "Popover" version, the popover root + // (if there is no trigger, we set it in handleDrilldownRef) + this.handleRef(element) + }} + ref={(el) => { + this._popover = el + if (typeof popoverRef === 'function') { + popoverRef(el) + } + }} + renderTrigger={safeCloneElement(trigger as ReactElement, { + ref: (el: (React.ReactInstance & { ref?: Element }) | null) => { + this._trigger = el + }, + 'aria-haspopup': this.props.role, + id: this._triggerId, + disabled: !!( + (trigger as ReactElement).props.disabled || disabled + ), + 'aria-disabled': + (trigger as ReactElement).props.disabled || disabled + ? 'true' + : undefined + })} + > + {this.renderPage()} + + ) : ( + this.renderPage() + ) + } +} + +export default Drilldown +export { Drilldown } diff --git a/packages/ui-drilldown/src/Drilldown/v2/props.ts b/packages/ui-drilldown/src/Drilldown/v2/props.ts new file mode 100644 index 0000000000..34da6e9a27 --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/props.ts @@ -0,0 +1,361 @@ +/* + * 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 { Popover } from '@instructure/ui-popover/latest' +import type { WithStyleProps, ComponentStyle } from '@instructure/emotion' +import type { WithDeterministicIdProps } from '@instructure/ui-react-utils' +import type { + PlacementPropValues, + PositionConstraint, + PositionMountNode +} from '@instructure/ui-position' +import type { + DrilldownTheme, + OtherHTMLAttributes, + AsElementType +} from '@instructure/shared-types' + +import { DrilldownPage } from './DrilldownPage' +import type { DrilldownPageProps } from './DrilldownPage/props' +import { DrilldownGroup } from './DrilldownGroup' +import type { DrilldownGroupProps } from './DrilldownGroup/props' +import { DrilldownOption } from './DrilldownOption' +import type { + DrilldownOptionProps, + DrilldownOptionValue +} from './DrilldownOption/props' +import { DrilldownSeparator } from './DrilldownSeparator' +import type { DrilldownSeparatorProps } from './DrilldownSeparator/props' + +import { Drilldown } from './index' + +type PageChild = React.ComponentElement +type GroupChild = React.ComponentElement +type OptionChild = React.ComponentElement +type SeparatorChild = React.ComponentElement< + DrilldownSeparatorProps, + DrilldownSeparator +> + +type DrilldownOwnProps = { + /** + * The id of the root page + */ + rootPageId: string + + /** + * Children of type `` + */ + children?: PageChild | PageChild[] // TODO: type Children.oneOf([DrilldownPage]) + + /** + * The id of the `` + */ + id?: string + + /** + * Description of the `` (used as `aria-label` attribute) + */ + label?: string + + /** + * Is the `` disabled + */ + disabled?: boolean + + /** + * Whether the focus should rotate with keyboard navigation + * when reaching the first or last option + */ + rotateFocus?: boolean + + /** + * Element type to render as. + */ + as?: AsElementType + + /** + * The ARIA role of the element + */ + role?: string + + /** + * A function that returns a reference to root HTML element + */ + elementRef?: (el: Element | null) => void + + /** + * A function that returns a reference to the `` + */ + drilldownRef?: (el: HTMLDivElement | null) => void + + // View props + overflowX?: 'auto' | 'hidden' | 'visible' + overflowY?: 'auto' | 'hidden' | 'visible' + height?: string | number + width?: string | number + minHeight?: string | number + minWidth?: string | number + maxHeight?: string | number + maxWidth?: string | number + + // Popover version props + /** + * The trigger element, if the `` is to render as a popover + */ + trigger?: React.ReactNode + + /** + * If a trigger is supplied, where should the `` be placed + * (relative to the trigger) + * + * `stretch` will stretch the `Drilldown` to the same size as its `trigger` + * (provided that the `trigger` is at least as large as the `Drilldown`). + * In this case you should not set `width`/`maxHeight`, it will be set + * automatically. + */ + placement?: PlacementPropValues + + /** + * Should the `` be open for the initial render + */ + defaultShow?: boolean + + /** + * Is the `` open (should be accompanied by `onToggle` and `trigger`) + */ + show?: boolean // TODO: type controllable(PropTypes.bool, 'onToggle', 'defaultShow') + + /** + * Callback fired when the `` is toggled open/closed. + * When used with `show`, the component will not control its own state. + */ + onToggle?: ( + event: React.UIEvent | React.FocusEvent, + args: { + shown: boolean + drilldown: Drilldown + pageHistory: string[] + goToPage: ( + pageId: string + ) => { prevPageId: string; newPageId: string } | undefined + goToPreviousPage: () => + | { prevPageId: string; newPageId: string } + | undefined + } + ) => void + + /** + * Callback fired when an item within the `` is selected + */ + onSelect?: ( + event: React.SyntheticEvent, + args: { + value: DrilldownOptionValue | DrilldownOptionValue[] + isSelected: boolean + selectedOption: OptionChild + drilldown: Drilldown + } + ) => void + + /** + * If a trigger is supplied, callback fired when the `` is closed + */ + onDismiss?: ( + event: React.UIEvent | React.FocusEvent, + documentClick: boolean + ) => void + + /** + * If a trigger is supplied, callback fired when the `` trigger is focused + */ + onFocus?: (event: React.FocusEvent) => void + + /** + * If a trigger is supplied, callback fired onMouseOver for the `` trigger + */ + onMouseOver?: (event: React.MouseEvent) => void + + /** + * If a trigger is supplied, A function that returns a reference to the `` + */ + popoverRef?: (el: Popover | null) => void + + /** + * If a trigger is supplied, an element or a function returning an element + * to use as the mount node for the `` (defaults to the component itself) + */ + mountNode?: PositionMountNode + + /** + * Target element for positioning the Popover (if it differs from the trigger) + */ + positionTarget?: PositionMountNode + + /** + * If a trigger is supplied, this prop can set the CSS `display` property on the `` container element of the underlying Position component + */ + positionContainerDisplay?: 'inline-block' | 'block' + + /** + * The parent in which to constrain the placement. + */ + constrain?: PositionConstraint + + /** + * If a trigger is supplied, should the `` hide when an item is selected + */ + shouldHideOnSelect?: boolean + + /** + * Whether focus should be contained within the `` when it is open. + * Works only if `trigger` is provided. + */ + shouldContainFocus?: boolean + + /** + * Whether focus should be returned to the trigger + * when the `` is closed. + * Works only if `trigger` is provided. + */ + shouldReturnFocus?: boolean + + /** + * Whether or not an arrow pointing to the trigger should be rendered. + * Works only if `trigger` is provided. + */ + withArrow?: boolean + + /** + * The horizontal offset for the positioned content. + * Works only if `trigger` is provided. + */ + offsetX?: string | number + + /** + * The vertical offset for the positioned content. + * Works only if `trigger` is provided. + */ + offsetY?: string | number + /** + * If true (default), then the aria-expanded prop is added to the trigger. + * If its supplied via the aria-expanded prop then it takes the given value, + * otherwise its calculated automatically based on whether the content is shown. + */ + shouldSetAriaExpanded?: boolean +} + +type PropKeys = keyof DrilldownOwnProps + +type AllowedPropKeys = Readonly> + +type DrilldownProps = DrilldownOwnProps & + WithStyleProps & + WithDeterministicIdProps & + OtherHTMLAttributes + +type DrilldownStyle = ComponentStyle< + | 'drilldown' + | 'container' + | 'headerBack' + | 'headerTitle' + | 'optionContainer' + | 'optionLabelInfo' + | 'optionContent' +> & { headerActionColor: string } + +type DrilldownStyleProps = { + hasHighlightedOption: boolean +} + +type SelectedGroupOptionsMap = { + [groupId: string]: Map +} + +type DrilldownState = { + isShowingPopover: boolean + activePageId: string + highlightedOptionId?: string + // needed for rerender + lastSelectedId?: string + selectedGroupOptionsMap: SelectedGroupOptionsMap +} +const allowedProps: AllowedPropKeys = [ + 'rootPageId', + 'children', + 'id', + 'label', + 'disabled', + 'rotateFocus', + 'as', + 'role', + 'overflowX', + 'overflowY', + 'height', + 'width', + 'minHeight', + 'minWidth', + 'maxHeight', + 'maxWidth', + + // Popover version props + 'trigger', + 'placement', + 'defaultShow', + 'show', + 'onToggle', + 'onSelect', + 'onDismiss', + 'onFocus', + 'onMouseOver', + 'elementRef', + 'drilldownRef', + 'popoverRef', + 'mountNode', + 'positionTarget', + 'positionContainerDisplay', + 'constrain', + 'shouldHideOnSelect', + 'shouldContainFocus', + 'shouldReturnFocus', + 'withArrow', + 'offsetX', + 'offsetY' +] + +export type { + DrilldownProps, + DrilldownState, + DrilldownStyle, + DrilldownStyleProps, + DrilldownPageProps, + PageChild, + GroupChild, + OptionChild, + SeparatorChild, + SelectedGroupOptionsMap +} +export { allowedProps } diff --git a/packages/ui-drilldown/src/Drilldown/v2/styles.ts b/packages/ui-drilldown/src/Drilldown/v2/styles.ts new file mode 100644 index 0000000000..19af4731ff --- /dev/null +++ b/packages/ui-drilldown/src/Drilldown/v2/styles.ts @@ -0,0 +1,97 @@ +/* + * 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 { + DrilldownProps, + DrilldownStyleProps, + DrilldownStyle +} from './props' + +/** + * --- + * private: true + * --- + * 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['Drilldown'], + _props: DrilldownProps, + state: DrilldownStyleProps +): DrilldownStyle => { + const { hasHighlightedOption } = state + + return { + drilldown: { + label: 'drilldown', + overflow: 'hidden', // needed for focus ring! + borderColor: componentTheme.borderColor, + borderRadius: componentTheme.borderRadius, + ...(hasHighlightedOption && { + '&:focus::before': { + display: 'none' + } + }) + }, + container: { + label: 'drilldown__container' + }, + headerBack: { + label: 'drilldown__headerBack', + minHeight: '1.25em' + }, + headerTitle: { + label: 'drilldown__headerTitle', + fontWeight: componentTheme.headerTitleFontWeight + }, + optionContainer: { + label: 'drilldown__optionContainer', + alignItems: 'center', + display: 'flex', + height: '100%' + }, + optionLabelInfo: { + label: 'drilldown__optionLabelInfo', + display: 'flex', + flexShrink: 0, + height: '100%', + alignItems: 'center', + paddingInlineStart: componentTheme.labelInfoPadding, + color: componentTheme.labelInfoColor + }, + optionContent: { + label: 'drilldown__optionContent', + flexGrow: 1 + }, + + // we use it in the index file + headerActionColor: componentTheme.headerActionColor + } +} + +export default generateStyle diff --git a/packages/ui-drilldown/src/exports/b.ts b/packages/ui-drilldown/src/exports/b.ts new file mode 100644 index 0000000000..13a5b84729 --- /dev/null +++ b/packages/ui-drilldown/src/exports/b.ts @@ -0,0 +1,44 @@ +/* + * 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 { Drilldown } from '../Drilldown/v2' +export { DrilldownGroup } from '../Drilldown/v2/DrilldownGroup' +export { DrilldownOption } from '../Drilldown/v2/DrilldownOption' +export { DrilldownPage } from '../Drilldown/v2/DrilldownPage' +export { DrilldownSeparator } from '../Drilldown/v2/DrilldownSeparator' + +export type { + DrilldownProps, + PageChild as DrilldownPageChild, + GroupChild as DrilldownGroupChild, + OptionChild as DrilldownOptionChild, + SeparatorChild as DrilldownSeparatorChild +} from '../Drilldown/v2/props' +export type { DrilldownGroupProps } from '../Drilldown/v2/DrilldownGroup/props' +export type { DrilldownOptionProps } from '../Drilldown/v2/DrilldownOption/props' +export type { + DrilldownPageProps, + PageChildren as DrilldownPageChildren +} from '../Drilldown/v2/DrilldownPage/props' +export type { DrilldownSeparatorProps } from '../Drilldown/v2/DrilldownSeparator/props' diff --git a/packages/ui-options/src/Options/v2/Item/theme.ts b/packages/ui-options/src/Options/v2/Item/theme.ts deleted file mode 100644 index 3a5d03c5e5..0000000000 --- a/packages/ui-options/src/Options/v2/Item/theme.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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 { Theme, ThemeSpecificStyle } from '@instructure/ui-themes' -import { OptionsItemTheme } from '@instructure/shared-types' - -/** - * Generates the theme object for the component from the theme and provided additional information - * @param {Object} theme The actual theme object. - * @return {Object} The final theme object with the overrides and component variables - */ -const generateComponentTheme = (theme: Theme): OptionsItemTheme => { - const { colors, typography, spacing, key: themeName } = theme - - const themeSpecificStyle: ThemeSpecificStyle = { - canvas: { - color: theme['ic-brand-font-color-dark'], - highlightedBackground: theme['ic-brand-primary'] - } - } - - const componentVariables: OptionsItemTheme = { - fontSize: typography?.fontSizeMedium, - fontFamily: typography?.fontFamily, - fontWeight: typography?.fontWeightNormal, - lineHeight: typography?.lineHeightCondensed, - fontWeightSelected: typography?.fontWeightNormal, - - color: colors?.contrasts?.grey125125, - background: colors?.contrasts?.white1010, - highlightedLabelColor: colors?.contrasts?.white1010, - highlightedBackground: colors?.contrasts?.blue4570, - selectedLabelColor: colors?.contrasts?.white1010, - selectedBackground: colors?.contrasts?.grey4570, - selectedHighlightedBackground: colors?.contrasts?.blue5782, - - padding: `${spacing?.xSmall} ${spacing?.small}`, - iconPadding: spacing?.small, - nestedPadding: spacing?.small, - - beforeLabelContentVOffset: '0.625rem', - afterLabelContentVOffset: '0.625rem', - - descriptionFontSize: typography.fontSizeSmall, - descriptionFontWeight: typography.fontWeightNormal, - descriptionLineHeight: typography.lineHeight, - descriptionPaddingStart: '0.25em', - descriptionColor: colors?.contrasts?.grey5782 - } - - return { - ...componentVariables, - ...themeSpecificStyle[themeName] - } -} - -export { generateComponentTheme as optionsItemThemeGenerator } -export default generateComponentTheme diff --git a/packages/ui-options/src/exports/b.ts b/packages/ui-options/src/exports/b.ts index 2e353ad2d6..ac7967e0d9 100644 --- a/packages/ui-options/src/exports/b.ts +++ b/packages/ui-options/src/exports/b.ts @@ -26,10 +26,6 @@ export { Options } from '../Options/v2' export { Item as OptionItem } from '../Options/v2/Item' export { Separator as OptionSeparator } from '../Options/v2/Separator' -export { optionsThemeGenerator } from '../Options/v2/theme' -export { optionsItemThemeGenerator } from '../Options/v2/Item/theme' -export { optionsSeparatorThemeGenerator } from '../Options/v2/Separator/theme' - export type { OptionsProps } from '../Options/v2/props' export type { OptionsItemProps, diff --git a/packages/ui/src/v11_7.ts b/packages/ui/src/v11_7.ts index 2bdad750ae..bcab35e498 100644 --- a/packages/ui/src/v11_7.ts +++ b/packages/ui/src/v11_7.ts @@ -265,10 +265,7 @@ export type { NumberInputProps } from '@instructure/ui-number-input/v11_7' export { Options, OptionSeparator, - OptionItem, - optionsThemeGenerator, - optionsItemThemeGenerator, - optionsSeparatorThemeGenerator + OptionItem } from '@instructure/ui-options/v11_7' export type { OptionsItemProps,