diff --git a/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx b/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx index 229546d..a460fc0 100644 --- a/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx +++ b/packages/lsd-react/src/components/CSSBaseline/CSSBaseline.tsx @@ -5,6 +5,8 @@ import { DropdownStyles } from '../Dropdown/Dropdown.styles' import { DropdownItemStyles } from '../DropdownItem/DropdownItem.styles' import { LsdIconStyles } from '../Icons/LsdIcon/LsdIcon.styles' import { ListBoxStyles } from '../ListBox/ListBox.styles' +import { TabItemStyles } from '../TabItem/TabItem.styles' +import { TabsStyles } from '../Tabs/Tabs.styles' import { defaultThemes, Theme, withTheme } from '../Theme' import { TypographyStyles } from '../Typography/Typography.styles' @@ -13,6 +15,8 @@ const componentStyles: Array | SerializedStyles> = ButtonStyles, TypographyStyles, LsdIconStyles, + TabItemStyles, + TabsStyles, ListBoxStyles, DropdownStyles, DropdownItemStyles, diff --git a/packages/lsd-react/src/components/Icons/FolderIcon/FolderIcon.tsx b/packages/lsd-react/src/components/Icons/FolderIcon/FolderIcon.tsx index 6dbf638..a8de849 100644 --- a/packages/lsd-react/src/components/Icons/FolderIcon/FolderIcon.tsx +++ b/packages/lsd-react/src/components/Icons/FolderIcon/FolderIcon.tsx @@ -1,17 +1,22 @@ import { LsdIcon } from '../LsdIcon' -export const FolderIcon = LsdIcon((props) => ( - - - -)) +export const FolderIcon = LsdIcon( + (props) => ( + + + + ), + { + filled: true, + }, +) diff --git a/packages/lsd-react/src/components/Icons/NavigateBeforeIcon/NavigateBeforeIcon.tsx b/packages/lsd-react/src/components/Icons/NavigateBeforeIcon/NavigateBeforeIcon.tsx index 62fc3cf..9fd51f3 100644 --- a/packages/lsd-react/src/components/Icons/NavigateBeforeIcon/NavigateBeforeIcon.tsx +++ b/packages/lsd-react/src/components/Icons/NavigateBeforeIcon/NavigateBeforeIcon.tsx @@ -1,17 +1,22 @@ import { LsdIcon } from '../LsdIcon' -export const NavigateBeforeIcon = LsdIcon((props) => ( - - - -)) +export const NavigateBeforeIcon = LsdIcon( + (props) => ( + + + + ), + { + filled: true, + }, +) diff --git a/packages/lsd-react/src/components/Icons/NavigateNextIcon/NavigateNextIcon.tsx b/packages/lsd-react/src/components/Icons/NavigateNextIcon/NavigateNextIcon.tsx index c5cb19f..8546c17 100644 --- a/packages/lsd-react/src/components/Icons/NavigateNextIcon/NavigateNextIcon.tsx +++ b/packages/lsd-react/src/components/Icons/NavigateNextIcon/NavigateNextIcon.tsx @@ -1,17 +1,22 @@ import { LsdIcon } from '../LsdIcon' -export const NavigateNextIcon = LsdIcon((props) => ( - - - -)) +export const NavigateNextIcon = LsdIcon( + (props) => ( + + + + ), + { + filled: true, + }, +) diff --git a/packages/lsd-react/src/components/ResizeObserver/ResizeObserverContext.tsx b/packages/lsd-react/src/components/ResizeObserver/ResizeObserverContext.tsx new file mode 100644 index 0000000..df150ae --- /dev/null +++ b/packages/lsd-react/src/components/ResizeObserver/ResizeObserverContext.tsx @@ -0,0 +1,32 @@ +import { createContext, MutableRefObject, useContext, useMemo } from 'react' + +export interface IResizeObserverContext { + observe: (id: string, ref: MutableRefObject) => void + unobserve: (id: string) => void + + ready?: boolean + rect: Record +} + +export const ResizeObserverContext = createContext( + null as any, +) + +export const useResizeObserverAPI = () => { + const { observe, unobserve, ready } = useContext(ResizeObserverContext) ?? {} + + return useMemo( + () => ({ + observe, + unobserve, + ready, + }), + [observe, unobserve, ready], + ) +} + +export const useDOMRect = (id: string, defaultValue?: any) => { + const ctx = useContext(ResizeObserverContext) + + return ctx?.rect?.[id] ?? defaultValue +} diff --git a/packages/lsd-react/src/components/ResizeObserver/ResizeObserverProvider.tsx b/packages/lsd-react/src/components/ResizeObserver/ResizeObserverProvider.tsx new file mode 100644 index 0000000..abf5afd --- /dev/null +++ b/packages/lsd-react/src/components/ResizeObserver/ResizeObserverProvider.tsx @@ -0,0 +1,83 @@ +import { omit } from 'lodash' +import React, { MutableRefObject, useEffect, useRef, useState } from 'react' +import { settleSync } from '../../utils/promise.utils' +import { ResizeObserverContext } from './ResizeObserverContext' + +export const ResizeObserverProvider: React.FC> = ({ + children, +}) => { + const ro = useRef() + const elements = useRef>>({}) + const [rects, setRects] = useState>({}) + const [ready, setReady] = useState(false) + + const onChange = (id: string) => { + const el = elements.current[id] + if (!el || !el.current) return + + settleSync(() => { + setRects((val) => ({ + ...val, + [id]: el.current.getBoundingClientRect(), + })) + }) + } + + useEffect(() => { + if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') + return + + ro.current = new ResizeObserver((entries) => { + settleSync(() => { + const arr = Object.entries(elements.current) + entries + .map((entry) => arr.find(([id, ref]) => ref.current === entry.target)) + .forEach((map) => { + if (map && map.length === 2) { + const [id] = map + onChange(id) + } + }) + }) + }) + + setReady(true) + + return () => { + ro.current?.disconnect() + } + }, []) + + const observe = (id: string, elementRef: MutableRefObject) => { + if (!ro.current) return + + elements.current[id] = elementRef + onChange(id) + elementRef.current instanceof Element && + ro.current.observe(elementRef.current) + } + + const unobserve = (id: string) => { + if (!ro.current) return + + const el = elements.current[id] + if (!el) return + + el.current instanceof Element && ro.current.unobserve(el.current) + delete elements.current[id] + setRects((val) => omit(val, id)) + } + + return ( + + {children} + + ) +} diff --git a/packages/lsd-react/src/components/ResizeObserver/index.ts b/packages/lsd-react/src/components/ResizeObserver/index.ts new file mode 100644 index 0000000..3ffa4a8 --- /dev/null +++ b/packages/lsd-react/src/components/ResizeObserver/index.ts @@ -0,0 +1 @@ +export { ResizeObserverProvider } from './ResizeObserverProvider' diff --git a/packages/lsd-react/src/components/TabItem/TabItem.classes.ts b/packages/lsd-react/src/components/TabItem/TabItem.classes.ts new file mode 100644 index 0000000..0d422d4 --- /dev/null +++ b/packages/lsd-react/src/components/TabItem/TabItem.classes.ts @@ -0,0 +1,11 @@ +export const tabItemClasses = { + root: `lsd-tab-item`, + text: `lsd-tab-item--text`, + icon: `lsd-tab-item--icon`, + + disabled: 'lsd-tab-item--disabled', + selected: 'lsd-tab-item--selected', + small: 'lsd-tab-item--small', + medium: 'lsd-tab-item--medium', + large: 'lsd-tab-item--large', +} diff --git a/packages/lsd-react/src/components/TabItem/TabItem.stories.tsx b/packages/lsd-react/src/components/TabItem/TabItem.stories.tsx new file mode 100644 index 0000000..265d934 --- /dev/null +++ b/packages/lsd-react/src/components/TabItem/TabItem.stories.tsx @@ -0,0 +1,43 @@ +import { Meta, Story } from '@storybook/react' +import { useStorybookIconComponent } from '../../utils/storybook.utils' +import { TabItem, TabItemProps } from './TabItem' + +export default { + title: 'TabItem', + component: TabItem, + argTypes: { + size: { + type: { + name: 'enum', + value: ['small', 'medium', 'large'], + }, + defaultValue: 'large', + }, + icon: { + type: { + name: 'enum', + value: useStorybookIconComponent.options, + }, + defaultValue: 'FolderIcon', + }, + }, +} as Meta + +export const Root: Story = ({ + icon, + ...args +}) => { + const Icon = useStorybookIconComponent(icon) + + return ( + }> + Tab + + ) +} + +Root.args = { + disabled: false, + selected: false, + size: 'large', +} diff --git a/packages/lsd-react/src/components/TabItem/TabItem.styles.ts b/packages/lsd-react/src/components/TabItem/TabItem.styles.ts new file mode 100644 index 0000000..a1bdf9a --- /dev/null +++ b/packages/lsd-react/src/components/TabItem/TabItem.styles.ts @@ -0,0 +1,70 @@ +import { css } from '@emotion/react' +import { tabItemClasses } from './TabItem.classes' + +export const TabItemStyles = css` + .${tabItemClasses.root} { + background: rgb(var(--lsd-surface-primary)); + border: 1px solid transparent; + cursor: pointer; + + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + &:hover { + text-decoration: underline; + } + } + + .${tabItemClasses.text} { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .${tabItemClasses.icon} { + margin-left: 14px; + } + + .${tabItemClasses.selected} { + border: 1px solid rgb(var(--lsd-border-primary)); + + &:hover { + text-decoration: none; + } + } + + .${tabItemClasses.disabled} { + cursor: default; + opacity: 0.34; + + &:hover { + text-decoration: none; + } + } + + .${tabItemClasses.small} { + padding: 6px 12px; + + .${tabItemClasses.icon} { + margin-left: 10px; + } + } + + .${tabItemClasses.medium} { + padding: 6px 14px; + + .${tabItemClasses.icon} { + margin-left: 12px; + } + } + + .${tabItemClasses.large} { + padding: 10px 18px; + + .${tabItemClasses.icon} { + margin-left: 14px; + } + } +` diff --git a/packages/lsd-react/src/components/TabItem/TabItem.tsx b/packages/lsd-react/src/components/TabItem/TabItem.tsx new file mode 100644 index 0000000..0bccb93 --- /dev/null +++ b/packages/lsd-react/src/components/TabItem/TabItem.tsx @@ -0,0 +1,61 @@ +import clsx from 'clsx' +import React from 'react' +import { useTabsContext } from '../Tabs/Tab.context' +import { Typography } from '../Typography' +import { tabItemClasses } from './TabItem.classes' + +export type TabItemProps = React.ButtonHTMLAttributes & { + name: string + selected?: boolean + inactive?: boolean + icon?: React.ReactNode + size?: 'small' | 'medium' | 'large' +} + +export const TabItem: React.FC & { + classes: typeof tabItemClasses +} = ({ + name, + size: _size = 'large', + selected: _selected = false, + inactive = false, + icon, + children, + ...props +}) => { + const tabs = useTabsContext() + const size = tabs?.size ?? _size + const selected = tabs ? tabs.activeTab === name : _selected + + const onClick: React.MouseEventHandler = (event) => { + props.onClick && props.onClick(event) + if (inactive) return + + tabs?.setActiveTab && tabs.setActiveTab(name) + } + + return ( + + ) +} + +TabItem.classes = tabItemClasses diff --git a/packages/lsd-react/src/components/TabItem/index.ts b/packages/lsd-react/src/components/TabItem/index.ts new file mode 100644 index 0000000..d8cff97 --- /dev/null +++ b/packages/lsd-react/src/components/TabItem/index.ts @@ -0,0 +1 @@ +export * from './TabItem' diff --git a/packages/lsd-react/src/components/Tabs/Tab.context.ts b/packages/lsd-react/src/components/Tabs/Tab.context.ts new file mode 100644 index 0000000..c0a265a --- /dev/null +++ b/packages/lsd-react/src/components/Tabs/Tab.context.ts @@ -0,0 +1,13 @@ +import React from 'react' +import { TabsProps } from './Tabs' + +export type TabContextType = { + activeTab?: string | null + setActiveTab: (name: string) => void + + size?: TabsProps['size'] +} + +export const TabsContext = React.createContext(null as any) + +export const useTabsContext = () => React.useContext(TabsContext) diff --git a/packages/lsd-react/src/components/Tabs/Tabs.classes.ts b/packages/lsd-react/src/components/Tabs/Tabs.classes.ts new file mode 100644 index 0000000..b1325d4 --- /dev/null +++ b/packages/lsd-react/src/components/Tabs/Tabs.classes.ts @@ -0,0 +1,7 @@ +export const tabsClasses = { + root: `lsd-tabs`, + fullWidth: 'lsd-tabs--full-width', + withScrollControls: 'lsd-tabs--with-scroll-controls', + leftScrollControl: 'lsd-tabs__left-scroll-control', + rightScrollControl: 'lsd-tabs__right-scroll-control', +} diff --git a/packages/lsd-react/src/components/Tabs/Tabs.stories.tsx b/packages/lsd-react/src/components/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000..5818de7 --- /dev/null +++ b/packages/lsd-react/src/components/Tabs/Tabs.stories.tsx @@ -0,0 +1,63 @@ +import { Meta, Story } from '@storybook/react' +import { useStorybookIconComponent } from '../../utils/storybook.utils' +import { TabItem } from '../TabItem' +import { Tabs, TabsProps } from './Tabs' + +export default { + title: 'Tabs', + component: Tabs, + argTypes: { + size: { + type: { + name: 'enum', + value: ['small', 'medium', 'large'], + }, + defaultValue: 'large', + }, + icon: { + type: { + name: 'enum', + value: useStorybookIconComponent.options, + }, + defaultValue: 'FolderIcon', + }, + tabs: { + type: { + name: 'number', + }, + defaultValue: 4, + }, + }, +} as Meta + +export const Root: Story< + TabsProps & { + icon: string + tabs: number + } +> = ({ icon, tabs, ...args }) => { + const IconComponent = useStorybookIconComponent(icon) + + return ( +
+ + {new Array(Math.max(1, tabs)).fill(null).map((tab, index) => ( + + } + > + {`Tab ${index + 1}`} + + ))} + +
+ ) +} + +Root.args = { + fullWidth: false, + scrollControls: true, +} diff --git a/packages/lsd-react/src/components/Tabs/Tabs.styles.ts b/packages/lsd-react/src/components/Tabs/Tabs.styles.ts new file mode 100644 index 0000000..0e31590 --- /dev/null +++ b/packages/lsd-react/src/components/Tabs/Tabs.styles.ts @@ -0,0 +1,47 @@ +import { css } from '@emotion/react' +import { tabsClasses } from './Tabs.classes' + +export const TabsStyles = css` + .${tabsClasses.root} { + display: flex; + flex-direction: row; + overflow: auto; + + & > * { + flex-shrink: 0; + } + } + + .${tabsClasses.fullWidth} { + width: 100%; + justify-content: stretch; + + & > * { + width: 100%; + flex: 1 0; + } + } + + .${tabsClasses.root} { + &::-webkit-scrollbar { + display: none; + } + + -ms-overflow-style: none; + scrollbar-width: none; + } + + .${tabsClasses.leftScrollControl} { + left: 0; + } + + .${tabsClasses.rightScrollControl} { + right: 0; + } + + .${tabsClasses.rightScrollControl}, .${tabsClasses.leftScrollControl} { + top: 0; + flex: 0 1; + position: sticky; + } +` diff --git a/packages/lsd-react/src/components/Tabs/Tabs.tsx b/packages/lsd-react/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000..df8905c --- /dev/null +++ b/packages/lsd-react/src/components/Tabs/Tabs.tsx @@ -0,0 +1,87 @@ +import clsx from 'clsx' +import React, { useEffect, useRef, useState } from 'react' +import { useHorizontalScroll } from '../../utils/useHorizontalScroll' +import { NavigateBeforeIcon, NavigateNextIcon } from '../Icons' +import { TabItem } from '../TabItem' +import { TabsContext } from './Tab.context' +import { tabsClasses } from './Tabs.classes' + +export type TabsProps = Omit< + React.HTMLAttributes, + 'onChange' +> & { + activeTab?: string | null + fullWidth?: boolean + onChange?: (activeTab: string) => void + size?: 'small' | 'medium' | 'large' + scrollControls?: boolean +} + +export const Tabs: React.FC & { + classes: typeof tabsClasses +} = ({ + size = 'large', + fullWidth = false, + scrollControls = false, + onChange, + activeTab, + children, + ...props +}) => { + const ref = useRef(null) + const [value, setValue] = useState(activeTab) + + const setActiveTab = (tab: string) => { + if (onChange) onChange(tab) + else setValue(tab) + } + + useEffect(() => setValue(activeTab), [activeTab]) + + const scroll = useHorizontalScroll( + ref as React.MutableRefObject, + { scrollBehavior: 'smooth', deps: [children] }, + ) + const canScroll = scroll.left !== 0 || scroll.right !== 0 + + return ( + +
+ {scrollControls && canScroll && ( + scroll.toLeft()} + className={tabsClasses.leftScrollControl} + > + + + )} + {children} + {scrollControls && canScroll && ( + scroll.toRight()} + className={tabsClasses.rightScrollControl} + > + + + )} +
+
+ ) +} + +Tabs.classes = tabsClasses diff --git a/packages/lsd-react/src/components/Tabs/index.ts b/packages/lsd-react/src/components/Tabs/index.ts new file mode 100644 index 0000000..443079c --- /dev/null +++ b/packages/lsd-react/src/components/Tabs/index.ts @@ -0,0 +1 @@ +export * from './Tabs' diff --git a/packages/lsd-react/src/components/Theme/ThemeProvider.tsx b/packages/lsd-react/src/components/Theme/ThemeProvider.tsx index d2ab302..c7da4f4 100644 --- a/packages/lsd-react/src/components/Theme/ThemeProvider.tsx +++ b/packages/lsd-react/src/components/Theme/ThemeProvider.tsx @@ -2,6 +2,7 @@ import { Global, ThemeProvider as EmotionThemeProvider } from '@emotion/react' import React from 'react' import { CSSBaseline } from '../CSSBaseline' import { PortalProvider } from '../PortalProvider' +import { ResizeObserverProvider } from '../ResizeObserver' import { Theme } from './types' export type ThemeProviderProps = React.PropsWithChildren<{ @@ -13,13 +14,15 @@ export const ThemeProvider: React.FC = ({ children, }) => { return ( - + - {children} - - + + {children} + + + - + ) } diff --git a/packages/lsd-react/src/index.ts b/packages/lsd-react/src/index.ts index 9d309ab..d728ac5 100644 --- a/packages/lsd-react/src/index.ts +++ b/packages/lsd-react/src/index.ts @@ -3,4 +3,6 @@ export * from './components/Dropdown' export * from './components/DropdownItem' export * from './components/Icons' export * from './components/ListBox' +export * from './components/TabItem' +export * from './components/Tabs' export * from './components/Theme' diff --git a/packages/lsd-react/src/utils/counter.util.ts b/packages/lsd-react/src/utils/counter.util.ts new file mode 100644 index 0000000..25b7bf1 --- /dev/null +++ b/packages/lsd-react/src/utils/counter.util.ts @@ -0,0 +1,9 @@ +export const createCounter = (start = 0) => { + let i = start - 1 + + return () => { + i++ + + return i + } +} diff --git a/packages/lsd-react/src/utils/promise.utils.ts b/packages/lsd-react/src/utils/promise.utils.ts new file mode 100644 index 0000000..0be3fc7 --- /dev/null +++ b/packages/lsd-react/src/utils/promise.utils.ts @@ -0,0 +1,21 @@ +export const settle = async ( + promise: Promise | (() => Promise), +): Promise<[R, undefined] | [undefined, E]> => { + try { + const result: R = + typeof promise === 'function' ? await promise() : await promise + return [result, undefined] + } catch (error) { + return [undefined, error as E] + } +} + +export const settleSync = ( + func: () => R, +): [R, undefined] | [undefined, E] => { + try { + return [func(), undefined] + } catch (error) { + return [undefined, error as E] + } +} diff --git a/packages/lsd-react/src/utils/storybook.utils.ts b/packages/lsd-react/src/utils/storybook.utils.ts new file mode 100644 index 0000000..698b8bf --- /dev/null +++ b/packages/lsd-react/src/utils/storybook.utils.ts @@ -0,0 +1,12 @@ +import React from 'react' +import * as Icons from '../components/Icons' +import { LsdIconProps } from '../components/Icons' + +export const useStorybookIconComponent = (name: string) => { + const Component = (Icons as any)[name] + + if (!Component) return undefined + return Component as React.ComponentType +} + +useStorybookIconComponent.options = ['None', ...Object.keys(Icons)] diff --git a/packages/lsd-react/src/utils/useEventListener.ts b/packages/lsd-react/src/utils/useEventListener.ts new file mode 100644 index 0000000..7115e06 --- /dev/null +++ b/packages/lsd-react/src/utils/useEventListener.ts @@ -0,0 +1,28 @@ +import { useEffect, useMemo } from 'react' + +export const useEventListener = < + T = any, + E extends { addEventListener: any; removeEventListener: any } = any, +>( + key: string | (() => string), + element: E | (() => E), + listener: (event: T) => void, + options?: boolean | AddEventListenerOptions, + deps?: any[], +) => { + const _key = useMemo(() => (typeof key === 'string' ? key : key()), []) + const _element = useMemo( + () => (typeof element === 'function' ? element() : element), + [element], + ) + + useEffect(() => { + if (!_element?.addEventListener || !_element?.removeEventListener) return + + _element.addEventListener(_key, listener, options) + + return () => { + _element.removeEventListener(_key, listener, options) + } + }, [_key, _element]) +} diff --git a/packages/lsd-react/src/utils/useHorizontalScroll.ts b/packages/lsd-react/src/utils/useHorizontalScroll.ts new file mode 100644 index 0000000..d46c023 --- /dev/null +++ b/packages/lsd-react/src/utils/useHorizontalScroll.ts @@ -0,0 +1,88 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { useEventListener } from './useEventListener' +import { useResizeObserver } from './useResizeObserver' + +export type UseHorizontalScrollReturnType = { + right: number + left: number + toRight: (width?: number) => void + toLeft: (width?: number) => void +} + +const calcAvailableWidth = ( + direction: number, + scrollLeft: number, + scrollWidth: number, + clientWidth: number, +) => (direction === -1 ? scrollLeft : scrollWidth - (clientWidth + scrollLeft)) + +export const useHorizontalScroll = ( + ref: React.MutableRefObject, + options?: { + scrollBehavior?: ScrollBehavior + deps?: any[] + }, +) => { + const rect = useResizeObserver(ref) + const [scroll, setScroll] = useState(ref?.current?.scrollLeft ?? 0) + const [leftAvailableWidth, setLeftAvailableWidth] = useState(0) + const [rightAvailableWidth, setRightAvailableWidth] = useState(0) + + const timeoutRef = useRef(null) + + useEventListener( + 'scroll', + ref.current, + (event) => { + setScroll((event.target as HTMLDivElement).scrollLeft) + }, + { passive: true }, + [], + ) + + const onChange = () => { + timeoutRef.current && clearTimeout(timeoutRef.current) + + if (!ref.current) return + const { scrollLeft, scrollWidth, clientWidth } = ref.current + + setRightAvailableWidth( + calcAvailableWidth(1, scrollLeft, scrollWidth, clientWidth), + ) + setLeftAvailableWidth( + calcAvailableWidth(-1, scrollLeft, scrollWidth, clientWidth), + ) + } + + useEffect(onChange, [rect, scroll, options?.deps]) + + const toScroll = (direction: number, width?: number) => { + const { clientWidth, scrollLeft } = ref.current + + const firstChildInViewport = Array.from(ref.current.childNodes).find( + (child) => (child as HTMLElement).getBoundingClientRect().x >= 0, + ) + + const w = Math.max( + width ?? clientWidth / 3, + firstChildInViewport + ? (firstChildInViewport as HTMLElement).clientWidth + : 0, + ) + + ref.current.scrollTo({ + behavior: options?.scrollBehavior ?? 'smooth', + left: scrollLeft + w * direction, + }) + } + + return useMemo( + () => ({ + right: rightAvailableWidth, + left: leftAvailableWidth, + toRight: toScroll.bind(null, 1), + toLeft: toScroll.bind(null, -1), + }), + [rightAvailableWidth, leftAvailableWidth], + ) +} diff --git a/packages/lsd-react/src/utils/useResizeObserver.ts b/packages/lsd-react/src/utils/useResizeObserver.ts new file mode 100644 index 0000000..dada26e --- /dev/null +++ b/packages/lsd-react/src/utils/useResizeObserver.ts @@ -0,0 +1,37 @@ +import { MutableRefObject, useEffect, useMemo } from 'react' +import { + useDOMRect, + useResizeObserverAPI, +} from '../components/ResizeObserver/ResizeObserverContext' +import { createCounter } from './counter.util' + +const defaultValue = + typeof DOMRectReadOnly === 'undefined' + ? null + : new DOMRectReadOnly(0, 0, 0, 0) + +const genId = createCounter() + +export const useResizeObserver = ( + ref: MutableRefObject, + refId?: string, +): DOMRect => { + const api = useResizeObserverAPI() + + const id = useMemo(() => refId ?? genId().toString(), [refId]) + const rect = useDOMRect(id) ?? defaultValue + + useEffect(() => { + if (!api || !api.ready) return + + if (ref.current) { + api.observe(id, ref) + } + + return () => { + api.unobserve(id) + } + }, [api.ready, ref.current]) + + return rect +}