diff --git a/apps/web-twig-demo/assets/scripts/tooltip-advanced-usage.ts b/apps/web-twig-demo/assets/scripts/tooltip-advanced-usage.ts new file mode 100644 index 0000000000..731c037ab0 --- /dev/null +++ b/apps/web-twig-demo/assets/scripts/tooltip-advanced-usage.ts @@ -0,0 +1,40 @@ +import { Tooltip } from '@lmc-eu/spirit-web/src/js/index.esm'; + +const checkboxFlip = document.getElementById('my-advanced-flip') as HTMLInputElement; +const checkboxFlipCrossAxis = document.getElementById('my-advanced-flip-cross-axis') as HTMLInputElement; +const checkboxShift = document.getElementById('my-advanced-shift') as HTMLInputElement; +const checkboxSize = document.getElementById('my-advanced-size') as HTMLInputElement; +const select = document.getElementById('my-advanced-select') as HTMLSelectElement; +const selectFallback = document.getElementById('my-advanced-select-fallback') as HTMLSelectElement; + +const tooltip = Tooltip.getOrCreateInstance(document.getElementById('my-advanced-tooltip')); + +tooltip.show(); + +checkboxFlip.addEventListener('change', () => tooltip.updateConfig({ enableFlipping: checkboxFlip.checked })); + +checkboxFlipCrossAxis.addEventListener('change', () => { + tooltip.updateConfig({ enableFlippingCrossAxis: checkboxFlipCrossAxis.checked }); +}); + +checkboxShift.addEventListener('change', () => { + tooltip.updateConfig({ enableShifting: checkboxShift.checked }); +}); + +checkboxSize.addEventListener('change', () => { + tooltip.updateConfig({ enableSizing: checkboxSize.checked }); +}); + +select.addEventListener('change', () => { + tooltip.updateConfig({ placement: select.value }); +}); + +selectFallback.addEventListener('change', () => { + tooltip.updateConfig({ flipFallbackPlacements: selectFallback.value }); +}); + +const viewport = document.getElementById('my-advanced-viewport'); +const content = document.getElementById('my-advanced-content'); + +viewport.scrollLeft = (content.offsetWidth - viewport.offsetWidth) / 2; +viewport.scrollTop = (content.offsetHeight - viewport.offsetHeight) / 2; diff --git a/apps/web-twig-demo/assets/scripts/tooltip-dismissible-via-js.ts b/apps/web-twig-demo/assets/scripts/tooltip-dismissible-via-js.ts index c2f8014bc5..879d13cb58 100644 --- a/apps/web-twig-demo/assets/scripts/tooltip-dismissible-via-js.ts +++ b/apps/web-twig-demo/assets/scripts/tooltip-dismissible-via-js.ts @@ -1,4 +1,4 @@ -import Tooltip from '@lmc-eu/spirit-web/src/js/Tooltip'; +import { Tooltip } from '@lmc-eu/spirit-web/src/js/index.esm'; const myTooltipEl = document.getElementById('my-dismissible-tooltip2'); const myTooltip = new Tooltip(myTooltipEl); diff --git a/apps/web-twig-demo/package.json b/apps/web-twig-demo/package.json index ab2a094284..7cb15da035 100644 --- a/apps/web-twig-demo/package.json +++ b/apps/web-twig-demo/package.json @@ -8,10 +8,11 @@ "@babel/core": "7.23.7", "@babel/preset-env": "7.23.7", "@csstools/normalize.css": "12.1.1", + "@floating-ui/dom": "^1.5.4", "@hotwired/stimulus": "3.2.2", - "@lmc-eu/spirit-web": "1.6.0", - "@lmc-eu/spirit-form-validations": "1.0.9", "@lmc-eu/spirit-demo": "0.1.0", + "@lmc-eu/spirit-form-validations": "1.0.9", + "@lmc-eu/spirit-web": "1.6.0", "@symfony/stimulus-bridge": "3.2.2", "@symfony/webpack-encore": "4.5.0", "core-js": "3.35.0", diff --git a/apps/web-twig-demo/webpack.config.js b/apps/web-twig-demo/webpack.config.js index 3d74ef8377..7cc9dc1197 100644 --- a/apps/web-twig-demo/webpack.config.js +++ b/apps/web-twig-demo/webpack.config.js @@ -1,3 +1,4 @@ +const path = require('path'); // eslint-disable-next-line import/no-unresolved const Encore = require('@symfony/webpack-encore'); // eslint-disable-next-line import/no-extraneous-dependencies @@ -28,6 +29,7 @@ Encore .addEntry('fileUploaderMetaData', './assets/scripts/file-uploader-meta-data.ts') .addEntry('formValidations', './assets/scripts/form-validations.ts') .addEntry('tooltipDismissibleViaJS', './assets/scripts/tooltip-dismissible-via-js.ts') + .addEntry('tooltipAdvancedUsage', './assets/scripts/tooltip-advanced-usage.ts') // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js) // .enableStimulusBridge('./assets/controllers.json') @@ -93,4 +95,10 @@ Encore // .enableIntegrityHashes(Encore.isProduction()) ; -module.exports = Encore.getWebpackConfig(); +const config = Encore.getWebpackConfig(); + +config.resolve.alias = { + '@floating-ui/dom': path.resolve(__dirname, 'node_modules/@floating-ui/dom'), +}; + +module.exports = config; diff --git a/apps/web-twig-demo/yarn.lock b/apps/web-twig-demo/yarn.lock index dc67122ea1..ebb33a0c8e 100644 --- a/apps/web-twig-demo/yarn.lock +++ b/apps/web-twig-demo/yarn.lock @@ -1150,6 +1150,26 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@floating-ui/core@^1.5.3": + version "1.5.3" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.5.3.tgz#b6aa0827708d70971c8679a16cf680a515b8a52a" + integrity sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q== + dependencies: + "@floating-ui/utils" "^0.2.0" + +"@floating-ui/dom@^1.5.4": + version "1.5.4" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.5.4.tgz#28df1e1cb373884224a463235c218dcbd81a16bb" + integrity sha512-jByEsHIY+eEdCjnTVu+E3ephzTOzkQ8hgUfGwos+bg7NlH33Zc5uO+QHz1mrQUOgIKKDD1RtS201P9NvAfq3XQ== + dependencies: + "@floating-ui/core" "^1.5.3" + "@floating-ui/utils" "^0.2.0" + +"@floating-ui/utils@^0.2.0": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== + "@hotwired/stimulus-webpack-helpers@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@hotwired/stimulus-webpack-helpers/-/stimulus-webpack-helpers-1.0.1.tgz#4cd74487adeca576c9865ac2b9fe5cb20cef16dd" @@ -1966,25 +1986,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001449: - version "1.0.30001481" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001481.tgz#f58a717afe92f9e69d0e35ff64df596bfad93912" - integrity sha512-KCqHwRnaa1InZBtqXzP98LPg0ajCVujMKjqKDhZEthIpAsJl/YEIa3YvXjGXPVqzZVguccuu7ga9KOE1J9rKPQ== - -caniuse-lite@^1.0.30001503: - version "1.0.30001517" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001517.tgz#90fabae294215c3495807eb24fc809e11dc2f0a8" - integrity sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA== - -caniuse-lite@^1.0.30001541: - version "1.0.30001553" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001553.tgz#e64e7dc8fd4885cd246bb476471420beb5e474b5" - integrity sha512-N0ttd6TrFfuqKNi+pMgWJTb9qrdJu4JSpgPFLe/lrD19ugC6fZgF0pUewRowDwzdDnb9V41mFcdlYgl/PyKf4A== - -caniuse-lite@^1.0.30001565: - version "1.0.30001570" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001570.tgz#b4e5c1fa786f733ab78fc70f592df6b3f23244ca" - integrity sha512-+3e0ASu4sw1SWaoCtvPeyXp+5PsjigkSt8OXZbF9StH5pQWbxEjLAZE3n8Aup5udop1uRiKA7a4utUk/uoSpUw== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001449, caniuse-lite@^1.0.30001503, caniuse-lite@^1.0.30001541, caniuse-lite@^1.0.30001565: + version "1.0.30001579" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz" + integrity sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA== chalk@^2.0.0, chalk@^2.3.2, chalk@^2.4.2: version "2.4.2" diff --git a/packages/web-react/.eslintrc.js b/packages/web-react/.eslintrc.js index 9fd0f232f7..a56f78c234 100644 --- a/packages/web-react/.eslintrc.js +++ b/packages/web-react/.eslintrc.js @@ -62,7 +62,13 @@ module.exports = { // allow reassign in properties 'no-param-reassign': ['warn', { props: false }], // support monorepos - 'import/no-extraneous-dependencies': ['error', { packageDir: ['./', '../../'] }], + 'import/no-extraneous-dependencies': [ + 'error', + { + packageDir: ['./', '../../'], + peerDependencies: true, + }, + ], // disable double quotes quotes: ['warn', 'single'], // use useIsomorphicLayoutEffect instead of useLayoutEffect diff --git a/packages/web-react/package.json b/packages/web-react/package.json index 9911197c0d..c3da6c557a 100644 --- a/packages/web-react/package.json +++ b/packages/web-react/package.json @@ -17,6 +17,7 @@ "module": "./index.js", "types": "./index.d.ts", "dependencies": { + "@floating-ui/react": "^0.26.5", "@react-hook/resize-observer": "^1.2.6", "classnames": "^2.3.1", "react-transition-group": "^4.4.5" diff --git a/packages/web-react/src/components/Tooltip/README.md b/packages/web-react/src/components/Tooltip/README.md index 571b3d4d26..41c11e654c 100644 --- a/packages/web-react/src/components/Tooltip/README.md +++ b/packages/web-react/src/components/Tooltip/README.md @@ -145,7 +145,7 @@ const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, d ref={refs.setReference} {...getReferenceProps()} > - I have a flipping tooltip! + I have a tooltip! - I have a flipping tooltip! + I have a tooltip! ; ``` +# TooltipModern + +⚠️ `TooltipModern` component is [deprecated] and will be renamed to `Tooltip` in the next major version. + +## Usage + +To enable the advanced floating functionality, you need to have activated feature flag `spirit-feature-tooltip-enable-data-placement` on any parent element. +This requirement will be removed in future major version. + +### Basic + +```javascript +import { TooltipModern, TooltipTrigger, TooltipPopover, Button } from '@lmc-eu/spirit-web-react/components'; + +const [open, setOpen] = React.useState(false); + +
+ + I have a tooltip! + Hello there! + +
; +``` + +### Dismissible + +Add `isDismissible` prop to `TooltipModern` component. +there will be automatically displayed close button in `TooltipPopover`` component + +```javascript +import { TooltipModern, TooltipTrigger, TooltipPopover, Button } from '@lmc-eu/spirit-web-react/components'; + +const [open, setOpen] = React.useState(false); + +
+ + I have a tooltip 😎 + Close me + +
; +``` + +## API + +| Attribute | Type | Default | Required | Description | +| ------------------------------- | -------------------------------------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `children` | `ReactNode` | β€” | βœ” | Tooltip children's nodes - `TooltipTrigger` and `TooltipPopover` | +| `enableFlipping` | `bool` | true | βœ• | Enables [flipping][floating-ui-flip] of the element’s placement when it starts to overflow its boundary area. For example `top` can be flipped to `bottom`. | +| `enableFlippingCrossAxis` | `bool` | true | βœ• | Enables flipping on the [cross axis][floating-ui-flip-cross-axis], the axis perpendicular to main axis. For example `top-end` can be flipped to the `top-start`. | +| `enableShifting` | `bool` | true | βœ• | Enables [shifting][floating-ui-shift] of the element to keep it inside the boundary area by adjusting its position. | +| `enableSizing` | `bool` | true | βœ• | Enables [sizing][floating-ui-size] of the element to keep it inside the boundary area by setting the max width. | +| `flipFallbackAxisSideDirection` | ["none" \| "start" \| "end"] | "none" | βœ• | Whether to allow [fallback to the opposite axis][floating-ui-flip-fallback-axis-side-direction] if no placements along the preferred placement axis fit, and if so, which side direction along that axis to choose. If necessary, it will fallback to the other direction. | +| `flipFallbackPlacements` | `string` | - | βœ• | This describes a list of [explicit placements][floating-ui-flip-fallback-placements] to try if the initial placement doesn’t fit on the axes in which overflow is checked. For example you can set `"top, right, bottom"` | +| `id` | `string` | - | βœ” | Tooltip id | +| `isDismissible` | `bool` | false | βœ• | Make tooltip dismissible | +| `isOpen` | `bool` | - | βœ” | Open state | +| `onToggle` | `() => void` | - | βœ” | Function for toggle open state of dropdown | +| `placement` | [Placement Dictionary][dictionary-placement] | "bottom" | βœ• | Placement of tooltip | + [dictionary-placement]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#placement +[example]: https://spirit-design-system-demo.netlify.app/src/scss/components/tooltip/#advanced-positioning +[floating-ui-flip-cross-axis]: https://floating-ui.com/docs/flip#crossaxis +[floating-ui-flip-fallback-axis-side-direction]: https://floating-ui.com/docs/flip#fallbackaxissidedirection +[floating-ui-flip-fallback-placements]: https://floating-ui.com/docs/flip#fallbackplacements +[floating-ui-flip]: https://floating-ui.com/docs/flip +[floating-ui-shift]: https://floating-ui.com/docs/shift +[floating-ui-size]: https://floating-ui.com/docs/size +[floating-ui]: https://floating-ui.com +[readme-deprecations]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/README.md#deprecations +[readme-feature-flags]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/README.md#feature-flags diff --git a/packages/web-react/src/components/Tooltip/TooltipContext.ts b/packages/web-react/src/components/Tooltip/TooltipContext.ts new file mode 100644 index 0000000000..6de86f4d81 --- /dev/null +++ b/packages/web-react/src/components/Tooltip/TooltipContext.ts @@ -0,0 +1,57 @@ +import { MiddlewareData, Placement } from '@floating-ui/react'; +import { HTMLProps, MutableRefObject, createContext, useContext } from 'react'; + +type refType = ((node: HTMLElement | null) => void) & ((node: HTMLElement | null) => void); + +type TooltipContextType = { + anchorRef: refType; + arrowRef: MutableRefObject; + getFloatingProps: (userProps?: HTMLProps | undefined) => Record; + getReferenceProps: (userProps?: HTMLProps | undefined) => Record; + id: string; + isDismissible?: boolean; + isOpen: boolean; + middlewareData: MiddlewareData; + onToggle: (isOpen: boolean) => void; + placement?: Placement | undefined; + sizeMaxWidth?: number; + tooltipMaxWidth?: number; + tooltipRef: refType; + triggerRef: refType; + x: number; + y: number; +}; + +const defaultContext: TooltipContextType = { + anchorRef: () => {}, + arrowRef: { current: null }, + getReferenceProps: () => ({ + id: '', + ref: () => {}, + }), + getFloatingProps: () => ({ + id: '', + ref: () => {}, + }), + id: '', + isDismissible: false, + isOpen: false, + middlewareData: {}, + onToggle: () => {}, + placement: 'bottom', + sizeMaxWidth: undefined, + tooltipMaxWidth: undefined, + tooltipRef: () => {}, + triggerRef: () => {}, + x: 0, + y: 0, +}; + +const TooltipContext = createContext(defaultContext); +const TooltipProvider = TooltipContext.Provider; +const TooltipConsumer = TooltipContext.Consumer; +const useTooltipContext = (): TooltipContextType => useContext(TooltipContext); + +export default TooltipContext; +export { TooltipConsumer, TooltipProvider, useTooltipContext }; +export type { TooltipContextType }; diff --git a/packages/web-react/src/components/Tooltip/TooltipModern.tsx b/packages/web-react/src/components/Tooltip/TooltipModern.tsx new file mode 100644 index 0000000000..e9e6b23d81 --- /dev/null +++ b/packages/web-react/src/components/Tooltip/TooltipModern.tsx @@ -0,0 +1,106 @@ +import React, { useRef } from 'react'; +import { useDeprecationMessage, useStyleProps } from '../../hooks'; +import { ChildrenProps, SpiritTooltipModernProps } from '../../types'; +import { TooltipProvider } from './TooltipContext'; +import { useFloating } from './useFloating'; + +interface TooltipModernProps extends ChildrenProps, SpiritTooltipModernProps {} + +const TooltipModern = (props: TooltipModernProps) => { + const { + children, + enableFlipping: flipProp = true, + enableShifting: shiftProp = true, + enableSizing: sizeProp = false, + enableFlippingCrossAxis: flipCrossAxis = true, + flipFallbackAxisSideDirection = 'none', + flipFallbackPlacements = ['bottom', 'top'], + id, + isDismissible = false, + isOpen = false, + onToggle, + placement: tooltipPlacement, + ...rest + } = props; + + const { styleProps, props: otherProps } = useStyleProps({ ...rest }); + + // Refs for FloatingUI + const arrowRef = useRef(null); + const tooltipRef = useRef(null); + + // Get `maxWidth` and `--tooltip-offset` from CSS variables + let tooltipMaxWidth; + let tooltipOffset; + let tooltipCornerOffset; + let tooltipArrowWidth; + const tooltipElement = tooltipRef.current?.querySelector('[data-spirit-element="tooltip"]'); + const tooltipArrowElement = tooltipElement?.querySelector('[data-spirit-element="tooltip-arrow"]'); + + if (tooltipElement) { + const tooltipComputedStyle = window.getComputedStyle(tooltipElement); + const tooltipArrowComputedStyle = tooltipArrowElement && window.getComputedStyle(tooltipArrowElement); + tooltipMaxWidth = parseInt(tooltipComputedStyle.getPropertyValue('--tooltip-max-width'), 10); + tooltipOffset = parseInt(tooltipComputedStyle.getPropertyValue('--tooltip-offset'), 10); + tooltipCornerOffset = tooltipArrowComputedStyle + ? parseInt(tooltipArrowComputedStyle.getPropertyValue('--tooltip-arrow-corner-offset'), 10) + : 0; + tooltipArrowWidth = tooltipArrowComputedStyle ? parseInt(tooltipArrowComputedStyle.width, 10) : 0; + } + + // Get props for the FloatingUI hook + const { getFloatingProps, getReferenceProps, maxWidth, middlewareData, placement, refs, x, y } = useFloating({ + arrowRef, + cornerOffset: tooltipCornerOffset, + flipCrossAxis, + flipFallbackAxisSideDirection, + flipFallbackPlacements, + flipProp, + isOpen, + offset: tooltipOffset, + onToggle, + shiftProp, + sizeProp, + tooltipArrowWidth, + tooltipMaxWidth, + tooltipPlacement, + }); + + useDeprecationMessage({ + method: 'component', + trigger: true, + componentName: 'TooltipModern', + componentProps: { + newName: 'Tooltip', + }, + }); + + return ( + +
+ {children} +
+
+ ); +}; + +export default TooltipModern; diff --git a/packages/web-react/src/components/Tooltip/TooltipPopover.tsx b/packages/web-react/src/components/Tooltip/TooltipPopover.tsx new file mode 100644 index 0000000000..452b674831 --- /dev/null +++ b/packages/web-react/src/components/Tooltip/TooltipPopover.tsx @@ -0,0 +1,110 @@ +import classNames from 'classnames'; +import React, { useMemo } from 'react'; +import { useStyleProps } from '../../hooks'; +import { ChildrenProps, StyleProps } from '../../types'; +import TooltipCloseButton from './TooltipCloseButton'; +import { useTooltipContext } from './TooltipContext'; +import { useTooltipModernStyleProps } from './useTooltipModernStyleProps'; + +interface TooltipPopoverProps extends ChildrenProps, StyleProps {} + +const TooltipPopover = (props: TooltipPopoverProps) => { + const { children, ...rest } = props; + const { + arrowRef, + getFloatingProps, + isDismissible, + isOpen, + middlewareData, + onToggle, + placement, + tooltipRef, + x, + y, + sizeMaxWidth, + tooltipMaxWidth, + } = useTooltipContext(); + const { classProps, props: modifiedProps } = useTooltipModernStyleProps({ + isOpen, + isDismissible, + placement, + ...rest, + }); + const { styleProps: contentStyleProps, props: contentOtherProps } = useStyleProps({ ...modifiedProps }); + + const renderCloseButton = useMemo( + () => isDismissible && onToggle(false)} label="close" />, + [isDismissible, onToggle], + ); + + const getMaxHeightAndWidth = () => { + if (isOpen && sizeMaxWidth && tooltipMaxWidth) { + return { + maxWidth: tooltipMaxWidth < sizeMaxWidth ? tooltipMaxWidth : sizeMaxWidth, + }; + } + + return undefined; + }; + + const getArrowStaticSidePosition = () => { + if (placement && arrowRef.current) { + const { arrow } = middlewareData; + const side = placement.split('-')[0]; + + const staticSide = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[side] as string; + + const arrowEl = arrowRef.current as HTMLElement; + + const offset = + staticSide === 'top' || staticSide === 'bottom' + ? arrowEl.offsetHeight + : (arrowEl.offsetHeight + arrowEl.offsetWidth) / 2; + + return { + left: arrow?.x, + top: arrow?.y, + [staticSide]: -offset, + }; + } + + return undefined; + }; + + return ( +
+ {children} + {renderCloseButton} + +
+ ); +}; + +export default TooltipPopover; diff --git a/packages/web-react/src/components/Tooltip/TooltipTrigger.tsx b/packages/web-react/src/components/Tooltip/TooltipTrigger.tsx new file mode 100644 index 0000000000..ceea9507b7 --- /dev/null +++ b/packages/web-react/src/components/Tooltip/TooltipTrigger.tsx @@ -0,0 +1,30 @@ +import React, { ElementType, ReactNode } from 'react'; +import { useStyleProps } from '../../hooks'; +import { StyleProps } from '../../types'; +import { useTooltipContext } from './TooltipContext'; + +interface TooltipTriggerProps extends StyleProps { + elementType?: ElementType | string; + children: string | ReactNode | ((props: { isOpen: boolean }) => React.ReactNode); +} + +const TooltipTrigger = (props: TooltipTriggerProps) => { + const { elementType = 'button', children, ...rest } = props; + const { id, isOpen, triggerRef, getReferenceProps } = useTooltipContext(); + + const Component = elementType; + + const { styleProps: triggerStyleProps, props: transferProps } = useStyleProps(rest); + + return ( + + {typeof children === 'function' ? children({ isOpen }) : children} + + ); +}; + +TooltipTrigger.defaultProps = { + elementType: 'button', +}; + +export default TooltipTrigger; diff --git a/packages/web-react/src/components/Tooltip/__tests__/Tooltip.test.tsx b/packages/web-react/src/components/Tooltip/__tests__/Tooltip.test.tsx index bd8d1a63ca..9f781af1be 100644 --- a/packages/web-react/src/components/Tooltip/__tests__/Tooltip.test.tsx +++ b/packages/web-react/src/components/Tooltip/__tests__/Tooltip.test.tsx @@ -1,10 +1,11 @@ import '@testing-library/jest-dom'; import React from 'react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; -import Tooltip from '../Tooltip'; +import { Button } from '../../Button'; +import { Tooltip, TooltipModern, TooltipTrigger, TooltipPopover } from '..'; describe('Tooltip', () => { classNamePrefixProviderTest(Tooltip, 'Tooltip'); @@ -29,3 +30,61 @@ describe('Tooltip', () => { expect(element.textContent).toBe('Hello World'); }); }); + +describe('TooltipModern', () => { + stylePropsTest((props) => , 'TooltipModern-test'); + + restPropsTest((props) => , 'div'); + + const id = 'TooltipModernTest'; + const triggerText = 'TooltipTrigger'; + const popoverText = 'TooltipPopover'; + + it('should render tooltip', () => { + const onToggle = () => null; + const open = true; + + const dom = render( + + {triggerText} + {popoverText} + , + ); + + const triggerElement = dom.container.querySelector(`#${id}`) as HTMLElement; + const popoverElement = dom.container.querySelector('[data-spirit-element="tooltip"]') as HTMLElement; + + expect(triggerElement.textContent).toBe(triggerText); + expect(popoverElement.textContent).toBe(popoverText); + }); + + it('should be opened', () => { + const onToggle = jest.fn(); + + const dom = render( + + trigger + {popoverText} + , + ); + const element = dom.container.querySelector('.Tooltip') as HTMLElement; + + expect(element).not.toHaveClass('is-hidden'); + }); + + it('should call toggle function', () => { + const onToggle = jest.fn(); + + const dom = render( + + trigger + Hello World + , + ); + const trigger = dom.container.querySelector(`button#${id}`) as HTMLElement; + + fireEvent.click(trigger); + + expect(onToggle).toHaveBeenCalled(); + }); +}); diff --git a/packages/web-react/src/components/Tooltip/__tests__/TooltipPopover.test.tsx b/packages/web-react/src/components/Tooltip/__tests__/TooltipPopover.test.tsx new file mode 100644 index 0000000000..2e7b5f246d --- /dev/null +++ b/packages/web-react/src/components/Tooltip/__tests__/TooltipPopover.test.tsx @@ -0,0 +1,24 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { classNamePrefixProviderTest } from '../../../../tests/providerTests/classNamePrefixProviderTest'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { TooltipPopover } from '..'; + +describe('TooltipPopover', () => { + classNamePrefixProviderTest(TooltipPopover, 'Tooltip'); + + stylePropsTest((props) => , 'TooltipPopover-test'); + + restPropsTest((props) => , 'div'); + + it('should render tooltip popover', () => { + const popoverText = 'TooltipPopover'; + + const dom = render({popoverText}); + const element = dom.container.querySelector('.Tooltip') as HTMLElement; + + expect(element.textContent).toBe(popoverText); + }); +}); diff --git a/packages/web-react/src/components/Tooltip/__tests__/TooltipTrigger.test.tsx b/packages/web-react/src/components/Tooltip/__tests__/TooltipTrigger.test.tsx new file mode 100644 index 0000000000..261352dc50 --- /dev/null +++ b/packages/web-react/src/components/Tooltip/__tests__/TooltipTrigger.test.tsx @@ -0,0 +1,23 @@ +import '@testing-library/jest-dom'; +import React from 'react'; +import { render } from '@testing-library/react'; +import { stylePropsTest } from '../../../../tests/providerTests/stylePropsTest'; +import { restPropsTest } from '../../../../tests/providerTests/restPropsTest'; +import { Button } from '../../Button'; +import { TooltipTrigger } from '..'; + +describe('TooltipTrigger', () => { + stylePropsTest((props) => , 'TooltipTrigger-test'); + + restPropsTest((props) => , 'button'); + + it('should render tooltip trigger', () => { + const id = 'TooltipTriggerTest'; + const triggerText = 'TooltipTrigger'; + + const dom = render({triggerText}); + const element = dom.container.querySelector(`[data-spirit-testid="${id}"]`) as HTMLElement; + + expect(element.textContent).toBe(triggerText); + }); +}); diff --git a/packages/web-react/src/components/Tooltip/__tests__/useFloatingUI.test.ts b/packages/web-react/src/components/Tooltip/__tests__/useFloatingUI.test.ts new file mode 100644 index 0000000000..86569e0aea --- /dev/null +++ b/packages/web-react/src/components/Tooltip/__tests__/useFloatingUI.test.ts @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useFloating } from '../useFloating'; + +describe('useFloatingUI', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => + useFloating({ + arrowRef: { current: null }, + flipCrossAxis: false, + flipFallbackAxisSideDirection: 'none', + flipFallbackPlacements: 'bottom', + flipProp: false, + isOpen: false, + onToggle: jest.fn(), + shiftProp: false, + sizeProp: false, + tooltipPlacement: undefined, + }), + ); + + expect(result.current.context).toBeDefined(); + expect(result.current.context.x).toBe(0); + expect(result.current.context.y).toBe(0); + expect(result.current.context.placement).toBe('bottom'); + expect(result.current.getFloatingProps).toBeDefined(); + expect(result.current.getReferenceProps).toBeDefined(); + expect(result.current.middlewareData).toBeDefined(); + expect(result.current.placement).toBeDefined(); + expect(result.current.refs).toBeDefined(); + expect(result.current.x).toBeDefined(); + expect(result.current.y).toBeDefined(); + }); +}); diff --git a/packages/web-react/src/components/Tooltip/__tests__/useTooltipModernStyleProps.test.ts b/packages/web-react/src/components/Tooltip/__tests__/useTooltipModernStyleProps.test.ts new file mode 100644 index 0000000000..8fab93bd05 --- /dev/null +++ b/packages/web-react/src/components/Tooltip/__tests__/useTooltipModernStyleProps.test.ts @@ -0,0 +1,18 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useTooltipModernStyleProps } from '../useTooltipModernStyleProps'; + +describe('useTooltipModernStyleProps', () => { + it('should return defaults', () => { + const { result } = renderHook(() => useTooltipModernStyleProps({})); + + expect(result.current.classProps.rootClassName).toBe('Tooltip'); + expect(result.current.classProps.arrowClassName).toBe('Tooltip__arrow'); + expect(result.current.classProps.closeButtonClassName).toBe('Tooltip__close'); + }); + + it('should return dismissible class', () => { + const { result } = renderHook(() => useTooltipModernStyleProps({ isDismissible: true })); + + expect(result.current.classProps.rootClassName).toBe('Tooltip Tooltip--dismissible'); + }); +}); diff --git a/packages/web-react/src/components/Tooltip/demo/TooltipDismissibleFloatingUi.tsx b/packages/web-react/src/components/Tooltip/demo/TooltipDismissibleFloatingUi.tsx index c100198033..a08e331bc8 100644 --- a/packages/web-react/src/components/Tooltip/demo/TooltipDismissibleFloatingUi.tsx +++ b/packages/web-react/src/components/Tooltip/demo/TooltipDismissibleFloatingUi.tsx @@ -41,7 +41,7 @@ const Story: ComponentStory = () => {
{

πŸ–± Try scrolling the example to see how Tooltip placement is updated.

; + cornerOffset?: number; + flipCrossAxis: boolean; + flipFallbackAxisSideDirection: 'none' | 'start' | 'end'; + flipFallbackPlacements?: Placement | Placement[]; + flipProp: boolean; + isOpen?: boolean; + offset?: number; + onToggle: (isOpen: boolean) => void; + shiftProp: boolean; + sizeProp: boolean; + tooltipArrowWidth?: number; + tooltipMaxWidth?: number; + tooltipPlacement?: Placement; +}; + +// Convert a string or array of strings to an array of strings (for placements) +const stringToArray = (value: Placement | Placement[]): Placement[] | undefined => + Array.isArray(value) ? value : [value]; + +export const useFloating = (props: UseTooltipUIProps) => { + const { + arrowRef, + cornerOffset = 0, + flipCrossAxis, + flipFallbackAxisSideDirection = 'none', + flipFallbackPlacements, + flipProp, + isOpen = false, + offset: tooltipOffset = 0, + onToggle, + shiftProp, + sizeProp, + tooltipArrowWidth = 0, + tooltipPlacement, + } = props; + + const [maxWidth, setMaxWidth] = useState(undefined); + const mainAxisOffset = cornerOffset + tooltipArrowWidth; + + // Floating UI library settings + const { x, y, refs, context, placement, middlewareData } = useFloatingUI({ + open: isOpen, + onOpenChange: onToggle, + placement: tooltipPlacement, + whileElementsMounted: autoUpdate, + middleware: [ + // order matters for correct behavior, please don't sort by alphabet or change order! + offset(tooltipOffset), // offset from CSS variable (--tooltip-offset) + inline(), + flipProp && + flip({ + crossAxis: flipCrossAxis, + fallbackPlacements: flipFallbackPlacements ? stringToArray(flipFallbackPlacements) : undefined, + fallbackAxisSideDirection: flipFallbackAxisSideDirection, + }), + shiftProp && + shift({ + limiter: limitShift({ + offset: () => ({ + mainAxis: mainAxisOffset, + }), + }), + }), + sizeProp && + size({ + apply({ availableWidth }: { availableWidth: number }) { + setMaxWidth(availableWidth); + }, + }), + arrow({ element: arrowRef.current, padding: cornerOffset }), // should be the last middleware + ], + }); + + // Floating UI library interaction hooks + const click = useClick(context); + const role = useRole(context, { role: 'tooltip' }); + const { getReferenceProps, getFloatingProps } = useInteractions([click, role]); + + return { + context, + getFloatingProps, + getReferenceProps, + maxWidth, + middlewareData, + placement, + refs, + x, + y, + }; +}; diff --git a/packages/web-react/src/components/Tooltip/useTooltipModernStyleProps.ts b/packages/web-react/src/components/Tooltip/useTooltipModernStyleProps.ts new file mode 100644 index 0000000000..1e2da8ac8f --- /dev/null +++ b/packages/web-react/src/components/Tooltip/useTooltipModernStyleProps.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import classNames from 'classnames'; +import { TooltipModernProps, TooltipProps } from '../../types'; +import { useClassNamePrefix } from '../../hooks'; + +export interface UseTooltipModernStyleProps extends TooltipModernProps {} + +export interface UseTooltipModernStylePropsReturn { + classProps: { + rootClassName: string; + wrapperClassName: string; + arrowClassName: string; + closeButtonClassName: string; + }; + props: TooltipProps; +} + +export const useTooltipModernStyleProps = ( + props: Omit, +): UseTooltipModernStylePropsReturn => { + const { isDismissible, isOpen, ...modifiedProps } = props; + + const tooltipClass = useClassNamePrefix('Tooltip'); + const tooltipWrapperClass = `${tooltipClass}Wrapper`; + const arrowClass = `${tooltipClass}__arrow`; + const closeButtonClass = `${tooltipClass}__close`; + const rootDismissibleClass = `${tooltipClass}--dismissible`; + const rootHiddenClass = 'is-hidden'; + + const isHiddenClass = useMemo(() => isOpen === false, [isOpen]); + + const tooltipClassName = classNames(tooltipClass, { + [rootDismissibleClass]: isDismissible, + [rootHiddenClass]: isHiddenClass, + }); + const arrowClassName = arrowClass; + const closeButtonClassName = closeButtonClass; + + return { + classProps: { + rootClassName: tooltipClassName, + wrapperClassName: tooltipWrapperClass, + arrowClassName, + closeButtonClassName, + }, + props: modifiedProps, + }; +}; diff --git a/packages/web-react/src/components/TooltipModern/README.md b/packages/web-react/src/components/TooltipModern/README.md new file mode 100644 index 0000000000..be7eb547bf --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/README.md @@ -0,0 +1,9 @@ +# TooltipModern + +⚠️ `TooltipModern` component is [deprecated][deprecated] and will be renamed to `Tooltip` in the next major version. + +For more information and implementation examples, please visit the [`TooltipModern` section][tooltip-modern-section] section in the [readme][tooltip] for the `Tooltip` component. + +[deprecated]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web-react/README.md#deprecations +[tooltip-modern-section]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/components/Tooltip/README.md#tooltipmodern +[tooltip]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-react/src/components/Tooltip/README.md diff --git a/packages/web-react/src/components/TooltipModern/demo/TooltipDefault.tsx b/packages/web-react/src/components/TooltipModern/demo/TooltipDefault.tsx new file mode 100644 index 0000000000..fb16c68996 --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/demo/TooltipDefault.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import TooltipWrapper from '../../Tooltip/TooltipWrapper'; +import Tooltip from '../../Tooltip/Tooltip'; + +const TooltipDefault = () => ( + + + Tooltips +
+ all day long… +
+ + Hello there! + Hello there! + Hello there! There is slightly more text in this tooltip. + Hello there! +
+); + +export default TooltipDefault; diff --git a/packages/web-react/src/components/TooltipModern/demo/TooltipDismissible.tsx b/packages/web-react/src/components/TooltipModern/demo/TooltipDismissible.tsx new file mode 100644 index 0000000000..c663843d1d --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/demo/TooltipDismissible.tsx @@ -0,0 +1,18 @@ +import React, { useState } from 'react'; +import { Button } from '../../Button'; +import { TooltipModern, TooltipPopover, TooltipTrigger } from '../../Tooltip'; + +const TooltipDismissible = () => { + const [open, setOpen] = useState(true); + + return ( +
+ + I have a tooltip 😎 + Close me + +
+ ); +}; + +export default TooltipDismissible; diff --git a/packages/web-react/src/components/TooltipModern/demo/TooltipDismissibleViaJS.tsx b/packages/web-react/src/components/TooltipModern/demo/TooltipDismissibleViaJS.tsx new file mode 100644 index 0000000000..b0d2a715e3 --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/demo/TooltipDismissibleViaJS.tsx @@ -0,0 +1,24 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '../../Button'; +import { TooltipModern, TooltipPopover, TooltipTrigger } from '../../Tooltip'; + +const TooltipDismissibleViaJS = () => { + const localStorageValue = localStorage.getItem('my-tooltip-react') === 'true'; + const [open, setOpen] = useState(localStorageValue); + + useEffect(() => { + localStorage.setItem('my-tooltip-react', open.toString()); + }, [open]); + + return ( +
+

Saves data to local storage.

+ + I have a tooltip 😎 + Close me + +
+ ); +}; + +export default TooltipDismissibleViaJS; diff --git a/packages/web-react/src/components/TooltipModern/demo/TooltipOnHover.tsx b/packages/web-react/src/components/TooltipModern/demo/TooltipOnHover.tsx new file mode 100644 index 0000000000..a4dd834d59 --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/demo/TooltipOnHover.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ButtonLink } from '../../Button'; +import Tooltip from '../../Tooltip/Tooltip'; +import TooltipWrapper from '../../Tooltip/TooltipWrapper'; + +const TooltipOnHover = () => ( +
+ + + Tooltip on top + + + Hello there! + + {' '} + + + Tooltip on right + + + Hello there! + + {' '} + + + Tooltip on bottom + + + Hello there! + + {' '} + + + Tooltip on left + + + Hello there! + + +
+); + +export default TooltipOnHover; diff --git a/packages/web-react/src/components/TooltipModern/demo/TooltipPlacements.tsx b/packages/web-react/src/components/TooltipModern/demo/TooltipPlacements.tsx new file mode 100644 index 0000000000..c6f24cb301 --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/demo/TooltipPlacements.tsx @@ -0,0 +1,157 @@ +import React, { ChangeEvent, useState } from 'react'; +import DocsBox from '../../../../docs/DocsBox'; +import { PlacementDictionaryType } from '../../../types'; +import { Grid, GridItem } from '../../Grid'; +import { Radio } from '../../Radio'; +import Tooltip from '../../Tooltip/Tooltip'; +import TooltipWrapper from '../../Tooltip/TooltipWrapper'; + +const TooltipPlacements = () => { + const [placement, setPlacement] = useState('bottom'); + + const handlePlacementChange = (event: ChangeEvent) => { + setPlacement(event.target.value as PlacementDictionaryType); + }; + + return ( +
+ + + {' '} + {' '} + + + + {' '} + {' '} + + + + + + + + + + + + + + + + Click +
the dots! +
+ {placement} +
+
+
+
+ ); +}; + +export default TooltipPlacements; diff --git a/packages/web-react/src/components/TooltipModern/demo/TooltipWithFloatingUI.tsx b/packages/web-react/src/components/TooltipModern/demo/TooltipWithFloatingUI.tsx new file mode 100644 index 0000000000..72dade40fb --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/demo/TooltipWithFloatingUI.tsx @@ -0,0 +1,145 @@ +import { Placement } from '@floating-ui/react'; +import React, { useEffect, useRef, useState } from 'react'; +import { Button } from '../../Button'; +import { Checkbox } from '../../Checkbox'; +import { Grid } from '../../Grid'; +import { Select } from '../../Select'; +import { TooltipModern, TooltipPopover, TooltipTrigger } from '../../Tooltip'; + +const TooltipWithFloatingUI = () => { + const [isOpen, setIsOpen] = useState(true); + const [flip, setFlip] = useState(true); + const [flipCrossAxis, setFlipCrossAxis] = useState(true); + const [shift, setShift] = useState(true); + const [size, setSize] = useState(true); + const [suggestedPlacement, setSuggestedPlacement] = useState('top-start'); + const [suggestedFallbackPlacement, setSuggestedFallbackPlacement] = useState([ + 'top', + 'right', + 'left', + 'bottom', + ]); + + const suggestedPlacementRef = useRef(null); + const suggestedFallbackPlacementRef = useRef(null); + + const viewportRef = useRef(null); + const contentRef = useRef(null); + + const handleSuggestedPlacementChange = () => { + if (suggestedPlacementRef.current) { + setSuggestedPlacement((suggestedPlacementRef.current.value || 'top-start') as Placement); + } + }; + + const handleSuggestedFallbackPlacementChange = () => { + if (suggestedFallbackPlacementRef.current) { + setSuggestedFallbackPlacement( + (suggestedFallbackPlacementRef.current.value.split(',').map((item) => item.trim()) || [ + 'top', + 'right', + 'left', + 'bottom', + ]) as Placement[], + ); + } + }; + + useEffect(() => { + const viewport = viewportRef.current; + const content = contentRef.current; + if (viewport && content) { + viewport.scrollLeft = (content.offsetWidth - viewport.offsetWidth) / 2; + viewport.scrollTop = (content.offsetHeight - viewport.offsetHeight) / 2; + } + }, []); + + return ( +
+

+ Try scrolling the frame or resizing the window to see how the Tooltip behaves. The Floating UI library is trying + to keep the Tooltip in the viewport and it is also flipping, shifting and resizing the Tooltip when it is not + possible to keep it in the viewport. +

+ + setFlip(!flip)} /> + setFlipCrossAxis(!flipCrossAxis)} + /> + setShift(!shift)} /> + setSize(!size)} /> + + + + + +
+
+ + I have a tooltip 😎 + + This long tooltip is flipping, resizing and shifting to stay in the viewport. Also its arrow is always + trying to point to the center of the trigger. + + +
+
+
+ ); +}; + +export default TooltipWithFloatingUI; diff --git a/packages/web-react/src/components/TooltipModern/demo/index.tsx b/packages/web-react/src/components/TooltipModern/demo/index.tsx new file mode 100644 index 0000000000..5a8bbd4cf8 --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/demo/index.tsx @@ -0,0 +1,40 @@ +// Because there is no `dist` directory during the CI run +/* eslint-disable import/no-extraneous-dependencies, import/extensions, import/no-unresolved */ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment, import/extensions, import/no-unresolved +// @ts-ignore: No declaration file +import icons from '@lmc-eu/spirit-icons/dist/icons'; +import DocsSection from '../../../../docs/DocsSections'; +import { IconsProvider } from '../../../context'; +import TooltipDefault from './TooltipDefault'; +import TooltipDismissible from './TooltipDismissible'; +import TooltipDismissibleViaJS from './TooltipDismissibleViaJS'; +import TooltipOnHover from './TooltipOnHover'; +import TooltipPlacements from './TooltipPlacements'; +import TooltipWithFloatingUI from './TooltipWithFloatingUI'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + + + + + + + + + + + + + + + + , +); diff --git a/packages/web-react/src/components/TooltipModern/index.html b/packages/web-react/src/components/TooltipModern/index.html new file mode 100644 index 0000000000..c697014256 --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/index.html @@ -0,0 +1 @@ +{{> demo}} diff --git a/packages/web-react/src/components/TooltipModern/index.ts b/packages/web-react/src/components/TooltipModern/index.ts new file mode 100644 index 0000000000..57967cd472 --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/index.ts @@ -0,0 +1 @@ +export * from '../Tooltip'; diff --git a/packages/web-react/src/components/TooltipModern/stories/TooltipModern.stories.tsx b/packages/web-react/src/components/TooltipModern/stories/TooltipModern.stories.tsx new file mode 100644 index 0000000000..77aecbd2fa --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/stories/TooltipModern.stories.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { SpiritTooltipModernProps } from '../../../types'; +import ReadMe from '../../Tooltip/README.md'; +import { Button } from '../..'; +import { TooltipModern, TooltipTrigger, TooltipPopover } from '../../Tooltip'; + +const Placements = [ + 'top', + 'bottom', + 'left', + 'right', + 'left-end', + 'left-start', + 'right-end', + 'right-start', + 'top-end', + 'top-start', + 'bottom-end', + 'bottom-start', +]; + +const meta: Meta = { + title: 'Components/TooltipModern', + component: TooltipModern, + parameters: { + docs: { + page: () => {ReadMe}, + }, + layout: 'centered', + }, + argTypes: { + children: { + control: 'object', + }, + id: { + control: 'text', + }, + placement: { + control: 'select', + options: Object.values(Placements), + table: { + defaultValue: { summary: 'bottom' }, + }, + }, + }, + args: { + children: ( + <> + This long tooltip is flipping, resizing and shifting to stay in the viewport. Also its arrow is always trying to + point to the center of the trigger. + + ), + id: 'TooltipModernExample', + enableFlipping: true, + enableShifting: true, + enableSizing: true, + enableFlippingCrossAxis: true, + placement: 'bottom', + flipFallbackPlacements: ['bottom', 'left', 'right', 'top'], + }, +}; + +export default meta; +type Story = StoryObj; + +const TooltipModernWithHooks = (args: SpiritTooltipModernProps) => { + const { children, isOpen } = args; + const [isTooltipOpen, setIsTooltipOpen] = useState(true); + const onTooltipToggle = () => setIsTooltipOpen(!isTooltipOpen); + + const viewportRef = useRef(null); + const contentRef = useRef(null); + + useEffect(() => { + const viewport = viewportRef.current; + const content = contentRef.current; + if (viewport && content) { + viewport.scrollLeft = (content.offsetWidth - viewport.offsetWidth) / 2; + viewport.scrollTop = (content.offsetHeight - viewport.offsetHeight) / 2; + } + }, []); + + return ( +
+
+
+ + Button as anchor + {children} + +
+
+
+ ); +}; + +export const TooltipModernPlayground: Story = { + name: 'TooltipModern', + render: (args) => , +}; diff --git a/packages/web-react/src/components/TooltipModern/stories/TooltipPopover.stories.tsx b/packages/web-react/src/components/TooltipModern/stories/TooltipPopover.stories.tsx new file mode 100644 index 0000000000..a13c4c81cc --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/stories/TooltipPopover.stories.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ReadMe from '../../Tooltip/README.md'; +import { Button } from '../..'; +import { TooltipModern, TooltipPopover, TooltipTrigger } from '../../Tooltip'; + +const meta: Meta = { + title: 'Components/TooltipModern', + component: TooltipPopover, + parameters: { + docs: { + page: () => {ReadMe}, + }, + layout: 'centered', + }, + argTypes: { + children: { + control: 'object', + }, + }, + args: { + children: <>Tooltip popover, + }, +}; + +export default meta; +type Story = StoryObj; + +export const TooltipPopoverPlayground: Story = { + name: 'TooltipPopover', + render: (args) => ( + {}}> + Button as anchor + {args.children} + + ), +}; diff --git a/packages/web-react/src/components/TooltipModern/stories/TooltipTrigger.stories.tsx b/packages/web-react/src/components/TooltipModern/stories/TooltipTrigger.stories.tsx new file mode 100644 index 0000000000..4cd8699771 --- /dev/null +++ b/packages/web-react/src/components/TooltipModern/stories/TooltipTrigger.stories.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Markdown } from '@storybook/blocks'; +import type { Meta, StoryObj } from '@storybook/react'; + +import ReadMe from '../../Tooltip/README.md'; +import { TooltipTrigger } from '../../Tooltip'; +import { Button } from '../../Button'; + +const meta: Meta = { + title: 'Components/TooltipModern', + component: TooltipTrigger, + parameters: { + docs: { + page: () => {ReadMe}, + }, + layout: 'centered', + }, + argTypes: { + children: { + control: 'object', + description: 'The content to display in the component.', + }, + elementType: { + control: 'object', + table: { + defaultValue: { summary: Button }, + }, + }, + }, + args: { + children: 'Hello World', + elementType: Button, + }, +}; + +export default meta; +type Story = StoryObj; + +export const TooltipTriggerPlayground: Story = { + name: 'TooltipTrigger', + render: (args) => {args.children}, +}; diff --git a/packages/web-react/src/types/tooltip.ts b/packages/web-react/src/types/tooltip.ts index 1a0814581b..a4a6234e05 100644 --- a/packages/web-react/src/types/tooltip.ts +++ b/packages/web-react/src/types/tooltip.ts @@ -1,3 +1,4 @@ +import { Placement } from '@floating-ui/react'; import { ChildrenProps, PlacementDictionaryType, StyleProps, ClickEvent } from './shared'; export interface TooltipHandlingProps { @@ -24,3 +25,27 @@ export interface TooltipProps extends BaseTooltipProps, TooltipHandlingProps {} export interface UncontrolledTooltipProps extends BaseTooltipProps {} export interface SpiritTooltipProps extends TooltipProps {} + +// TooltipModern types +export interface TooltipModernHandlingProps { + isOpen?: boolean; + onToggle: (isOpen: boolean) => void; +} + +export interface BaseTooltipModernProps extends ChildrenProps, StyleProps { + closeLabel?: string; + id: string; + isDismissible?: boolean; + placement?: Placement; +} + +export interface TooltipModernProps extends BaseTooltipModernProps, TooltipModernHandlingProps {} + +export interface SpiritTooltipModernProps extends TooltipModernProps, ChildrenProps { + enableFlipping?: boolean; + enableFlippingCrossAxis?: boolean; + enableShifting?: boolean; + enableSizing?: boolean; + flipFallbackAxisSideDirection?: 'none' | 'start' | 'end'; + flipFallbackPlacements?: Placement | Placement[]; +} diff --git a/packages/web-twig/src/Resources/components/Tooltip/README.md b/packages/web-twig/src/Resources/components/Tooltip/README.md index 421ad6795b..20670f6ddf 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/README.md +++ b/packages/web-twig/src/Resources/components/Tooltip/README.md @@ -1,5 +1,6 @@ # Tooltip +⚠️ Tooltip component is [deprecated][deprecated] and will be removed in the next major version. Please use "TooltipModern" component instead. This is Twig implementation of the [Tooltip] component. Basic usage: @@ -72,10 +73,86 @@ These attributes will be passed to the topmost HTML element of the component. ### TooltipWrapper +⚠️ `TooltipWrapper` component is [deprecated] and will be renamed to the `Tooltip` in the next major version. + You can add `id`, `data-*` or `aria-*` attributes to further extend the component's descriptiveness and accessibility. Also, UNSAFE styling props are available, see the [Escape hatches][escape-hatches] section in README to learn how and when to use them. +### TooltipPopover + +#### Basic + +```html +
+ + + Hello there! + +
+``` + +#### Dismissible + +Add `isDismissible` prop to `TooltipPopover` component. +there will be automatically displayed close button in `TooltipPopover`` component + +```html +
+ + + Close me + +
+``` + +#### Advanced Floating Functionality + +To enable the advanced floating functionality, you need to have activated feature flag `spirit-feature-tooltip-enable-data-placement` on any parent element. +This requirement will be removed in future major version. + +For more info about feature flags, see main [README][readme-feature-flags]. + +Advanced floating functionality is provided by JavaScript plugin and by [Floating UI][floating-ui] library. + +```html +
+ + + + Close me + + +
+``` + +#### API + +| Attribute | Type | Default | Required | Description | +| ------------------------------- | -------------------------------------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `closeLabel` | `string` | `Close` | βœ• | Close label | +| `enableFlipping` | `bool` | true | βœ• | Enables [flipping][floating-ui-flip] of the element’s placement when it starts to overflow its boundary area. For example `top` can be flipped to `bottom`. | +| `enableFlippingCrossAxis` | `bool` | true | βœ• | Enables flipping on the [cross axis][floating-ui-flip-cross-axis], the axis perpendicular to main axis. For example `top-end` can be flipped to the `top-start`. | +| `enableShifting` | `bool` | true | βœ• | Enables [shifting][floating-ui-shift] of the element to keep it inside the boundary area by adjusting its position. | +| `enableSizing` | `bool` | true | βœ• | Enables [sizing][floating-ui-size] of the element to keep it inside the boundary area by setting the max width. | +| `flipFallbackAxisSideDirection` | ["none" \| "start" \| "end"] | "none" | βœ• | Whether to allow [fallback to the opposite axis][floating-ui-flip-fallback-axis-side-direction] if no placements along the preferred placement axis fit, and if so, which side direction along that axis to choose. If necessary, it will fallback to the other direction. | +| `flipFallbackPlacements` | `string` | - | βœ• | This describes a list of [explicit placements][floating-ui-flip-fallback-placements] to try if the initial placement doesn’t fit on the axes in which overflow is checked. For example you can set `"top, right, bottom"` | +| `id` | `string` | - | βœ” | Tooltip id | +| `isDismissible` | `bool` | false | βœ• | Make tooltip dismissible | +| `placement` | [Placement Dictionary][dictionary-placement] | "bottom" | βœ• | Placement of tooltip | + ## JavaScript Plugin For full functionality, you need to provide Spirit JavaScript: @@ -90,8 +167,17 @@ Or, feel free to write the controlling script yourself. πŸ‘‰ Check the [component's docs in the web package][web-js-api] to see the full documentation and API of the plugin. +[deprecated]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web-twig/README.md#deprecations +[dictionary-placement]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#placement +[escape-hatches]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web-twig/README.md#escape-hatches +[floating-ui-flip-cross-axis]: https://floating-ui.com/docs/flip#crossaxis +[floating-ui-flip-fallback-axis-side-direction]: https://floating-ui.com/docs/flip#fallbackaxissidedirection +[floating-ui-flip-fallback-placements]: https://floating-ui.com/docs/flip#fallbackplacements +[floating-ui-flip]: https://floating-ui.com/docs/flip +[floating-ui-shift]: https://floating-ui.com/docs/shift +[floating-ui-size]: https://floating-ui.com/docs/size +[floating-ui]: https://floating-ui.com +[readme-feature-flags]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/README.md#feature-flags +[tooltip]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/src/scss/components/Tooltip [web-js-api]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/src/scss/components/Tooltip/README.md#javascript-api [web-readme]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/README.md -[tooltip]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/src/scss/components/Tooltip -[dictionary-placement]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#placement -[escape-hatches]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web-twig/README.md#escape-hatches diff --git a/packages/web-twig/src/Resources/components/Tooltip/Tooltip.stories.twig b/packages/web-twig/src/Resources/components/Tooltip/Tooltip.stories.twig index 4cb6f43ea2..b703a709ea 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/Tooltip.stories.twig +++ b/packages/web-twig/src/Resources/components/Tooltip/Tooltip.stories.twig @@ -14,6 +14,10 @@ {% include '@components/Tooltip/stories/TooltipOnHover.twig' %} + + {% include '@components/Tooltip/stories/TooltipWithJsPlugin.twig' %} + + {% include '@components/Tooltip/stories/TooltipClickable.twig' %} @@ -22,11 +26,11 @@ {% include '@components/Tooltip/stories/TooltipDismissible.twig' %} - + {% include '@components/Tooltip/stories/TooltipDismissibleViaJS.twig' %} - + {% include '@components/Tooltip/stories/TooltipFloatingUI.twig' %} diff --git a/packages/web-twig/src/Resources/components/Tooltip/Tooltip.twig b/packages/web-twig/src/Resources/components/Tooltip/Tooltip.twig index ba7122d1e7..d453bf491d 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/Tooltip.twig +++ b/packages/web-twig/src/Resources/components/Tooltip/Tooltip.twig @@ -79,6 +79,8 @@ {% deprecated 'Tooltip: Non-flow-relative values (eg. "' ~ _placement ~ '") in "placement" property are deprecated and will be removed in the next major version. Use flow-relative (eg. "' ~ deprecatedPlacements[_placement] ~ '") instead.' %} {% endif %} +{% deprecated 'Tooltip: The implementation of this component is deprecated and will be replaced with the implementation of the TooltipPopover in the next major version. Please, use TooltipPopover instead.' %} +
prop is not same as('id')) -%} + +
+ {% block content %}{% endblock %} + {% if _isDismissible == 'true' %} + + {% endif %} + +
diff --git a/packages/web-twig/src/Resources/components/Tooltip/TooltipWrapper.twig b/packages/web-twig/src/Resources/components/Tooltip/TooltipWrapper.twig index 4a3f8e2419..6001509d55 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/TooltipWrapper.twig +++ b/packages/web-twig/src/Resources/components/Tooltip/TooltipWrapper.twig @@ -8,10 +8,14 @@ {%- set _styleProps = useStyleProps(props) -%} {%- set _classNames = [ _rootClassName, _styleProps.className ] -%} +{# Deprecations #} +{% deprecated 'TooltipWrapper: This component will be renamed to Tooltip in the next major version.' %} +
{% block content %}{% endblock %}
diff --git a/packages/web-twig/src/Resources/components/Tooltip/__tests__/__fixtures__/tooltipPopover.twig b/packages/web-twig/src/Resources/components/Tooltip/__tests__/__fixtures__/tooltipPopover.twig new file mode 100644 index 0000000000..81c0db6a46 --- /dev/null +++ b/packages/web-twig/src/Resources/components/Tooltip/__tests__/__fixtures__/tooltipPopover.twig @@ -0,0 +1,18 @@ +Hello there! + + + + Hello there! + diff --git a/packages/web-twig/src/Resources/components/Tooltip/__tests__/__snapshots__/tooltipPopover.twig.snap.html b/packages/web-twig/src/Resources/components/Tooltip/__tests__/__snapshots__/tooltipPopover.twig.snap.html new file mode 100644 index 0000000000..aa840ef489 --- /dev/null +++ b/packages/web-twig/src/Resources/components/Tooltip/__tests__/__snapshots__/tooltipPopover.twig.snap.html @@ -0,0 +1,19 @@ + + + + + + + +
+ Hello there! +
+ + +
+ Hello there! +
+ + diff --git a/packages/web-twig/src/Resources/components/Tooltip/__tests__/__snapshots__/tooltipWrapper.twig.snap.html b/packages/web-twig/src/Resources/components/Tooltip/__tests__/__snapshots__/tooltipWrapper.twig.snap.html index b0d42cbde1..3aa1d879ee 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/__tests__/__snapshots__/tooltipWrapper.twig.snap.html +++ b/packages/web-twig/src/Resources/components/Tooltip/__tests__/__snapshots__/tooltipWrapper.twig.snap.html @@ -5,7 +5,7 @@ -
+
content
diff --git a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipClickable.twig b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipClickable.twig index 6eb5672680..c84abb0d00 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipClickable.twig +++ b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipClickable.twig @@ -1,3 +1,5 @@ +

Without Floating UI

+ + + +
+ I have an externally-triggered tooltip +
+ + Hello there! + +
diff --git a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipDismissibleViaJS.twig b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipDismissibleViaJS.twig index 9920a883e1..7a968cc38b 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipDismissibleViaJS.twig +++ b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipDismissibleViaJS.twig @@ -1,12 +1,19 @@

- Saves data to local storage. + Saves data to local storage.

- - - - Close me - + + + + Close me + {{ encore_entry_script_tags('tooltipDismissibleViaJS') }} diff --git a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipFloatingUI.twig b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipFloatingUI.twig index bd0e280436..f5ea61f5a8 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipFloatingUI.twig +++ b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipFloatingUI.twig @@ -1,30 +1,39 @@ -

- The following example is using external library Floating UI. - Try scrolling the frame or resizing the window to see how the Tooltip behaves. The Floating UI - library is trying to keep the Tooltip in the viewport and it is also flipping, shifting and - resizing the Tooltip when it is not possible to keep it in the viewport. +

+ Try scrolling the frame or resizing the window to see how the Tooltip behaves. + The Floating UI library is trying to keep the Tooltip in the viewport and it is also flipping, + shifting and resizing the Tooltip when it is not possible to keep it in the viewport.

- - Please note that the Floating UI library is trying to point the arrow to the center - of the trigger element. This is not possible to achieve in CSS only so our behavior - is slightly different for tooltips not using Floating UI. - - - + + + + + + + + + +
- - - This long tooltip is flipping, resizing and shifting to stay in the viewport. - Also its arrow is always trying to point to the center of the trigger. - + + + + This long tooltip is flipping, resizing and shifting to stay in the viewport. + Also its arrow is always trying to point to the center of the trigger. + +
- +{{ encore_entry_script_tags('tooltipAdvancedUsage') }} diff --git a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipPlacements.twig b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipPlacements.twig index 646d55296a..55759d0f13 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipPlacements.twig +++ b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipPlacements.twig @@ -1,5 +1,5 @@
- + @@ -28,13 +28,13 @@ - + - + Click
the dots!
- + bottom - +
diff --git a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipStatic.twig b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipStatic.twig index 10174ac2f9..00860fedb1 100644 --- a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipStatic.twig +++ b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipStatic.twig @@ -1,4 +1,4 @@ - + Tooltips
diff --git a/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipWithJsPlugin.twig b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipWithJsPlugin.twig new file mode 100644 index 0000000000..9e9768a105 --- /dev/null +++ b/packages/web-twig/src/Resources/components/Tooltip/stories/TooltipWithJsPlugin.twig @@ -0,0 +1,39 @@ +

Without Floating UI

+ + + + + This long tooltip is not flipping to stay in the viewport. + + + +

With Floating UI and placement fallbacks

+ + + + + This long tooltip is flipping to stay in the viewport. + + diff --git a/packages/web-twig/src/Resources/twig-components/tooltipPopover.twig b/packages/web-twig/src/Resources/twig-components/tooltipPopover.twig new file mode 100644 index 0000000000..5a514f5edf --- /dev/null +++ b/packages/web-twig/src/Resources/twig-components/tooltipPopover.twig @@ -0,0 +1 @@ +{% extends '@spirit/Tooltip/TooltipPopover.twig' %} diff --git a/packages/web/package.json b/packages/web/package.json index 6eba322b5a..f3b508689a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -55,6 +55,7 @@ }, "dependencies": { "@csstools/normalize.css": "^12.0.0", + "@floating-ui/dom": "^1.5.3", "@lmc-eu/spirit-design-tokens": "^1.1.3", "@lmc-eu/spirit-icons": "^0.10.2" }, diff --git a/packages/web/src/js/Tooltip.ts b/packages/web/src/js/Tooltip.ts index 15f22eb6a7..8eaaa9a358 100644 --- a/packages/web/src/js/Tooltip.ts +++ b/packages/web/src/js/Tooltip.ts @@ -1,3 +1,4 @@ +import * as FloatingUI from '@floating-ui/dom'; import BaseComponent from './BaseComponent'; import EventHandler from './dom/EventHandler'; import SelectorEngine from './dom/SelectorEngine'; @@ -12,16 +13,62 @@ const EVENT_HIDDEN = `hidden${EVENT_KEY}`; const EVENT_SHOW = `show${EVENT_KEY}`; const EVENT_SHOWN = `shown${EVENT_KEY}`; +const SELECTOR_ARROW = '[data-spirit-element="arrow"]'; const CLASS_NAME_VISIBLE = 'is-visible'; const CLASS_NAME_HIDDEN = 'is-hidden'; +type Config = { + enableFlipping: boolean; + enableShifting: boolean; + enableSizing: boolean; + enableFlippingCrossAxis: boolean; + flipFallbackAxisSideDirection: 'none' | 'start' | 'end'; + flipFallbackPlacements: string; + placement: FloatingUI.Placement; + placementControlled: boolean; +}; + +export const transformStringToArray = (str: string) => + str.split(',').map((item) => item.trim()) as FloatingUI.Placement[]; + class Tooltip extends BaseComponent { + arrow?: HTMLElement; + arrowWidth?: number; + arrowCornerOffset?: number; tip: HTMLElement; + tooltipComputedStyle?: CSSStyleDeclaration; + tooltipMaxWidth?: number; + tooltipOffset?: number; + trigger?: HTMLElement; constructor(element: SpiritElement, config?: SpiritConfig) { + if (typeof FloatingUI === 'undefined') { + throw new TypeError('Floating UI dependency is missing. Please, install it (https://floating-ui.com/)'); + } + super(element, config); this.tip = this.getTipElement(); + + if (this.isPlacementControlled()) { + this.trigger = this.getTipTooltipWrapper(); + this.arrow = this.tip.querySelector(SELECTOR_ARROW) as HTMLElement; + this.tooltipComputedStyle = window.getComputedStyle(this.tip); // The tooltip computed style + this.tooltipMaxWidth = parseInt(this.tooltipComputedStyle.maxWidth, 10); // The tooltip max width + this.tooltipOffset = parseInt(this.tooltipComputedStyle.getPropertyValue('--tooltip-offset'), 10); // The tooltip offset + this.arrowCornerOffset = + this.arrow && + parseInt(window.getComputedStyle(this.arrow).getPropertyValue('--tooltip-arrow-corner-offset'), 10); // The tooltip arrow corner offset + this.arrowWidth = this.arrow && parseInt(window.getComputedStyle(this.arrow).getPropertyValue('width'), 10); // The tooltip arrow width + + if (this.tip && this.trigger) { + FloatingUI.autoUpdate( + this.trigger, + this.tip, + () => this.trigger && this.updateTooltipPosition(this.trigger, this.tip), + ); + } + } } static get NAME() { @@ -36,6 +83,23 @@ class Tooltip extends BaseComponent { } } + isPlacementControlled() { + const config = this.config as Config; + const placementControlledKey = 'placementControlled'; + + // key can exist in the dataset but can have undefined value + return placementControlledKey in config && config[placementControlledKey] !== false; + } + + updateConfig(newConfig: Partial) { + const config = this.config as Config; + + if (this.trigger && this.tip) { + this.config = { ...config, ...newConfig }; + this.updateTooltipPosition(this.trigger, this.tip); + } + } + show() { if (this.element?.style?.display === 'none') { throw new Error('Please use show on elements without `display: none`'); @@ -64,6 +128,10 @@ class Tooltip extends BaseComponent { } } + if (this.trigger && this.tip) { + this.updateTooltipPosition(this.trigger, this.tip); + } + EventHandler.trigger(this.element, Tooltip.eventName(EVENT_SHOWN)); } @@ -99,6 +167,16 @@ class Tooltip extends BaseComponent { ); } + getTipTooltipWrapper() { + const id = this.tip.getAttribute('id') as string; + + const triggerWrapperElement = document + .getElementById(id) + ?.closest('[data-spirit-element="tooltip-wrapper"]') as HTMLElement; + + return triggerWrapperElement; + } + getTipElement() { if (!this.tip) { this.tip = @@ -109,6 +187,141 @@ class Tooltip extends BaseComponent { return this.tip; } + + getTooltipFloatingProps() { + const { + enableFlipping, + enableShifting, + enableSizing, + enableFlippingCrossAxis, + flipFallbackAxisSideDirection, + flipFallbackPlacements, + placement, + } = this.config as Config; + + return { + placement, + flip: enableFlipping ?? true, + shift: enableShifting ?? true, + size: enableSizing ?? true, + flipCrossAxis: enableFlippingCrossAxis ?? true, + flipFallbackPlacements: flipFallbackPlacements && transformStringToArray(flipFallbackPlacements), + flipFallbackAxisSideDirection: flipFallbackAxisSideDirection || 'none', + }; + } + + getFlipConfig() { + const { flip } = FloatingUI; + const tooltipFloatingProps = this.getTooltipFloatingProps(); + const { + flip: flipProp, + flipCrossAxis, + flipFallbackAxisSideDirection, + flipFallbackPlacements, + } = tooltipFloatingProps; + + return ( + flipProp && + flip({ + mainAxis: flipProp, + crossAxis: flipCrossAxis, + fallbackAxisSideDirection: flipFallbackAxisSideDirection, + /* eslint-disable no-undefined */ + fallbackPlacements: flipFallbackPlacements || undefined, + }) + ); + } + + getShiftConfig() { + const { shift, limitShift } = FloatingUI; + const tooltipFloatingProps = this.getTooltipFloatingProps(); + const mainAxisOffset = (this.arrowCornerOffset || 0) + (this.arrowWidth || 0); + + return ( + tooltipFloatingProps.shift && + shift({ + limiter: limitShift({ + offset: () => ({ + mainAxis: mainAxisOffset, + }), + }), + }) + ); + } + + getSizeConfig(tooltip: HTMLElement) { + const { size } = FloatingUI; + const { tooltipMaxWidth } = this; + const tooltipFloatingProps = this.getTooltipFloatingProps(); + + if (tooltipFloatingProps.size && tooltipMaxWidth) { + return size({ + apply({ availableWidth }: { availableWidth: number }) { + Object.assign(tooltip.style, { + maxWidth: `${tooltipMaxWidth < availableWidth ? tooltipMaxWidth : availableWidth}px`, + }); + }, + }); + } + + return null; + } + + updateTooltipPosition(button: HTMLElement, tooltip: HTMLElement) { + const { computePosition, offset: floatingOffset, arrow: floatingArrow } = FloatingUI; + const { tooltipOffset, arrowCornerOffset, arrow } = this; + const { placement: userPlacement } = this.getTooltipFloatingProps(); + + const hasFlip = this.getFlipConfig(); + const hasShift = this.getShiftConfig(); + const hasSize = this.getSizeConfig(tooltip); + + if (button && tooltip && arrow) { + computePosition(button, tooltip, { + placement: userPlacement, + middleware: [ + floatingOffset(tooltipOffset), + hasFlip, + hasShift, + hasSize, + floatingArrow({ element: arrow, padding: arrowCornerOffset }), // arrow() should be placed at the end + ], + }).then(({ x, y, middlewareData, placement }) => { + Object.assign(tooltip.style, { + top: `${y}px`, + left: `${x}px`, + }); + + const side = placement.split('-')[0]; + + const staticSide = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[side] as 'top' | 'right' | 'bottom' | 'left'; + + if (middlewareData.arrow && arrow) { + const offset = + staticSide === 'top' || staticSide === 'bottom' + ? arrow.offsetHeight + : (arrow.offsetHeight + arrow.offsetWidth) / 2; + + const { x: arrowX, y: arrowY } = middlewareData.arrow; + + Object.assign(arrow.style, { + left: arrowX != null ? `${arrowX}px` : '', + top: arrowY != null ? `${arrowY}px` : '', + bottom: '', + right: '', + [staticSide]: `-${Math.floor(offset)}px`, + }); + } + + tooltip.dataset.spiritPlacement = placement; + }); + } + } } enableToggleTrigger(Tooltip, 'toggle'); diff --git a/packages/web/src/js/__tests__/Tooltip.test.ts b/packages/web/src/js/__tests__/Tooltip.test.ts index a1aa1c9417..7cb0dc8674 100644 --- a/packages/web/src/js/__tests__/Tooltip.test.ts +++ b/packages/web/src/js/__tests__/Tooltip.test.ts @@ -427,5 +427,91 @@ describe('Tooltip', () => { expect(Tooltip.getOrCreateInstance(div)).toBeInstanceOf(Tooltip); }); }); + + describe('getTooltipFloatingProps', () => { + it('should return object with data from data attributes', () => { + fixtureEl.innerHTML = ` +
+ +
+ `; + + const tooltipEl = fixtureEl.querySelector('.Tooltip') as HTMLElement; + const tooltip = new Tooltip(tooltipEl); + + const tooltipProps = tooltip.getTooltipFloatingProps(); + + expect(tooltipProps).toEqual({ + placement: 'top-start', + flip: true, + shift: true, + size: true, + flipCrossAxis: true, + flipFallbackPlacements: ['top', 'right', 'left', 'bottom'], + flipFallbackAxisSideDirection: 'none', + }); + }); + }); + + describe('updateConfig', () => { + it('should update config', () => { + fixtureEl.innerHTML = ` +
+ +
+ `; + + const tooltipEl = fixtureEl.querySelector('.Tooltip') as HTMLElement; + const tooltip = new Tooltip(tooltipEl); + + tooltip.updateConfig({ + enableFlipping: false, + enableFlippingCrossAxis: false, + enableShifting: false, + enableSizing: false, + flipFallbackAxisSideDirection: 'start', + flipFallbackPlacements: 'top, right, bottom', + placement: 'bottom', + }); + + const tooltipProps = tooltip.getTooltipFloatingProps(); + + expect(tooltipProps).toEqual({ + flip: false, + flipCrossAxis: false, + flipFallbackAxisSideDirection: 'start', + flipFallbackPlacements: ['top', 'right', 'bottom'], + placement: 'bottom', + shift: false, + size: false, + }); + }); + }); }); }); diff --git a/packages/web/src/scss/components/Tooltip/README.md b/packages/web/src/scss/components/Tooltip/README.md index 6a66224528..dab662e00f 100644 --- a/packages/web/src/scss/components/Tooltip/README.md +++ b/packages/web/src/scss/components/Tooltip/README.md @@ -224,14 +224,46 @@ on the `.Tooltip__arrow` to control it and prevent conflicts with the default CS
``` -### Example +### Advanced Floating Functionality -πŸ’» Check our [example] that uses external library -[Floating UI][floating-ui] (see the [JS source](./floating-ui-example.mjs)). +To enable the advanced floating functionality, you need to have activated [feature flag][readme-feature-flags] for placement, activate the JS plugin, wrap your tooltip with an element having the `data-spirit-element="tooltip-wrapper"` data attribute, and add the `data-spirit-placement-controlled` attribute to your tooltip element to modify the styling of arrows and tooltip placement. + +```html +
+
+ + +
+
+``` πŸ‘‰ Please consult [Floating UI][floating-ui] documentation to understand how it works and to get an idea of all possible cases you may need to cover. +## Floating UI Attributes + +| Attribute | Type | Default | Required | Description | +| ----------------------------------------------- | -------------------------------------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `data-spirit-enable-flipping` | [true \| false] | true | βœ• | Enables [flipping[floating-ui-flip] of the element’s placement when it starts to overflow its boundary area. For example `top` can be flipped to `bottom`. | +| `data-spirit-enable-flipping-cross-axis` | [true \| false] | true | βœ• | Enables flipping on the [cross axis][floating-ui-flip-cross-axis], the axis perpendicular to main axis. For example `top-end` can be flipped to the `top-start`. | +| `data-spirit-flip-fallback-axis-side-direction` | ["none" \| "start" \| "end" ] | "none" | βœ• | Whether to allow [fallback to the opposite axis][floating-ui-flip-fallback-axis-side-direction] if no placements along the preferred placement axis fit, and if so, which side direction along that axis to choose. If necessary, it will fallback to the other direction. | +| `data-spirit-flip-fallback-placements` | string | - | βœ• | This describes a list of [explicit placements][floating-ui-flip-fallback-placements] to try if the initial placement doesn’t fit on the axes in which overflow is checked. For example you can set `"top, right, bottom"` | +| `data-spirit-placement` | [Placement Dictionary][dictionary-placement] | "bottom" | βœ• | Placement of tooltip | +| `data-spirit-enable-shifting` | [true \| false] | true | βœ• | Enables [shifting][floating-ui-shift] of the element to keep it inside the boundary area by adjusting its position. | +| `data-spirit-enable-sizing` | [true \| false] | true | βœ• | Enables [sizing][floating-ui-size] of the element to keep it inside the boundary area by setting the max width. | + +πŸ‘† All the attributes mentioned above can be also set as an object in the `config` attribute, like this: `data-spirit-config='{"flip": "true", "flipFallbackPlacements": "top, right, bottom"}'`. Please note that this configuration has lower priority than individual attributes and will be overwritten by them. + ## JavaScript API ### Methods @@ -254,8 +286,8 @@ tooltip.show(); | Method | Description | | ---------------- | ------------------------------------------------------------------------------------- | -| `hide.tooltip` | This event is fired immediately when the `hide` instance method has been called. | | `hidden.tooltip` | This event is fired when the `hide` instance has finished being hidden from the user. | +| `hide.tooltip` | This event is fired immediately when the `hide` instance method has been called. | | `show.tooltip` | This event fires immediately when the `show` instance method is called. | | `shown.tooltip` | This event is fired when the `show` instance has finished being shown to the user. | @@ -270,8 +302,14 @@ myTooltipEl.addEventListener('hidden.tooltip', () => { tooltip.hide(); ``` -[example]: https://spirit-design-system-demo.netlify.app/src/scss/components/tooltip/#advanced-positioning [dictionary-placement]: https://github.com/lmc-eu/spirit-design-system/tree/main/docs/DICTIONARIES.md#placement +[example]: https://spirit-design-system-demo.netlify.app/src/scss/components/tooltip/#advanced-positioning +[floating-ui-flip-cross-axis]: https://floating-ui.com/docs/flip#crossaxis +[floating-ui-flip-floating-ui-flip-fallback-axis-side-direction]: https://floating-ui.com/docs/flip#fallbackaxissidedirection +[floating-ui-flip-fallback-placements]: https://floating-ui.com/docs/flip#fallbackplacements +[floating-ui-flip]: https://floating-ui.com/docs/flip +[floating-ui-shift]: https://floating-ui.com/docs/shift +[floating-ui-size]: https://floating-ui.com/docs/size [floating-ui]: https://floating-ui.com -[readme-feature-flags]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/README.md#feature-flags -[readme-deprecations]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web/README.md#deprecations +[readme-deprecations]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/README.md#deprecations +[readme-feature-flags]: https://github.com/lmc-eu/spirit-design-system/blob/main/packages/web/README.md#feature-flags diff --git a/packages/web/src/scss/components/Tooltip/_Tooltip.scss b/packages/web/src/scss/components/Tooltip/_Tooltip.scss index fcae1768e0..636f03d4de 100644 --- a/packages/web/src/scss/components/Tooltip/_Tooltip.scss +++ b/packages/web/src/scss/components/Tooltip/_Tooltip.scss @@ -18,8 +18,10 @@ @include placement.child(); @include typography.generate(theme.$typography); + --tooltip-max-width: #{theme.$max-width}; + width: max-content; - max-width: theme.$max-width; + max-width: var(--tooltip-max-width); padding: theme.$padding; text-align: left; white-space: normal; diff --git a/packages/web/src/scss/components/Tooltip/_theme.scss b/packages/web/src/scss/components/Tooltip/_theme.scss index 6be8dcf3fa..4738c13b29 100644 --- a/packages/web/src/scss/components/Tooltip/_theme.scss +++ b/packages/web/src/scss/components/Tooltip/_theme.scss @@ -2,7 +2,7 @@ @use '../../settings/dictionaries'; @use '../../settings/transitions'; -$max-width: 18rem; +$max-width: 288px; $typography: tokens.$body-medium-text-regular; $padding: tokens.$space-500 tokens.$space-600; $gap: tokens.$space-500; diff --git a/packages/web/src/scss/components/Tooltip/dismissible-tooltip.mjs b/packages/web/src/scss/components/Tooltip/dismissible-tooltip.mjs index b03cf2a454..5ce636741c 100644 --- a/packages/web/src/scss/components/Tooltip/dismissible-tooltip.mjs +++ b/packages/web/src/scss/components/Tooltip/dismissible-tooltip.mjs @@ -1,6 +1,6 @@ import Tooltip from '../../../js/Tooltip'; -const myTooltipEl = document.getElementById('my-dismissible-tooltip2'); +const myTooltipEl = document.getElementById('my-dismissible-tooltip-with-floating-ui'); const myTooltip = new Tooltip(myTooltipEl); if (!window.localStorage.getItem('my-tooltip')) { @@ -12,7 +12,7 @@ document.getElementById('my-dismissible-button').addEventListener('click', () => window.localStorage.removeItem('my-tooltip'); }); -document.getElementById('my-dismissible-tooltip2-button').addEventListener('click', () => { +document.getElementById('my-dismissible-tooltip-with-floating-ui-button').addEventListener('click', () => { myTooltip.hide(); window.localStorage.setItem('my-tooltip', true); }); diff --git a/packages/web/src/scss/components/Tooltip/floating-ui-example.mjs b/packages/web/src/scss/components/Tooltip/floating-ui-example.mjs index fe92095248..7bfc536766 100644 --- a/packages/web/src/scss/components/Tooltip/floating-ui-example.mjs +++ b/packages/web/src/scss/components/Tooltip/floating-ui-example.mjs @@ -1,100 +1,38 @@ -// To fully understand Floating UI and its options, please consult Floating UI docs: -// @see https://floating-ui.com +import Tooltip from '../../../js/Tooltip'; -import { - arrow, - autoUpdate, - computePosition, - flip, - offset, - limitShift, - shift, - size, -} from 'https://cdn.skypack.dev/@floating-ui/dom@1.5.3'; - -const button = document.getElementById('my-button'); -const tooltip = document.getElementById('my-advanced-tooltip'); +const checkboxFlip = document.getElementById('my-advanced-flip'); +const checkboxFlipCrossAxis = document.getElementById('my-advanced-flipCrossAxis'); +const checkboxShift = document.getElementById('my-advanced-shift'); +const checkboxSize = document.getElementById('my-advanced-size'); const select = document.getElementById('my-advanced-select'); -const viewport = document.getElementById('my-advanced-viewport'); -const content = document.getElementById('my-advanced-content'); -const arrowEl = tooltip.querySelector('[data-spirit-element="arrow"]'); - -const tooltipComputedStyle = window.getComputedStyle(tooltip); -const tooltipMaxWidth = parseInt(tooltipComputedStyle.maxWidth, 10); -const tooltipOffset = parseInt(tooltipComputedStyle.getPropertyValue('--tooltip-offset'), 10); -const arrowCornerOffset = parseInt( - window.getComputedStyle(arrowEl).getPropertyValue('--tooltip-arrow-corner-offset'), - 10, -); +const selectFallback = document.getElementById('my-advanced-select-fallback'); +const tooltip = Tooltip.getOrCreateInstance(document.getElementById('my-advanced-tooltip')); -function updateTooltipPosition() { - computePosition(button, tooltip, { - placement: tooltip.dataset.spiritPlacement, - middleware: [ - offset(tooltipOffset), - flip({ - crossAxis: false, - }), - shift({ - limiter: limitShift({ - offset: ({ rects }) => ({ - mainAxis: rects.reference.height, - }), - }), - }), - size({ - apply({ availableWidth }) { - Object.assign(tooltip.style, { - maxWidth: `${tooltipMaxWidth < availableWidth ? tooltipMaxWidth : availableWidth}px`, - }); - }, - }), - arrow({ element: arrowEl, padding: arrowCornerOffset }), // arrow() should be placed at the end - ], - }).then(({ x, y, middlewareData, placement }) => { - Object.assign(tooltip.style, { - top: `${y}px`, - left: `${x}px`, - }); +tooltip.show(); - const side = placement.split('-')[0]; +checkboxFlip.addEventListener('change', () => tooltip.updateConfig({ enableFlipping: checkboxFlip.checked })); - const staticSide = { - top: 'bottom', - right: 'left', - bottom: 'top', - left: 'right', - }[side]; - - if (middlewareData.arrow) { - const offset = - staticSide === 'top' || staticSide === 'bottom' - ? arrowEl.offsetHeight - : (arrowEl.offsetHeight + arrowEl.offsetWidth) / 2; - const { x, y } = middlewareData.arrow; - Object.assign(arrowEl.style, { - left: x != null ? `${x}px` : '', - top: y != null ? `${y}px` : '', - bottom: '', - right: '', - [staticSide]: `-${offset}px`, - }); - } - - tooltip.dataset.spiritPlacement = placement; - }); -} +checkboxFlipCrossAxis.addEventListener('change', () => { + tooltip.updateConfig({ enableFlippingCrossAxis: checkboxFlipCrossAxis.checked }); +}); -window.onload = () => { - viewport.scrollLeft = (content.offsetWidth - viewport.offsetWidth) / 2; -}; +checkboxShift.addEventListener('change', () => { + tooltip.updateConfig({ enableShifting: checkboxShift.checked }); +}); -const cleanup = autoUpdate(button, tooltip, updateTooltipPosition); +checkboxSize.addEventListener('change', () => { + tooltip.updateConfig({ enableSizing: checkboxSize.checked }); +}); select.addEventListener('change', () => { - tooltip.dataset.spiritPlacement = select.value; - updateTooltipPosition(); + tooltip.updateConfig({ placement: select.value }); +}); + +selectFallback.addEventListener('change', () => { + tooltip.updateConfig({ flipFallbackPlacements: selectFallback.value }); }); -// Call cleanup function when tooltip is removed from DOM. -// cleanup(); +const viewport = document.getElementById('my-advanced-viewport'); +const content = document.getElementById('my-advanced-content'); +viewport.scrollLeft = (content.offsetWidth - viewport.offsetWidth) / 2; +viewport.scrollTop = (content.offsetHeight - viewport.offsetHeight) / 2; diff --git a/packages/web/src/scss/components/Tooltip/index.html b/packages/web/src/scss/components/Tooltip/index.html index d335340971..3b8f4c7802 100644 --- a/packages/web/src/scss/components/Tooltip/index.html +++ b/packages/web/src/scss/components/Tooltip/index.html @@ -155,7 +155,7 @@

Static Tooltip (No Interaction)

-
+
Tooltips
all day long… @@ -231,19 +231,89 @@

Tooltip on Hover (Pure CSS)

+
+ +

Tooltip with JS plugin

+ +
+ +

Without Floating UI

+ + + + +
+ + +
+ +

With Floating UI and placement fallbacks

+ + + + + + +
+ + +
+ +
+ +
+

Tooltip on Click (JavaScript)

+

Without Floating UI

+ @@ -251,9 +321,44 @@

Tooltip on Click (JavaScript)

I have an externally-triggered tooltip
- + +

With Floating UI and placement fallbacks

+ + + +
+
+ I have an externally-triggered tooltip +
+
@@ -269,7 +374,11 @@

Dismissible Tooltip

-
+
Close me - +
@@ -294,7 +403,7 @@

Dismissible Tooltip

-

Dismissible Tooltip via JS API

+

Dismissible Tooltip via JS API and Floating UI

@@ -303,16 +412,23 @@

Dismissible Tooltip via JS API

-
- -
-
+
-

Full Floating UI Usage

+ + + + +

Advanced Floating Functionality

+ +

+ Try scrolling the frame or resizing the window to see how the Tooltip behaves. The Floating UI + library is trying to keep the Tooltip in the viewport and it is also flipping, shifting and + resizing the Tooltip when it is not possible to keep it in the viewport. +

-

- The following example is using external library Floating UI. - Try scrolling the frame or resizing the window to see how the Tooltip behaves. The Floating UI - library is trying to keep the Tooltip in the viewport and it is also flipping, shifting and - resizing the Tooltip when it is not possible to keep it in the viewport. -

- -
{{/ layout/plain }}