diff --git a/examples/next-with-app-router/package.json b/examples/next-with-app-router/package.json index 6a0b75b081..058c8c29e5 100644 --- a/examples/next-with-app-router/package.json +++ b/examples/next-with-app-router/package.json @@ -15,7 +15,8 @@ "@lmc-eu/spirit-web-react": "workspace:^", "next": "14.2.23", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "sass": "^1.83.0" }, "devDependencies": { "@next/eslint-plugin-next": "14.2.23", diff --git a/packages/web-react/src/components/Button/Button.tsx b/packages/web-react/src/components/Button/Button.tsx index f5d4cba1e7..9ee16ac5ba 100644 --- a/packages/web-react/src/components/Button/Button.tsx +++ b/packages/web-react/src/components/Button/Button.tsx @@ -34,7 +34,7 @@ const _Button = ( const { buttonProps } = useButtonAriaProps(restProps); const { classProps, props: modifiedProps } = useButtonStyleProps(restProps); - const { styleProps, props: otherProps } = useStyleProps(modifiedProps); + const { styleProps, props: otherProps } = useStyleProps({ ElementTag, ...modifiedProps }); return ( ( const Button = forwardRef>(_Button); +Button.spiritComponent = 'Button'; + export default Button; diff --git a/packages/web-react/src/components/Dropdown/DropdownTrigger.tsx b/packages/web-react/src/components/Dropdown/DropdownTrigger.tsx index 5619f2a8c5..efbe2aa991 100644 --- a/packages/web-react/src/components/Dropdown/DropdownTrigger.tsx +++ b/packages/web-react/src/components/Dropdown/DropdownTrigger.tsx @@ -1,6 +1,5 @@ 'use client'; -import classNames from 'classnames'; import React, { ElementType } from 'react'; import { useStyleProps } from '../../hooks'; import { DropdownTriggerProps } from '../../types'; @@ -14,25 +13,23 @@ const defaultProps = { const DropdownTrigger = (props: DropdownTriggerProps) => { const propsWithDefaults = { ...defaultProps, ...props }; - const { elementType = 'button', children, ...rest } = propsWithDefaults; + const { elementType: ElementTag = 'button', children, ...rest } = propsWithDefaults; const { id, isOpen, onToggle, fullWidthMode, triggerRef } = useDropdownContext(); - const Component = elementType; const { classProps, props: modifiedProps } = useDropdownStyleProps({ isOpen, ...rest }); - const { styleProps, props: otherProps } = useStyleProps(modifiedProps); + const { styleProps: triggerStyleProps, props: transferProps } = useStyleProps({ + ElementTag, + transferClassName: classProps.trigger, + ...modifiedProps, + }); const { triggerProps } = useDropdownAriaProps({ id, isOpen, toggleHandler: onToggle, fullWidthMode }); return ( - + {typeof children === 'function' ? children({ isOpen }) : children} - + ); }; +DropdownTrigger.spiritComponent = 'DropdownTrigger'; + export default DropdownTrigger; diff --git a/packages/web-react/src/components/Dropdown/__tests__/DropdownTrigger.test.tsx b/packages/web-react/src/components/Dropdown/__tests__/DropdownTrigger.test.tsx index ab4212cbbb..9527f40fd3 100644 --- a/packages/web-react/src/components/Dropdown/__tests__/DropdownTrigger.test.tsx +++ b/packages/web-react/src/components/Dropdown/__tests__/DropdownTrigger.test.tsx @@ -8,8 +8,16 @@ import DropdownTrigger from '../DropdownTrigger'; jest.mock('../../../hooks/useIcon'); describe('DropdownTrigger', () => { + // pass style props to the default trigger + stylePropsTest((props) => ); + + // pass style props to the custom trigger stylePropsTest((props) => ); + // pass rest props to the default trigger + restPropsTest((props) => , 'button'); + + // pass rest props to the custom trigger restPropsTest((props) => , 'button'); it('should have Button elementType', () => { diff --git a/packages/web-react/src/components/Tooltip/README.md b/packages/web-react/src/components/Tooltip/README.md index 2ba732cbcf..559711bb66 100644 --- a/packages/web-react/src/components/Tooltip/README.md +++ b/packages/web-react/src/components/Tooltip/README.md @@ -30,7 +30,7 @@ const [open, setOpen] = React.useState(false); ; ``` -### Trigger +### TooltipTrigger You can choose whether you want to open the tooltip on `click` and/or `hover`. By default, both options are active, e.g., `trigger={['click', 'hover']}`. @@ -60,6 +60,7 @@ const [open, setOpen] = React.useState(false); | Attribute | Type | Default | Required | Description | | ------------------------------- | ----------------------------------------------------------------- | -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `children` | `ReactNode` | — | ✓ | Tooltip children's nodes - `TooltipTrigger` and `TooltipPopover` | +| `elementType` | `ElementType` | "button" | ✕ | Type of element used as trigger | | `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. | @@ -68,11 +69,11 @@ const [open, setOpen] = React.useState(false); | `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 | +| `isFocusableOnHover` | `bool` | false | ✕ | Allows you to mouse over a tooltip without closing it. We suggest turning off the `click` trigger if you use this feature. | | `isOpen` | `bool` | - | ✓ | Open state | | `onToggle` | `() => void` | - | ✓ | Function for toggle open state of dropdown | | `placement` | [Placement Dictionary][dictionary-placement] | "bottom" | ✕ | Placement of tooltip | | `positionStrategy` | \[`absolute` \| `fixed`] ([Strategy type][use-floating-strategy]) | "absolute" | ✕ | This is the type of CSS position property to use. | -| `isFocusableOnHover` | `bool` | false | ✕ | Allows you to mouse over a tooltip without closing it. We suggest turning off the `click` trigger if you use this feature. | | `trigger` | \[`click` \| `hover` \| `manual`] | \["click", "hover" ] | ✕ | How tooltip is triggered: `click`, `hover`, `manual`. You may pass multiple triggers. If you pass `manual`, there will be no toggle functionality and you should provide your own toggle solution. | On top of the API options, the components accept [additional attributes][readme-additional-attributes]. diff --git a/packages/web-react/src/components/Tooltip/TooltipTrigger.tsx b/packages/web-react/src/components/Tooltip/TooltipTrigger.tsx index b8b86e98f9..a17853f977 100644 --- a/packages/web-react/src/components/Tooltip/TooltipTrigger.tsx +++ b/packages/web-react/src/components/Tooltip/TooltipTrigger.tsx @@ -1,34 +1,29 @@ 'use client'; -import React, { ElementType, ReactNode } from 'react'; +import React from 'react'; import { useStyleProps } from '../../hooks'; -import { StyleProps, TransferProps } from '../../types'; +import { TooltipTriggerProps } from '../../types'; import { useTooltipContext } from './TooltipContext'; -interface TooltipTriggerProps extends StyleProps, TransferProps { - elementType?: ElementType | string; - children?: string | ReactNode | ((props: { isOpen: boolean }) => ReactNode); -} - -const defaultProps: TooltipTriggerProps = { +const defaultProps: Partial = { elementType: 'button', children: null, }; const TooltipTrigger = (props: TooltipTriggerProps) => { const propsWithDefaults = { ...defaultProps, ...props }; - const { elementType = 'button', children, ...rest } = propsWithDefaults; + const { elementType: ElementTag = 'button', children, ...rest } = propsWithDefaults; const { id, isOpen, triggerRef, getReferenceProps } = useTooltipContext(); - const Component = elementType; - - const { styleProps: triggerStyleProps, props: transferProps } = useStyleProps(rest); + const { styleProps: triggerStyleProps, props: transferProps } = useStyleProps({ ElementTag, ...rest }); return ( - + {typeof children === 'function' ? children({ isOpen }) : children} - + ); }; +TooltipTrigger.spiritComponent = 'TooltipTrigger'; + export default TooltipTrigger; diff --git a/packages/web-react/src/global.d.ts b/packages/web-react/src/global.d.ts index c45dcd0e4e..c113a37e6d 100644 --- a/packages/web-react/src/global.d.ts +++ b/packages/web-react/src/global.d.ts @@ -1,7 +1,5 @@ -declare module '*.md' { - const content: string; - export default content; -} +/* eslint-disable @typescript-eslint/ban-types */ +import type { ExoticComponent, FC, StaticLifecycle } from 'react'; interface Window { console: Console; @@ -15,6 +13,28 @@ interface Window { * Try `npm i --save-dev @types/prettier` if it exists or add a new declaration (.d.ts) file containing `declare module 'prettier';` * 5 import { BuiltInParserName } from 'prettier'; */ -declare module 'prettier' { - export type BuiltInParserName = unknown; + +declare global { + module '*.md' { + const content: string; + export default content; + } + + module 'prettier' { + export type BuiltInParserName = unknown; + } + + namespace React { + interface NamedExoticComponent

extends ExoticComponent

{ + spiritComponent?: string; + } + + interface FunctionComponent

extends FC

{ + spiritComponent?: string; + } + + interface ComponentClass

extends StaticLifecycle { + spiritComponent?: string; + } + } } diff --git a/packages/web-react/src/hooks/__tests__/styleProps.test.ts b/packages/web-react/src/hooks/__tests__/styleProps.test.ts index bc5e28190e..2a527ff47b 100644 --- a/packages/web-react/src/hooks/__tests__/styleProps.test.ts +++ b/packages/web-react/src/hooks/__tests__/styleProps.test.ts @@ -1,4 +1,5 @@ import { renderHook } from '@testing-library/react'; +import { Button } from '../../components/Button'; import { StyleProps } from '../../types'; import { useStyleProps } from '../styleProps'; @@ -15,6 +16,10 @@ describe('styleProps', () => { { className: undefined, style: { 'vertical-align': 'center' } }, ], [{ role: 'button' }, { className: undefined, style: undefined }], + [ + { ElementTag: Button, UNSAFE_className: 'test-class' }, + { UNSAFE_className: 'test-class', style: undefined }, + ], ])('should use UNSAFE_style and UNSAFE_className props', (input, expected) => { const { result } = renderHook(() => useStyleProps(input as StyleProps)); @@ -125,6 +130,10 @@ describe('styleProps', () => { { margin: 'space-100', UNSAFE_className: 'm-500' }, { className: 'm-500 m-100', style: undefined }, ], + [ + { ElementTag: Button, margin: 'space-100', UNSAFE_className: 'm-500' }, + { UNSAFE_className: 'm-500 m-100', style: undefined }, + ], ])('should return correct combination of class and style', (input, expected) => { const { result } = renderHook(() => useStyleProps(input as StyleProps)); diff --git a/packages/web-react/src/hooks/styleProps.ts b/packages/web-react/src/hooks/styleProps.ts index 791f78f6f0..c1eda36ff1 100644 --- a/packages/web-react/src/hooks/styleProps.ts +++ b/packages/web-react/src/hooks/styleProps.ts @@ -5,8 +5,15 @@ import ClassNamePrefixContext from '../context/ClassNamePrefixContext'; import { StyleProps } from '../types'; import { useStyleUtilities } from './useStyleUtilities'; +export type UnsafeStylePropsResult = { + UNSAFE_className?: string; + UNSAFE_style?: CSSProperties; + className?: string; + style?: CSSProperties; +}; + export type StylePropsResult = { - styleProps: HTMLAttributes; + styleProps: HTMLAttributes | UnsafeStylePropsResult; props: HTMLAttributes; }; @@ -15,46 +22,61 @@ export function useStyleProps( additionalUtilities?: Record, ): StylePropsResult { const classNamePrefix = useContext(ClassNamePrefixContext); - const { UNSAFE_className, UNSAFE_style, ...otherProps } = props; + const { UNSAFE_className, UNSAFE_style, ElementTag, transferClassName, ...otherProps } = props; const { styleUtilities, props: modifiedProps } = useStyleUtilities(otherProps, classNamePrefix, additionalUtilities); const style: CSSProperties = { ...UNSAFE_style }; - // Want to check if className prop exists, but not to define it in StyleProps type - // @ts-expect-error Property 'className' does not exist on type 'Omit'. - if (modifiedProps.className) { - warning( - false, - 'The className prop is unsafe and is unsupported in Spirit Web React. ' + - 'Please use style props with Spirit Design Tokens, or UNSAFE_className if you absolutely must do something custom. ' + - 'Note that this may break in future versions due to DOM structure changes.', - ); - - // @ts-expect-error same as above, let me live my life - delete modifiedProps.className; - } + // If component is not Spirit component and is HTML element, we need to handle className and style props + if (typeof ElementTag === 'string' || !ElementTag?.spiritComponent) { + // Want to check if className prop exists, but not to define it in StyleProps type + // @ts-expect-error Property 'className' does not exist on type 'Omit'. + if (modifiedProps.className) { + warning( + false, + 'The className prop is unsafe and is unsupported in Spirit Web React. ' + + 'Please use style props with Spirit Design Tokens, or UNSAFE_className if you absolutely must do something custom. ' + + 'Note that this may break in future versions due to DOM structure changes.', + ); - // Want to check if style prop exists, but not to define it in StyleProps type - // @ts-expect-error Property 'style' does not exist on type 'Omit'. - if (modifiedProps.style) { - warning( - false, - 'The style prop is unsafe and is unsupported in Spirit Web React. ' + - 'Please use style props with Spirit Design Tokens, or UNSAFE_style if you absolutely must do something custom. ' + - 'Note that this may break in future versions due to DOM structure changes.', - ); - - // @ts-expect-error same as above, let me live my life - delete modifiedProps.style; - } + // @ts-expect-error same as above, let me live my life + delete modifiedProps.className; + } - const styleProps = { - style: Object.keys(style).length > 0 ? style : undefined, - className: classNames(UNSAFE_className, ...styleUtilities) || undefined, - }; + // Want to check if style prop exists, but not to define it in StyleProps type + // @ts-expect-error Property 'style' does not exist on type 'Omit'. + if (modifiedProps.style) { + warning( + false, + 'The style prop is unsafe and is unsupported in Spirit Web React. ' + + 'Please use style props with Spirit Design Tokens, or UNSAFE_style if you absolutely must do something custom. ' + + 'Note that this may break in future versions due to DOM structure changes.', + ); + + // @ts-expect-error same as above, let me live my life + delete modifiedProps.style; + } + + const styleProps = { + style: Object.keys(style).length > 0 ? style : undefined, + className: classNames(UNSAFE_className, ...styleUtilities, transferClassName) || undefined, + }; + + return { + styleProps, + props: { ...(modifiedProps as HTMLAttributes) }, + }; + } + // If component is a Spirit component, we can safely pass the UNSAFE props + // transferClassName: you can send internal component classes as 'transferClassName' prop to add them to the component className list return { - styleProps, + styleProps: { + ...(UNSAFE_style !== undefined && { UNSAFE_style }), + ...((UNSAFE_className !== undefined || styleUtilities !== undefined) && { + UNSAFE_className: classNames(UNSAFE_className, ...styleUtilities, transferClassName), + }), + }, props: modifiedProps as HTMLAttributes, }; } diff --git a/packages/web-react/src/types/shared/style.ts b/packages/web-react/src/types/shared/style.ts index 3bf1577dd0..74854e7eb7 100644 --- a/packages/web-react/src/types/shared/style.ts +++ b/packages/web-react/src/types/shared/style.ts @@ -1,4 +1,4 @@ -import { CSSProperties } from 'react'; +import { CSSProperties, ElementType } from 'react'; import { SpacingStyleProp } from '../../constants'; import { BreakpointToken, SpaceToken } from './tokens'; @@ -20,7 +20,11 @@ export interface SpacingCSSProperties extends CSSProperties { [index: `--${string}`]: string | undefined | number; } +export type ElementTypeProp = string | ElementType; + export interface StyleProps extends SpacingProps { + ElementTag?: ElementTypeProp; + transferClassName?: string; // For backward compatibility! /** Sets the CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. Only use as a **last resort**. Use style props instead. */ UNSAFE_className?: string; diff --git a/packages/web-react/src/types/tooltip.ts b/packages/web-react/src/types/tooltip.ts index f961b7d2c1..f0ecd61511 100644 --- a/packages/web-react/src/types/tooltip.ts +++ b/packages/web-react/src/types/tooltip.ts @@ -1,5 +1,6 @@ import { Placement, Strategy } from '@floating-ui/react'; -import { ChildrenProps, ClickEvent, StyleProps } from './shared'; +import { ReactNode } from 'react'; +import { ChildrenProps, ClickEvent, ElementTypeProp, StyleProps, TransferProps } from './shared'; export const TOOLTIP_TRIGGER = { CLICK: 'click', @@ -11,6 +12,11 @@ export const TOOLTIP_TRIGGER = { export type TooltipTriggerType = 'click' | 'hover' | 'manual'; +export interface TooltipTriggerProps extends StyleProps, TransferProps { + elementType?: ElementTypeProp; + children?: string | ReactNode | ((props: { isOpen: boolean }) => ReactNode); +} + export interface UncontrolledTooltipProps extends BaseTooltipProps {} export interface TooltipCloseButtonProps extends StyleProps { diff --git a/yarn.lock b/yarn.lock index 977c54aa9e..1a4c4f864b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -40,6 +40,7 @@ __metadata: next: "npm:14.2.23" react: "npm:^18" react-dom: "npm:^18" + sass: "npm:^1.83.0" typescript: "npm:5.6.3" languageName: unknown linkType: soft @@ -12196,6 +12197,15 @@ __metadata: languageName: node linkType: hard +"chokidar@npm:^4.0.0": + version: 4.0.3 + resolution: "chokidar@npm:4.0.3" + dependencies: + readdirp: "npm:^4.0.1" + checksum: 10/bf2a575ea5596000e88f5db95461a9d59ad2047e939d5a4aac59dd472d126be8f1c1ff3c7654b477cf532d18f42a97279ef80ee847972fd2a25410bf00b80b59 + languageName: node + linkType: hard + "chokidar@npm:^4.0.1": version: 4.0.1 resolution: "chokidar@npm:4.0.1" @@ -29575,6 +29585,23 @@ __metadata: languageName: node linkType: hard +"sass@npm:^1.83.0": + version: 1.83.4 + resolution: "sass@npm:1.83.4" + dependencies: + "@parcel/watcher": "npm:^2.4.1" + chokidar: "npm:^4.0.0" + immutable: "npm:^5.0.2" + source-map-js: "npm:>=0.6.2 <2.0.0" + dependenciesMeta: + "@parcel/watcher": + optional: true + bin: + sass: sass.js + checksum: 10/9a7d1c6be1a9e711a1c561d189b9816aa7715f6d0ec0b2ec181f64163788d0caaf4741924eeadce558720b58b1de0e9b21b9dae6a0d14489c4d2a142d3f3b12e + languageName: node + linkType: hard + "sax@npm:^1.2.1": version: 1.4.1 resolution: "sax@npm:1.4.1" @@ -30222,7 +30249,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10/ff9d8c8bf096d534a5b7707e0382ef827b4dd360a577d3f34d2b9f48e12c9d230b5747974ee7c607f0df65113732711bb701fe9ece3c7edbd43cb2294d707df3