diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6c33330..9d88ca4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,6 +8,7 @@ /.storybook/ @fransiscus-hermanto @fransiscushermanto # packages +/packages/components/alert @albertusip /packages/components/badge @fransiscus-hermanto @fransiscushermanto /packages/components/button @fransiscus-hermanto @fransiscushermanto /packages/components/card @albertusip diff --git a/packages/components/alert/package.json b/packages/components/alert/package.json new file mode 100644 index 0000000..809656b --- /dev/null +++ b/packages/components/alert/package.json @@ -0,0 +1,51 @@ +{ + "name": "@julo-ui/alert", + "version": "0.0.1", + "description": "A React component used to communicate a state that affect a system, feature or page", + "keywords": ["alert"], + "main": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "scripts": { + "build": "tsup --dts", + "build:fast": "tsup", + "clean": "rimraf dist .turbo", + "prebuild": "pnpm run clean", + "dev": "pnpm run build:fast", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/julofinance/julo-ui.git", + "directory": "packages/components/alert" + }, + "author": "Albertus Istora P", + "license": "ISC", + "bugs": { + "url": "https://github.com/julofinance/julo-ui/issues" + }, + "homepage": "https://github.com/julofinance/julo-ui#readme", + "dependencies": { + "@emotion/react": "^11.10.6", + "@julo-ui/context": "workspace:*", + "@julo-ui/dom-utils": "workspace:*", + "@julo-ui/typography": "workspace:*" + }, + "devDependencies": { "@julo-ui/system": "workspace:*" }, + "peerDependencies": { + "@emotion/react": "^11.10.6", + "react": ">=18", + "@julo-ui/system": "workspace:*" + }, + "clean-package": "../../../clean-package.config.json", + "tsup": { + "entry": ["src", "!src/**/*.md"], + "clean": true, + "target": "es2019", + "format": ["cjs", "esm"] + } +} diff --git a/packages/components/alert/src/Alert.tsx b/packages/components/alert/src/Alert.tsx new file mode 100644 index 0000000..655d84e --- /dev/null +++ b/packages/components/alert/src/Alert.tsx @@ -0,0 +1,31 @@ +import { forwardRef, julo, cx } from '@julo-ui/system'; + +import { alertCx, alertStatus } from './styles'; +import type { AlertProps } from './types'; +import { AlertProvider } from './AlertProvider'; + +const Alert = forwardRef((props, ref) => { + const { children, className, status = 'neutrals', sx, ...resProps } = props; + + return ( + + + {children} + + + ); +}); + +Alert.displayName = 'Alert'; + +export default Alert; diff --git a/packages/components/alert/src/AlertProvider.tsx b/packages/components/alert/src/AlertProvider.tsx new file mode 100644 index 0000000..50043b6 --- /dev/null +++ b/packages/components/alert/src/AlertProvider.tsx @@ -0,0 +1,13 @@ +import { createContext } from '@julo-ui/context'; + +import type { AlertProps } from './types'; + +type AlertContextProps = Pick; + +const [AlertProvider, useAlertContext] = createContext({ + name: 'AlertContext', + hookName: 'useAlertContext', + providerName: '', +}); + +export { AlertProvider, useAlertContext }; diff --git a/packages/components/alert/src/components/alert-description/AlertDescription.tsx b/packages/components/alert/src/components/alert-description/AlertDescription.tsx new file mode 100644 index 0000000..f0c9ce1 --- /dev/null +++ b/packages/components/alert/src/components/alert-description/AlertDescription.tsx @@ -0,0 +1,34 @@ +import { Typography } from '@julo-ui/typography'; +import { forwardRef, cx } from '@julo-ui/system'; + +import { useAlertContext } from '../../AlertProvider'; + +import { alertDescription } from './styles'; +import type { AlertDescriptionProps } from './types'; + +const AlertDescription = forwardRef( + (props, ref) => { + const { children, className, sx, ...resProps } = props; + const { status = 'neutrals' } = useAlertContext( + 'AlertDescription should be within Alert', + ); + + return ( + + {children} + + ); + }, +); + +AlertDescription.displayName = 'AlertDescription'; + +export default AlertDescription; diff --git a/packages/components/alert/src/components/alert-description/index.ts b/packages/components/alert/src/components/alert-description/index.ts new file mode 100644 index 0000000..19cc5fc --- /dev/null +++ b/packages/components/alert/src/components/alert-description/index.ts @@ -0,0 +1,2 @@ +export { default as AlertDescription } from './AlertDescription'; +export * from './types'; diff --git a/packages/components/alert/src/components/alert-description/styles.ts b/packages/components/alert/src/components/alert-description/styles.ts new file mode 100644 index 0000000..da1ce77 --- /dev/null +++ b/packages/components/alert/src/components/alert-description/styles.ts @@ -0,0 +1,21 @@ +import { SystemStyleObject } from '@julo-ui/system'; + +import { AlertStatus } from '../../types'; + +export const alertDescription: Record = { + info: { + color: 'var(--colors-blue-40)', + }, + negative: { + color: 'var(--colors-red-40)', + }, + positive: { + color: 'var(--colors-green-40)', + }, + warning: { + color: 'var(--colors-orange-40)', + }, + neutrals: { + color: 'var(--colors-neutrals-90)', + }, +}; diff --git a/packages/components/alert/src/components/alert-description/types.ts b/packages/components/alert/src/components/alert-description/types.ts new file mode 100644 index 0000000..59d6316 --- /dev/null +++ b/packages/components/alert/src/components/alert-description/types.ts @@ -0,0 +1,3 @@ +import { HTMLJuloProps } from '@julo-ui/system'; + +export type AlertDescriptionProps = HTMLJuloProps<'div'>; diff --git a/packages/components/alert/src/components/alert-icon/AlertIcon.tsx b/packages/components/alert/src/components/alert-icon/AlertIcon.tsx new file mode 100644 index 0000000..0a8eb2f --- /dev/null +++ b/packages/components/alert/src/components/alert-icon/AlertIcon.tsx @@ -0,0 +1,34 @@ +import { forwardRef, julo, cx } from '@julo-ui/system'; + +import { useAlertContext } from '../../AlertProvider'; + +import { useHandleIcon } from './usecase'; +import { alertIconStatusSx, alertIconCx } from './styles'; +import { AlertIconProps } from './types'; + +const AlertIcon = forwardRef((props, ref) => { + const { placement = 'left', className, sx, ...resProps } = props; + const { status = 'neutrals' } = useAlertContext( + 'AlertIcon should be within Alert', + ); + + const Icon = useHandleIcon({ status: status }); + + return ( + + + + ); +}); + +AlertIcon.displayName = 'AlertIcon'; + +export default AlertIcon; diff --git a/packages/components/alert/src/components/alert-icon/index.ts b/packages/components/alert/src/components/alert-icon/index.ts new file mode 100644 index 0000000..9cee6c8 --- /dev/null +++ b/packages/components/alert/src/components/alert-icon/index.ts @@ -0,0 +1,2 @@ +export { default as AlertIcon } from './AlertIcon'; +export * from './types'; diff --git a/packages/components/alert/src/components/alert-icon/styles.ts b/packages/components/alert/src/components/alert-icon/styles.ts new file mode 100644 index 0000000..841a3ec --- /dev/null +++ b/packages/components/alert/src/components/alert-icon/styles.ts @@ -0,0 +1,49 @@ +import { css } from '@emotion/react'; +import { SystemStyleObject } from '@julo-ui/system'; + +import { AlertStatus } from '../../types'; + +export const alertIconCx = css` + &[data-icon-placement='left'] { + order: 0; + margin-right: 0.5rem; + } + + &[data-icon-placement='right'] { + order: 999; + margin-left: 0.5rem; + } +`; + +export const alertIconStatusSx: Record = { + info: { + color: 'var(--colors-blue-40)', + svg: { + fill: 'var(--colors-blue-40)', + }, + }, + negative: { + color: 'var(--colors-red-40)', + svg: { + fill: 'var(--colors-red-40)', + }, + }, + positive: { + color: 'var(--colors-green-40)', + svg: { + fill: 'var(--colors-green-40)', + }, + }, + warning: { + color: 'var(--colors-orange-40)', + svg: { + fill: 'var(--colors-orange-40)', + }, + }, + neutrals: { + color: 'var(--colors-neutrals-90)', + svg: { + fill: 'var(--colors-neutrals-90)', + }, + }, +}; diff --git a/packages/components/alert/src/components/alert-icon/types.ts b/packages/components/alert/src/components/alert-icon/types.ts new file mode 100644 index 0000000..77f0ded --- /dev/null +++ b/packages/components/alert/src/components/alert-icon/types.ts @@ -0,0 +1,10 @@ +import { HTMLJuloProps } from '@julo-ui/system'; + +export interface AlertIconProps extends HTMLJuloProps<'svg'> { + /** + * The placement option for icon and affect in style margin left or right + * if placement={null}, the placement style will not be applied + * + */ + placement?: 'left' | null | 'right'; +} diff --git a/packages/components/alert/src/components/alert-icon/usecase/index.ts b/packages/components/alert/src/components/alert-icon/usecase/index.ts new file mode 100644 index 0000000..e30b5ad --- /dev/null +++ b/packages/components/alert/src/components/alert-icon/usecase/index.ts @@ -0,0 +1 @@ +export { default as useHandleIcon } from './use-handle-icon'; diff --git a/packages/components/alert/src/components/alert-icon/usecase/use-handle-icon.ts b/packages/components/alert/src/components/alert-icon/usecase/use-handle-icon.ts new file mode 100644 index 0000000..234cf2f --- /dev/null +++ b/packages/components/alert/src/components/alert-icon/usecase/use-handle-icon.ts @@ -0,0 +1,32 @@ +import { + InfoIcon, + NegativeIcon, + NeutralsIcon, + PositiveIcon, + WarningIcon, +} from '../../../icons'; + +interface UseHandleIconOptions { + status: string; +} + +function useHandleIcon(options: UseHandleIconOptions) { + const { status } = options; + + switch (status) { + case 'info': + return InfoIcon; + case 'negative': + return NegativeIcon; + case 'positive': + return PositiveIcon; + case 'warning': + return WarningIcon; + case 'neutrals': + return NeutralsIcon; + default: + return NeutralsIcon; + } +} + +export default useHandleIcon; diff --git a/packages/components/alert/src/components/alert-title/AlertTitle.tsx b/packages/components/alert/src/components/alert-title/AlertTitle.tsx new file mode 100644 index 0000000..400d2d5 --- /dev/null +++ b/packages/components/alert/src/components/alert-title/AlertTitle.tsx @@ -0,0 +1,32 @@ +import { Typography } from '@julo-ui/typography'; +import { forwardRef, cx } from '@julo-ui/system'; + +import { useAlertContext } from '../../AlertProvider'; + +import { alertTitle } from './styles'; +import type { AlertTitleProps } from './types'; + +const AlertTitle = forwardRef((props, ref) => { + const { children, className, sx, ...resProps } = props; + const { status = 'neutrals' } = useAlertContext( + 'AlertTitle should be within Alert', + ); + + return ( + + {children} + + ); +}); + +AlertTitle.displayName = 'AlertTitle'; + +export default AlertTitle; diff --git a/packages/components/alert/src/components/alert-title/index.ts b/packages/components/alert/src/components/alert-title/index.ts new file mode 100644 index 0000000..b5ad4c5 --- /dev/null +++ b/packages/components/alert/src/components/alert-title/index.ts @@ -0,0 +1,2 @@ +export { default as AlertTitle } from './AlertTitle'; +export * from './types'; diff --git a/packages/components/alert/src/components/alert-title/styles.ts b/packages/components/alert/src/components/alert-title/styles.ts new file mode 100644 index 0000000..0e84529 --- /dev/null +++ b/packages/components/alert/src/components/alert-title/styles.ts @@ -0,0 +1,21 @@ +import { SystemStyleObject } from '@julo-ui/system'; + +import { AlertStatus } from '../../types'; + +export const alertTitle: Record = { + info: { + color: 'var(--colors-blue-50)', + }, + negative: { + color: 'var(--colors-red-50)', + }, + positive: { + color: 'var(--colors-green-50)', + }, + warning: { + color: 'var(--colors-orange-50)', + }, + neutrals: { + color: 'var(--colors-neutrals-100)', + }, +}; diff --git a/packages/components/alert/src/components/alert-title/types.ts b/packages/components/alert/src/components/alert-title/types.ts new file mode 100644 index 0000000..000c3e6 --- /dev/null +++ b/packages/components/alert/src/components/alert-title/types.ts @@ -0,0 +1,3 @@ +import { HTMLJuloProps } from '@julo-ui/system'; + +export type AlertTitleProps = HTMLJuloProps<'div'>; diff --git a/packages/components/alert/src/icons.tsx b/packages/components/alert/src/icons.tsx new file mode 100644 index 0000000..36c8845 --- /dev/null +++ b/packages/components/alert/src/icons.tsx @@ -0,0 +1,92 @@ +import { julo, HTMLJuloProps } from '@julo-ui/system'; + +export function InfoIcon(props: HTMLJuloProps<'svg'>) { + return ( + + + + ); +} + +export function NegativeIcon(props: HTMLJuloProps<'svg'>) { + return ( + + + + ); +} + +export function PositiveIcon(props: HTMLJuloProps<'svg'>) { + return ( + + + + ); +} + +export function WarningIcon(props: HTMLJuloProps<'svg'>) { + return ( + + + + ); +} + +export function NeutralsIcon(props: HTMLJuloProps<'svg'>) { + return ( + + + + ); +} diff --git a/packages/components/alert/src/index.ts b/packages/components/alert/src/index.ts new file mode 100644 index 0000000..a1a6a58 --- /dev/null +++ b/packages/components/alert/src/index.ts @@ -0,0 +1,6 @@ +export { default } from './Alert'; +export { default as Alert } from './Alert'; +export * from './components/alert-description'; +export * from './components/alert-icon'; +export * from './components/alert-title'; +export * from './types'; diff --git a/packages/components/alert/src/styles.ts b/packages/components/alert/src/styles.ts new file mode 100644 index 0000000..2d48c5d --- /dev/null +++ b/packages/components/alert/src/styles.ts @@ -0,0 +1,34 @@ +import { css } from '@emotion/react'; + +import { SystemStyleObject } from '@julo-ui/system'; + +import { AlertStatus } from './types'; + +export const alertCx = css` + border-radius: 0.5rem; + display: flex; + padding: 0.5rem 0.75rem; +`; + +export const alertStatus: Record = { + info: { + border: '1px solid var(--colors-blue-20)', + background: 'var(--colors-blue-10)', + }, + negative: { + border: '1px solid var(--colors-red-20)', + background: 'var(--colors-red-10)', + }, + positive: { + border: '1px solid var(--colors-green-20)', + background: 'var(--colors-green-10)', + }, + warning: { + border: '1px solid var(--colors-orange-20)', + background: 'var(--colors-orange-10)', + }, + neutrals: { + border: '1px solid var(--colors-neutrals-50)', + background: 'var(--colors-neutrals-30)', + }, +}; diff --git a/packages/components/alert/src/types.ts b/packages/components/alert/src/types.ts new file mode 100644 index 0000000..9ba1df7 --- /dev/null +++ b/packages/components/alert/src/types.ts @@ -0,0 +1,16 @@ +import { HTMLJuloProps } from '@julo-ui/system'; + +export type AlertStatus = + | 'info' + | 'negative' + | 'positive' + | 'warning' + | 'neutrals'; + +export interface AlertProps extends HTMLJuloProps<'div'> { + /** + * @default "neutrals" + * The `status` of the Alert will affect style colors and Typography + */ + status?: AlertStatus; +} diff --git a/packages/components/alert/stories/Alert.stories.tsx b/packages/components/alert/stories/Alert.stories.tsx new file mode 100644 index 0000000..af58178 --- /dev/null +++ b/packages/components/alert/stories/Alert.stories.tsx @@ -0,0 +1,139 @@ +import { Story, Meta } from '@storybook/react'; +import { css } from '@emotion/react'; + +import { julo } from '@julo-ui/system'; +import { Typography } from '@julo-ui/typography'; + +import DefaultAlert, { + AlertDescription, + AlertIcon, + AlertProps, + AlertTitle, +} from '../src'; + +export default { + title: 'Components/Alert', + component: DefaultAlert, + parameters: { + docs: { + description: { + component: "`import { Alert } from '@julo-ui/alert';`", + }, + }, + }, +} as Meta; + +const Alert: Story = (args) => ; + +export const Info = () => { + return ( + + +
+ Hello! + + To improve our services, system maintenance is carried out on the JULO + website so that it cannot be accessed temporarily + +
+
+ ); +}; + +export const Negative = () => { + return ( + + +
+ 500 Internal Server Error + + Try to refresh this page or feel free to contact us if the problem + persist. + +
+
+ ); +}; + +export const Positive = () => { + return ( + + +
+ Application submitted! + + Thanks for submitting your application. We will review your + application within the next 48 hours. + +
+
+ ); +}; + +export const Warning = () => { + return ( + + +
+ Hello User + Seems your bill is due, pay it now +
+
+ ); +}; + +export const Neutrals = () => { + return ( + + +
+ Hello User + + Julo never ask your NIK and Password + +
+
+ ); +}; + +export const AlertWithIconPlacement = () => { + return ( + + + + Hello User + Seems your bill is due, pay it now + + + + ); +}; + +export const AlertCustom = () => { + const styleCustom = css` + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 15px; + padding-bottom: 15px; + + svg { + width: 50px; + height: 50px; + } + `; + return ( + + + + + Application submitted! + + + + Thanks for submitting your application. We will review your application + within the next 48 hours. + + + ); +}; diff --git a/packages/components/alert/tests/Alert.spec.tsx b/packages/components/alert/tests/Alert.spec.tsx new file mode 100644 index 0000000..098b96a --- /dev/null +++ b/packages/components/alert/tests/Alert.spec.tsx @@ -0,0 +1,178 @@ +import { render, renderer, screen, testA11y } from '@julo-ui/rtl-utils'; + +import Alert, { AlertDescription, AlertIcon, AlertTitle } from '../src'; + +describe('Accessibility', () => { + test('passes a11y test in default state', async () => { + await testA11y(); + }); +}); + +describe('Alert', () => { + test('should render Alert correctly', () => { + const { container } = render(Hello World); + const svgEl = + container.firstElementChild?.getAttribute('data-alert-status'); + + expect(svgEl).toContain('neutrals'); + screen.getByText('Hello World'); + }); + + test('should render Alert with status default correctly', () => { + const alert = renderer + .create(alert default Success) + .toJSON(); + + expect(alert).toHaveStyleRule('background', 'var(--colors-neutrals-30)'); + expect(alert).toHaveStyleRule( + 'border', + '1px solid var(--colors-neutrals-50)', + ); + expect(alert).toHaveStyleRule('border-radius', '0.5rem'); + expect(alert).toHaveStyleRule('padding', '0.5rem 0.75rem'); + expect(alert).toHaveStyleRule('display', 'flex'); + }); + + test('should render Alert with status "info" correctly', () => { + const alert = renderer + .create( + + alert Info + , + ) + .toJSON(); + + expect(alert).toHaveStyleRule('background', 'var(--colors-blue-10)'); + expect(alert).toHaveStyleRule('border', '1px solid var(--colors-blue-20)'); + }); + + test('should render Alert with status "warning" correctly', () => { + const alert = renderer + .create( + + alert Warning + , + ) + .toJSON(); + + expect(alert).toHaveStyleRule('background', 'var(--colors-orange-10)'); + expect(alert).toHaveStyleRule( + 'border', + '1px solid var(--colors-orange-20)', + ); + }); + + test('should render Alert with status "positive" correctly', () => { + const alert = renderer + .create( + + alert Positive + , + ) + .toJSON(); + + expect(alert).toHaveStyleRule('background', 'var(--colors-green-10)'); + expect(alert).toHaveStyleRule('border', '1px solid var(--colors-green-20)'); + }); + + test('should render Alert with status "negative" correctly', () => { + const alert = renderer + .create( + + alert Negative + , + ) + .toJSON(); + + expect(alert).toHaveStyleRule('background', 'var(--colors-red-10)'); + expect(alert).toHaveStyleRule('border', '1px solid var(--colors-red-20)'); + }); + + test('should render Alert with status "neutrals" correctly', () => { + const alert = renderer + .create( + + alert Neutrals + , + ) + .toJSON(); + + expect(alert).toHaveStyleRule('background', 'var(--colors-neutrals-30)'); + expect(alert).toHaveStyleRule( + 'border', + '1px solid var(--colors-neutrals-50)', + ); + }); + + test('should render Alert with Title correctly', () => { + render( + + Alert Title + , + ); + + screen.getByText('Alert Title'); + }); + + test('should render Alert with Description correctly', () => { + render( + + Alert Description + , + ); + + screen.getByText('Alert Description'); + }); + + test('should render Alert with Icon and Title correctly', () => { + const { container } = render( + + + Alert Icon and Title + , + ); + const svgEl = container.querySelector('svg'); + + expect(svgEl?.getAttribute('data-icon')).toContain('neutrals'); + screen.getByText('Alert Icon and Title'); + }); + + test('should render Alert with Icon and Description correctly', () => { + const { container } = render( + + + Alert Icon and Description + , + ); + const iconLeft = screen.getByTestId('icon'); + const svgEl = container.querySelector('svg'); + + expect(iconLeft.getAttribute('data-icon-placement')).toContain('left'); + expect(svgEl?.getAttribute('data-icon')).toContain('neutrals'); + screen.getByText('Alert Icon and Description'); + }); + + test('should render Alert with Icon "Positive", Title and Description correctly', () => { + const { container } = render( + + + Alert Title + + Alert Description + + , + ); + const iconLeft = screen.getByTestId('iconLeft'); + const iconMiddle = screen.getByTestId('iconMiddle'); + const iconRight = screen.getByTestId('iconRight'); + const svgEl = container.querySelector('svg'); + + expect(iconLeft.getAttribute('data-icon-placement')).toContain('left'); + expect(iconMiddle.getAttribute('data-icon-placement')).toBeNull(); + expect(iconRight.getAttribute('data-icon-placement')).toContain('right'); + expect(svgEl?.getAttribute('data-icon')).toContain('positive'); + + screen.getByText('Alert Title'); + screen.getByText('Alert Description'); + }); +}); diff --git a/packages/components/alert/tsconfig.json b/packages/components/alert/tsconfig.json new file mode 100644 index 0000000..b59af2e --- /dev/null +++ b/packages/components/alert/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "stories", "tests"] +} diff --git a/packages/components/card/package.json b/packages/components/card/package.json index 8c05926..23de064 100644 --- a/packages/components/card/package.json +++ b/packages/components/card/package.json @@ -23,7 +23,7 @@ "url": "git+https://github.com/julofinance/julo-ui.git", "directory": "packages/components/card" }, - "author": "Fransiscus Hermanto", + "author": "Albertus Istora P", "license": "ISC", "bugs": { "url": "https://github.com/julofinance/julo-ui/issues" diff --git a/packages/components/card/tests/Card.spec.tsx b/packages/components/card/tests/Card.spec.tsx index 6543891..2a6c047 100644 --- a/packages/components/card/tests/Card.spec.tsx +++ b/packages/components/card/tests/Card.spec.tsx @@ -90,16 +90,42 @@ describe('Card', () => { }); test('should render Card default with Header, Body and Footer and another children correctly', () => { - const card = renderer - .create( - - card header -
another children
- card body - card footer -
, - ) - .toJSON(); + const { getByTestId } = render( + + card header +
another children
+ card body + card footer +
, + ); + + const card = getByTestId('card'); + const body = getByTestId('body'); + const footer = getByTestId('footer'); + + expect(card).toHaveStyleRule( + 'background-color', + 'var(--colors-neutrals-10)', + ); + expect(card).toHaveStyleRule('box-shadow', 'var(--shadows-md)'); + expect(card).toHaveStyleRule('padding-top', '0.75rem'); + expect(card).toHaveStyleRule('padding-bottom', '0.75rem'); + expect(body).toHaveStyle({ 'margin-top': '' }); + expect(footer).toHaveStyle({ 'margin-top': '0.75rem' }); + }); + + test('should render Card default with Header, Body and Footer correctly', () => { + const { getByTestId } = render( + + card header + card body + card footer + , + ); + + const card = getByTestId('card'); + const body = getByTestId('body'); + const footer = getByTestId('footer'); expect(card).toHaveStyleRule( 'background-color', @@ -108,8 +134,7 @@ describe('Card', () => { expect(card).toHaveStyleRule('box-shadow', 'var(--shadows-md)'); expect(card).toHaveStyleRule('padding-top', '0.75rem'); expect(card).toHaveStyleRule('padding-bottom', '0.75rem'); - expect(card).toHaveStyleRule('margin-top', '0.75rem', { - target: ':not(:first-of-type)', - }); + expect(body).toHaveStyle({ 'margin-top': '0.75rem' }); + expect(footer).toHaveStyle({ 'margin-top': '0.75rem' }); }); }); diff --git a/packages/components/checkbox/package.json b/packages/components/checkbox/package.json index 9615baf..754411e 100644 --- a/packages/components/checkbox/package.json +++ b/packages/components/checkbox/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/checkbox", - "version": "0.0.13", + "version": "0.0.15", "description": "A React Component for Checkbox used in forms", "keywords": ["checkbox"], "main": "src/index.ts", diff --git a/packages/components/checkbox/src/use-checkbox.ts b/packages/components/checkbox/src/use-checkbox.ts index 16ea4f8..7781d3f 100644 --- a/packages/components/checkbox/src/use-checkbox.ts +++ b/packages/components/checkbox/src/use-checkbox.ts @@ -65,11 +65,12 @@ export function useCheckbox(props: UseCheckboxProps = {}) { const [isRootLabelElement, setIsRootLabelElement] = useState(true); const [isCheckedLocal, setIsCheckedLocal] = useState(Boolean(defaultChecked)); - const isControlledNative = checked !== undefined; + const isControlledNatively = checked !== undefined; const isControlled = isCheckedProp !== undefined; + const isTrullyControlled = isControlledNatively && isControlled; const isChecked = isControlled ? isCheckedProp - : isControlledNative + : isControlledNatively ? checked : isCheckedLocal; const isTrulyDisabled = isDisabled && !isFocusable; @@ -111,7 +112,7 @@ export function useCheckbox(props: UseCheckboxProps = {}) { return; } - if (!isControlled) { + if (!isTrullyControlled) { if (isChecked) { setIsCheckedLocal(event.target.checked); } else { @@ -122,12 +123,12 @@ export function useCheckbox(props: UseCheckboxProps = {}) { onChangeProp(event); }, [ - isChecked, - isControlled, - isDisabled, - isIndeterminate, isReadOnly, + isDisabled, + isTrullyControlled, onChangeProp, + isChecked, + isIndeterminate, ], ); diff --git a/packages/components/form-control/package.json b/packages/components/form-control/package.json index 9320355..bcbb066 100644 --- a/packages/components/form-control/package.json +++ b/packages/components/form-control/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/form-control", - "version": "0.0.12", + "version": "0.0.13", "description": "React component to provide validation states for form fields", "keywords": ["form-control"], "main": "src/index.ts", diff --git a/packages/components/form-control/src/use-form-control.ts b/packages/components/form-control/src/use-form-control.ts index 8754ef5..a2e1446 100644 --- a/packages/components/form-control/src/use-form-control.ts +++ b/packages/components/form-control/src/use-form-control.ts @@ -78,7 +78,7 @@ export function useFormControl( disabled: isDisabled, required: isRequired, readOnly: isReadOnly, - ...(!isDisabled && !isReadOnly && { 'aria-invalid': ariaAttr(isInvalid) }), + 'aria-invalid': ariaAttr(isInvalid), 'aria-required': ariaAttr(isRequired), 'aria-readonly': ariaAttr(isReadOnly), 'aria-busy': ariaAttr(isLoading), diff --git a/packages/components/input/package.json b/packages/components/input/package.json index 08bc3b1..15f32d0 100644 --- a/packages/components/input/package.json +++ b/packages/components/input/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/input", - "version": "0.0.12", + "version": "0.0.17", "description": "A React Component for Input Text Field", "keywords": ["input"], "main": "src/index.ts", diff --git a/packages/components/input/src/components/input-group/styles.ts b/packages/components/input/src/components/input-group/styles.ts index 9f0bf87..8834eb7 100644 --- a/packages/components/input/src/components/input-group/styles.ts +++ b/packages/components/input/src/components/input-group/styles.ts @@ -18,23 +18,26 @@ export const inputGroupWithTextAreaCx = css` `; export const inputGroupWithElementCx = css` + position: relative; border: 1px solid var(--colors-neutrals-40); border-radius: 0.5rem; - &[data-input-disabled='true'] { - border-color: var(--colors-neutrals-50); - background-color: var(--colors-neutrals-30); - * { - background-color: transparent; + &[data-input-readonly='false'] { + &[data-input-invalid='true'] { + border-color: var(--colors-red-30); } - } - &[data-input-invalid='true'] { - border-color: var(--colors-red-30); - } + &[data-input-disabled='true'] { + border-color: var(--colors-neutrals-50); + background-color: var(--colors-neutrals-30); + * { + background-color: transparent; + } + } - &[data-input-focus='true'] { - border-color: var(--colors-primary-20); + &[data-input-focus='true'] { + border-color: var(--colors-primary-20); + } } `; @@ -78,14 +81,6 @@ export const inputElementSx = ({ border: 'none', }), ...(!isOnlyElement && { - ...(leftAddon && { - borderTopLeftRadius: '0', - borderBottomLeftRadius: '0', - }), - ...(rightAddon && { - borderTopRightRadius: '0', - borderBottomRightRadius: '0', - }), ...(leftElement && { paddingLeft: '3rem', }), @@ -93,4 +88,12 @@ export const inputElementSx = ({ paddingRight: '3rem', }), }), + ...((leftAddon || (leftElement && isOnlyElement)) && { + borderTopLeftRadius: '0', + borderBottomLeftRadius: '0', + }), + ...((rightAddon || (rightElement && isOnlyElement)) && { + borderTopRightRadius: '0', + borderBottomRightRadius: '0', + }), }); diff --git a/packages/components/input/src/components/input-group/usecase/use-handle-children.tsx b/packages/components/input/src/components/input-group/usecase/use-handle-children.tsx index 65e1bd9..d90b133 100644 --- a/packages/components/input/src/components/input-group/usecase/use-handle-children.tsx +++ b/packages/components/input/src/components/input-group/usecase/use-handle-children.tsx @@ -36,6 +36,7 @@ function useHandleChildren({ children, inputRef }: UseHandleChildrenOptions) { rightAddon, leftElement, rightElement, + isElementExist, } = validChildren.reduce>( (prevValue, child) => { return { @@ -45,6 +46,10 @@ function useHandleChildren({ children, inputRef }: UseHandleChildrenOptions) { child.type.id === 'input-right-addon') && { isAddonExist: true, }), + ...((child.type.id === 'input-left-element' || + child.type.id === 'input-right-element') && { + isElementExist: true, + }), ...(child.type.id === 'input-left-addon' && { leftAddon: true }), ...(child.type.id === 'input-right-addon' && { rightAddon: true }), ...(child.type.id === 'input-left-element' && { leftElement: true }), @@ -60,6 +65,7 @@ function useHandleChildren({ children, inputRef }: UseHandleChildrenOptions) { rightAddon: false, leftElement: false, rightElement: false, + isElementExist: false, }, ); @@ -67,15 +73,13 @@ function useHandleChildren({ children, inputRef }: UseHandleChildrenOptions) { if (child.type.id === 'input' || child.type.id === 'textarea') { const isTextArea = child.type.id === 'textarea'; return cloneElement(child, { - ...(!isAddonExist && { - ref: mergeRefs(inputRef, child.ref), - }), + ref: mergeRefs(inputRef, child.ref), sx: inputElementSx({ leftAddon, rightAddon, leftElement, rightElement, - isOnlyElement: !isAddonExist, + isOnlyElement: !isAddonExist && isElementExist, }), ...(isTextArea && { isResizeable: false }), }); @@ -86,7 +90,11 @@ function useHandleChildren({ children, inputRef }: UseHandleChildrenOptions) { const extendedInputGroupCx = [ isTextArea && inputGroupWithTextAreaCx, - isAddonExist ? inputGroupWithAddonCx : inputGroupWithElementCx, + isAddonExist + ? inputGroupWithAddonCx + : isElementExist + ? inputGroupWithElementCx + : null, ].filter(Boolean) as Array; return { clones, extendedInputGroupCx }; diff --git a/packages/components/input/src/components/input-group/usecase/use-listen-input.tsx b/packages/components/input/src/components/input-group/usecase/use-listen-input.tsx index c062a24..3307957 100644 --- a/packages/components/input/src/components/input-group/usecase/use-listen-input.tsx +++ b/packages/components/input/src/components/input-group/usecase/use-listen-input.tsx @@ -9,6 +9,7 @@ const attributes = { disabled: 'data-input-disabled', invalid: 'data-input-invalid', focus: 'data-input-focus', + readonly: 'data-input-readonly', }; function useListenInput({ groupRef, inputRef }: UseListenInputOptions) { @@ -24,16 +25,15 @@ function useListenInput({ groupRef, inputRef }: UseListenInputOptions) { const observer = new MutationObserver((mutations) => { mutations.forEach((record) => { if (record.type === 'attributes') { - const isInvalid = (record.target as HTMLElement).getAttribute( - 'aria-invalid', - ); + const element = record.target as HTMLElement; - const disabled = (record.target as HTMLElement).getAttribute( - 'disabled', - ); + const isInvalid = element.getAttribute('aria-invalid'); + const disabled = element.getAttribute('disabled'); + const readOnly = element.getAttribute('aria-readonly'); syncGroupAttribute(attributes.invalid, Boolean(isInvalid)); syncGroupAttribute(attributes.disabled, Boolean(disabled !== null)); + syncGroupAttribute(attributes.readonly, Boolean(readOnly)); } }); }); @@ -55,11 +55,14 @@ function useListenInput({ groupRef, inputRef }: UseListenInputOptions) { attributes.disabled, Boolean(input.getAttribute('disabled') !== null), ); - syncGroupAttribute( attributes.invalid, Boolean(input.getAttribute('aria-invalid')), ); + syncGroupAttribute( + attributes.readonly, + Boolean(input.getAttribute('aria-readonly')), + ); } () => { diff --git a/packages/components/input/tests/Input.spec.tsx b/packages/components/input/tests/Input.spec.tsx index cc27986..d2e748a 100644 --- a/packages/components/input/tests/Input.spec.tsx +++ b/packages/components/input/tests/Input.spec.tsx @@ -1,6 +1,19 @@ -import { render, screen, testA11y } from '@julo-ui/rtl-utils'; +import { + fireEvent, + render, + renderer, + screen, + testA11y, +} from '@julo-ui/rtl-utils'; -import Input, { InputGroup, InputLeftElement, InputRightElement } from '../src'; +import Input, { + InputGroup, + InputLeftAddon, + InputLeftElement, + InputRightAddon, + InputRightElement, +} from '../src'; +import { FormControl } from '@julo-ui/form-control'; describe('Accesibility', () => { test('should passes a11y test', async () => { @@ -39,6 +52,154 @@ describe('Input', () => { expect(screen.queryByText('World')).toBeInTheDocument(); }); + test('should sync InputGroup attribute with Input', () => { + render( + + + + + , + ); + + const inputGroup = screen.queryByTestId('input-group'); + + expect(inputGroup).toHaveAttribute('data-input-invalid', 'true'); + expect(inputGroup).toHaveAttribute('data-input-disabled', 'true'); + expect(inputGroup).toHaveAttribute('data-input-readonly', 'true'); + + fireEvent.focus(screen.getByTestId('input')); + + expect(inputGroup).toHaveAttribute('data-input-focus', 'true'); + }); + + test('should apply or remove border radius correctly on input if InputElement and InputAddon exist', () => { + const jsonOnlyElement = renderer + .create( + + + Hello + + + + World + + , + ) + .toJSON(); + + if (Array.isArray(jsonOnlyElement) || !jsonOnlyElement) return; + + if (!jsonOnlyElement.children) return; + + const [, inputOnlyElement] = jsonOnlyElement.children; + + expect(inputOnlyElement).toHaveStyleRule('border-top-left-radius', '0'); + expect(inputOnlyElement).toHaveStyleRule('border-bottom-left-radius', '0'); + expect(inputOnlyElement).toHaveStyleRule('border-top-right-radius', '0'); + expect(inputOnlyElement).toHaveStyleRule('border-bottom-right-radius', '0'); + + const jsonOnlyAddon = renderer + .create( + + + Hello + + + + World + + , + ) + .toJSON(); + + if (Array.isArray(jsonOnlyAddon) || !jsonOnlyAddon) return; + + if (!jsonOnlyAddon.children) return; + + const [, inputOnlyAddon] = jsonOnlyAddon.children; + + expect(inputOnlyAddon).toHaveStyleRule('border-top-left-radius', '0'); + expect(inputOnlyAddon).toHaveStyleRule('border-bottom-left-radius', '0'); + expect(inputOnlyAddon).toHaveStyleRule('border-top-right-radius', '0'); + expect(inputOnlyAddon).toHaveStyleRule('border-bottom-right-radius', '0'); + + const jsonLeftAddonRightElement = renderer + .create( + + + Hello + + + + World + + , + ) + .toJSON(); + + if (Array.isArray(jsonLeftAddonRightElement) || !jsonLeftAddonRightElement) + return; + + if (!jsonLeftAddonRightElement.children) return; + + const [, inputLeftAddonRightElement] = jsonLeftAddonRightElement.children; + + expect(inputLeftAddonRightElement).toHaveStyleRule( + 'border-top-left-radius', + '0', + ); + expect(inputLeftAddonRightElement).toHaveStyleRule( + 'border-bottom-left-radius', + '0', + ); + expect(inputLeftAddonRightElement).not.toHaveStyleRule( + 'border-top-right-radius', + '0', + ); + expect(inputLeftAddonRightElement).not.toHaveStyleRule( + 'border-bottom-right-radius', + '0', + ); + + const jsonLeftElementRightAddon = renderer + .create( + + + Hello + + + + World + + , + ) + .toJSON(); + + if (Array.isArray(jsonLeftElementRightAddon) || !jsonLeftElementRightAddon) + return; + + if (!jsonLeftElementRightAddon.children) return; + + const [, inputLeftElementRightAddon] = jsonLeftElementRightAddon.children; + + expect(inputLeftElementRightAddon).not.toHaveStyleRule( + 'border-top-left-radius', + '0', + ); + expect(inputLeftElementRightAddon).not.toHaveStyleRule( + 'border-bottom-left-radius', + '0', + ); + expect(inputLeftElementRightAddon).toHaveStyleRule( + 'border-top-right-radius', + '0', + ); + expect(inputLeftElementRightAddon).toHaveStyleRule( + 'border-bottom-right-radius', + '0', + ); + }); + test('should render invalid input correctly', () => { render(); diff --git a/packages/components/otp-input/package.json b/packages/components/otp-input/package.json index d6c4cc4..515db3c 100644 --- a/packages/components/otp-input/package.json +++ b/packages/components/otp-input/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/otp-input", - "version": "0.0.2", + "version": "0.0.8", "description": "React Input Component for entering sequences of digits", "keywords": ["otp-input"], "main": "src/index.ts", diff --git a/packages/components/radio/package.json b/packages/components/radio/package.json index d72fe69..2edaf1d 100644 --- a/packages/components/radio/package.json +++ b/packages/components/radio/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/radio", - "version": "0.0.10", + "version": "0.0.12", "description": "A React Component for Radio used in forms", "keywords": ["radio"], "main": "src/index.ts", diff --git a/packages/components/radio/src/use-radio.ts b/packages/components/radio/src/use-radio.ts index a35b320..29c2b15 100644 --- a/packages/components/radio/src/use-radio.ts +++ b/packages/components/radio/src/use-radio.ts @@ -72,6 +72,7 @@ export function useRadio(props: UseRadioProps = {}) { const isControlledNatively = checked !== undefined; const isControlled = isCheckedProp !== undefined; + const isTrullyControlled = isControlledNatively && isControlled; const isChecked = isControlled ? isCheckedProp : isControlledNatively @@ -115,13 +116,13 @@ export function useRadio(props: UseRadioProps = {}) { return; } - if (!isControlled) { + if (!isTrullyControlled) { setIsCheckedLocal(event.target.checked); } onChangeProp(event); }, - [isControlled, isDisabled, isReadOnly, onChangeProp], + [isTrullyControlled, isDisabled, isReadOnly, onChangeProp], ); const handleInputKeyDown = useCallback((event: React.KeyboardEvent) => { diff --git a/packages/components/react/package.json b/packages/components/react/package.json index 6114fdf..6a1b6b8 100644 --- a/packages/components/react/package.json +++ b/packages/components/react/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/react", - "version": "0.0.1-alpha.41", + "version": "0.0.1-alpha.50", "description": "React UI components built with React and Emotion", "keywords": ["react"], "main": "src/index.ts", @@ -30,6 +30,7 @@ }, "homepage": "https://github.com/julofinance/julo-ui#readme", "dependencies": { + "@julo-ui/alert": "workspace:*", "@julo-ui/badge": "workspace:*", "@julo-ui/button": "workspace:*", "@julo-ui/card": "workspace:*", @@ -44,7 +45,8 @@ "@julo-ui/switch": "workspace:*", "@julo-ui/system": "workspace:*", "@julo-ui/textarea": "workspace:*", - "@julo-ui/typography": "workspace:*" + "@julo-ui/typography": "workspace:*", + "@julo-ui/tooltip": "workspace:*" }, "devDependencies": { "@emotion/react": "^11.10.6", diff --git a/packages/components/react/src/index.ts b/packages/components/react/src/index.ts index bf0837f..7e7136c 100644 --- a/packages/components/react/src/index.ts +++ b/packages/components/react/src/index.ts @@ -1,3 +1,4 @@ +export * from '@julo-ui/alert'; export * from '@julo-ui/badge'; export * from '@julo-ui/button'; export * from '@julo-ui/card'; diff --git a/packages/components/switch/package.json b/packages/components/switch/package.json index 556f9a1..590747c 100644 --- a/packages/components/switch/package.json +++ b/packages/components/switch/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/switch", - "version": "0.0.5", + "version": "0.0.7", "description": "A React Component to view and switch between on or off states", "keywords": ["switch"], "main": "src/index.ts", diff --git a/packages/components/textarea/package.json b/packages/components/textarea/package.json index b42eff4..8285528 100644 --- a/packages/components/textarea/package.json +++ b/packages/components/textarea/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/textarea", - "version": "0.0.9", + "version": "0.0.15", "description": "A React Component for Text Area Field", "keywords": ["text-area"], "main": "src/index.ts", diff --git a/packages/components/tooltip/package.json b/packages/components/tooltip/package.json new file mode 100644 index 0000000..013de27 --- /dev/null +++ b/packages/components/tooltip/package.json @@ -0,0 +1,59 @@ +{ + "name": "@julo-ui/tooltip", + "version": "0.0.1", + "description": "React Tooltip Component", + "keywords": ["tooltip"], + "main": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "scripts": { + "build": "tsup --dts", + "build:fast": "tsup", + "clean": "rimraf dist .turbo", + "prebuild": "pnpm run clean", + "dev": "pnpm run build:fast", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/julofinance/julo-ui.git", + "directory": "packages/components/tooltip" + }, + "author": "Arga Tahta", + "license": "ISC", + "bugs": { + "url": "https://github.com/julofinance/julo-ui/issues" + }, + "homepage": "https://github.com/julofinance/julo-ui#readme", + "dependencies": { + "@chakra-ui/popper": "3.1.0", + "@emotion/react": "^11.10.6", + "@julo-ui/context": "workspace:*", + "@julo-ui/dom-utils": "workspace:*", + "@julo-ui/function-utils": "workspace:*", + "@julo-ui/use-disclosure": "workspace:*", + "@julo-ui/use-delay-unmount": "workspace:*", + "@julo-ui/use-event-listener": "workspace:*" + }, + "devDependencies": { + "@julo-ui/system": "workspace:*", + "@types/react-dom": "^18.2.4" + }, + "peerDependencies": { + "@emotion/react": "^11.10.6", + "react": ">=18", + "react-dom": ">=18", + "@julo-ui/system": "workspace:*" + }, + "clean-package": "../../../clean-package.config.json", + "tsup": { + "entry": ["src", "!src/**/*.md"], + "clean": true, + "target": "es2019", + "format": ["cjs", "esm"] + } +} diff --git a/packages/components/tooltip/src/Tooltip.tsx b/packages/components/tooltip/src/Tooltip.tsx new file mode 100644 index 0000000..47772eb --- /dev/null +++ b/packages/components/tooltip/src/Tooltip.tsx @@ -0,0 +1,37 @@ +import { isValidElement } from 'react'; + +import { forwardRef, julo } from '@julo-ui/system'; + +import { TooltipRoot } from './components/tooltip-root'; +import { TooltipTrigger } from './components/tooltip-trigger'; +import { TooltipPositioner } from './components/tooltip-positioner'; +import { TooltipContent } from './components/tooltip-content'; +import { TooltipArrow } from './components/tooltip-arrow'; + +import type { TooltipProps } from './types'; + +const Tooltip = forwardRef((props, ref) => { + const { children, label, hasArrow, ...resProps } = props; + + return ( + + + {isValidElement(children) ? ( + children + ) : ( + {children} + )} + + + + {hasArrow && } + {label} + + + + ); +}); + +Tooltip.displayName = 'Tooltip'; + +export default Tooltip; diff --git a/packages/components/tooltip/src/TooltipProvider.tsx b/packages/components/tooltip/src/TooltipProvider.tsx new file mode 100644 index 0000000..69d03b9 --- /dev/null +++ b/packages/components/tooltip/src/TooltipProvider.tsx @@ -0,0 +1,12 @@ +import { createContext } from '@julo-ui/context'; +import { UseTooltipReturn } from './types'; + +interface TooltipContext extends UseTooltipReturn { + ariaLabel?: string; +} + +export const [TooltipContextProvider, useTooltipContext] = + createContext({ + name: `TooltipContext`, + errorMessage: `useTooltipContext: 'context' is undefined. You have to wrap the components in "`, + }); diff --git a/packages/components/tooltip/src/components/index.ts b/packages/components/tooltip/src/components/index.ts new file mode 100644 index 0000000..a3b6d7d --- /dev/null +++ b/packages/components/tooltip/src/components/index.ts @@ -0,0 +1,5 @@ +export * from './tooltip-arrow'; +export * from './tooltip-content'; +export * from './tooltip-positioner'; +export * from './tooltip-root'; +export * from './tooltip-trigger'; diff --git a/packages/components/tooltip/src/components/tooltip-arrow/TooltipArrow.tsx b/packages/components/tooltip/src/components/tooltip-arrow/TooltipArrow.tsx new file mode 100644 index 0000000..0186e9b --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-arrow/TooltipArrow.tsx @@ -0,0 +1,26 @@ +import { forwardRef } from 'react'; +import { cx, julo } from '@julo-ui/system'; + +import { TooltipArrowProps } from './types'; + +const TooltipArrow = forwardRef( + (props, ref) => { + return ( + + + + ); + }, +); + +TooltipArrow.displayName = 'TooltipArrow'; + +export default TooltipArrow; diff --git a/packages/components/tooltip/src/components/tooltip-arrow/index.ts b/packages/components/tooltip/src/components/tooltip-arrow/index.ts new file mode 100644 index 0000000..653ad3f --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-arrow/index.ts @@ -0,0 +1,2 @@ +export { default as TooltipArrow } from './TooltipArrow'; +export * from './types'; diff --git a/packages/components/tooltip/src/components/tooltip-arrow/types.ts b/packages/components/tooltip/src/components/tooltip-arrow/types.ts new file mode 100644 index 0000000..e31c9c2 --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-arrow/types.ts @@ -0,0 +1,3 @@ +import type { HTMLJuloProps } from '@julo-ui/system'; + +export interface TooltipArrowProps extends HTMLJuloProps<'div'> {} diff --git a/packages/components/tooltip/src/components/tooltip-content/TooltipContent.tsx b/packages/components/tooltip/src/components/tooltip-content/TooltipContent.tsx new file mode 100644 index 0000000..f729220 --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-content/TooltipContent.tsx @@ -0,0 +1,31 @@ +import { Fragment, forwardRef } from 'react'; +import { julo, cx } from '@julo-ui/system'; + +import { TooltipContentProps } from './types'; + +import { useTooltipContext } from '../../TooltipProvider'; +import { contentTooltipCx } from '../../styles'; + +const TooltipContent = forwardRef( + (props, ref) => { + const { getContentProps } = useTooltipContext(); + + const { children, className, ...resProps } = props; + + return ( + + + {children} + + + ); + }, +); + +TooltipContent.displayName = 'TooltipContent'; + +export default TooltipContent; diff --git a/packages/components/tooltip/src/components/tooltip-content/index.ts b/packages/components/tooltip/src/components/tooltip-content/index.ts new file mode 100644 index 0000000..2722d4d --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-content/index.ts @@ -0,0 +1,2 @@ +export { default as TooltipContent } from './TooltipContent'; +export * from './types'; diff --git a/packages/components/tooltip/src/components/tooltip-content/types.ts b/packages/components/tooltip/src/components/tooltip-content/types.ts new file mode 100644 index 0000000..73922b8 --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-content/types.ts @@ -0,0 +1,3 @@ +import type { HTMLJuloProps } from '@julo-ui/system'; + +export interface TooltipContentProps extends HTMLJuloProps<'div'> {} diff --git a/packages/components/tooltip/src/components/tooltip-positioner/TooltipPositioner.tsx b/packages/components/tooltip/src/components/tooltip-positioner/TooltipPositioner.tsx new file mode 100644 index 0000000..f63b97c --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-positioner/TooltipPositioner.tsx @@ -0,0 +1,38 @@ +import { forwardRef } from 'react'; +import { createPortal } from 'react-dom'; +import { cx, julo } from '@julo-ui/system'; + +import { TooltipPositionerProps } from './types'; + +import { useTooltipContext } from '../../TooltipProvider'; +import { positionerTooltipCx } from '../../styles'; +import { useDelayUnmount } from '@julo-ui/use-delay-unmount'; + +const TooltipPositioner = forwardRef( + function TooltipPositioner(props, ref) { + const { getPositionerProps, open } = useTooltipContext(); + + const isOpenAnimation = useDelayUnmount({ + isMounted: open, + delay: 200, + }); + + return ( + <> + {open && + createPortal( + , + document.body, + )} + + ); + }, +); + +TooltipPositioner.displayName = 'TooltipPositioner'; + +export default TooltipPositioner; diff --git a/packages/components/tooltip/src/components/tooltip-positioner/index.ts b/packages/components/tooltip/src/components/tooltip-positioner/index.ts new file mode 100644 index 0000000..31b5ac6 --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-positioner/index.ts @@ -0,0 +1,2 @@ +export { default as TooltipPositioner } from './TooltipPositioner'; +export * from './types'; diff --git a/packages/components/tooltip/src/components/tooltip-positioner/types.ts b/packages/components/tooltip/src/components/tooltip-positioner/types.ts new file mode 100644 index 0000000..e7a1436 --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-positioner/types.ts @@ -0,0 +1,3 @@ +import { HTMLJuloProps } from '@julo-ui/system'; + +export interface TooltipPositionerProps extends HTMLJuloProps<'div'> {} diff --git a/packages/components/tooltip/src/components/tooltip-root/TooltipRoot.tsx b/packages/components/tooltip/src/components/tooltip-root/TooltipRoot.tsx new file mode 100644 index 0000000..43a80d9 --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-root/TooltipRoot.tsx @@ -0,0 +1,21 @@ +import { TooltipRootProps } from './types'; + +import { TooltipContextProvider } from '../../TooltipProvider'; +import { useTooltip } from '../../use-tooltip'; + +/** + * Tooltip Root is a wrapper for create Tooltip and REQUIRED. + */ +function TooltipRoot(props: TooltipRootProps) { + const tooltipContext = useTooltip(props); + + return ( + + {props.children} + + ); +} + +TooltipRoot.displayName = 'TooltipRoot'; + +export default TooltipRoot; diff --git a/packages/components/tooltip/src/components/tooltip-root/index.ts b/packages/components/tooltip/src/components/tooltip-root/index.ts new file mode 100644 index 0000000..3f18aaa --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-root/index.ts @@ -0,0 +1,2 @@ +export { default as TooltipRoot } from './TooltipRoot'; +export * from './types'; diff --git a/packages/components/tooltip/src/components/tooltip-root/types.ts b/packages/components/tooltip/src/components/tooltip-root/types.ts new file mode 100644 index 0000000..01b625f --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-root/types.ts @@ -0,0 +1,9 @@ +import { UseTooltipProps } from '../../types'; + +export interface TooltipRootProps extends Partial { + /** + * The React component to use as the + * trigger for the tooltip + */ + children: React.ReactNode; +} diff --git a/packages/components/tooltip/src/components/tooltip-trigger/TooltipTrigger.tsx b/packages/components/tooltip/src/components/tooltip-trigger/TooltipTrigger.tsx new file mode 100644 index 0000000..a26a355 --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-trigger/TooltipTrigger.tsx @@ -0,0 +1,30 @@ +import { cloneElement, forwardRef, isValidElement } from 'react'; +import { julo } from '@julo-ui/system'; + +import { TooltipTriggerProps } from './types'; + +import { useTooltipContext } from '../../TooltipProvider'; + +const TooltipTrigger = forwardRef( + (props, ref) => { + const { getTriggerProps } = useTooltipContext(); + const clonedTriggerProps = getTriggerProps(props, ref); + // must delete to prevent duplicated elements + delete clonedTriggerProps.children; + + const clonedElement = cloneElement( + isValidElement(props.children) ? ( + props.children + ) : ( + {props.children} + ), + { ...clonedTriggerProps }, + ); + + return clonedElement; + }, +); + +TooltipTrigger.displayName = 'TooltipTrigger'; + +export default TooltipTrigger; diff --git a/packages/components/tooltip/src/components/tooltip-trigger/index.ts b/packages/components/tooltip/src/components/tooltip-trigger/index.ts new file mode 100644 index 0000000..deabfa7 --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-trigger/index.ts @@ -0,0 +1,2 @@ +export { default as TooltipTrigger } from './TooltipTrigger'; +export * from './types'; diff --git a/packages/components/tooltip/src/components/tooltip-trigger/types.ts b/packages/components/tooltip/src/components/tooltip-trigger/types.ts new file mode 100644 index 0000000..d8f81bc --- /dev/null +++ b/packages/components/tooltip/src/components/tooltip-trigger/types.ts @@ -0,0 +1,3 @@ +import { HTMLJuloProps } from '@julo-ui/system'; + +export interface TooltipTriggerProps extends HTMLJuloProps<'button'> {} diff --git a/packages/components/tooltip/src/index.ts b/packages/components/tooltip/src/index.ts new file mode 100644 index 0000000..89b5dfe --- /dev/null +++ b/packages/components/tooltip/src/index.ts @@ -0,0 +1,4 @@ +export { default } from './Tooltip'; +export { default as Tooltip } from './Tooltip'; +export * from './components'; +export * from './types'; diff --git a/packages/components/tooltip/src/styles.ts b/packages/components/tooltip/src/styles.ts new file mode 100644 index 0000000..5e5bf4e --- /dev/null +++ b/packages/components/tooltip/src/styles.ts @@ -0,0 +1,21 @@ +import { css } from '@emotion/react'; + +export const contentTooltipCx = css` + --popper-arrow-bg: var(--colors-neutrals-90); + background: var(--colors-neutrals-90); + color: var(--colors-neutrals-10); + line-height: var(--lineHeights-bodySmall); + padding: 8px 16px; + border-radius: 8px; + font-size: var(--fontSizes-bodySmall); + // max-width: var(--chakra-sizes-xs); + width: max-content; + z-index: 1800; +`; + +export const positionerTooltipCx = ({ open }: { open: boolean }) => css` + opacity: ${open ? 1 : 0}; + visibility: ${open ? 'visible' : 'hidden'}; + transition: opacity 0.3s ease-in-out; + pointer-events: none; +`; diff --git a/packages/components/tooltip/src/types.ts b/packages/components/tooltip/src/types.ts new file mode 100644 index 0000000..a8d45cc --- /dev/null +++ b/packages/components/tooltip/src/types.ts @@ -0,0 +1,80 @@ +import { type UsePopperProps } from '@chakra-ui/popper'; +import { type PropGetter } from '@julo-ui/system'; + +import { TooltipRootProps } from './components'; + +export type TooltipProps = TooltipRootProps & { + label?: string; + hasArrow?: boolean; +}; + +export type UseTooltipReturn = { + open: boolean; + show: () => void; + hide: () => void; + getTriggerProps: PropGetter<'button'>; + getContentProps: PropGetter<'div'>; + getPositionerProps: PropGetter<'div'>; + getArrowProps: ( + props?: React.HTMLAttributes, + ) => React.HTMLAttributes; + getArrowInnerProps: ( + props?: React.HTMLAttributes, + ) => React.HTMLAttributes; +}; + +export interface UseTooltipProps + extends Pick< + UsePopperProps, + 'modifiers' | 'gutter' | 'offset' | 'arrowPadding' | 'placement' + > { + /** + * Delay (in ms) before showing the tooltip + * @default 0ms + */ + openDelay?: number; + /** + * Delay (in ms) before hiding the tooltip + * @default 0ms + */ + closeDelay?: number; + /** + * If `true`, the tooltip will hide on click + * @default true + */ + closeOnClick?: boolean; + /** + * Callback to run when the tooltip shows + */ + onOpen?(): void; + /** + * Callback to run when the tooltip hides + */ + onClose?(): void; + /** + * Custom `id` to use in place of `uuid` + */ + id?: string; + /** + * If `true`, the tooltip will be shown (in controlled mode) + * @default false + */ + open?: boolean; + /** + * If `true`, the tooltip will be initially shown + * @default false + */ + defaultOpen?: boolean; + /** + * @default false + */ + disabled?: boolean; + /** + * @default 10 + */ + arrowSize?: number; + /** + * Refers to the `id` of the element that labels the checkbox element. + */ + 'aria-invalid'?: boolean; +} diff --git a/packages/components/tooltip/src/use-tooltip.ts b/packages/components/tooltip/src/use-tooltip.ts new file mode 100644 index 0000000..7b7b899 --- /dev/null +++ b/packages/components/tooltip/src/use-tooltip.ts @@ -0,0 +1,190 @@ +import React, { useCallback, useEffect, useId, useRef } from 'react'; + +import { popperCSSVars, usePopper } from '@chakra-ui/popper'; +import { callAllFn } from '@julo-ui/function-utils'; +import { mergeRefs } from '@julo-ui/dom-utils'; +import { useDisclosure } from '@julo-ui/use-disclosure'; +import { useEventListener } from '@julo-ui/use-event-listener'; +import type { PropGetter } from '@julo-ui/system'; + +import { getWindow, useCloseEvent } from './usecase'; +import { UseTooltipProps, UseTooltipReturn } from './types'; + +export function useTooltip( + props: Partial = {}, +): UseTooltipReturn { + const { + openDelay = 0, + closeDelay = 0, + closeOnClick = true, + onOpen: onOpenProp, + onClose: onCloseProp, + placement, + id, + open: openProp, + defaultOpen, + arrowSize = 10, + arrowPadding, + modifiers, + disabled, + gutter, + offset, + 'aria-invalid': ariaInvalid, + } = props; + + const { open, onOpen, onClose } = useDisclosure({ + open: openProp, + defaultOpen, + onOpen: onOpenProp, + onClose: onCloseProp, + }); + + const { referenceRef, getPopperProps, getArrowInnerProps, getArrowProps } = + usePopper({ + enabled: open, + placement, + arrowPadding, + modifiers, + gutter, + offset, + }); + + const uuid = useId(); + const uid = id ?? uuid; + const tooltipId = `tooltip-${uid}`; + + const ref = useRef(null); + + const enterTimeout = useRef(); + const clearEnterTimeout = useCallback(() => { + if (enterTimeout.current) { + clearTimeout(enterTimeout.current); + enterTimeout.current = undefined; + } + }, []); + + const exitTimeout = useRef(); + const clearExitTimeout = useCallback(() => { + if (exitTimeout.current) { + clearTimeout(exitTimeout.current); + exitTimeout.current = undefined; + } + }, []); + + const closeNow = useCallback(() => { + clearExitTimeout(); + onClose(); + }, [onClose, clearExitTimeout]); + + const dispatchCloseEvent = useCloseEvent(ref, closeNow); + + const openWithDelay = useCallback(() => { + if (!disabled && !enterTimeout.current) { + if (open) dispatchCloseEvent(); + const win = getWindow(ref); + enterTimeout.current = win.setTimeout(onOpen, openDelay); + } + }, [dispatchCloseEvent, disabled, open, onOpen, openDelay]); + + const closeWithDelay = useCallback(() => { + clearEnterTimeout(); + const win = getWindow(ref); + exitTimeout.current = win.setTimeout(closeNow, closeDelay); + }, [closeDelay, closeNow, clearEnterTimeout]); + + const onClick = useCallback(() => { + if (open && closeOnClick) { + closeWithDelay(); + } + }, [closeOnClick, closeWithDelay, open]); + + useEffect(() => { + if (!disabled) return; + clearEnterTimeout(); + if (open) onClose(); + }, [disabled, open, onClose, clearEnterTimeout]); + + useEffect(() => { + return () => { + clearEnterTimeout(); + clearExitTimeout(); + }; + }, [clearEnterTimeout, clearExitTimeout]); + + useEventListener(() => ref.current, 'pointerleave', closeWithDelay); + + const getTriggerProps: PropGetter<'button'> = useCallback( + (props = {}, _ref = null) => { + return { + ...props, + type: 'button', + ref: mergeRefs(ref, _ref, referenceRef), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onPointerEnter: callAllFn(props.onPointerEnter, (e: any) => { + if (e.pointerType === 'touch') return; + openWithDelay(); + }), + onClick: callAllFn(props.onClick, onClick), + onFocus: callAllFn(props.onFocus, openWithDelay), + onBlur: callAllFn(props.onBlur, closeWithDelay), + 'aria-describedby': open ? tooltipId : undefined, + 'aria-invalid': ariaInvalid ?? ariaInvalid, + }; + }, + [ + openWithDelay, + closeWithDelay, + open, + tooltipId, + onClick, + referenceRef, + ariaInvalid, + ], + ); + + const getPositionerProps: PropGetter<'div'> = useCallback( + (props = {}, forwardedRef = null) => + getPopperProps( + { + ...props, + style: { + ...props.style, + [popperCSSVars.arrowSize.var]: arrowSize + ? `${arrowSize}px` + : undefined, + }, + }, + forwardedRef, + ), + [getPopperProps, arrowSize], + ); + + const getContentProps: PropGetter<'div'> = useCallback( + (props = {}, ref = null) => { + const styles: React.CSSProperties = { + ...props.style, + position: 'relative', + }; + + return { + ref, + ...props, + id: tooltipId, + role: 'tooltip', + style: styles, + }; + }, + [tooltipId], + ); + + return { + open, + show: openWithDelay, + hide: closeWithDelay, + getTriggerProps, + getContentProps, + getPositionerProps, + getArrowProps, + getArrowInnerProps, + }; +} diff --git a/packages/components/tooltip/src/usecase/index.ts b/packages/components/tooltip/src/usecase/index.ts new file mode 100644 index 0000000..04c8f91 --- /dev/null +++ b/packages/components/tooltip/src/usecase/index.ts @@ -0,0 +1,5 @@ +export { + default as useCloseEvent, + getDocument, + getWindow, +} from './use-close-event'; diff --git a/packages/components/tooltip/src/usecase/use-close-event.ts b/packages/components/tooltip/src/usecase/use-close-event.ts new file mode 100644 index 0000000..49f1bd1 --- /dev/null +++ b/packages/components/tooltip/src/usecase/use-close-event.ts @@ -0,0 +1,25 @@ +import { type RefObject, useEffect } from 'react'; + +export const getDocument = (ref: React.RefObject) => + ref.current?.ownerDocument || document; + +export const getWindow = (ref: React.RefObject) => + ref.current?.ownerDocument?.defaultView || window; + +const closeEventName = 'julo-ui:close-tooltip'; + +function useCloseEvent(ref: RefObject, close: () => void) { + useEffect(() => { + const doc = getDocument(ref); + doc.addEventListener(closeEventName, close); + return () => doc.removeEventListener(closeEventName, close); + }, [close, ref]); + + return () => { + const doc = getDocument(ref); + const win = getWindow(ref); + doc.dispatchEvent(new win.CustomEvent(closeEventName)); + }; +} + +export default useCloseEvent; diff --git a/packages/components/tooltip/stories/Tooltip.stories.tsx b/packages/components/tooltip/stories/Tooltip.stories.tsx new file mode 100644 index 0000000..32bbee9 --- /dev/null +++ b/packages/components/tooltip/stories/Tooltip.stories.tsx @@ -0,0 +1,82 @@ +import { Story, Meta } from '@storybook/react'; +import { julo } from '@julo-ui/system'; + +import Tooltip, { TooltipProps } from '../src'; + +export default { + title: 'Components/Tooltip', + component: Tooltip, + parameters: { + docs: { + description: { + component: "`import { Tooltip } from '@julo-ui/tooltip';`", + }, + }, + }, + decorators: [ + (story) => ( + + {story()} + + ), + ], +} as Meta; + +const Template: Story = (args) => ( + + + +); + +const WithIconTemplate: Story = (args) => ( + + + +); + +export const Basic = Template.bind({}); +Basic.argTypes = { + label: { + control: 'text', + defaultValue: 'This is tooltip', + }, + closeDelay: { + control: 'number', + }, + closeOnClick: { + control: 'boolean', + }, + disabled: { + control: 'boolean', + defaultValue: false, + }, + gutter: { + control: 'number', + defaultValue: 8, + }, + open: { + control: 'boolean', + }, + defaultOpen: { + control: 'boolean', + }, + hasArrow: { + control: 'boolean', + }, + openDelay: { + control: 'number', + }, + placement: { + control: 'radio', + options: ['bottom', 'left', 'right', 'top'], + defaultValue: 'bottom', + }, +}; + +export const WithIcon = WithIconTemplate.bind({}); diff --git a/packages/components/tooltip/tests/Tooltip.spec.tsx b/packages/components/tooltip/tests/Tooltip.spec.tsx new file mode 100644 index 0000000..415f4ed --- /dev/null +++ b/packages/components/tooltip/tests/Tooltip.spec.tsx @@ -0,0 +1,76 @@ +import { render, screen, act, testA11y, waitFor } from '@julo-ui/rtl-utils'; + +import { + TooltipContent, + TooltipPositioner, + TooltipRoot, + TooltipRootProps, + TooltipTrigger, +} from '../src'; + +const DemoTooltip = (props: Omit) => { + const { disabled, ...rest } = props; + return ( + + + Hover me + + + Tooltip label + + + ); +}; + +const trigger = () => screen.getByText('Hover me'); +const tooltip = () => screen.queryByText('Tooltip label'); + +describe('Tooltip', () => { + test('passes a11y test when hovered', async () => { + const { user } = render(); + + await act(() => user.hover(trigger())); + expect(tooltip()).toBeInTheDocument(); + + await testA11y(tooltip()!); + }); + + test('shows on pointerover and closes on pointerleave', async () => { + const { user } = render(); + + await act(() => user.hover(trigger())); + expect(tooltip()).toBeInTheDocument(); + + await act(() => user.unhover(trigger())); + await waitFor(() => expect(tooltip()).not.toBeInTheDocument()); + }); + + test('should not show on pointerover if disabled is true', async () => { + const { user } = render(); + await act(() => user.hover(trigger())); + expect(tooltip()).not.toBeInTheDocument(); + }); + + test('should close on pointerleave if openDelay is set', async () => { + const { user } = render(); + + await act(() => user.hover(trigger())); + expect(tooltip()).not.toBeInTheDocument(); + + await act(() => user.unhover(trigger())); + await waitFor(() => expect(tooltip()).not.toBeInTheDocument()); + }); + + test('should call onClose prop on pointerleave', async () => { + const onClose = jest.fn(); + const { user } = render(); + + await act(() => user.hover(trigger())); + expect(tooltip()).toBeInTheDocument(); + + expect(onClose).not.toBeCalled(); + + await act(() => user.unhover(trigger())); + await waitFor(() => expect(onClose).toBeCalledTimes(1)); + }); +}); diff --git a/packages/components/tooltip/tsconfig.json b/packages/components/tooltip/tsconfig.json new file mode 100644 index 0000000..b59af2e --- /dev/null +++ b/packages/components/tooltip/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "stories", "tests"] +} diff --git a/packages/hooks/use-delay-unmount/package.json b/packages/hooks/use-delay-unmount/package.json new file mode 100644 index 0000000..34b3b99 --- /dev/null +++ b/packages/hooks/use-delay-unmount/package.json @@ -0,0 +1,44 @@ +{ + "name": "@julo-ui/use-delay-unmount", + "version": "0.0.1", + "description": "React hook for delay unmount", + "keywords": ["use-delay-unmount"], + "main": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "scripts": { + "build": "tsup --dts", + "build:fast": "tsup", + "clean": "rimraf dist .turbo", + "prebuild": "pnpm run clean", + "dev": "pnpm run build:fast", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/julofinance/julo-ui.git", + "directory": "packages/hooks/use-delay-unmount" + }, + "author": "Fransiscus Hermanto", + "license": "ISC", + "bugs": { + "url": "https://github.com/julofinance/julo-ui/issues" + }, + "homepage": "https://github.com/julofinance/julo-ui#readme", + "dependencies": { "@julo-ui/use-safe-layout-effect": "workspace:*" }, + "devDependencies": { "@julo-ui/system": "workspace:*" }, + "peerDependencies": { + "react": ">=18" + }, + "clean-package": "../../../clean-package.config.json", + "tsup": { + "entry": ["src", "!src/**/*.md"], + "clean": true, + "target": "es2019", + "format": ["cjs", "esm"] + } +} diff --git a/packages/hooks/use-delay-unmount/src/index.ts b/packages/hooks/use-delay-unmount/src/index.ts new file mode 100644 index 0000000..8c233b2 --- /dev/null +++ b/packages/hooks/use-delay-unmount/src/index.ts @@ -0,0 +1 @@ +export * from './use-delay-unmount'; diff --git a/packages/hooks/use-delay-unmount/src/types.ts b/packages/hooks/use-delay-unmount/src/types.ts new file mode 100644 index 0000000..ff3b922 --- /dev/null +++ b/packages/hooks/use-delay-unmount/src/types.ts @@ -0,0 +1,9 @@ +export interface UseDelayUnmountOptions { + isMounted: boolean; + /** + * Time of the component unmount after x millisecond + * + * @default 500 + */ + delay?: number; +} diff --git a/packages/hooks/use-delay-unmount/src/use-delay-unmount.ts b/packages/hooks/use-delay-unmount/src/use-delay-unmount.ts new file mode 100644 index 0000000..cef5e5c --- /dev/null +++ b/packages/hooks/use-delay-unmount/src/use-delay-unmount.ts @@ -0,0 +1,33 @@ +import { useRef, useState } from 'react'; + +import { useSafeLayoutEffect } from '@julo-ui/use-safe-layout-effect'; + +import { UseDelayUnmountOptions } from './types'; + +export function useDelayUnmount(options: UseDelayUnmountOptions) { + const { delay = 500, isMounted } = options; + + const [isShouldRender, setIsShouldRender] = useState(false); + + const isPrevMounted = useRef(isMounted); + + useSafeLayoutEffect(() => { + let timeoutId: ReturnType; + + if (!isPrevMounted.current && !isMounted) return; + + if (isPrevMounted.current && !isMounted) { + timeoutId = setTimeout(() => { + setIsShouldRender(false); + isPrevMounted.current = isMounted; + }, delay); + } else { + setIsShouldRender(true); + isPrevMounted.current = isMounted; + } + + return () => clearTimeout(timeoutId); + }, [isMounted, delay]); + + return isShouldRender; +} diff --git a/packages/hooks/use-delay-unmount/tsconfig.json b/packages/hooks/use-delay-unmount/tsconfig.json new file mode 100644 index 0000000..b59af2e --- /dev/null +++ b/packages/hooks/use-delay-unmount/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "stories", "tests"] +} diff --git a/packages/hooks/use-disclosure/package.json b/packages/hooks/use-disclosure/package.json new file mode 100644 index 0000000..b760689 --- /dev/null +++ b/packages/hooks/use-disclosure/package.json @@ -0,0 +1,44 @@ +{ + "name": "@julo-ui/use-disclosure", + "version": "0.0.1", + "description": "React hook for handle common open, close, or toggle scenarios", + "keywords": ["use-disclosure"], + "main": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "scripts": { + "build": "tsup --dts", + "build:fast": "tsup", + "clean": "rimraf dist .turbo", + "prebuild": "pnpm run clean", + "dev": "pnpm run build:fast", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/julofinance/julo-ui.git", + "directory": "packages/hooks/use-disclosure" + }, + "author": "Arga Tahta", + "license": "ISC", + "bugs": { + "url": "https://github.com/julofinance/julo-ui/issues" + }, + "homepage": "https://github.com/julofinance/julo-ui#readme", + "dependencies": { "@julo-ui/use-callback-ref": "workspace:*" }, + "devDependencies": { "@julo-ui/system": "workspace:*" }, + "peerDependencies": { + "react": ">=18" + }, + "clean-package": "../../../clean-package.config.json", + "tsup": { + "entry": ["src", "!src/**/*.md"], + "clean": true, + "target": "es2019", + "format": ["cjs", "esm"] + } +} diff --git a/packages/hooks/use-disclosure/src/index.ts b/packages/hooks/use-disclosure/src/index.ts new file mode 100644 index 0000000..0562e20 --- /dev/null +++ b/packages/hooks/use-disclosure/src/index.ts @@ -0,0 +1 @@ +export * from './use-disclosure'; diff --git a/packages/hooks/use-disclosure/src/types.ts b/packages/hooks/use-disclosure/src/types.ts new file mode 100644 index 0000000..26faa76 --- /dev/null +++ b/packages/hooks/use-disclosure/src/types.ts @@ -0,0 +1,7 @@ +export interface UseDisclosureProps { + open?: boolean; + defaultOpen?: boolean; + onClose?(): void; + onOpen?(): void; + id?: string; +} diff --git a/packages/hooks/use-disclosure/src/use-disclosure.ts b/packages/hooks/use-disclosure/src/use-disclosure.ts new file mode 100644 index 0000000..5eabe99 --- /dev/null +++ b/packages/hooks/use-disclosure/src/use-disclosure.ts @@ -0,0 +1,86 @@ +import React, { useCallback, useId, useState } from 'react'; + +import { useCallbackRef } from '@julo-ui/use-callback-ref'; + +import { UseDisclosureProps } from './types'; + +type HTMLProps = React.HTMLAttributes; + +/** + * `useDisclosure` is a custom hook used to help handle common open, close, or toggle scenarios. + * It can be used to control feedback component such as `Tooltip`, `Modal`, etc. + */ +export function useDisclosure(props: UseDisclosureProps = {}) { + const { + onClose: onCloseProp, + onOpen: onOpenProp, + open: openProp, + id: idProp, + } = props; + + const handleOpen = useCallbackRef(onOpenProp); + const handleClose = useCallbackRef(onCloseProp); + + const [openState, setopen] = useState(props.defaultOpen || false); + + const open = openProp !== undefined ? openProp : openState; + + const isControlled = openProp !== undefined; + + const uid = useId(); + const id = idProp ?? `disclosure-${uid}`; + + const onClose = useCallback(() => { + if (!isControlled) { + setopen(false); + } + handleClose?.(); + }, [isControlled, handleClose]); + + const onOpen = useCallback(() => { + if (!isControlled) { + setopen(true); + } + handleOpen?.(); + }, [isControlled, handleOpen]); + + const onToggle = useCallback(() => { + if (open) { + onClose(); + } else { + onOpen(); + } + }, [open, onOpen, onClose]); + + function getButtonProps(props: HTMLProps = {}): HTMLProps { + return { + ...props, + 'aria-expanded': open, + 'aria-controls': id, + onClick(event) { + props.onClick?.(event); + onToggle(); + }, + }; + } + + function getDisclosureProps(props: HTMLProps = {}): HTMLProps { + return { + ...props, + hidden: !open, + id, + }; + } + + return { + open, + onOpen, + onClose, + onToggle, + isControlled, + getButtonProps, + getDisclosureProps, + }; +} + +export type UseDisclosureReturn = ReturnType; diff --git a/packages/hooks/use-disclosure/tsconfig.json b/packages/hooks/use-disclosure/tsconfig.json new file mode 100644 index 0000000..b59af2e --- /dev/null +++ b/packages/hooks/use-disclosure/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "stories", "tests"] +} diff --git a/packages/hooks/use-event-listener/package.json b/packages/hooks/use-event-listener/package.json new file mode 100644 index 0000000..49d395b --- /dev/null +++ b/packages/hooks/use-event-listener/package.json @@ -0,0 +1,44 @@ +{ + "name": "@julo-ui/use-event-listener", + "version": "0.0.1", + "description": "React hook for event listener", + "keywords": ["use-event-listener"], + "main": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "scripts": { + "build": "tsup --dts", + "build:fast": "tsup", + "clean": "rimraf dist .turbo", + "prebuild": "pnpm run clean", + "dev": "pnpm run build:fast", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/julofinance/julo-ui.git", + "directory": "packages/hooks/use-event-listener" + }, + "author": "Arga Tahta", + "license": "ISC", + "bugs": { + "url": "https://github.com/julofinance/julo-ui/issues" + }, + "homepage": "https://github.com/julofinance/julo-ui#readme", + "dependencies": { "@julo-ui/use-callback-ref": "workspace:*" }, + "devDependencies": { "@julo-ui/system": "workspace:*" }, + "peerDependencies": { + "react": ">=18" + }, + "clean-package": "../../../clean-package.config.json", + "tsup": { + "entry": ["src", "!src/**/*.md"], + "clean": true, + "target": "es2019", + "format": ["cjs", "esm"] + } +} diff --git a/packages/hooks/use-event-listener/src/index.ts b/packages/hooks/use-event-listener/src/index.ts new file mode 100644 index 0000000..110de4a --- /dev/null +++ b/packages/hooks/use-event-listener/src/index.ts @@ -0,0 +1 @@ +export * from './use-event-listener'; diff --git a/packages/hooks/use-event-listener/src/use-event-listener.ts b/packages/hooks/use-event-listener/src/use-event-listener.ts new file mode 100644 index 0000000..d4874f1 --- /dev/null +++ b/packages/hooks/use-event-listener/src/use-event-listener.ts @@ -0,0 +1,49 @@ +import { useEffect } from 'react'; + +import { useCallbackRef } from '@julo-ui/use-callback-ref'; + +type Target = EventTarget | null | (() => EventTarget | null); +type Options = boolean | AddEventListenerOptions; + +export function useEventListener( + target: Target, + event: K, + handler?: (event: DocumentEventMap[K]) => void, + options?: Options, +): VoidFunction; +export function useEventListener( + target: Target, + event: K, + handler?: (event: WindowEventMap[K]) => void, + options?: Options, +): VoidFunction; +export function useEventListener( + target: Target, + event: K, + handler?: (event: GlobalEventHandlersEventMap[K]) => void, + options?: Options, +): VoidFunction; +export function useEventListener( + target: Target, + event: string, + handler: ((event: Event) => void) | undefined, + options?: Options, +) { + const listener = useCallbackRef(handler); + + useEffect(() => { + const node = typeof target === 'function' ? target() : target ?? document; + + if (!handler || !node) return; + + node.addEventListener(event, listener, options); + return () => { + node.removeEventListener(event, listener, options); + }; + }, [event, target, options, listener, handler]); + + return () => { + const node = typeof target === 'function' ? target() : target ?? document; + node?.removeEventListener(event, listener, options); + }; +} diff --git a/packages/hooks/use-event-listener/tsconfig.json b/packages/hooks/use-event-listener/tsconfig.json new file mode 100644 index 0000000..b59af2e --- /dev/null +++ b/packages/hooks/use-event-listener/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src", "stories", "tests"] +} diff --git a/packages/pluggables/plug/package.json b/packages/pluggables/plug/package.json index 61ec19c..29a260b 100644 --- a/packages/pluggables/plug/package.json +++ b/packages/pluggables/plug/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/plug", - "version": "0.0.1-alpha.22", + "version": "0.0.1-alpha.28", "description": "React UI pluggables component", "keywords": ["plug"], "main": "src/index.ts", diff --git a/packages/pluggables/text-field/package.json b/packages/pluggables/text-field/package.json index 8248aa1..b795d7d 100644 --- a/packages/pluggables/text-field/package.json +++ b/packages/pluggables/text-field/package.json @@ -1,6 +1,6 @@ { "name": "@julo-ui/text-field", - "version": "0.0.9", + "version": "0.0.15", "description": "A complete form control that contains input, label, helper text, error message.", "keywords": ["text-field"], "main": "src/index.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8549f2..3c0eea0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,28 @@ importers: specifier: ^0.6.0 version: 0.6.0 + packages/components/alert: + dependencies: + '@emotion/react': + specifier: ^11.10.6 + version: 11.10.6(@types/react@18.2.6)(react@18.2.0) + '@julo-ui/context': + specifier: workspace:* + version: link:../../hooks/context + '@julo-ui/dom-utils': + specifier: workspace:* + version: link:../../utilities/dom-utils + '@julo-ui/typography': + specifier: workspace:* + version: link:../typography + react: + specifier: '>=18' + version: 18.2.0 + devDependencies: + '@julo-ui/system': + specifier: workspace:* + version: link:../../core/system + packages/components/badge: dependencies: '@emotion/react': @@ -471,6 +493,9 @@ importers: packages/components/react: dependencies: + '@julo-ui/alert': + specifier: workspace:* + version: link:../alert '@julo-ui/badge': specifier: workspace:* version: link:../badge @@ -513,6 +538,9 @@ importers: '@julo-ui/textarea': specifier: workspace:* version: link:../textarea + '@julo-ui/tooltip': + specifier: workspace:* + version: link:../tooltip '@julo-ui/typography': specifier: workspace:* version: link:../typography @@ -605,6 +633,46 @@ importers: specifier: workspace:* version: link:../../core/system + packages/components/tooltip: + dependencies: + '@chakra-ui/popper': + specifier: 3.1.0 + version: 3.1.0(react@18.2.0) + '@emotion/react': + specifier: ^11.10.6 + version: 11.10.6(@types/react@18.2.6)(react@18.2.0) + '@julo-ui/context': + specifier: workspace:* + version: link:../../hooks/context + '@julo-ui/dom-utils': + specifier: workspace:* + version: link:../../utilities/dom-utils + '@julo-ui/function-utils': + specifier: workspace:* + version: link:../../utilities/function-utils + '@julo-ui/use-delay-unmount': + specifier: workspace:* + version: link:../../hooks/use-delay-unmount + '@julo-ui/use-disclosure': + specifier: workspace:* + version: link:../../hooks/use-disclosure + '@julo-ui/use-event-listener': + specifier: workspace:* + version: link:../../hooks/use-event-listener + react: + specifier: '>=18' + version: 18.2.0 + react-dom: + specifier: '>=18' + version: 18.2.0(react@18.2.0) + devDependencies: + '@julo-ui/system': + specifier: workspace:* + version: link:../../core/system + '@types/react-dom': + specifier: ^18.2.4 + version: 18.2.4 + packages/components/typography: dependencies: '@emotion/react': @@ -676,6 +744,45 @@ importers: specifier: workspace:* version: link:../../core/system + packages/hooks/use-delay-unmount: + dependencies: + '@julo-ui/use-safe-layout-effect': + specifier: workspace:* + version: link:../use-safe-layout-effect + react: + specifier: '>=18' + version: 18.2.0 + devDependencies: + '@julo-ui/system': + specifier: workspace:* + version: link:../../core/system + + packages/hooks/use-disclosure: + dependencies: + '@julo-ui/use-callback-ref': + specifier: workspace:* + version: link:../use-callback-ref + react: + specifier: '>=18' + version: 18.2.0 + devDependencies: + '@julo-ui/system': + specifier: workspace:* + version: link:../../core/system + + packages/hooks/use-event-listener: + dependencies: + '@julo-ui/use-callback-ref': + specifier: workspace:* + version: link:../use-callback-ref + react: + specifier: '>=18' + version: 18.2.0 + devDependencies: + '@julo-ui/system': + specifier: workspace:* + version: link:../../core/system + packages/hooks/use-safe-layout-effect: dependencies: react: @@ -2354,6 +2461,17 @@ packages: resolution: {integrity: sha512-IGM/yGUHS+8TOQrZGpAKOJl/xGBrmRYJrmbHfUE7zrG3PpQyXvbLDP1M+RggkCFVgHlJi2wpYIf0QtQlU0XZfw==} dev: false + /@chakra-ui/popper@3.1.0(react@18.2.0): + resolution: {integrity: sha512-ciDdpdYbeFG7og6/6J8lkTFxsSvwTdMLFkpVylAF6VNC22jssiWfquj2eyD4rJnzkRFPvIWJq8hvbfhsm+AjSg==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-types': 2.0.7(react@18.2.0) + '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.2.0) + '@popperjs/core': 2.11.8 + react: 18.2.0 + dev: false + /@chakra-ui/react-context@2.1.0(react@18.2.0): resolution: {integrity: sha512-iahyStvzQ4AOwKwdPReLGfDesGG+vWJfEsn0X/NoGph/SkN+HXtv2sCfYFFR9k7bb+Kvc6YfpLlSuLvKMHi2+w==} peerDependencies: @@ -2362,6 +2480,14 @@ packages: react: 18.2.0 dev: false + /@chakra-ui/react-types@2.0.7(react@18.2.0): + resolution: {integrity: sha512-12zv2qIZ8EHwiytggtGvo4iLT0APris7T0qaAWqzpUGS0cdUtR8W+V1BJ5Ocq+7tA6dzQ/7+w5hmXih61TuhWQ==} + peerDependencies: + react: '>=18' + dependencies: + react: 18.2.0 + dev: false + /@chakra-ui/react-use-latest-ref@2.1.0(react@18.2.0): resolution: {integrity: sha512-m0kxuIYqoYB0va9Z2aW4xP/5b7BzlDeWwyXCH6QpT2PpW3/281L3hLCm1G0eOUcdVlayqrQqOeD6Mglq+5/xoQ==} peerDependencies: @@ -4448,6 +4574,10 @@ packages: webpack: 5.76.1(@swc/core@1.3.57)(esbuild@0.15.18) dev: true + /@popperjs/core@2.11.8: + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + dev: false + /@rollup/plugin-babel@5.3.1(@babel/core@7.22.15)(rollup@2.79.1): resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'}