diff --git a/.storybook/utils.ts b/.storybook/utils.ts index 3a4cfcada53..e84dc4a816b 100644 --- a/.storybook/utils.ts +++ b/.storybook/utils.ts @@ -57,7 +57,7 @@ const replaceSubComps = { ListItemBase: ['ListItemStandard', 'ListItemCustom', 'ListItemGroup'], InputSuggestionItem: ['SuggestionItem', 'SuggestionItemGroup'], NotificationListItemBase: ['NotificationListItem'], - ToolbarItem: ['ToolbarSeparatorV2', 'ToolbarSpacerV2', 'ToolbarButton', 'ToolbarSelect', 'ToolbarSelectOption'], + ToolbarItem: ['ToolbarSeparator', 'ToolbarSpacer', 'ToolbarButton', 'ToolbarSelect', 'ToolbarSelectOption'], TreeItemBase: ['TreeItem', 'TreeItemCustom'], AvatarGroupItem: ['Avatar'], TableFeature: ['TableGrowing', 'TableSelection'] diff --git a/docs/MigrationGuide.mdx b/docs/MigrationGuide.mdx index 8abda876c14..d41902fc153 100644 --- a/docs/MigrationGuide.mdx +++ b/docs/MigrationGuide.mdx @@ -424,6 +424,22 @@ function MyComponent() { } ``` +### Toolbar + +The `Toolbar` component has been replaced with the UI5 Web Components Toolbar component (which was previously exported in this project as `ToolbarV2`). +The old `Toolbar` implementation has been moved to the `@ui5/webcomponents-react-compat` package with all its subcomponents: + +- `ToolbarSeparator` +- `ToolbarSpacer` +- `OverflowToolbarButton` +- `OverflowToolbarToggleButton` +- enum `ToolbarDesign` +- enum `ToolbarStyle` + +Although the old `Toolbar` is still available in the `@ui5/webcomponents-react-compat` package, we strongly recommend to migrate to the new `Toolbar` instead. + +As the new `Toolbar` is a completely different component, we can't offer a proper migration guide, so it's best to check the [Toolbar documentation](?path=/docs/layouts-floorplans-toolbar--docs) and update your implementation accordingly with the new components. + ## Components with API Changes ### ActionSheet diff --git a/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json b/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json index 19e09d0c14c..cec83b07dc6 100644 --- a/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json +++ b/packages/cli/src/scripts/codemod/transforms/v2/codemodConfig.json @@ -622,6 +622,7 @@ "TitleLevel": "@ui5/webcomponents/dist/types/TitleLevel.js", "ToastPlacement": "@ui5/webcomponents/dist/types/ToastPlacement.js", "ToolbarAlign": "@ui5/webcomponents/dist/types/ToolbarAlign.js", + "ToolbarDesign": "@ui5/webcomponents/dist/types/ToolbarDesign.js", "ToolbarItemOverflowBehavior": "@ui5/webcomponents/dist/types/ToolbarItemOverflowBehavior.js", "UploadState": "@ui5/webcomponents-fiori/dist/types/UploadState.js", "ValueState": "@ui5/webcomponents-base/dist/types/ValueState.js", diff --git a/packages/main/src/components/OverflowToolbarButton/index.tsx b/packages/compat/src/components/OverflowToolbarButton/index.tsx similarity index 92% rename from packages/main/src/components/OverflowToolbarButton/index.tsx rename to packages/compat/src/components/OverflowToolbarButton/index.tsx index 0eeb424bf39..1b35588bda8 100644 --- a/packages/main/src/components/OverflowToolbarButton/index.tsx +++ b/packages/compat/src/components/OverflowToolbarButton/index.tsx @@ -1,10 +1,10 @@ 'use client'; +import { Button } from '@ui5/webcomponents-react'; +import type { ButtonDomRef, ButtonPropTypes } from '@ui5/webcomponents-react'; import type { ReactNode } from 'react'; import { forwardRef } from 'react'; import { useOverflowPopoverContext } from '../../internal/OverflowPopoverContext.js'; -import type { ButtonDomRef, ButtonPropTypes } from '../../webComponents/index.js'; -import { Button } from '../../webComponents/index.js'; export interface OverflowToolbarButtonPropTypes extends Omit { /** diff --git a/packages/main/src/components/OverflowToolbarToggleButton/index.tsx b/packages/compat/src/components/OverflowToolbarToggleButton/index.tsx similarity index 95% rename from packages/main/src/components/OverflowToolbarToggleButton/index.tsx rename to packages/compat/src/components/OverflowToolbarToggleButton/index.tsx index 0c358370847..d4619f99277 100644 --- a/packages/main/src/components/OverflowToolbarToggleButton/index.tsx +++ b/packages/compat/src/components/OverflowToolbarToggleButton/index.tsx @@ -1,10 +1,10 @@ 'use client'; +import type { ToggleButtonDomRef, ToggleButtonPropTypes } from '@ui5/webcomponents-react'; +import { ToggleButton } from '@ui5/webcomponents-react'; import type { ReactNode } from 'react'; import { forwardRef } from 'react'; import { useOverflowPopoverContext } from '../../internal/OverflowPopoverContext.js'; -import type { ToggleButtonDomRef, ToggleButtonPropTypes } from '../../webComponents/index.js'; -import { ToggleButton } from '../../webComponents/index.js'; export interface OverflowToolbarToggleButtonPropTypes extends Omit { /** diff --git a/packages/compat/src/components/Toolbar/OverflowPopover.tsx b/packages/compat/src/components/Toolbar/OverflowPopover.tsx new file mode 100644 index 00000000000..019eff9d66d --- /dev/null +++ b/packages/compat/src/components/Toolbar/OverflowPopover.tsx @@ -0,0 +1,187 @@ +import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js'; +import PopoverPlacement from '@ui5/webcomponents/dist/types/PopoverPlacement.js'; +import PopupAccessibleRole from '@ui5/webcomponents/dist/types/PopupAccessibleRole.js'; +import iconOverflow from '@ui5/webcomponents-icons/dist/overflow.js'; +import type { + ButtonPropTypes, + PopoverDomRef, + ToggleButtonDomRef, + ToggleButtonPropTypes +} from '@ui5/webcomponents-react'; +import { Popover, ToggleButton } from '@ui5/webcomponents-react'; +import { useCanRenderPortal } from '@ui5/webcomponents-react/dist/internal/ssr.js'; +import { stopPropagation } from '@ui5/webcomponents-react/dist/internal/stopPropagation.js'; +import { getUi5TagWithSuffix } from '@ui5/webcomponents-react/dist/internal/utils.js'; +import { Device, useSyncRef } from '@ui5/webcomponents-react-base'; +import { clsx } from 'clsx'; +import type { Dispatch, FC, ReactElement, ReactNode, Ref, SetStateAction } from 'react'; +import { cloneElement, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { getOverflowPopoverContext } from '../../internal/OverflowPopoverContext.js'; +import type { ToolbarSeparatorPropTypes } from '../ToolbarSeparator/index.js'; +import type { ToolbarPropTypes } from './index.js'; + +interface OverflowPopoverProps { + lastVisibleIndex: number; + classes: Record; + children: ReactNode[]; + portalContainer: Element; + overflowContentRef: Ref; + numberOfAlwaysVisibleItems?: number; + showMoreText: string; + overflowPopoverRef?: Ref; + overflowButton?: ReactElement | ReactElement; + setIsMounted: Dispatch>; + a11yConfig?: ToolbarPropTypes['a11yConfig']; +} + +const isPhone = Device.isPhone(); + +export const OverflowPopover: FC = (props: OverflowPopoverProps) => { + const { + lastVisibleIndex, + classes, + children, + portalContainer, + overflowContentRef, + numberOfAlwaysVisibleItems, + showMoreText, + overflowButton, + overflowPopoverRef, + setIsMounted, + a11yConfig + } = props; + const [pressed, setPressed] = useState(false); + const toggleBtnRef = useRef(null); + const [componentRef, popoverRef] = useSyncRef(overflowPopoverRef); + + useEffect(() => { + setIsMounted(true); + return () => { + setIsMounted(false); + }; + }, []); + + const handleToggleButtonClick = (e) => { + e.stopPropagation(); + setPressed((prev) => { + if (!prev) { + if (popoverRef.current) { + popoverRef.current.opener = e.target; + } + return true; + } + return false; + }); + }; + + const handleBeforeOpen = () => { + if (toggleBtnRef.current) { + toggleBtnRef.current.accessibilityAttributes = { expanded: true, hasPopup: 'menu' }; + } + }; + const handleAfterOpen = () => { + setPressed(true); + }; + + const handleClose = (e) => { + if (toggleBtnRef.current) { + toggleBtnRef.current.accessibilityAttributes = { expanded: false, hasPopup: 'menu' }; + } + stopPropagation(e); + setPressed(false); + }; + + useEffect(() => { + const tagName = getUi5TagWithSuffix('ui5-toggle-button'); + void customElements.whenDefined(tagName).then(() => { + if (toggleBtnRef.current) { + toggleBtnRef.current.accessibilityAttributes = { expanded: pressed, hasPopup: 'menu' }; + } + }); + }, []); + + const clonedOverflowButtonClick = (e) => { + if (typeof overflowButton?.props?.onClick === 'function') { + overflowButton.props.onClick(e); + } + if (!e.defaultPrevented) { + handleToggleButtonClick(e); + } + }; + + const canRenderPortal = useCanRenderPortal(); + + const accessibleRole = (() => { + if (a11yConfig?.overflowPopover?.contentRole) { + return PopupAccessibleRole.None; + } + return a11yConfig?.overflowPopover?.role; + })(); + + const OverflowPopoverContextProvider = getOverflowPopoverContext().Provider; + + return ( + + {overflowButton ? ( + cloneElement(overflowButton, { onClick: clonedOverflowButtonClick }) + ) : ( + + )} + {canRenderPortal && + createPortal( + +
+ {children.map((item, index) => { + if (index > lastVisibleIndex && index > numberOfAlwaysVisibleItems - 1) { + // @ts-expect-error: if props is not defined, it doesn't have an id (is not a ReactElement) + if (item?.props?.id) { + // @ts-expect-error: item is ReactElement + return cloneElement(item, { id: `${item.props.id}-overflow` }); + } + // @ts-expect-error: if type is not defined, it's not a spacer + if (item.type?.displayName === 'ToolbarSeparator') { + return cloneElement(item as ReactElement, { + style: { + height: '0.0625rem', + margin: '0.375rem 0.1875rem', + width: '100%' + } + }); + } + return item; + } + return null; + })} +
+
, + portalContainer ?? document.body + )} +
+ ); +}; diff --git a/packages/main/src/components/Toolbar/Toolbar.cy.tsx b/packages/compat/src/components/Toolbar/Toolbar.cy.tsx similarity index 96% rename from packages/main/src/components/Toolbar/Toolbar.cy.tsx rename to packages/compat/src/components/Toolbar/Toolbar.cy.tsx index d0b9d5d876d..97a4a482c40 100644 --- a/packages/main/src/components/Toolbar/Toolbar.cy.tsx +++ b/packages/compat/src/components/Toolbar/Toolbar.cy.tsx @@ -2,22 +2,18 @@ import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js'; import PopupAccessibleRole from '@ui5/webcomponents/dist/types/PopupAccessibleRole.js'; import { setTheme } from '@ui5/webcomponents-base/dist/config/Theme.js'; import menu2Icon from '@ui5/webcomponents-icons/dist/menu2.js'; +import type { PopoverDomRef } from '@ui5/webcomponents-react'; +import { Button, Input, Text, ToggleButton } from '@ui5/webcomponents-react'; import { ThemingParameters } from '@ui5/webcomponents-react-base'; import { useRef, useState } from 'react'; -import type { PopoverDomRef, ToolbarPropTypes } from '../..'; -import { - Button, - Input, - OverflowToolbarButton, - Text, - Toolbar, - ToggleButton, - ToolbarSeparator, - ToolbarSpacer, - ToolbarStyle, - OverflowToolbarToggleButton -} from '../..'; -import { ToolbarDesign } from '../../enums/index.js'; +import { ToolbarDesign } from '../../enums/ToolbarDesign.js'; +import { ToolbarStyle } from '../../enums/ToolbarStyle.js'; +import { OverflowToolbarButton } from '../OverflowToolbarButton/index.js'; +import { OverflowToolbarToggleButton } from '../OverflowToolbarToggleButton/index.js'; +import { ToolbarSeparator } from '../ToolbarSeparator/index.js'; +import { ToolbarSpacer } from '../ToolbarSpacer/index.js'; +import type { ToolbarPropTypes } from './index.js'; +import { Toolbar } from './index.js'; import { cssVarToRgb, cypressPassThroughTestsFactory, mountWithCustomTagName } from '@/cypress/support/utils'; interface PropTypes { diff --git a/packages/main/src/components/Toolbar/Toolbar.mdx b/packages/compat/src/components/Toolbar/Toolbar.mdx similarity index 92% rename from packages/main/src/components/Toolbar/Toolbar.mdx rename to packages/compat/src/components/Toolbar/Toolbar.mdx index 125e8b1646a..d89496227c7 100644 --- a/packages/main/src/components/Toolbar/Toolbar.mdx +++ b/packages/compat/src/components/Toolbar/Toolbar.mdx @@ -1,15 +1,10 @@ import { ArgTypesWithNote, ControlsWithNote, DocsHeader, Footer } from '@sb/components'; import { Canvas, Description, Markdown, Meta } from '@storybook/blocks'; import MessageStripDesign from '@ui5/webcomponents/dist/types/MessageStripDesign.js'; +import { MessageStrip } from '@ui5/webcomponents-react'; import * as ComponentStories from './Toolbar.stories'; import SubcomponentsSection from '@sb/docs/SubcomponentsSection.md?raw'; -import { - OverflowToolbarButton, - OverflowToolbarToggleButton, - MessageStrip, - ToolbarSpacer, - ToolbarSeparator -} from '../..'; +import { OverflowToolbarButton, OverflowToolbarToggleButton, ToolbarSpacer, ToolbarSeparator } from '../..'; @@ -17,18 +12,6 @@ import { subComponents={['OverflowToolbarButton', 'OverflowToolbarToggleButton', 'ToolbarSpacer', 'ToolbarSeparator']} /> - - This component may be replaced by the ui5-toolbar web-component (currently available as{' '} - ToolbarV2) with our next major release. If you only need to pass components supported by{' '} - ToolbarV2 then please consider using ToolbarV2 instead of this component. - - } -/> - ## Example @@ -140,6 +123,8 @@ You can achieve that either by leveraging the `onOverflowChange` event and retri
+{' '} + Set opener ID via click handler ```jsx @@ -176,9 +161,11 @@ const ToolbarComponent = () => { ```
- +
+{' '} + Set opener ID via onOverflowChange handler ```jsx diff --git a/packages/compat/src/components/Toolbar/Toolbar.module.css b/packages/compat/src/components/Toolbar/Toolbar.module.css new file mode 100644 index 00000000000..80740722b48 --- /dev/null +++ b/packages/compat/src/components/Toolbar/Toolbar.module.css @@ -0,0 +1,137 @@ +.outerContainer { + box-sizing: border-box; + width: 100%; + max-width: 100%; + height: var(--_ui5wcr-ToolbarHeight); + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + border-block-end: 0.0625rem solid var(--sapGroup_TitleBorderColor); + overflow: hidden; +} + +.hasOverflow { + .toolbar { + max-width: calc(100% - 44px); + } +} + +.clear { + border-block-end: none; +} + +.active { + cursor: pointer; + + &:hover { + background-color: var(--sapList_Hover_Background); + } + + &:focus { + outline: var(--_ui5wcr_Toolbar_FocusOutline); + outline-offset: -0.1875rem; + box-shadow: var(--_ui5wcr_Toolbar_FocusShadow); + } + + &:active { + background-color: var(--sapActiveColor); + } +} + +.info { + height: 2rem; + background-color: var(--sapInfobar_NonInteractive_Background); + color: var(--sapList_TextColor); + + &.active { + outline-color: var(--sapContent_ContrastFocusColor); + background-color: var(--sapInfobar_Background); + color: var(--sapInfobar_TextColor); + + &:hover { + background-color: var(--sapInfobar_Hover_Background); + } + + &:active { + background-color: var(--sapInfobar_Active_Background); + } + } +} + +.solid { + background-color: var(--sapToolbar_Background); +} + +.transparent { + background-color: transparent; +} + +.toolbar { + display: flex; + align-items: center; + width: 100%; + max-width: 100%; + + > :first-child:not(:global(.spacer)) { + margin-inline: 0.5rem 0.25rem; + } + + > :last-child:not(:global(.spacer)) { + margin-inline: 0.25rem 0.5rem; + } + + > *:not(:first-child):not(:last-child):not(:global(.spacer)) { + margin-inline: 0.25rem; + } +} + +.overflowButtonContainer { + display: flex; + margin-inline: 0 0.5rem; +} + +.popover { + &[ui5-popover]::part(content) { + padding: 0; + } +} + +.popoverPhone { + width: calc(100% - 10px); + max-width: calc(100% - 10px); + inset-inline-start: 5px !important; +} + +.popoverContent { + padding: var(--_ui5wcr-ToolbarPopoverContentPadding); + display: flex; + flex-direction: column; + + > [ui5-toggle-button], + > [ui5-button] { + margin-block: 0.25rem; + } + + > [ui5-button]::part(button), + > [ui5-toggle-button]::part(button) { + justify-content: flex-start; + } + + > [ui5-button][icon-only]::part(button), + > [ui5-toggle-button][icon-only]::part(button) { + padding: revert; + } + + &:last-child { + margin-block-end: 0; + } + + &:first-child { + margin-block-start: 0; + } +} + +.childContainer { + display: flex; +} diff --git a/packages/main/src/components/Toolbar/Toolbar.stories.tsx b/packages/compat/src/components/Toolbar/Toolbar.stories.tsx similarity index 89% rename from packages/main/src/components/Toolbar/Toolbar.stories.tsx rename to packages/compat/src/components/Toolbar/Toolbar.stories.tsx index ccabda56677..850883976bf 100644 --- a/packages/main/src/components/Toolbar/Toolbar.stories.tsx +++ b/packages/compat/src/components/Toolbar/Toolbar.stories.tsx @@ -4,27 +4,30 @@ import downloadIcon from '@ui5/webcomponents-icons/dist/download.js'; import editIcon from '@ui5/webcomponents-icons/dist/edit.js'; import favoriteIcon from '@ui5/webcomponents-icons/dist/favorite.js'; import settingsIcon from '@ui5/webcomponents-icons/dist/settings.js'; +import { + Button, + DatePicker, + Icon, + Input, + Menu, + MenuItem, + Select, + Slider, + Switch, + Text, + ToggleButton +} from '@ui5/webcomponents-react'; import { useState } from 'react'; -import { ToolbarDesign, ToolbarStyle } from '../../enums/index.js'; -import { Button } from '../../webComponents/Button/index.js'; -import { DatePicker } from '../../webComponents/DatePicker/index.js'; -import { Icon } from '../../webComponents/Icon/index.js'; -import { Input } from '../../webComponents/Input/index.js'; -import { Menu } from '../../webComponents/Menu/index.js'; -import { MenuItem } from '../../webComponents/MenuItem/index.js'; -import { Select } from '../../webComponents/Select/index.js'; -import { Slider } from '../../webComponents/Slider/index.js'; -import { Switch } from '../../webComponents/Switch/index.js'; -import { Text } from '../../webComponents/Text/index.js'; -import { ToggleButton } from '../../webComponents/ToggleButton/index.js'; -import { OverflowToolbarButton } from '../OverflowToolbarButton'; -import { OverflowToolbarToggleButton } from '../OverflowToolbarToggleButton'; +import { ToolbarDesign } from '../../enums/ToolbarDesign.js'; +import { ToolbarStyle } from '../../enums/ToolbarStyle.js'; +import { OverflowToolbarButton } from '../OverflowToolbarButton/index.js'; +import { OverflowToolbarToggleButton } from '../OverflowToolbarToggleButton/index.js'; import { ToolbarSeparator } from '../ToolbarSeparator/index.js'; import { ToolbarSpacer } from '../ToolbarSpacer/index.js'; import { Toolbar } from './index.js'; const meta = { - title: 'Layouts & Floorplans / Toolbar', + title: 'Toolbar', component: Toolbar, argTypes: { children: { control: { disable: true } }, diff --git a/packages/compat/src/components/Toolbar/index.tsx b/packages/compat/src/components/Toolbar/index.tsx new file mode 100644 index 00000000000..f443218a1cd --- /dev/null +++ b/packages/compat/src/components/Toolbar/index.tsx @@ -0,0 +1,428 @@ +'use client'; + +import type PopupAccessibleRole from '@ui5/webcomponents/dist/types/PopupAccessibleRole.js'; +import type { ButtonPropTypes, CommonProps, PopoverDomRef, ToggleButtonPropTypes } from '@ui5/webcomponents-react'; +import { SHOW_MORE } from '@ui5/webcomponents-react/dist/i18n/i18n-defaults.js'; +import { flattenFragments } from '@ui5/webcomponents-react/dist/internal/utils.js'; +import { + debounce, + useI18nBundle, + useIsomorphicLayoutEffect, + useStylesheet, + useSyncRef +} from '@ui5/webcomponents-react-base'; +import { clsx } from 'clsx'; +import type { ElementType, HTMLAttributes, ReactElement, ReactNode, Ref, RefObject } from 'react'; +import { + Children, + cloneElement, + createRef, + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; +import { ToolbarDesign } from '../../enums/ToolbarDesign.js'; +import { ToolbarStyle } from '../../enums/ToolbarStyle.js'; +import { OverflowPopover } from './OverflowPopover.js'; +import { classNames, styleData } from './Toolbar.module.css.js'; + +export interface ToolbarPropTypes extends Omit { + /** + * Defines the content of the `Toolbar`. + * + * __Note:__ Although this prop accepts all `ReactNode` types, it is strongly recommended to not pass `string`, `number` or a React Portal to it. + * + * __Note:__ Only components displayed inside the Toolbar are supported as children, i.e. elements positioned outside the normal flow of the document (like dialogs or popovers), can cause undesired behavior. + */ + children?: ReactNode | ReactNode[]; + /** + * Defines the button shown when the `Toolbar` goes into overflow. + * + * __Note:__ It is strongly recommended that you only use `ToggleButton` in icon only mode in order to preserve the intended design. + * + * __Note:__ Per default a `ToggleButton` with the `"overflow"` icon and all necessary a11y attributes will be rendered. + */ + overflowButton?: ReactElement | ReactElement; + /** + * Defines the visual style of the `Toolbar`. + * + * __Note:__ The visual styles are theme-dependent. + */ + toolbarStyle?: ToolbarStyle | keyof typeof ToolbarStyle; + /** + * Defines the `Toolbar` design.
+ * Note: Design settings are theme-dependent. + */ + design?: ToolbarDesign | keyof typeof ToolbarDesign; + /** + * Indicates that the whole `Toolbar` is clickable. The Press event is fired only if `active` is set to true. + */ + active?: boolean; + /** + * Sets the components outer HTML tag. + * + * __Note:__ For TypeScript the types of `ref` are bound to the default tag name, if you change it you are responsible to set the respective types yourself. + */ + as?: keyof HTMLElementTagNameMap; + /** + * Defines where modals are rendered into via `React.createPortal`. + * + * You can find out more about this [here](https://sap.github.io/ui5-webcomponents-react/?path=/docs/knowledge-base-working-with-portals--page). + * + * Defaults to: `document.body` + */ + portalContainer?: Element; + /** + * Defines the number of items inside the toolbar which should always be visible. + * _E.g.: `numberOfAlwaysVisibleItems={3}` would always show the first three items, no matter the size of the toolbar._ + * + * __Note__: To preserve the intended design, it's not recommended to overwrite the `min-width` when using this prop. + */ + numberOfAlwaysVisibleItems?: number; + /** + * Exposes the React Ref of the overflow popover. + * This can be useful, for example, when wanting to close the popover on click or selection of a child element. + */ + overflowPopoverRef?: Ref; + /** + * Defines internally used a11y properties. + * + * __Note:__ When setting `contentRole` of the `overflowPopover`, the `role` is set to `"None"`. + */ + a11yConfig?: { + overflowPopover?: { + /** + * Defines the `accessibleRole` of the overflow `Popover`. + */ + role?: PopupAccessibleRole | keyof typeof PopupAccessibleRole; + /** + * Defines the `role` of the content div inside the overflow `Popover`. + * + * __Note:__ When setting `contentRole`, the `role` is set to `"None"`. + */ + contentRole?: HTMLAttributes['role']; + }; + }; + /** + * Fired if the `active` prop is set to true and the user clicks or presses Enter/Space on the `Toolbar`. + */ + onClick?: (event: CustomEvent) => void; + /** + * Fired when the content of the overflow popover has changed. + */ + onOverflowChange?: (event: { + toolbarElements: HTMLElement[]; + overflowElements: HTMLCollection | undefined; + target: HTMLElement; + }) => void; +} + +function getSpacerWidths(ref) { + if (!ref) { + return 0; + } + + let spacerWidths = 0; + if (ref.dataset.componentName === 'ToolbarSpacer') { + spacerWidths += ref.offsetWidth; + } + return spacerWidths + getSpacerWidths(ref.previousElementSibling); +} + +const OVERFLOW_BUTTON_WIDTH = 36 + 8 + 8; // width + padding end + spacing start + +/** + * + * __Note:__ The `Toolbar` component may be replaced by the `ui5-toolbar` web-component (currently available as `ToolbarV2`) with our next major release. If you only need to pass components supported by `ToolbarV2` then please consider using `ToolbarV2` instead of this component. + * + * ___ + * + * Horizontal container most commonly used to display buttons, labels, selects and various other input controls. + * + * The content of the `Toolbar` moves into the overflow area from right to left when the available space is not enough in the visible area of the container. + * It can be accessed by the user through the overflow button that opens it in a popover. + * + * __Note:__ The overflow popover is mounted only when the overflow button is displayed, i.e., any child component of the popover will be remounted, when moved into it. + * + * __Note:__ To prevent duplicate child `id`s in the DOM, all child `id`s get an `-overflow` suffix. This is especially important when popovers are opened by id. + */ +const Toolbar = forwardRef((props, ref) => { + const { + children, + toolbarStyle = ToolbarStyle.Standard, + design = ToolbarDesign.Auto, + active = false, + style, + className, + onClick, + slot, + as = 'div', + portalContainer, + numberOfAlwaysVisibleItems = 0, + onOverflowChange, + overflowPopoverRef, + overflowButton, + a11yConfig, + ...rest + } = props; + + useStylesheet(styleData, Toolbar.displayName); + const [componentRef, outerContainer] = useSyncRef(ref); + const controlMetaData = useRef([]); + const [lastVisibleIndex, setLastVisibleIndex] = useState(null); + const [isPopoverMounted, setIsPopoverMounted] = useState(false); + const contentRef = useRef(null); + const overflowContentRef = useRef(null); + const overflowBtnRef = useRef(null); + const [minWidth, setMinWidth] = useState('0'); + + const i18nBundle = useI18nBundle('@ui5/webcomponents-react'); + const showMoreText = i18nBundle.getText(SHOW_MORE); + + const toolbarClasses = clsx( + classNames.outerContainer, + toolbarStyle === ToolbarStyle.Clear && classNames.clear, + active && classNames.active, + design === ToolbarDesign.Solid && classNames.solid, + design === ToolbarDesign.Transparent && classNames.transparent, + design === ToolbarDesign.Info && classNames.info, + className + ); + const flatChildren = useMemo(() => { + return flattenFragments(children, 10); + }, [children]); + + const childrenWithRef = useMemo(() => { + controlMetaData.current = []; + + return flatChildren.map((item, index) => { + const itemRef: RefObject = createRef(); + // @ts-expect-error: if type is not defined, it's not a spacer + const isSpacer = item?.type?.displayName === 'ToolbarSpacer'; + controlMetaData.current.push({ + ref: itemRef, + isSpacer + }); + if (isSpacer) { + return item; + } + return ( +
+ {item} +
+ ); + }); + }, [flatChildren, controlMetaData, classNames.childContainer]); + + const overflowNeeded = + (lastVisibleIndex || lastVisibleIndex === 0) && + Children.count(childrenWithRef) !== lastVisibleIndex + 1 && + numberOfAlwaysVisibleItems < Children.count(flatChildren); + + useEffect(() => { + let lastElementResizeObserver; + const lastElement = contentRef.current.children[numberOfAlwaysVisibleItems - 1]; + const debouncedObserverFn = debounce(() => { + const spacerWidth = getSpacerWidths(lastElement); + const isRtl = outerContainer.current?.matches(':dir(rtl)'); + if (isRtl) { + setMinWidth( + `${lastElement.offsetParent.offsetWidth - lastElement.offsetLeft + OVERFLOW_BUTTON_WIDTH - spacerWidth}px` + ); + } else { + setMinWidth( + `${ + lastElement.offsetLeft + lastElement.getBoundingClientRect().width + OVERFLOW_BUTTON_WIDTH - spacerWidth + }px` + ); + } + }, 200); + if (numberOfAlwaysVisibleItems && overflowNeeded && lastElement) { + lastElementResizeObserver = new ResizeObserver(debouncedObserverFn); + lastElementResizeObserver.observe(contentRef.current); + } + return () => { + debouncedObserverFn.cancel(); + lastElementResizeObserver?.disconnect(); + }; + }, [numberOfAlwaysVisibleItems, overflowNeeded]); + + const requestAnimationFrameRef = useRef(undefined); + const calculateVisibleItems = useCallback(() => { + requestAnimationFrameRef.current = requestAnimationFrame(() => { + if (!outerContainer.current) return; + const availableWidth = outerContainer.current.getBoundingClientRect().width; + let consumedWidth = 0; + let lastIndex = null; + if (availableWidth - OVERFLOW_BUTTON_WIDTH <= 0) { + lastIndex = -1; + } else { + let prevItemsAreSpacer = true; + controlMetaData.current.forEach((item, index) => { + const currentMeta = controlMetaData.current[index] as { ref: RefObject }; + if (currentMeta && currentMeta.ref && currentMeta.ref.current) { + let nextWidth = currentMeta.ref.current.getBoundingClientRect().width; + nextWidth += index === 0 || index === controlMetaData.current.length - 1 ? 4 : 8; // first & last element = padding: 4px + if (index === controlMetaData.current.length - 1) { + if (consumedWidth + nextWidth <= availableWidth - 8) { + lastIndex = index; + } else if (index === 0 || prevItemsAreSpacer) { + lastIndex = index - 1; + } + } else { + if (consumedWidth + nextWidth <= availableWidth - OVERFLOW_BUTTON_WIDTH) { + lastIndex = index; + } + if ( + consumedWidth < availableWidth - OVERFLOW_BUTTON_WIDTH && + consumedWidth + nextWidth >= availableWidth - OVERFLOW_BUTTON_WIDTH + ) { + lastIndex = index - 1; + } + } + if (prevItemsAreSpacer && !item.isSpacer) { + prevItemsAreSpacer = false; + } + consumedWidth += nextWidth; + } + }); + } + setLastVisibleIndex(lastIndex); + }); + }, [overflowNeeded]); + + useEffect(() => { + const observer = new ResizeObserver(calculateVisibleItems); + + if (outerContainer.current) { + observer.observe(outerContainer.current); + } + return () => { + cancelAnimationFrame(requestAnimationFrameRef.current); + observer.disconnect(); + }; + }, [calculateVisibleItems]); + + useEffect(() => { + if (Children.count(children) > 0) { + calculateVisibleItems(); + } + }, [children]); + + useIsomorphicLayoutEffect(() => { + calculateVisibleItems(); + }, [calculateVisibleItems]); + + const handleToolbarClick = (e) => { + if (active && typeof onClick === 'function') { + const isSpaceEnterDown = e.type === 'keydown' && (e.code === 'Enter' || e.code === 'Space'); + if (isSpaceEnterDown && e.target !== e.currentTarget) { + return; + } + if (e.type === 'click' || isSpaceEnterDown) { + if (isSpaceEnterDown) { + e.preventDefault(); + } + onClick(e); + } + } + }; + + const prevChildren = useRef(flatChildren); + const debouncedOverflowChange = useRef(undefined); + + useEffect(() => { + if (typeof onOverflowChange === 'function') { + debouncedOverflowChange.current = debounce(onOverflowChange, 60); + } + }, [onOverflowChange]); + + useEffect(() => { + const haveChildrenChanged = prevChildren.current.length !== flatChildren.length; + if ((lastVisibleIndex !== null || haveChildrenChanged) && typeof debouncedOverflowChange.current === 'function') { + prevChildren.current = flatChildren; + const toolbarChildren = contentRef.current?.children; + let toolbarElements = []; + let overflowElements; + if (isPopoverMounted) { + overflowElements = overflowContentRef.current?.children; + } + if (toolbarChildren?.length > 0) { + toolbarElements = Array.from(toolbarChildren).filter((item, index) => index <= lastVisibleIndex); + } + debouncedOverflowChange.current({ + toolbarElements, + overflowElements, + target: outerContainer.current + }); + } + return () => { + if (debouncedOverflowChange.current) { + debouncedOverflowChange.current.cancel(); + } + }; + }, [lastVisibleIndex, flatChildren.length, isPopoverMounted]); + + const CustomTag = as as ElementType; + const styleWithMinWidth = minWidth !== '0' ? { minWidth, ...style } : style; + return ( + +
+ {overflowNeeded && + Children.map(childrenWithRef, (item, index) => { + if (index >= lastVisibleIndex + 1 && index > numberOfAlwaysVisibleItems - 1) { + return cloneElement(item as ReactElement, { + style: { visibility: 'hidden', position: 'absolute', pointerEvents: 'none' } + }); + } + return item; + })} + {!overflowNeeded && childrenWithRef} +
+ {overflowNeeded && ( +
+ + {flatChildren} + +
+ )} +
+ ); +}); + +Toolbar.displayName = 'Toolbar'; +export { Toolbar }; diff --git a/packages/compat/src/components/ToolbarSeparator/ToolbarSeparator.module.css b/packages/compat/src/components/ToolbarSeparator/ToolbarSeparator.module.css new file mode 100644 index 00000000000..6c975fcd93b --- /dev/null +++ b/packages/compat/src/components/ToolbarSeparator/ToolbarSeparator.module.css @@ -0,0 +1,5 @@ +.separator { + width: 0.0625rem; + height: var(--_ui5wcr-ToolbarSeparatorHeight); + background: var(--sapToolbar_SeparatorColor); +} diff --git a/packages/compat/src/components/ToolbarSeparator/index.tsx b/packages/compat/src/components/ToolbarSeparator/index.tsx new file mode 100644 index 00000000000..847af73987b --- /dev/null +++ b/packages/compat/src/components/ToolbarSeparator/index.tsx @@ -0,0 +1,25 @@ +'use client'; + +import type { CommonProps } from '@ui5/webcomponents-react'; +import { useStylesheet } from '@ui5/webcomponents-react-base'; +import { clsx } from 'clsx'; +import { forwardRef } from 'react'; +import { classNames, styleData } from './ToolbarSeparator.module.css.js'; + +export type ToolbarSeparatorPropTypes = CommonProps; + +/** + * Creates a visual separator between the preceding and succeeding `Toolbar` item. + * + * __Note:__ This component is only compatible with the `Toolbar` component from the `@ui5/webcomponents-react-compat` package. + */ +const ToolbarSeparator = forwardRef((props, ref) => { + const { style, className, ...rest } = props; + + useStylesheet(styleData, ToolbarSeparator.displayName); + const separatorClasses = clsx(classNames.separator, className); + + return
; +}); +ToolbarSeparator.displayName = 'ToolbarSeparator'; +export { ToolbarSeparator }; diff --git a/packages/compat/src/components/ToolbarSpacer/index.tsx b/packages/compat/src/components/ToolbarSpacer/index.tsx new file mode 100644 index 00000000000..edcb79a7b49 --- /dev/null +++ b/packages/compat/src/components/ToolbarSpacer/index.tsx @@ -0,0 +1,15 @@ +import type { CommonProps } from '@ui5/webcomponents-react'; +import { forwardRef } from 'react'; + +export type ToolbarSpacerPropTypes = CommonProps; +/** + * Adds horizontal space between the items used within a `Toolbar`. + * + * __Note:__ This component is only compatible with the `Toolbar` component from the `@ui5/webcomponents-react-compat` package. + */ +const ToolbarSpacer = forwardRef((props, ref) => { + return ; +}); + +ToolbarSpacer.displayName = 'ToolbarSpacer'; +export { ToolbarSpacer }; diff --git a/packages/main/src/enums/ToolbarDesign.ts b/packages/compat/src/enums/ToolbarDesign.ts similarity index 100% rename from packages/main/src/enums/ToolbarDesign.ts rename to packages/compat/src/enums/ToolbarDesign.ts diff --git a/packages/main/src/enums/ToolbarStyle.ts b/packages/compat/src/enums/ToolbarStyle.ts similarity index 100% rename from packages/main/src/enums/ToolbarStyle.ts rename to packages/compat/src/enums/ToolbarStyle.ts diff --git a/packages/compat/src/index.ts b/packages/compat/src/index.ts index eddc7052577..dae78941495 100644 --- a/packages/compat/src/index.ts +++ b/packages/compat/src/index.ts @@ -1,8 +1,15 @@ +export * from './components/Loader/index.js'; +export * from './components/OverflowToolbarButton/index.js'; +export * from './components/OverflowToolbarToggleButton/index.js'; export * from './components/Table/index.js'; export * from './components/TableCell/index.js'; export * from './components/TableColumn/index.js'; export * from './components/TableGroupRow/index.js'; export * from './components/TableRow/index.js'; -export * from './components/Loader/index.js'; +export * from './components/Toolbar/index.js'; +export * from './components/ToolbarSeparator/index.js'; +export * from './components/ToolbarSpacer/index.js'; export { LoaderType } from './enums/LoaderType.js'; +export { ToolbarDesign } from './enums/ToolbarDesign.js'; +export { ToolbarStyle } from './enums/ToolbarStyle.js'; diff --git a/packages/compat/src/internal/OverflowPopoverContext.ts b/packages/compat/src/internal/OverflowPopoverContext.ts new file mode 100644 index 00000000000..53e52bdfe34 --- /dev/null +++ b/packages/compat/src/internal/OverflowPopoverContext.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; + +const SYMBOL = Symbol.for('@ui5/webcomponents-react/OverflowPopoverContext'); + +interface IOverflowPopoverContext { + inPopover: boolean; +} + +const OverflowPopoverContext = createContext({ inPopover: false }); + +export function getOverflowPopoverContext(): typeof OverflowPopoverContext { + globalThis[SYMBOL] ??= OverflowPopoverContext; + return globalThis[SYMBOL]; +} + +export function useOverflowPopoverContext() { + return useContext(getOverflowPopoverContext()); +} diff --git a/packages/main/src/components/FilterBar/FilterDialog.tsx b/packages/main/src/components/FilterBar/FilterDialog.tsx index 584a3e4a52e..94bd64f1b89 100644 --- a/packages/main/src/components/FilterBar/FilterDialog.tsx +++ b/packages/main/src/components/FilterBar/FilterDialog.tsx @@ -9,13 +9,7 @@ import { enrichEventWithDetails, useI18nBundle, useIsomorphicId, useStylesheet } import type { Dispatch, ReactElement, RefObject, SetStateAction } from 'react'; import { Children, cloneElement, useEffect, useReducer, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { - FlexBoxDirection, - FlexBoxJustifyContent, - MessageBoxAction, - MessageBoxType, - ToolbarStyle -} from '../../enums/index.js'; +import { FlexBoxDirection, FlexBoxJustifyContent, MessageBoxAction, MessageBoxType } from '../../enums/index.js'; import { ACTIVE, ALL, @@ -525,7 +519,7 @@ export const FilterDialog = (props: FilterDialogPropTypes) => { } > - +