diff --git a/.changeset/shaggy-windows-float.md b/.changeset/shaggy-windows-float.md new file mode 100644 index 000000000..15dacf305 --- /dev/null +++ b/.changeset/shaggy-windows-float.md @@ -0,0 +1,5 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Add Panel component. diff --git a/.changeset/silent-plants-drum.md b/.changeset/silent-plants-drum.md new file mode 100644 index 000000000..9691ec398 --- /dev/null +++ b/.changeset/silent-plants-drum.md @@ -0,0 +1,5 @@ +--- +'@cube-dev/ui-kit': minor +--- + +Add ResizablePanel component. diff --git a/src/components/layout/Panel.tsx b/src/components/layout/Panel.tsx new file mode 100644 index 000000000..28f949f17 --- /dev/null +++ b/src/components/layout/Panel.tsx @@ -0,0 +1,193 @@ +import { + createContext, + ForwardedRef, + forwardRef, + ReactNode, + useMemo, +} from 'react'; + +import { + BASE_STYLES, + BaseProps, + BaseStyleProps, + BLOCK_STYLES, + BlockStyleProps, + COLOR_STYLES, + ColorStyleProps, + DIMENSION_STYLES, + OUTER_STYLES, + OuterStyleProps, + Styles, + tasty, +} from '../../tasty'; + +export interface PanelContextData { + layout: 'grid' | 'flex'; +} + +export const PanelContext = createContext({ + layout: 'grid', +}); + +const PanelElement = tasty({ + as: 'section', + qa: 'Panel', + styles: { + position: { + '': 'relative', + 'stretched | floating': 'absolute', + }, + inset: { + '': 'initial', + stretched: true, + }, + display: 'block', + radius: { + '': '0', + card: '1r', + }, + border: { + '': '0', + card: '1bw', + }, + flexGrow: 1, + }, +}); + +const PanelInnerElement = tasty({ + styles: { + position: 'absolute', + display: { + '': 'grid', + flex: 'flex', + }, + top: 0, + left: 0, + right: 0, + bottom: 0, + overflow: 'auto', + styledScrollbar: true, + gridColumns: 'minmax(100%, 100%)', + gridRows: { + '': 'initial', + stretched: 'minmax(0, 1fr)', + }, + radius: { + '': '0', + card: '(1r - 1bw)', + }, + flow: 'row', + placeContent: 'start stretch', + }, + styleProps: [...OUTER_STYLES, ...BASE_STYLES, ...COLOR_STYLES], +}); + +export interface CubePanelProps + extends OuterStyleProps, + BlockStyleProps, + BaseStyleProps, + ColorStyleProps, + BaseProps { + isStretched?: boolean; + isCard?: boolean; + isFloating?: boolean; + styles?: Styles; + innerStyles?: Styles; + placeContent?: Styles['placeContent']; + placeItems?: Styles['placeItems']; + gridColumns?: Styles['gridTemplateColumns']; + gridRows?: Styles['gridTemplateRows']; + flow?: Styles['flow']; + gap?: Styles['gap']; + isFlex?: boolean; + children?: ReactNode; + extra?: ReactNode; +} + +const STYLES = [ + 'placeContent', + 'placeItems', + 'gridColumns', + 'gridRows', + 'flow', + 'gap', + 'padding', + 'overflow', + 'fill', + 'color', + 'preset', +] as const; + +function Panel(props: CubePanelProps, ref: ForwardedRef) { + let { + qa, + mods, + isStretched, + isFloating, + isCard, + isFlex, + styles, + innerStyles, + children, + extra, + style, + ...otherProps + } = props; + + STYLES.forEach((style) => { + if (props[style]) { + innerStyles = { ...innerStyles, [style]: props[style] }; + } + }); + + [ + ...OUTER_STYLES, + ...BASE_STYLES, + ...BLOCK_STYLES, + ...COLOR_STYLES, + ...DIMENSION_STYLES, + ].forEach((style) => { + if (style in props) { + styles = { ...styles, [style]: props[style] }; + } + }); + + const appliedMods = useMemo( + () => ({ + floating: isFloating, + stretched: isStretched, + card: isCard, + flex: isFlex, + ...mods, + }), + [isStretched, isCard, mods], + ); + + return ( + + + + {children} + + {extra} + + + ); +} + +const _Panel = forwardRef(Panel); + +_Panel.displayName = 'Panel'; + +export { _Panel as Panel }; diff --git a/src/components/layout/ResizablePanel.stories.tsx b/src/components/layout/ResizablePanel.stories.tsx new file mode 100644 index 000000000..c1727d069 --- /dev/null +++ b/src/components/layout/ResizablePanel.stories.tsx @@ -0,0 +1,88 @@ +import { Meta, StoryFn } from '@storybook/react'; +import { useState } from 'react'; + +import { Panel } from './Panel'; +import { ResizablePanel, CubeResizablePanelProps } from './ResizablePanel'; + +export default { + title: 'Layout/ResizablePanel', + component: ResizablePanel, + args: {}, +} as Meta; + +const TemplateRight: StoryFn = (args) => ( + + + + +); + +const TemplateLeft: StoryFn = (args) => { + return ( + + + + + ); +}; + +const TemplateBottom: StoryFn = (args) => ( + + + + +); + +const TemplateTop: StoryFn = (args) => { + return ( + + + + + ); +}; + +const TemplateControllable: StoryFn = (args) => { + const [size, setSize] = useState(200); + + return ( + setSize(Math.min(500, Math.max(100, size)))} + {...args} + > + ); +}; + +export const ResizeRight = TemplateRight.bind({}); +ResizeRight.args = { + direction: 'right', +}; + +export const ResizeLeft = TemplateLeft.bind({}); +ResizeLeft.args = { + direction: 'left', +}; + +export const ResizeBottom = TemplateBottom.bind({}); +ResizeBottom.args = { + direction: 'bottom', +}; + +export const ResizeTop = TemplateTop.bind({}); +ResizeTop.args = { + direction: 'top', +}; + +export const Controllable = TemplateControllable.bind({}); +Controllable.args = { + direction: 'right', +}; + +export const Disabled = TemplateRight.bind({}); +Disabled.args = { + isDisabled: true, +}; diff --git a/src/components/layout/ResizablePanel.tsx b/src/components/layout/ResizablePanel.tsx new file mode 100644 index 000000000..d8095c6cd --- /dev/null +++ b/src/components/layout/ResizablePanel.tsx @@ -0,0 +1,290 @@ +import { + ForwardedRef, + forwardRef, + useContext, + useEffect, + useState, +} from 'react'; +import { useHover, useMove } from 'react-aria'; + +import { useWarn } from '../../_internal/index'; +import { BasePropsWithoutChildren, Styles, tasty } from '../../tasty/index'; +import { mergeProps, useCombinedRefs } from '../../utils/react/index'; + +import { Panel, CubePanelProps, PanelContext } from './Panel'; + +type Direction = 'top' | 'right' | 'bottom' | 'left'; + +export interface CubeResizablePanelProps extends CubePanelProps { + handlerStyles?: Styles; + direction: Direction; + size?: number; + onSizeChange?: (size: number) => void; + minSize?: string | number; + maxSize?: string | number; + isDisabled?: boolean; +} + +const HandlerElement = tasty({ + styles: { + // The real size is slightly bigger than the visual one. + width: { + '': 'auto', + horizontal: '9px', + }, + height: { + '': '9px', + horizontal: 'auto', + }, + top: { + '': 'initial', + horizontal: 0, + '[data-direction="top"]': -2, + }, + bottom: { + '': 'initial', + horizontal: 0, + '[data-direction="bottom"]': -2, + }, + right: { + '': 0, + horizontal: 'initial', + '[data-direction="right"]': -2, + }, + left: { + '': 0, + horizontal: 'initial', + '[data-direction="left"]': -2, + }, + position: 'absolute', + zIndex: 1, + cursor: { + '': 'col-resize', + disabled: 'not-allowed', + }, + fill: { + '': '#clear', + drag: '#purple-02', + }, + padding: 0, + boxSizing: 'border-box', + transition: 'theme', + + Track: { + width: { + '': 'initial', + horizontal: '5px', + }, + height: { + '': '5px', + horizontal: 'initial', + }, + position: 'absolute', + inset: { + '': '2px 0', + horizontal: '0 2px', + }, + fill: { + '': '#border-opaque', + '(hovered | drag) & !disabled': '#purple-03', + }, + transition: 'theme', + }, + + Drag: { + width: { + '': '3x', + horizontal: '3px', + }, + height: { + '': '3px', + horizontal: '3x', + }, + radius: true, + fill: { + '': '#dark-03', + 'hovered | drag': '#dark-02', + disabled: '#dark-04', + }, + inset: { + '': '3px 50% auto auto', + horizontal: '50% 3px auto auto', + }, + position: 'absolute', + transition: 'theme', + }, + }, +}); + +interface HandlerProps extends BasePropsWithoutChildren { + direction: Direction; +} + +const Handler = (props: HandlerProps) => { + const { direction = 'right', isDisabled } = props; + const { hoverProps, isHovered } = useHover({}); + const isHorizontal = direction === 'left' || direction === 'right'; + + return ( + +
+
+ + ); +}; + +const PanelElement = tasty(Panel, { + styles: { + flexGrow: 0, + width: { + '': '@min-size @size @max-size', + '[data-direction="top"] | [data-direction="bottom"]': 'initial', + }, + height: { + '': '@min-size @size @max-size', + '[data-direction="left"] | [data-direction="right"]': 'initial', + }, + placeSelf: 'stretch', + touchAction: 'none', + }, +}); + +function ResizablePanel( + props: CubeResizablePanelProps, + ref: ForwardedRef, +) { + const panelContext = useContext(PanelContext); + + useWarn(panelContext.layout !== 'flex', { + once: true, + key: 'resizable panel layout requirement', + args: [ + 'ResizablePanel can only be used inside a flex layout. Use Panel with `isFlex` property to create one.', + ], + }); + + const isControllable = typeof props.size === 'number'; + const { + isDisabled, + direction = 'right', + size: providedSize, + onSizeChange, + minSize = 200, + maxSize = isControllable ? undefined : 400, + } = props; + + const [isDragging, setIsDragging] = useState(false); + const isHorizontal = direction === 'left' || direction === 'right'; + + ref = useCombinedRefs(ref); + + function clamp(size: number) { + if (typeof maxSize === 'number') { + size = Math.min(maxSize, size); + } + + if (typeof minSize === 'number' || !minSize) { + size = Math.max((minSize as number) || 0, size); + } + + return Math.max(size, 0); + } + + let [size, setSize] = useState( + providedSize != null ? clamp(providedSize) : 200, + ); + + let { moveProps } = useMove({ + onMoveStart(e) { + if (isDisabled) { + return; + } + + setIsDragging(true); + + const offsetProp = isHorizontal ? 'offsetWidth' : 'offsetHeight'; + + if (ref.current && Math.abs(ref.current[offsetProp] - size) > 1) { + setSize(ref.current[offsetProp]); + } + }, + onMove(e) { + setSize((size) => { + if (isDisabled) { + return size; + } + + if (e.pointerType === 'keyboard') { + return size; + } + + size += isHorizontal + ? e.deltaX * (direction === 'right' ? 1 : -1) + : e.deltaY * (direction === 'bottom' ? 1 : -1); + + return clamp(size); + }); + }, + onMoveEnd(e) { + setIsDragging(false); + }, + }); + + useEffect(() => { + onSizeChange?.(size); + }, [size]); + + useEffect(() => { + if (providedSize) { + setSize(providedSize); + } + }, [providedSize]); + + return ( + + } + {...mergeProps(props, { + style: { + '--size': `${size}px`, + '--min-size': typeof minSize === 'number' ? `${minSize}px` : minSize, + '--max-size': typeof maxSize === 'number' ? `${maxSize}px` : maxSize, + }, + innerStyles: { + margin: `5px ${direction}`, + }, + })} + /> + ); +} + +const _ResizablePanel = forwardRef(ResizablePanel); + +_ResizablePanel.displayName = 'ResizablePanel'; + +export { _ResizablePanel as ResizablePanel }; diff --git a/src/index.ts b/src/index.ts index 245dc5ab6..5e7dd9e7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,10 @@ export { Space } from './components/layout/Space'; export type { CubeSpaceProps } from './components/layout/Space'; export { Flow } from './components/layout/Flow'; export type { CubeFlowProps } from './components/layout/Flow'; +export { Panel } from './components/layout/Panel'; +export type { CubePanelProps } from './components/layout/Panel'; +export { ResizablePanel } from './components/layout/ResizablePanel'; +export type { CubeResizablePanelProps } from './components/layout/ResizablePanel'; export { Root } from './components/Root'; export type { CubeRootProps } from './components/Root'; export { PrismCode } from './components/content/PrismCode/PrismCode'; diff --git a/src/tokens.ts b/src/tokens.ts index 492e675e8..8c17f4686 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -48,6 +48,7 @@ const TOKENS = { transition: '80ms', 'clear-color': 'transparent', 'border-color': color('dark', 0.1), + 'border-opaque-color': 'rgb(227, 227, 233)', 'shadow-color': color('dark-03', 0.1), 'draft-color': color('dark', 0.2), 'minor-color': color('dark', 0.65),