From 7cd6925c60c03f46287fc166b3f4ed34ec428a70 Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Fri, 1 Aug 2025 16:40:34 -0400 Subject: [PATCH 1/6] LG-5361: Resizable hook (#2979) * wip * can drag the drawer and it snaps shut * comments * add todos * keyboard interaction * update keyboard logic to work with handle position * remove css transition from within hook * change drawer onChange type * fix handleType issues * fix some TS errors * remove handlerType from getResizerProps * remove size obj * some more cleanup * remove dup code * fix some TS errors * remove snap close logic * create DrawerLayoutContext * fix weird transition * remove error in file * fix open and resizable checks * add top and bottom support * make getKeyboardInteraction DRYer * add useResizable story * all tests are passing * update useResizable tests * rename handleType to dragFrom * add comments to useResizable * create a utils folder for useResizable * only prevent default when arrow keys are pressed * update types * aria-value updates on resize * add className to getResizerProps * update body cursor on resize * remove comment * rename dragFrom to position * fix failing tests * remove comments * lint * lint and clean up * move useResizable hook to hook package * revert drawer changes * fix build error, improve useResizable utility functions * update pnpm-lock * update tsdocs * lint * reorganize and update types * remove defaults from minSize and maxSize * use enum values for SIZE_GROWTH_KEY_MAPPINGS * add util folder * move calculateNewSize to utils * move getNextKeyboardSize to utils * update tests * lint * move updateSize function * add comments * fix ketboard decrease bug * move utils to util folder * fix validate error * update lockfile * move hook to a package * update lock file * remove palette from hooks --- packages/resizable/README.md | 53 +++ packages/resizable/package.json | 31 ++ packages/resizable/src/Resizable.stories.tsx | 143 +++++++ packages/resizable/src/index.ts | 1 + packages/resizable/src/useResizable/index.ts | 2 + .../useResizable/useResizable.constants.ts | 29 ++ .../src/useResizable/useResizable.spec.tsx | 361 ++++++++++++++++++ .../src/useResizable/useResizable.ts | 300 +++++++++++++++ .../src/useResizable/useResizable.types.ts | 94 +++++ .../calculateNewSize/calculateNewSize.spec.ts | 149 ++++++++ .../calculateNewSize/calculateNewSize.ts | 51 +++ .../utils/calculateNewSize/index.ts | 1 + .../getNextKeyboardSize.spec.ts | 168 ++++++++ .../getNextKeyboardSize.ts | 60 +++ .../utils/getNextKeyboardSize/index.ts | 1 + .../utils/getResizerAriaAttributes.ts | 19 + .../useResizable/utils/getResizerStyles.ts | 34 ++ .../resizable/src/useResizable/utils/index.ts | 3 + packages/resizable/tsconfig.json | 31 ++ pnpm-lock.yaml | 132 ++++--- tools/install/src/ALL_PACKAGES.ts | 1 + 21 files changed, 1604 insertions(+), 60 deletions(-) create mode 100644 packages/resizable/README.md create mode 100644 packages/resizable/package.json create mode 100644 packages/resizable/src/Resizable.stories.tsx create mode 100644 packages/resizable/src/index.ts create mode 100644 packages/resizable/src/useResizable/index.ts create mode 100644 packages/resizable/src/useResizable/useResizable.constants.ts create mode 100644 packages/resizable/src/useResizable/useResizable.spec.tsx create mode 100644 packages/resizable/src/useResizable/useResizable.ts create mode 100644 packages/resizable/src/useResizable/useResizable.types.ts create mode 100644 packages/resizable/src/useResizable/utils/calculateNewSize/calculateNewSize.spec.ts create mode 100644 packages/resizable/src/useResizable/utils/calculateNewSize/calculateNewSize.ts create mode 100644 packages/resizable/src/useResizable/utils/calculateNewSize/index.ts create mode 100644 packages/resizable/src/useResizable/utils/getNextKeyboardSize/getNextKeyboardSize.spec.ts create mode 100644 packages/resizable/src/useResizable/utils/getNextKeyboardSize/getNextKeyboardSize.ts create mode 100644 packages/resizable/src/useResizable/utils/getNextKeyboardSize/index.ts create mode 100644 packages/resizable/src/useResizable/utils/getResizerAriaAttributes.ts create mode 100644 packages/resizable/src/useResizable/utils/getResizerStyles.ts create mode 100644 packages/resizable/src/useResizable/utils/index.ts create mode 100644 packages/resizable/tsconfig.json diff --git a/packages/resizable/README.md b/packages/resizable/README.md new file mode 100644 index 0000000000..4668e32f97 --- /dev/null +++ b/packages/resizable/README.md @@ -0,0 +1,53 @@ +# Resizable + +![npm (scoped)](https://img.shields.io/npm/v/@leafygreen-ui/resizable.svg) + +#### [View on MongoDB.design](https://www.mongodb.design/component/resizable/live-example/) + +## Installation + +### PNPM + +```shell +pnpm add @leafygreen-ui/resizable +``` + +### Yarn + +```shell +yarn add @leafygreen-ui/resizable +``` + +### NPM + +```shell +npm install @leafygreen-ui/resizable +``` + +## Overview + +`Resizable` is an internal hook that provides resizable functionality to an element. It allows you to specify the position of the resizer and provides props to attach to the resizable element. + +## Usage + +```tsx +import { useResizable, Position } from '@leafygreen-ui/resizable'; +const MyComponent = () => { + const { resizableRef, getResizerProps } = useResizable({ + position: Position.Right, + }); + + return ( +
+ {/* Your content here */} +
+
+ ); +}; +``` + +TODO: continue + +``` + +``` diff --git a/packages/resizable/package.json b/packages/resizable/package.json new file mode 100644 index 0000000000..4a8ef848ac --- /dev/null +++ b/packages/resizable/package.json @@ -0,0 +1,31 @@ + +{ + "name": "@leafygreen-ui/resizable", + "version": "0.1.0", + "description": "LeafyGreen UI Kit Resizable", + "main": "./dist/umd/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/types/index.d.ts", + "license": "Apache-2.0", + "scripts": { + "build": "lg-build bundle", + "tsc": "lg-build tsc", + "docs": "lg-build docs" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@leafygreen-ui/emotion": "workspace:^", + "@leafygreen-ui/lib": "workspace:^", + "@leafygreen-ui/palette": "workspace:^" + }, + "homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/resizable", + "repository": { + "type": "git", + "url": "https://github.com/mongodb/leafygreen-ui" + }, + "bugs": { + "url": "https://jira.mongodb.org/projects/LG/summary" + } +} diff --git a/packages/resizable/src/Resizable.stories.tsx b/packages/resizable/src/Resizable.stories.tsx new file mode 100644 index 0000000000..63708b5b7e --- /dev/null +++ b/packages/resizable/src/Resizable.stories.tsx @@ -0,0 +1,143 @@ +import React, { ElementType } from 'react'; +import { StoryMetaType } from '@lg-tools/storybook-utils'; +import { StoryFn, StoryObj } from '@storybook/react'; + +import { Position } from './useResizable/useResizable.types'; +import { useResizable } from './useResizable'; + +export default { + title: 'internal/hooks/useResizable/position', + parameters: { + default: null, + chromatic: { + disableSnapshot: true, + }, + controls: { + exclude: ['position'], + }, + }, +} satisfies StoryMetaType>; + +interface HandleConfig { + containerStyles: React.CSSProperties; + resizerBaseStyles: React.CSSProperties; +} + +const POSITION_CONFIGS: Record = { + right: { + containerStyles: { + right: 0, + top: 0, + }, + resizerBaseStyles: { + left: 0, + top: 0, + }, + }, + left: { + containerStyles: { + left: 0, + top: 0, + }, + resizerBaseStyles: { + right: 0, + top: 0, + }, + }, + bottom: { + containerStyles: { + left: 0, + bottom: 0, + }, + resizerBaseStyles: { + left: 0, + top: 0, + }, + }, + top: { + containerStyles: { + left: 0, + top: 0, + }, + resizerBaseStyles: { + left: 0, + bottom: 0, + }, + }, +}; + +const CreateResizableStory: StoryFn = args => { + const { position } = args; + const config = POSITION_CONFIGS[position]; + const { getResizerProps, size, resizableRef } = useResizable({ + enabled: true, + initialSize: 300, + minSize: 200, + maxSize: 600, + position, + }); + + const resizerProps = getResizerProps(); + const isVertical = position === Position.Left || position === Position.Right; + + const containerStyles = { + ...config.containerStyles, + [isVertical ? 'width' : 'height']: size, + ...(isVertical + ? { height: '100%', maxWidth: '50vw' } + : { width: '100vw', maxHeight: '50vh' }), + backgroundColor: 'lightgray', + position: 'absolute' as const, + }; + + return ( +
+
+
+ Resizable element is on the {position} +
+
+ ); +}; + +export const Left: StoryObj = { + render: CreateResizableStory, + args: { + position: Position.Left, + }, +}; + +export const Right: StoryObj = { + render: CreateResizableStory, + args: { + position: Position.Right, + }, +}; + +export const Top: StoryObj = { + render: CreateResizableStory, + args: { + position: Position.Top, + }, +}; + +export const Bottom: StoryObj = { + render: CreateResizableStory, + args: { + position: Position.Bottom, + }, +}; diff --git a/packages/resizable/src/index.ts b/packages/resizable/src/index.ts new file mode 100644 index 0000000000..73a6eec85b --- /dev/null +++ b/packages/resizable/src/index.ts @@ -0,0 +1 @@ +export { Position, useResizable } from './useResizable/'; diff --git a/packages/resizable/src/useResizable/index.ts b/packages/resizable/src/useResizable/index.ts new file mode 100644 index 0000000000..9865034de8 --- /dev/null +++ b/packages/resizable/src/useResizable/index.ts @@ -0,0 +1,2 @@ +export { useResizable } from './useResizable'; +export { Position, ResizableProps } from './useResizable.types'; diff --git a/packages/resizable/src/useResizable/useResizable.constants.ts b/packages/resizable/src/useResizable/useResizable.constants.ts new file mode 100644 index 0000000000..b4f63b6af4 --- /dev/null +++ b/packages/resizable/src/useResizable/useResizable.constants.ts @@ -0,0 +1,29 @@ +import { keyMap } from '@leafygreen-ui/lib'; + +import { Arrow, Position, SizeGrowth } from './useResizable.types'; + +export const RESIZER_SIZE = 2; +export const KEYBOARD_RESIZE_PIXEL_STEP = 50; + +// Mappings for keyboard interactions based on the position +export const SIZE_GROWTH_KEY_MAPPINGS: Record< + Position, + Partial> +> = { + [Position.Right]: { + [keyMap.ArrowLeft]: SizeGrowth.Increase, + [keyMap.ArrowRight]: SizeGrowth.Decrease, + }, + [Position.Left]: { + [keyMap.ArrowRight]: SizeGrowth.Increase, + [keyMap.ArrowLeft]: SizeGrowth.Decrease, + }, + [Position.Bottom]: { + [keyMap.ArrowUp]: SizeGrowth.Increase, + [keyMap.ArrowDown]: SizeGrowth.Decrease, + }, + [Position.Top]: { + [keyMap.ArrowDown]: SizeGrowth.Increase, + [keyMap.ArrowUp]: SizeGrowth.Decrease, + }, +}; diff --git a/packages/resizable/src/useResizable/useResizable.spec.tsx b/packages/resizable/src/useResizable/useResizable.spec.tsx new file mode 100644 index 0000000000..35eeea1e26 --- /dev/null +++ b/packages/resizable/src/useResizable/useResizable.spec.tsx @@ -0,0 +1,361 @@ +import { fireEvent } from '@testing-library/dom'; +import { act } from '@testing-library/react'; + +import { keyMap } from '@leafygreen-ui/lib'; +import { renderHook } from '@leafygreen-ui/testing-lib'; + +import { useResizable } from './useResizable'; +import { KEYBOARD_RESIZE_PIXEL_STEP } from './useResizable.constants'; +import { Position } from './useResizable.types'; + +// Mock window dimensions +Object.defineProperty(window, 'innerWidth', { value: 1024 }); +Object.defineProperty(window, 'innerHeight', { value: 768 }); + +describe('useResizable', () => { + const mockRef = { + current: { + offsetWidth: 300, + offsetHeight: 300, + style: { + setProperty: jest.fn(), + removeProperty: jest.fn(), + }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('returns initial values when initialized', () => { + const { result } = renderHook(() => + useResizable({ + initialSize: 300, + minSize: 100, + maxSize: 500, + position: Position.Left, + }), + ); + + expect(result.current.size).toBe(300); + expect(result.current.isResizing).toBe(false); + expect(typeof result.current.getResizerProps).toBe('function'); + expect(result.current.resizableRef).toBeDefined(); + }); + + test('updates size when setSize is called', () => { + const { result } = renderHook(() => + useResizable({ + initialSize: 300, + minSize: 100, + maxSize: 500, + position: Position.Left, + }), + ); + + act(() => { + result.current.setSize(400); + }); + + expect(result.current.size).toBe(400); + }); + + test('calls onResize callback when resizing', () => { + const onResize = jest.fn(); + const { result } = renderHook(() => + useResizable({ + initialSize: 300, + minSize: 100, + maxSize: 500, + position: Position.Left, + onResize, + }), + ); + + // current is read-only from outside the hook but for testing we can set it directly + (result.current.resizableRef as any).current = mockRef.current; + + // Start resizing + const resizerProps = result.current.getResizerProps(); + act(() => { + // @ts-expect-error - onMouseDown expects all properties of MouseEvent + resizerProps?.onMouseDown({ + preventDefault: jest.fn(), + clientX: 300, + clientY: 300, + }); + }); + + // Simulate mouse movement + act(() => { + fireEvent( + window, + new MouseEvent('mousemove', { + clientX: 400, + clientY: 300, + }), + ); + }); + + // Check if onResize was called + expect(onResize).toHaveBeenCalledWith(400); + }); + + describe.each([Position.Bottom, Position.Top, Position.Left, Position.Right])( + 'position: %s', + position => { + test('respects minSize constraint', () => { + const onResize = jest.fn(); + const initialSize = 300; + const { result } = renderHook(() => + useResizable({ + initialSize, + minSize: 250, + maxSize: 500, + position: position as Position, + onResize, + }), + ); + + // Override the ref to mock DOM element + // current is read-only from outside the hook but for testing we can set it directly + (result.current.resizableRef as any).current = mockRef.current; + + // Start resizing + const resizerProps = result.current.getResizerProps(); + act(() => { + // @ts-expect-error - onMouseDown expects all properties of MouseEvent + resizerProps?.onMouseDown({ + preventDefault: jest.fn(), + clientX: initialSize, + clientY: initialSize, + }); + }); + + // Simulate mouse movement that would make size below minSize + act(() => { + fireEvent( + window, + new MouseEvent('mousemove', { + clientX: + position === Position.Right + ? initialSize + 100 + : initialSize - 100, + clientY: + position === Position.Bottom + ? initialSize + 100 + : initialSize - 100, + }), + ); + }); + + // Check if minSize constraint was applied + expect(result.current.size).toBe(250); + expect(onResize).toHaveBeenCalledWith(250); + }); + + test('respects maxSize constraint', () => { + const onResize = jest.fn(); + const initialSize = 300; + const { result } = renderHook(() => + useResizable({ + initialSize, + minSize: 100, + maxSize: 400, + position: position as Position, + onResize, + }), + ); + + // Override the ref to mock DOM element + // current is read-only from outside the hook but for testing we can set it directly + (result.current.resizableRef as any).current = mockRef.current; + + // Start resizing + const resizerProps = result.current.getResizerProps(); + act(() => { + // @ts-expect-error - onMouseDown expects all properties of MouseEvent + resizerProps?.onMouseDown({ + preventDefault: jest.fn(), + clientX: initialSize, + clientY: initialSize, + }); + }); + + // Simulate mouse movement that would make size above maxSize + act(() => { + fireEvent( + window, + new MouseEvent('mousemove', { + clientX: + position === Position.Right + ? initialSize - 200 + : initialSize + 200, + clientY: + position === Position.Bottom + ? initialSize - 200 + : initialSize + 200, + }), + ); + }); + + // Check if maxSize constraint was applied + expect(result.current.size).toBe(400); + expect(onResize).toHaveBeenCalledWith(400); + }); + }, + ); + + test('stops resizing on mouseup event', async () => { + const { result } = renderHook(() => + useResizable({ + initialSize: 300, + minSize: 100, + maxSize: 500, + position: Position.Right, + }), + ); + + // Override the ref to mock DOM element + // current is read-only from outside the hook but for testing we can set it directly + (result.current.resizableRef as any).current = mockRef.current; + + // Start resizing + const resizerProps = result.current.getResizerProps(); + act(() => { + // @ts-expect-error - onMouseDown expects all properties of MouseEvent + resizerProps?.onMouseDown({ + preventDefault: jest.fn(), + clientX: 300, + clientY: 300, + }); + }); + + expect(result.current.isResizing).toBe(true); + + // Mock requestAnimationFrame to execute immediately + const originalRAF = window.requestAnimationFrame; + + window.requestAnimationFrame = cb => { + return setTimeout(cb, 0); + }; + + // Stop resizing + act(() => { + fireEvent(window, new MouseEvent('mouseup')); + }); + + // Wait for all pending promises to resolve (including the setTimeout that mocks rAF) + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + expect(result.current.isResizing).toBe(false); + + // Restore original requestAnimationFrame + window.requestAnimationFrame = originalRAF; + }); + + test('handles keyboard interactions with KEYBOARD_RESIZE_PIXEL_STEP increments', () => { + const onResize = jest.fn(); + const initialSize = 300; + const { result } = renderHook(() => + useResizable({ + initialSize: 300, + minSize: 100, + maxSize: 500, + position: Position.Left, + onResize, + }), + ); + + // Focus the resizer + const resizerProps = result.current.getResizerProps(); + act(() => { + resizerProps?.onFocus(); + }); + + // Press right arrow to increase size + act(() => { + fireEvent.keyDown(window, { code: keyMap.ArrowRight }); + }); + + const increasedSize = initialSize + KEYBOARD_RESIZE_PIXEL_STEP; + expect(result.current.size).toBe(increasedSize); + expect(onResize).toHaveBeenCalledWith(increasedSize); + + // Press left arrow to decrease size + act(() => { + fireEvent.keyDown(window, { code: keyMap.ArrowLeft }); + }); + + // Should be back to initial size + expect(result.current.size).toBe(initialSize); + expect(onResize).toHaveBeenCalledWith(initialSize); + }); + + test('does not resize when disabled', () => { + const onResize = jest.fn(); + const { result } = renderHook(() => + useResizable({ + initialSize: 300, + minSize: 100, + maxSize: 500, + position: Position.Right, + onResize, + enabled: false, + }), + ); + + // Check that getResizerProps returns undefined + const resizerProps = result.current.getResizerProps(); + expect(resizerProps).toBeUndefined(); + }); + + test('applies min/max constraints when using keyboard navigation', () => { + const onResize = jest.fn(); + const initialSize = 450; + const maxSize = 500; + const minSize = 100; + const { result } = renderHook(() => + useResizable({ + initialSize, + minSize, + maxSize, + position: Position.Right, + onResize, + }), + ); + + // Focus the resizer + const resizerProps = result.current.getResizerProps(); + act(() => { + resizerProps?.onFocus(); + }); + + // Press key to increase size beyond max + act(() => { + fireEvent.keyDown(window, { code: keyMap.ArrowLeft }); + }); + + // Should be constrained to maxSize + expect(result.current.size).toBe(maxSize); + expect(onResize).toHaveBeenCalledWith(maxSize); + + // Reset the size to near the minimum + act(() => { + result.current.setSize(minSize + 10); + }); + onResize.mockClear(); + + // Press key to decrease size below min + act(() => { + fireEvent.keyDown(window, { code: keyMap.ArrowRight }); + }); + + // Should be constrained to minSize + expect(result.current.size).toBe(minSize); + expect(onResize).toHaveBeenCalledWith(minSize); + }); +}); diff --git a/packages/resizable/src/useResizable/useResizable.ts b/packages/resizable/src/useResizable/useResizable.ts new file mode 100644 index 0000000000..b860d0158d --- /dev/null +++ b/packages/resizable/src/useResizable/useResizable.ts @@ -0,0 +1,300 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { keyMap } from '@leafygreen-ui/lib'; + +import { getNextKeyboardSize } from './utils/getNextKeyboardSize'; +import { SIZE_GROWTH_KEY_MAPPINGS } from './useResizable.constants'; +import { + Arrow, + Position, + ResizableProps, + ResizableReturn, + ResizerProps, +} from './useResizable.types'; +import { + calculateNewSize, + getResizerAriaAttributes, + getResizerStyles, +} from './utils'; + +/** + * Custom hook to handle resizable functionality for a component. + * It allows resizing the component using mouse and keyboard interactions. + * + * @param {ResizableProps} props - The properties for the resizable functionality. + * @returns {ResizableReturn} - The state and methods for the resizable component. + * + * @example + * const { size, setSize, isResizing, getResizerProps, resizableRef } = useResizable({ + * enabled: true, + * initialSize: 200, + * minSize: 100, + * maxSize: 500, + * onResize: (newSize) => console.log('Resized to:', newSize), + * position: Position.Right, + * }); + */ +export const useResizable = ({ + enabled = true, + initialSize = 0, + minSize: minSizeProp, + maxSize: maxSizeProp, + onResize, + position, +}: ResizableProps): ResizableReturn => { + const resizableRef = useRef(null); + const [isResizing, setIsResizing] = useState(false); + const [isFocused, setIsFocused] = useState(false); + // Refs to store initial mouse position and element size at the start of a drag + const initialMousePos = useRef>({ + x: 0, + y: 0, + }); + const initialElementSize = useRef(0); + // Ref to hold the current value of isResizing to prevent stale closures in event handlers + const isResizingRef = useRef(isResizing); + const [size, setSize] = useState(initialSize); + // Use provided min/max sizes or fallback to initialSize as boundaries + const minSize = minSizeProp ?? initialSize; + const maxSize = maxSizeProp ?? initialSize; + // Determine if resizing is vertical (left/right) or horizontal (top/bottom) + const isVertical = position === Position.Left || position === Position.Right; + + // Update size when enabled state or initialSize changes + useEffect(() => { + setSize(initialSize); + }, [enabled, initialSize]); + + /** + * Handles the size update based on the next size value. + * Updates internal state and calls the onResize callback if provided. + * + * @param {number | undefined} nextSize - The new size value to set + */ + const updateSize = useCallback( + (nextSize: number | undefined) => { + if (nextSize !== undefined) { + setSize(nextSize); + onResize?.(nextSize); + } + }, + [onResize], + ); + + /** + * Calculates and sets the current resizing state and updates the ref synchronously. + * Uses the mouse movement to determine the new size. + */ + const handleMouseMove = useCallback( + (event: MouseEvent) => { + // Only proceed if resizing is enabled and the element is currently being resized + if (!isResizingRef.current) return; + + const newSize = calculateNewSize( + event, + initialElementSize.current, + initialMousePos.current, + position, + minSize, + maxSize, + ); + updateSize(newSize); + }, + [maxSize, minSize, position, updateSize], + ); + + /** + * Handles the mouse up event to stop resizing. + */ + const handleMouseUp = useCallback(() => { + // Use requestAnimationFrame to ensure any final size/transform changes + // are rendered with transitions enabled before stopping resizing. + requestAnimationFrame(() => { + isResizingRef.current = false; // Synchronously update ref + setIsResizing(false); // Set resizing state to false + }); + }, []); + + /** + * Handles keyboard interactions for resizing based on the position. + * + * For example: + * - If position is 'left' and the left arrow key is pressed, it decreases the size. + * - If position is 'right' and the left arrow key is pressed, it increases the size. + */ + const setNextKeyboardSize = useCallback( + (event: React.KeyboardEvent | KeyboardEvent, position: Position | null) => { + if (position && event.code in SIZE_GROWTH_KEY_MAPPINGS[position]) { + const sizeGrowth = + SIZE_GROWTH_KEY_MAPPINGS[position][event.code as Arrow]; + const nextSize = getNextKeyboardSize({ + sizeGrowth, + size, + maxSize, + minSize, + currentElement: resizableRef.current ?? null, + isVertical, + }); + updateSize(nextSize); + } + }, + [size, updateSize, minSize, maxSize, isVertical], + ); + + /** + * Prevents default behavior and calls setNextKeyboardSize to handle resizing. + */ + const handleKeyDown = useCallback( + (event: React.KeyboardEvent | KeyboardEvent) => { + if (position === Position.Left || position === Position.Right) { + if ( + event.code === keyMap.ArrowLeft || + event.code === keyMap.ArrowRight + ) { + event.preventDefault(); + } + } else { + if (event.code === keyMap.ArrowUp || event.code === keyMap.ArrowDown) { + event.preventDefault(); + } + } + setNextKeyboardSize(event, position); + }, + [setNextKeyboardSize, position], + ); + + /** + * Handles mouse down event for resizing + */ + const handleMouseDown = useCallback( + (e: MouseEvent | React.MouseEvent) => { + if (!enabled) return; + + e.preventDefault(); + + isResizingRef.current = true; + setIsResizing(true); + + initialMousePos.current = { x: e.clientX, y: e.clientY }; + + if (resizableRef.current) { + initialElementSize.current = isVertical + ? resizableRef.current.offsetWidth + : resizableRef.current.offsetHeight; + } + }, + [enabled, isVertical], + ); + + /** + * Handle focus event for the resizer + */ + const handleFocus = useCallback(() => { + setIsFocused(true); + }, []); + + /** + * Handle blur event for the resizer + */ + const handleBlur = useCallback(() => { + setIsFocused(false); + }, []); + + /** + * Returns the props for the resizer element. + * This includes mouse down, focus, blur, and style properties. + * The resizer is used to initiate resizing of the element. + * + * @returns {Object | undefined} An object containing props for the resizer element, or undefined if resizing is disabled + */ + const getResizerProps = useCallback((): ResizerProps | undefined => { + if (!enabled) { + return undefined; + } + + const props = { + onMouseDown: handleMouseDown, + onFocus: handleFocus, + onBlur: handleBlur, + ...getResizerAriaAttributes(size, minSize, maxSize, isVertical), + tabIndex: 0, // Make the resizer focusable + className: getResizerStyles(isVertical, isResizing), + }; + + return props; + }, [ + enabled, + size, + minSize, + maxSize, + handleMouseDown, + handleFocus, + handleBlur, + isResizing, + isVertical, + ]); + + /** + * Effect hook to add and remove global mouse event listeners + * These listeners are added to 'window' to ensure dragging works even if the mouse + * moves off the resizer handle during the drag. + */ + useEffect(() => { + if (!isResizing && !enabled) return; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing, handleMouseMove, handleMouseUp, enabled]); + + /** + * Effect hook to add and remove global keydown event listener + * This listener is added to 'window' to allow resizing with arrow keys + */ + useEffect(() => { + if (!isFocused && !enabled) return; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [enabled, isFocused, handleKeyDown]); + + /** + * Effect hook to handle CSS transitions for resizing + * This is to ensure that the resizing does not have a transition effect while resizing + * but transitions back to the new size smoothly after resizing is done. + * Also sets appropriate cursor style on the document body. + */ + useEffect(() => { + if (!isResizing) return; + const ref = resizableRef.current; + + // Disable transitions during resizing for smoother interaction + ref?.style.setProperty('transition', 'none'); + // Set appropriate cursor based on resize direction + document.body.style.setProperty( + 'cursor', + isVertical ? 'col-resize' : 'row-resize', + ); + + return () => { + // Restore default transition and cursor when resizing ends + ref?.style.removeProperty('transition'); + document.body.style.removeProperty('cursor'); + }; + }, [isResizing, isVertical]); + + return { + size, + setSize, + isResizing, + getResizerProps, + resizableRef, + }; +}; diff --git a/packages/resizable/src/useResizable/useResizable.types.ts b/packages/resizable/src/useResizable/useResizable.types.ts new file mode 100644 index 0000000000..118c766021 --- /dev/null +++ b/packages/resizable/src/useResizable/useResizable.types.ts @@ -0,0 +1,94 @@ +import { keyMap } from '@leafygreen-ui/lib'; + +export const Position = { + Left: 'left', + Right: 'right', + Top: 'top', + Bottom: 'bottom', +} as const; + +export type Position = (typeof Position)[keyof typeof Position]; + +export const SizeGrowth = { + Increase: 'increase', + Decrease: 'decrease', +} as const; + +export type SizeGrowth = (typeof SizeGrowth)[keyof typeof SizeGrowth]; + +// Define Arrow type using the keyMap values +export type Arrow = + | typeof keyMap.ArrowLeft + | typeof keyMap.ArrowRight + | typeof keyMap.ArrowUp + | typeof keyMap.ArrowDown; + +export interface ResizableProps { + /** + * Whether the resizable feature is enabled. + * @default false + */ + enabled?: boolean; + + /** + * The initial size of the resizable element. + */ + initialSize: number; + + /** + * The minimum size the resizable element can be resized to. + */ + minSize: number; + + /** + * The maximum size the resizable element can be resized to. + */ + maxSize: number; + + /** + * Callback function that is called when the resizable element is resized. + * This can be used to perform any actions based on the new size. + */ + onResize?: (size: number) => void; + + /** + * The position of the element to which the resizer handle is attached. + */ + position: Position; +} + +export interface ResizerProps { + onMouseDown: (e: React.MouseEvent | MouseEvent) => void; + tabIndex: number; + onFocus: () => void; + onBlur: () => void; + className?: string; + [key: string]: any; // For aria attributes +} + +export interface ResizableReturn { + /** + * The current size of the resizable element. + */ + size: number; + + /** + * Function to set the size of the resizable element. + */ + setSize: React.Dispatch>; + + /** + * Boolean indicating whether the resizable element is currently being resized. + */ + isResizing: boolean; + + /** + * A function that returns the props needed to be spread onto the resizer element. + */ + getResizerProps: () => ResizerProps | undefined; + + /** + * A ref to the resizable element that can be used to attach the resizer functionality. + */ + resizableRef: React.RefObject; +} diff --git a/packages/resizable/src/useResizable/utils/calculateNewSize/calculateNewSize.spec.ts b/packages/resizable/src/useResizable/utils/calculateNewSize/calculateNewSize.spec.ts new file mode 100644 index 0000000000..2c7584f321 --- /dev/null +++ b/packages/resizable/src/useResizable/utils/calculateNewSize/calculateNewSize.spec.ts @@ -0,0 +1,149 @@ +import { Position } from '../../useResizable.types'; + +import { calculateNewSize } from './calculateNewSize'; + +describe('calculateNewSize', () => { + // Mock window.innerWidth and window.innerHeight + const originalInnerWidth = window.innerWidth; + const originalInnerHeight = window.innerHeight; + + beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { + value: 1024, + writable: true, + }); + Object.defineProperty(window, 'innerHeight', { + value: 768, + writable: true, + }); + }); + + afterAll(() => { + Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth }); + Object.defineProperty(window, 'innerHeight', { + value: originalInnerHeight, + }); + }); + + test('calculates new size correctly when position is Right', () => { + const mockEvent = { clientX: 300, clientY: 200 } as MouseEvent; + const initialElementSize = 400; + const initialMousePosition = { x: 350, y: 200 }; + const position = Position.Right; + + const result = calculateNewSize( + mockEvent, + initialElementSize, + initialMousePosition, + position, + 100, + 800, + ); + + // Delta X = 300 - 350 = -50 + // New size = 400 - (-50) = 450 + expect(result).toBe(450); + }); + + test('calculates new size correctly when position is Left', () => { + const mockEvent = { clientX: 300, clientY: 200 } as MouseEvent; + const initialElementSize = 400; + const initialMousePosition = { x: 350, y: 200 }; + const position = Position.Left; + + const result = calculateNewSize( + mockEvent, + initialElementSize, + initialMousePosition, + position, + 100, + 800, + ); + + // Delta X = 300 - 350 = -50 + // New size = 400 + (-50) = 350 + expect(result).toBe(350); + }); + + test('calculates new size correctly when position is Bottom', () => { + const mockEvent = { clientX: 300, clientY: 200 } as MouseEvent; + const initialElementSize = 400; + const initialMousePosition = { x: 300, y: 250 }; + const position = Position.Bottom; + + const result = calculateNewSize( + mockEvent, + initialElementSize, + initialMousePosition, + position, + 100, + 800, + ); + + // Delta Y = 200 - 250 = -50 + // New size = 400 - (-50) = 450 + expect(result).toBe(450); + }); + + test('calculates new size correctly when position is Top', () => { + const mockEvent = { clientX: 300, clientY: 200 } as MouseEvent; + const initialElementSize = 400; + const initialMousePosition = { x: 300, y: 250 }; + const position = Position.Top; + + const result = calculateNewSize( + mockEvent, + initialElementSize, + initialMousePosition, + position, + 100, + 800, + ); + + // Delta Y = 200 - 250 = -50 + // New size = 400 + (-50) = 350 + expect(result).toBe(350); + }); + + test('constrains size to minSize when new size is less than minSize', () => { + const mockEvent = { clientX: 200, clientY: 200 } as MouseEvent; + const initialElementSize = 150; + const initialMousePosition = { x: 250, y: 200 }; + const position = Position.Left; + const minSize = 100; + + const result = calculateNewSize( + mockEvent, + initialElementSize, + initialMousePosition, + position, + minSize, + 800, + ); + + // Delta X = 200 - 250 = -50 + // New size = 150 + (-50) = 100 + expect(result).toBe(minSize); + }); + + test('constrains size to maxSize when new size is greater than maxSize', () => { + const mockEvent = { clientX: 400, clientY: 200 } as MouseEvent; + const initialElementSize = 450; + const initialMousePosition = { x: 250, y: 200 }; + const position = Position.Left; + const maxSize = 500; + + const result = calculateNewSize( + mockEvent, + initialElementSize, + initialMousePosition, + position, + 100, + maxSize, + ); + + // Delta X = 400 - 250 = 150 + // New size = 450 + 150 = 600, but maxSize is 500 + expect(result).toBe(maxSize); + }); +}); diff --git a/packages/resizable/src/useResizable/utils/calculateNewSize/calculateNewSize.ts b/packages/resizable/src/useResizable/utils/calculateNewSize/calculateNewSize.ts new file mode 100644 index 0000000000..a7881126d9 --- /dev/null +++ b/packages/resizable/src/useResizable/utils/calculateNewSize/calculateNewSize.ts @@ -0,0 +1,51 @@ +import { Position } from '../../useResizable.types'; + +/** + * Calculates the new size based on mouse position and constraints + * + * @param event - The mouse event containing the current mouse position. + * @param initialElementSize - The initial size of the element being resized. + * @param initialMousePosition - The initial mouse position when resizing started. + * @param position - The position of the resizer (e.g., Right, Left, Bottom, Top). + * @param minSize - The minimum size the element can be resized to. + * @param maxSize - The maximum size the element can be resized to. + * @returns The new size of the element after applying the resizing logic and constraints. + */ +export const calculateNewSize = ( + event: MouseEvent, + initialElementSize: number, + initialMousePosition: { x: number; y: number }, + position: Position, + minSize: number, + maxSize: number, +): number => { + let newSize = initialElementSize; + + // Calculate delta + const deltaX = event.clientX - initialMousePosition.x; + const deltaY = event.clientY - initialMousePosition.y; + + // Apply direction-specific calculation + switch (position) { + case Position.Right: + newSize = initialElementSize - deltaX; + break; + case Position.Left: + newSize = initialElementSize + deltaX; + break; + case Position.Bottom: + newSize = initialElementSize - deltaY; + break; + case Position.Top: + newSize = initialElementSize + deltaY; + break; + } + + if (newSize < minSize) { + newSize = minSize; + } else if (newSize > maxSize) { + newSize = maxSize; + } + + return newSize; +}; diff --git a/packages/resizable/src/useResizable/utils/calculateNewSize/index.ts b/packages/resizable/src/useResizable/utils/calculateNewSize/index.ts new file mode 100644 index 0000000000..68ecf8c7d4 --- /dev/null +++ b/packages/resizable/src/useResizable/utils/calculateNewSize/index.ts @@ -0,0 +1 @@ +export { calculateNewSize } from './calculateNewSize'; diff --git a/packages/resizable/src/useResizable/utils/getNextKeyboardSize/getNextKeyboardSize.spec.ts b/packages/resizable/src/useResizable/utils/getNextKeyboardSize/getNextKeyboardSize.spec.ts new file mode 100644 index 0000000000..f7440d24bd --- /dev/null +++ b/packages/resizable/src/useResizable/utils/getNextKeyboardSize/getNextKeyboardSize.spec.ts @@ -0,0 +1,168 @@ +import { KEYBOARD_RESIZE_PIXEL_STEP } from '../../useResizable.constants'; +import { SizeGrowth } from '../../useResizable.types'; + +import { getNextKeyboardSize } from './getNextKeyboardSize'; + +// Mock DOM element with offsetWidth +const mockElement = { + offsetWidth: 250, + offsetHeight: 250, +} as HTMLElement; + +describe('getNextKeyboardSize', () => { + test('returns current size when sizeGrowth is undefined', () => { + const result = getNextKeyboardSize({ + sizeGrowth: undefined, + size: 250, + maxSize: 500, + minSize: 100, + isVertical: true, + currentElement: mockElement, + }); + + expect(result).toBe(250); + }); + + test('increases size by KEYBOARD_RESIZE_PIXEL_STEP when sizeGrowth is Increase', () => { + const result = getNextKeyboardSize({ + sizeGrowth: SizeGrowth.Increase, + size: 250, + maxSize: 500, + minSize: 100, + isVertical: true, + currentElement: mockElement, + }); + + expect(result).toBe(250 + KEYBOARD_RESIZE_PIXEL_STEP); + }); + + test('decreases size by KEYBOARD_RESIZE_PIXEL_STEP when sizeGrowth is Decrease', () => { + const result = getNextKeyboardSize({ + sizeGrowth: SizeGrowth.Decrease, + size: 250, + maxSize: 500, + minSize: 100, + isVertical: true, + currentElement: mockElement, + }); + + // The implementation decreases by KEYBOARD_RESIZE_PIXEL_STEP (50) + expect(result).toBe(250 - KEYBOARD_RESIZE_PIXEL_STEP); + }); + + test('respects maxSize constraint when increasing beyond maxSize', () => { + // Mock DOM element with offsetWidth + const mockElement = { + offsetWidth: 500, + offsetHeight: 500, + } as HTMLElement; + + const result = getNextKeyboardSize({ + sizeGrowth: SizeGrowth.Increase, + size: 480, + maxSize: 500, + minSize: 100, + isVertical: true, + currentElement: mockElement, + }); + + // Should cap at maxSize + expect(result).toBe(500); + }); + + test('respects minSize constraint when decreasing below minSize', () => { + // Mock DOM element with offsetWidth + const mockElement = { + offsetWidth: 100, + offsetHeight: 100, + } as HTMLElement; + + const result = getNextKeyboardSize({ + sizeGrowth: SizeGrowth.Decrease, + size: 100, + maxSize: 500, + minSize: 100, + isVertical: true, + currentElement: mockElement, + }); + + // Should not go below minSize + expect(result).toBe(100); + }); + + test('decreases size of the element by KEYBOARD_RESIZE_PIXEL_STEP when the size of the element is smaller than the hook size', () => { + // Mock DOM element with offsetWidth + const mockElement = { + offsetWidth: 400, + offsetHeight: 400, + } as HTMLElement; + + const result = getNextKeyboardSize({ + sizeGrowth: SizeGrowth.Decrease, + size: 600, // Size is larger than element's offsetWidth + maxSize: 500, + minSize: 100, + currentElement: mockElement, + isVertical: true, + }); + + // Should use element's offsetWidth and decrease by KEYBOARD_RESIZE_PIXEL_STEP + expect(result).toBe(400 - KEYBOARD_RESIZE_PIXEL_STEP); + }); + + test('returns the minSize when the size of the element is smaller than the minSize when decreasing', () => { + // Mock DOM element with offsetWidth + const mockElement = { + offsetWidth: 100, + offsetHeight: 400, + } as HTMLElement; + + const minSize = 200; + + const result = getNextKeyboardSize({ + sizeGrowth: SizeGrowth.Decrease, + size: 300, + maxSize: 500, + minSize: 200, + currentElement: mockElement, + isVertical: true, + }); + + expect(result).toBe(minSize); + }); + + test('returns the maxSize when the size of the element is larger than the maxSize when increasing', () => { + // Mock DOM element with offsetWidth + const mockElement = { + offsetWidth: 600, + offsetHeight: 400, + } as HTMLElement; + + const maxSize = 500; + + const result = getNextKeyboardSize({ + sizeGrowth: SizeGrowth.Increase, + size: 600, // Size is larger than element's offsetWidth + maxSize, + minSize: 200, + currentElement: mockElement, + isVertical: true, + }); + + expect(result).toBe(maxSize); + }); + + test('handles null currentElement gracefully', () => { + const result = getNextKeyboardSize({ + sizeGrowth: SizeGrowth.Decrease, + size: 250, + maxSize: 500, + minSize: 100, + currentElement: null, + isVertical: true, + }); + + // Should just decrease by KEYBOARD_RESIZE_PIXEL_STEP from the current size + expect(result).toBe(250 - KEYBOARD_RESIZE_PIXEL_STEP); + }); +}); diff --git a/packages/resizable/src/useResizable/utils/getNextKeyboardSize/getNextKeyboardSize.ts b/packages/resizable/src/useResizable/utils/getNextKeyboardSize/getNextKeyboardSize.ts new file mode 100644 index 0000000000..b0ed4ab6a3 --- /dev/null +++ b/packages/resizable/src/useResizable/utils/getNextKeyboardSize/getNextKeyboardSize.ts @@ -0,0 +1,60 @@ +import { KEYBOARD_RESIZE_PIXEL_STEP } from '../../useResizable.constants'; +import { SizeGrowth } from '../../useResizable.types'; + +/** + * Returns the next size based on the current size and size growth direction. + * @param sizeGrowth - The direction of size growth (increase or decrease). + * @param size - The current size. + * @param maxSize - The maximum size allowed. + * @param minSize - The minimum size allowed. + * @param currentElement - The current element being resized, used to check its size constraints. + * @param isVertical - Whether the resizing is vertical or horizontal. + * @returns The next size based on the growth direction, or the current size if no change is needed. + */ +export const getNextKeyboardSize = ({ + sizeGrowth, + size, + maxSize, + minSize, + currentElement, + isVertical, +}: { + sizeGrowth: SizeGrowth | undefined; + size: number; + maxSize: number; + minSize: number; + currentElement: HTMLElement | null; + isVertical: boolean; +}) => { + if (!sizeGrowth) return size; // No change if sizeGrowth is undefined + + const currentElementSize = isVertical + ? currentElement?.offsetWidth + : currentElement?.offsetHeight; + + if (sizeGrowth === SizeGrowth.Increase) { + // if the current element size is greater than the maxSize, return maxSize + if (currentElement && currentElementSize && currentElementSize > maxSize) + return maxSize; + + // increase the size by the value but not exceeding maxSize + return Math.min(size + KEYBOARD_RESIZE_PIXEL_STEP, maxSize); + } else { + if (currentElement && currentElementSize) { + // If the current element size is less than the minSize, return minSize + if (currentElementSize < minSize) return minSize; + + // If the element has a max-width/max-height set in CSS, the hook size might exceed the max size in CSS. This ensures that the value is decreased using the implicit CSS max width/height and not the hook size. + // This also ensures that if the element is resized to a size smaller than the minSize in the browser, it will not be resized further down. + if (size > currentElementSize) { + return Math.max( + minSize, + currentElementSize - KEYBOARD_RESIZE_PIXEL_STEP, + ); + } + } + + // decrease the size by the value but not going below minSize + return Math.max(size - KEYBOARD_RESIZE_PIXEL_STEP, minSize); + } +}; diff --git a/packages/resizable/src/useResizable/utils/getNextKeyboardSize/index.ts b/packages/resizable/src/useResizable/utils/getNextKeyboardSize/index.ts new file mode 100644 index 0000000000..e30924d9af --- /dev/null +++ b/packages/resizable/src/useResizable/utils/getNextKeyboardSize/index.ts @@ -0,0 +1 @@ +export { getNextKeyboardSize } from './getNextKeyboardSize'; diff --git a/packages/resizable/src/useResizable/utils/getResizerAriaAttributes.ts b/packages/resizable/src/useResizable/utils/getResizerAriaAttributes.ts new file mode 100644 index 0000000000..8e98ae268f --- /dev/null +++ b/packages/resizable/src/useResizable/utils/getResizerAriaAttributes.ts @@ -0,0 +1,19 @@ +/** + * Generates ARIA attributes for the resizer element + */ +export const getResizerAriaAttributes = ( + size: number, + minSize: number, + maxSize: number, + isVertical: boolean, +) => { + return { + role: 'separator', // Defines the element as an interactive divider that separates content regions. + 'aria-valuenow': size, // Represents the current position of the separator as a value between aria-valuemin and aria-valuemax. + 'aria-valuemin': minSize, //The minimum value of the separator's range. + 'aria-valuemax': maxSize, // The maximum value of the separator's range. + 'aria-orientation': isVertical ? 'vertical' : 'horizontal', // The visual orientation of the separator bar. + 'aria-label': `${isVertical ? 'Vertical' : 'Horizontal'} resize handle`, // Descriptive label + 'aria-valuetext': `${size} pixels`, // Provide size in a more readable format + }; +}; diff --git a/packages/resizable/src/useResizable/utils/getResizerStyles.ts b/packages/resizable/src/useResizable/utils/getResizerStyles.ts new file mode 100644 index 0000000000..5c44ed246e --- /dev/null +++ b/packages/resizable/src/useResizable/utils/getResizerStyles.ts @@ -0,0 +1,34 @@ +import { css, cx } from '@leafygreen-ui/emotion'; +import { palette } from '@leafygreen-ui/palette'; + +import { RESIZER_SIZE } from '../useResizable.constants'; + +/** + * Generates styles for the resizer element based on its orientation and resizing state + */ +export const getResizerStyles = (isVertical: boolean, isResizing: boolean) => + cx( + css` + cursor: ${isVertical ? 'col-resize' : 'row-resize'}; + background-color: transparent; + + &:hover, + &:focus-visible { + background-color: ${palette.blue.light1}; + outline: none; + } + `, + { + [css` + width: ${RESIZER_SIZE}px; + height: 100%; + `]: isVertical, + [css` + height: ${RESIZER_SIZE}px; + width: 100%; + `]: !isVertical, + [css` + background-color: ${palette.blue.light1}; + `]: isResizing, + }, + ); diff --git a/packages/resizable/src/useResizable/utils/index.ts b/packages/resizable/src/useResizable/utils/index.ts new file mode 100644 index 0000000000..b252fd1f0d --- /dev/null +++ b/packages/resizable/src/useResizable/utils/index.ts @@ -0,0 +1,3 @@ +export { calculateNewSize } from './calculateNewSize'; +export { getResizerAriaAttributes } from './getResizerAriaAttributes'; +export { getResizerStyles } from './getResizerStyles'; diff --git a/packages/resizable/tsconfig.json b/packages/resizable/tsconfig.json new file mode 100644 index 0000000000..4cbdc5df8a --- /dev/null +++ b/packages/resizable/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "@lg-tools/build/config/package.tsconfig.json", + "compilerOptions": { + "paths": { + "@leafygreen-ui/icon/dist/*": [ + "../icon/src/generated/*" + ], + "@leafygreen-ui/*": [ + "../*/src" + ] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "**/*.spec.*", + "**/*.stories.*" + ], + "references": [ + { + "path": "../emotion" + }, + { + "path": "../lib" + }, + { + "path": "../palette" + }, + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1d4aaa6cd..b1734123e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1875,7 +1875,7 @@ importers: version: 11.1.0 ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723) + version: 10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801) xml2json: specifier: ^0.12.0 version: 0.12.0 @@ -2613,6 +2613,18 @@ importers: specifier: workspace:^ version: link:../../tools/build + packages/resizable: + dependencies: + '@leafygreen-ui/emotion': + specifier: workspace:^ + version: link:../emotion + '@leafygreen-ui/lib': + specifier: workspace:^ + version: link:../lib + '@leafygreen-ui/palette': + specifier: workspace:^ + version: link:../palette + packages/ripple: dependencies: '@leafygreen-ui/tokens': @@ -3892,10 +3904,10 @@ importers: version: 8.6.12(storybook@8.6.14(prettier@2.8.8)) '@storybook/react': specifier: 8.6.12 - version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723) + version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801) '@storybook/react-webpack5': specifier: 8.6.12 - version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723) + version: 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801) '@storybook/test': specifier: 8.6.12 version: 8.6.12(storybook@8.6.14(prettier@2.8.8)) @@ -3904,7 +3916,7 @@ importers: version: 8.6.12(storybook@8.6.14(prettier@2.8.8)) '@svgr/webpack': specifier: 8.0.1 - version: 8.0.1(typescript@5.9.0-dev.20250723) + version: 8.0.1(typescript@6.0.0-dev.20250801) assert: specifier: ^2.1.0 version: 2.1.0 @@ -3946,7 +3958,7 @@ importers: version: 18.3.1 react-docgen-typescript: specifier: 2.2.2 - version: 2.2.2(typescript@5.9.0-dev.20250723) + version: 2.2.2(typescript@6.0.0-dev.20250801) react-dom: specifier: ^17.0.0 || ^18.0.0 version: 18.3.1(react@18.3.1) @@ -4097,7 +4109,7 @@ importers: version: 11.1.1 jest: specifier: 29.6.2 - version: 29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)) + version: 29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)) jest-axe: specifier: 8.0.0 version: 8.0.0 @@ -10702,8 +10714,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.0-dev.20250723: - resolution: {integrity: sha512-cW9lMDuujd4h4Q/gHT7YdIxO3JElYuztGLsV9+kWkZncfifCVjAB3lmqpsiyKtztd//Uqt4ZPZ8SmY+qpoVelg==} + typescript@6.0.0-dev.20250801: + resolution: {integrity: sha512-b/X5+OCIRwL/sCMYpynN/Lkwx3H8Jnt+ttDIZo5bKWpYK1TTeh76tXoKsrUSex2dn8Sd8qqUf7OHifvdGmeKhg==} engines: {node: '>=14.17'} hasBin: true @@ -13264,7 +13276,7 @@ snapshots: - ts-node optional: true - '@jest/core@29.6.2(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723))': + '@jest/core@29.6.2(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -13278,7 +13290,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)) + jest-config: 29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -13335,7 +13347,7 @@ snapshots: - ts-node optional: true - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723))': + '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -13349,7 +13361,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)) + jest-config: 29.7.0(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -13881,7 +13893,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-webpack5@8.6.12(esbuild@0.25.5)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723)': + '@storybook/builder-webpack5@8.6.12(esbuild@0.25.5)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801)': dependencies: '@storybook/core-webpack': 8.6.12(storybook@8.6.14(prettier@2.8.8)) '@types/semver': 7.7.0 @@ -13891,7 +13903,7 @@ snapshots: constants-browserify: 1.0.0 css-loader: 6.11.0(webpack@5.88.0(esbuild@0.25.5)) es-module-lexer: 1.7.0 - fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.9.0-dev.20250723)(webpack@5.88.0(esbuild@0.25.5)) + fork-ts-checker-webpack-plugin: 8.0.0(typescript@6.0.0-dev.20250801)(webpack@5.88.0(esbuild@0.25.5)) html-webpack-plugin: 5.6.3(webpack@5.88.0(esbuild@0.25.5)) magic-string: 0.30.17 path-browserify: 1.0.1 @@ -13909,7 +13921,7 @@ snapshots: webpack-hot-middleware: 2.26.1 webpack-virtual-modules: 0.6.2 optionalDependencies: - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 transitivePeerDependencies: - '@rspack/core' - '@swc/core' @@ -13993,11 +14005,11 @@ snapshots: dependencies: storybook: 8.6.14(prettier@2.8.8) - '@storybook/preset-react-webpack@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723)': + '@storybook/preset-react-webpack@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801)': dependencies: '@storybook/core-webpack': 8.6.12(storybook@8.6.14(prettier@2.8.8)) - '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723) - '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.9.0-dev.20250723)(webpack@5.88.0(esbuild@0.25.5)) + '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801) + '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@6.0.0-dev.20250801)(webpack@5.88.0(esbuild@0.25.5)) '@types/semver': 7.7.0 find-up: 5.0.0 magic-string: 0.30.17 @@ -14010,7 +14022,7 @@ snapshots: tsconfig-paths: 4.2.0 webpack: 5.88.0(esbuild@0.25.5) optionalDependencies: - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 transitivePeerDependencies: - '@storybook/test' - '@swc/core' @@ -14023,16 +14035,16 @@ snapshots: dependencies: storybook: 8.6.14(prettier@2.8.8) - '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.9.0-dev.20250723)(webpack@5.88.0(esbuild@0.25.5))': + '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@6.0.0-dev.20250801)(webpack@5.88.0(esbuild@0.25.5))': dependencies: debug: 4.4.1 endent: 2.1.0 find-cache-dir: 3.3.2 flat-cache: 3.2.0 micromatch: 4.0.8 - react-docgen-typescript: 2.2.2(typescript@5.9.0-dev.20250723) + react-docgen-typescript: 2.2.2(typescript@6.0.0-dev.20250801) tslib: 2.8.1 - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 webpack: 5.88.0(esbuild@0.25.5) transitivePeerDependencies: - supports-color @@ -14043,16 +14055,16 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.14(prettier@2.8.8) - '@storybook/react-webpack5@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723)': + '@storybook/react-webpack5@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801)': dependencies: - '@storybook/builder-webpack5': 8.6.12(esbuild@0.25.5)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723) - '@storybook/preset-react-webpack': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723) - '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723) + '@storybook/builder-webpack5': 8.6.12(esbuild@0.25.5)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801) + '@storybook/preset-react-webpack': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(esbuild@0.25.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801) + '@storybook/react': 8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) storybook: 8.6.14(prettier@2.8.8) optionalDependencies: - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 transitivePeerDependencies: - '@rspack/core' - '@storybook/test' @@ -14077,7 +14089,7 @@ snapshots: '@storybook/test': 8.6.12(storybook@8.6.14(prettier@2.8.8)) typescript: 5.8.3 - '@storybook/react@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@5.9.0-dev.20250723)': + '@storybook/react@8.6.12(@storybook/test@8.6.12(storybook@8.6.14(prettier@2.8.8)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@2.8.8))(typescript@6.0.0-dev.20250801)': dependencies: '@storybook/components': 8.6.12(storybook@8.6.14(prettier@2.8.8)) '@storybook/global': 5.0.0 @@ -14090,7 +14102,7 @@ snapshots: storybook: 8.6.14(prettier@2.8.8) optionalDependencies: '@storybook/test': 8.6.12(storybook@8.6.14(prettier@2.8.8)) - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 '@storybook/test@8.5.3(storybook@8.6.14(prettier@2.8.8))': dependencies: @@ -14256,12 +14268,12 @@ snapshots: - supports-color - typescript - '@svgr/core@8.0.0(typescript@5.9.0-dev.20250723)': + '@svgr/core@8.0.0(typescript@6.0.0-dev.20250801)': dependencies: '@babel/core': 7.24.3 '@svgr/babel-preset': 8.0.0(@babel/core@7.24.3) camelcase: 6.3.0 - cosmiconfig: 8.3.6(typescript@5.9.0-dev.20250723) + cosmiconfig: 8.3.6(typescript@6.0.0-dev.20250801) snake-case: 3.0.4 transitivePeerDependencies: - supports-color @@ -14306,11 +14318,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@svgr/plugin-jsx@8.0.1(@svgr/core@8.0.0(typescript@5.9.0-dev.20250723))': + '@svgr/plugin-jsx@8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250801))': dependencies: '@babel/core': 7.24.3 '@svgr/babel-preset': 8.0.0(@babel/core@7.24.3) - '@svgr/core': 8.0.0(typescript@5.9.0-dev.20250723) + '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250801) '@svgr/hast-util-to-babel-ast': 8.0.0 svg-parser: 2.0.4 transitivePeerDependencies: @@ -14341,10 +14353,10 @@ snapshots: transitivePeerDependencies: - typescript - '@svgr/plugin-svgo@8.0.1(@svgr/core@8.0.0(typescript@5.9.0-dev.20250723))(typescript@5.9.0-dev.20250723)': + '@svgr/plugin-svgo@8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250801))(typescript@6.0.0-dev.20250801)': dependencies: - '@svgr/core': 8.0.0(typescript@5.9.0-dev.20250723) - cosmiconfig: 8.3.6(typescript@5.9.0-dev.20250723) + '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250801) + cosmiconfig: 8.3.6(typescript@6.0.0-dev.20250801) deepmerge: 4.3.1 svgo: 3.3.2 transitivePeerDependencies: @@ -14375,16 +14387,16 @@ snapshots: - supports-color - typescript - '@svgr/webpack@8.0.1(typescript@5.9.0-dev.20250723)': + '@svgr/webpack@8.0.1(typescript@6.0.0-dev.20250801)': dependencies: '@babel/core': 7.24.3 '@babel/plugin-transform-react-constant-elements': 7.27.1(@babel/core@7.24.3) '@babel/preset-env': 7.24.3(@babel/core@7.24.3) '@babel/preset-react': 7.24.1(@babel/core@7.24.3) '@babel/preset-typescript': 7.24.1(@babel/core@7.24.3) - '@svgr/core': 8.0.0(typescript@5.9.0-dev.20250723) - '@svgr/plugin-jsx': 8.0.1(@svgr/core@8.0.0(typescript@5.9.0-dev.20250723)) - '@svgr/plugin-svgo': 8.0.1(@svgr/core@8.0.0(typescript@5.9.0-dev.20250723))(typescript@5.9.0-dev.20250723) + '@svgr/core': 8.0.0(typescript@6.0.0-dev.20250801) + '@svgr/plugin-jsx': 8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250801)) + '@svgr/plugin-svgo': 8.0.1(@svgr/core@8.0.0(typescript@6.0.0-dev.20250801))(typescript@6.0.0-dev.20250801) transitivePeerDependencies: - supports-color - typescript @@ -15682,14 +15694,14 @@ snapshots: optionalDependencies: typescript: 5.8.3 - cosmiconfig@8.3.6(typescript@5.9.0-dev.20250723): + cosmiconfig@8.3.6(typescript@6.0.0-dev.20250801): dependencies: import-fresh: 3.3.1 js-yaml: 4.1.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 create-ecdh@4.0.4: dependencies: @@ -16062,7 +16074,7 @@ snapshots: dependencies: semver: 7.7.2 shelljs: 0.8.5 - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 dunder-proto@1.0.1: dependencies: @@ -16679,7 +16691,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@8.0.0(typescript@5.9.0-dev.20250723)(webpack@5.88.0(esbuild@0.25.5)): + fork-ts-checker-webpack-plugin@8.0.0(typescript@6.0.0-dev.20250801)(webpack@5.88.0(esbuild@0.25.5)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -16693,7 +16705,7 @@ snapshots: schema-utils: 3.3.0 semver: 7.7.2 tapable: 2.2.2 - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 webpack: 5.88.0(esbuild@0.25.5) form-data@2.5.3: @@ -17374,16 +17386,16 @@ snapshots: - ts-node optional: true - jest-cli@29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)): + jest-cli@29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 import-local: 3.2.0 - jest-config: 29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)) + jest-config: 29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)) jest-util: 29.7.0 jest-validate: 29.7.0 prompts: 2.4.2 @@ -17426,7 +17438,7 @@ snapshots: - supports-color optional: true - jest-config@29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)): + jest-config@29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)): dependencies: '@babel/core': 7.24.3 '@jest/test-sequencer': 29.7.0 @@ -17452,7 +17464,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.17.58 - ts-node: 10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723) + ts-node: 10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -17489,7 +17501,7 @@ snapshots: - supports-color optional: true - jest-config@29.7.0(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)): + jest-config@29.7.0(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)): dependencies: '@babel/core': 7.24.3 '@jest/test-sequencer': 29.7.0 @@ -17515,7 +17527,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.17.58 - ts-node: 10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723) + ts-node: 10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -17783,12 +17795,12 @@ snapshots: - ts-node optional: true - jest@29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)): + jest@29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)): dependencies: - '@jest/core': 29.6.2(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)) + '@jest/core': 29.6.2(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723)) + jest-cli: 29.6.2(@types/node@20.17.58)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -19173,9 +19185,9 @@ snapshots: dependencies: typescript: 5.8.3 - react-docgen-typescript@2.2.2(typescript@5.9.0-dev.20250723): + react-docgen-typescript@2.2.2(typescript@6.0.0-dev.20250801): dependencies: - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 react-docgen@7.1.1: dependencies: @@ -20104,7 +20116,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 - ts-node@10.9.2(@types/node@20.17.58)(typescript@5.9.0-dev.20250723): + ts-node@10.9.2(@types/node@20.17.58)(typescript@6.0.0-dev.20250801): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -20118,7 +20130,7 @@ snapshots: create-require: 1.1.1 diff: 4.0.2 make-error: 1.3.6 - typescript: 5.9.0-dev.20250723 + typescript: 6.0.0-dev.20250801 v8-compile-cache-lib: 3.0.1 yn: 3.1.1 @@ -20230,7 +20242,7 @@ snapshots: typescript@5.8.3: {} - typescript@5.9.0-dev.20250723: {} + typescript@6.0.0-dev.20250801: {} unbox-primitive@1.1.0: dependencies: diff --git a/tools/install/src/ALL_PACKAGES.ts b/tools/install/src/ALL_PACKAGES.ts index eae89f6867..c33ca121f2 100644 --- a/tools/install/src/ALL_PACKAGES.ts +++ b/tools/install/src/ALL_PACKAGES.ts @@ -53,6 +53,7 @@ export const ALL_PACKAGES = [ '@leafygreen-ui/preview-card', '@leafygreen-ui/radio-box-group', '@leafygreen-ui/radio-group', + '@leafygreen-ui/resizable', '@leafygreen-ui/ripple', '@leafygreen-ui/search-input', '@leafygreen-ui/section-nav', From b8b4fc96d89ad8a2e2fd447ca0d1170bf3d3007f Mon Sep 17 00:00:00 2001 From: Shaneeza Date: Mon, 4 Aug 2025 08:51:33 -0400 Subject: [PATCH 2/6] LG-5375: Resizable drawer (#2980) * wip * can drag the drawer and it snaps shut * comments * add todos * keyboard interaction * update keyboard logic to work with handle position * remove css transition from within hook * change drawer onChange type * fix handleType issues * fix some TS errors * remove handlerType from getResizerProps * remove size obj * some more cleanup * remove dup code * fix some TS errors * remove snap close logic * create DrawerLayoutContext * fix weird transition * remove error in file * fix open and resizable checks * add top and bottom support * make getKeyboardInteraction DRYer * add useResizable story * all tests are passing * update useResizable tests * rename handleType to dragFrom * add comments to useResizable * create a utils folder for useResizable * only prevent default when arrow keys are pressed * update types * aria-value updates on resize * add className to getResizerProps * update body cursor on resize * remove comment * rename dragFrom to position * fix failing tests * remove comments * lint * lint and clean up * move useResizable hook to hook package * revert drawer changes * extract drawer changes from resizable hook branch * clean up DrawerLayout types * update types and rename some consts * organize toolbar styles better * some more cleanup * lint * fix build error, improve useResizable utility functions * update pnpm-lock * update tsdocs * lint * fix embedded animation * lint * fix DrawerLayout.spec * update tsdocs and refactor DrawerToolbarLayout stories * clean up drawer.tsx * remove drawer check * update Drawer story open prop * isOverLay to isOverlay * remove TOOLBAR_WIDTH export * unify transition duration and timing function across components * remove resizableRef check * create setIsDrawerOpen to update value within components * fix failing test in DrawerLayoutContext * use css vars * update CSS var * add defaults * lint * add resolvedProp hooks * remove check in LayoutComponent * add more tests to DrawerLayoutContext and update spec file * fix build error * update css var * fix failing test * set a default for --drawer-width * story ts error * fix story ts error * trigger checks * reorganize and update types * remove defaults from minSize and maxSize * use enum values for SIZE_GROWTH_KEY_MAPPINGS * add util folder * move calculateNewSize to utils * move getNextKeyboardSize to utils * update tests * lint * move updateSize function * add comments * fix ketboard decrease bug * move utils to util folder * fix validate error * update lockfile * move hook to a package * update lock file * remove palette from hooks * use resizable package * lint --- packages/drawer/package.json | 1 + packages/drawer/src/Drawer.stories.tsx | 91 ++++---- .../drawer/src/Drawer/Drawer.constants.ts | 9 + packages/drawer/src/Drawer/Drawer.styles.ts | 61 ++++-- packages/drawer/src/Drawer/Drawer.tsx | 88 +++++++- packages/drawer/src/Drawer/Drawer.types.ts | 6 +- packages/drawer/src/Drawer/Drawer.utils.ts | 57 +++++ .../src/DrawerLayout/DrawerLayout.spec.tsx | 72 +++++-- .../drawer/src/DrawerLayout/DrawerLayout.tsx | 50 +++-- .../src/DrawerLayout/DrawerLayout.types.ts | 81 +++++-- .../DrawerLayoutContext.spec.tsx | 85 ++++++++ .../DrawerLayoutContext.tsx | 70 ++++++ .../DrawerLayoutContext.types.ts | 55 +++++ packages/drawer/src/DrawerLayout/index.ts | 4 + .../DrawerToolbarContext.spec.tsx | 8 +- .../DrawerToolbarContext.tsx | 4 +- ...awerToolbarLayout.interactions.stories.tsx | 201 ++++++++++-------- .../DrawerToolbarLayout.stories.tsx | 194 +++++++++-------- .../DrawerToolbarLayout.types.ts | 6 +- .../DrawerToolbarLayoutContainer.tsx | 26 +-- .../DrawerWithToolbarWrapper.styles.ts | 112 ++++++---- .../DrawerWithToolbarWrapper.tsx | 55 +++-- .../DrawerWithToolbarWrapper.types.ts | 13 +- .../EmbeddedDrawerLayout.styles.ts | 82 +++++-- .../EmbeddedDrawerLayout.tsx | 46 ++-- .../EmbeddedDrawerLayout.types.ts | 9 +- .../src/LayoutComponent/LayoutComponent.tsx | 32 ++- .../LayoutComponent/LayoutComponent.types.ts | 22 +- .../OverlayDrawerLayout.styles.ts | 4 +- .../OverlayDrawerLayout.tsx | 35 ++- .../OverlayDrawerLayout.types.ts | 4 +- packages/drawer/src/constants.ts | 9 +- .../drawer/src/testing/render.testutils.tsx | 5 +- packages/drawer/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 35 files changed, 1073 insertions(+), 530 deletions(-) create mode 100644 packages/drawer/src/Drawer/Drawer.utils.ts create mode 100644 packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.spec.tsx create mode 100644 packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.tsx create mode 100644 packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.types.ts diff --git a/packages/drawer/package.json b/packages/drawer/package.json index 6c34c1bb88..9c1e52a97d 100644 --- a/packages/drawer/package.json +++ b/packages/drawer/package.json @@ -35,6 +35,7 @@ "@leafygreen-ui/lib": "workspace:^", "@leafygreen-ui/palette": "workspace:^", "@leafygreen-ui/polymorphic": "workspace:^", + "@leafygreen-ui/resizable": "workspace:^", "@leafygreen-ui/tabs": "workspace:^", "@leafygreen-ui/tokens": "workspace:^", "@leafygreen-ui/toolbar": "workspace:^", diff --git a/packages/drawer/src/Drawer.stories.tsx b/packages/drawer/src/Drawer.stories.tsx index 9459337941..18b2e9c273 100644 --- a/packages/drawer/src/Drawer.stories.tsx +++ b/packages/drawer/src/Drawer.stories.tsx @@ -63,6 +63,8 @@ export default { args: { displayMode: DisplayMode.Overlay, title: 'Drawer Title', + onClose: undefined, + resizable: true, }, argTypes: { darkMode: storybookArgTypes.darkMode, @@ -75,6 +77,11 @@ export default { control: 'text', description: 'Title of the Drawer', }, + resizable: { + control: 'boolean', + description: + 'Determines if the Drawer is resizable. Only applies to Embedded display mode.', + }, }, } satisfies StoryMetaType; @@ -99,11 +106,15 @@ const LongContent = () => { ); }; -const TemplateComponent: StoryFn = ({ +type DrawerOmitOpen = Omit; +type StoryDrawerProps = DrawerOmitOpen & { resizable?: boolean }; + +const TemplateComponent: StoryFn = ({ displayMode = DisplayMode.Overlay, initialOpen, + resizable, ...rest -}: DrawerProps & { +}: StoryDrawerProps & { initialOpen?: boolean; }) => { const [open, setOpen] = useState(initialOpen ?? true); @@ -115,40 +126,50 @@ const TemplateComponent: StoryFn = ({ ); const renderDrawer = () => ( - setOpen(false)} - /> + setOpen(false)} /> ); + const isEmbedded = displayMode === DisplayMode.Embedded; + + const baseLayoutProps = { + drawer: renderDrawer(), + onClose: () => setOpen(false), + isDrawerOpen: open, + }; + + const layoutProps = isEmbedded + ? { + displayMode, + resizable, + ...baseLayoutProps, + } + : { + displayMode, + ...baseLayoutProps, + }; return ( - -
- -
- {renderTrigger()} - -
- {renderDrawer()} -
-
-
+
+ +
+ {renderTrigger()} + +
+
+
); }; @@ -250,7 +271,6 @@ export const LightModeOverlay: StoryObj = { children: , darkMode: false, displayMode: DisplayMode.Overlay, - open: true, }, parameters: { controls: { @@ -265,7 +285,6 @@ export const DarkModeOverlay: StoryObj = { children: , darkMode: true, displayMode: DisplayMode.Overlay, - open: true, }, parameters: { controls: { @@ -280,7 +299,6 @@ export const LightModeEmbedded: StoryObj = { children: , darkMode: false, displayMode: DisplayMode.Embedded, - open: true, }, parameters: { controls: { @@ -295,7 +313,6 @@ export const DarkModeEmbedded: StoryObj = { children: , darkMode: true, displayMode: DisplayMode.Embedded, - open: true, }, parameters: { controls: { diff --git a/packages/drawer/src/Drawer/Drawer.constants.ts b/packages/drawer/src/Drawer/Drawer.constants.ts index 67db13f77d..f9f0032ace 100644 --- a/packages/drawer/src/Drawer/Drawer.constants.ts +++ b/packages/drawer/src/Drawer/Drawer.constants.ts @@ -1,2 +1,11 @@ +import { DRAWER_TOOLBAR_WIDTH } from '../constants'; + export const HEADER_HEIGHT = 48; export const MOBILE_BREAKPOINT = 390; +export const DRAWER_MAX_WIDTH = 612; +export const DRAWER_MAX_WIDTH_WITH_TOOLBAR = + DRAWER_MAX_WIDTH - DRAWER_TOOLBAR_WIDTH; +export const DRAWER_MIN_WIDTH = 320; +export const DRAWER_MIN_WIDTH_WITH_TOOLBAR = + DRAWER_MIN_WIDTH - DRAWER_TOOLBAR_WIDTH; +export const DRAWER_MAX_PERCENTAGE_WIDTH = 50; diff --git a/packages/drawer/src/Drawer/Drawer.styles.ts b/packages/drawer/src/Drawer/Drawer.styles.ts index c897ec697f..4a12b0eae9 100644 --- a/packages/drawer/src/Drawer/Drawer.styles.ts +++ b/packages/drawer/src/Drawer/Drawer.styles.ts @@ -8,13 +8,16 @@ import { transitionDuration, } from '@leafygreen-ui/tokens'; -import { PANEL_WIDTH } from '../constants'; +import { + DRAWER_WIDTH, + DRAWER_WITH_TOOLBAR_WIDTH, + TRANSITION_DURATION, + TRANSITION_TIMING_FUNCTION, +} from '../constants'; import { HEADER_HEIGHT, MOBILE_BREAKPOINT } from './Drawer.constants'; import { DisplayMode } from './Drawer.types'; -export const drawerTransitionDuration = transitionDuration.slower; - export const drawerClassName = createUniqueClassName('lg-drawer'); // Because of .show() and .close() in the drawer component, transitioning from 0px to (x)px does not transition correctly. Having the drawer start at the open position while hidden, moving to the closed position, and then animating to the open position is a workaround to get the animation to work. @@ -54,13 +57,15 @@ const getBaseStyles = ({ theme }: { theme: Theme }) => css` all: unset; background-color: ${color[theme].background.primary.default}; border: 1px solid ${color[theme].border.secondary.default}; + max-width: 50vw; width: 100%; - max-width: ${PANEL_WIDTH}px; height: 100%; overflow: hidden; box-sizing: border-box; + position: relative; @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { + width: auto; max-width: 100%; height: 50vh; } @@ -91,10 +96,12 @@ const getOverlayStyles = ({ open, shouldAnimate, zIndex, + hasToolbar, }: { open: boolean; shouldAnimate: boolean; zIndex: number; + hasToolbar: boolean; }) => cx( css` @@ -105,10 +112,12 @@ const getOverlayStyles = ({ right: 0; overflow: visible; + max-width: ${hasToolbar ? DRAWER_WITH_TOOLBAR_WIDTH : DRAWER_WIDTH}px; + // By default, the drawer is positioned off-screen to the right. transform: translate3d(100%, 0, 0); - animation-timing-function: ease-in-out; - animation-duration: ${drawerTransitionDuration}ms; + animation-timing-function: ${TRANSITION_TIMING_FUNCTION}; + animation-duration: ${TRANSITION_DURATION}ms; animation-fill-mode: forwards; @media only screen and (max-width: ${MOBILE_BREAKPOINT}px) { @@ -118,9 +127,10 @@ const getOverlayStyles = ({ animation: none; position: fixed; transform: translate3d(0, 100%, 0); - transition: transform ${drawerTransitionDuration}ms ease-in-out, - opacity ${drawerTransitionDuration}ms ease-in-out - ${open ? '0ms' : `${drawerTransitionDuration}ms`}; + transition-property: transform, opacity; + transition-duration: ${TRANSITION_DURATION}ms; + transition-timing-function: ${TRANSITION_TIMING_FUNCTION}; + transition-delay: 0ms, ${open ? '0ms' : `${TRANSITION_DURATION}ms`}; } `, { @@ -134,14 +144,16 @@ const getDisplayModeStyles = ({ open, shouldAnimate, zIndex, + hasToolbar, }: { displayMode: DisplayMode; open: boolean; shouldAnimate: boolean; zIndex: number; + hasToolbar: boolean; }) => cx({ - [getOverlayStyles({ open, shouldAnimate, zIndex })]: + [getOverlayStyles({ open, shouldAnimate, zIndex, hasToolbar })]: displayMode === DisplayMode.Overlay, }); @@ -152,6 +164,7 @@ export const getDrawerStyles = ({ shouldAnimate, theme, zIndex, + hasToolbar = false, }: { className?: string; displayMode: DisplayMode; @@ -159,10 +172,17 @@ export const getDrawerStyles = ({ shouldAnimate: boolean; theme: Theme; zIndex: number; + hasToolbar?: boolean; }) => cx( getBaseStyles({ theme }), - getDisplayModeStyles({ displayMode, open, shouldAnimate, zIndex }), + getDisplayModeStyles({ + displayMode, + open, + shouldAnimate, + zIndex, + hasToolbar, + }), className, drawerClassName, ); @@ -199,13 +219,13 @@ const getBaseInnerContainerStyles = ({ theme }: { theme: Theme }) => css` opacity: 0; transition-property: opacity; transition-duration: ${transitionDuration.faster}ms; - transition-timing-function: linear; + transition-timing-function: ${TRANSITION_TIMING_FUNCTION}; `; const getInnerOpenContainerStyles = css` transition-property: opacity; transition-duration: ${transitionDuration.slowest}ms; - transition-timing-function: linear; + transition-timing-function: ${TRANSITION_TIMING_FUNCTION}; opacity: 1; `; @@ -229,7 +249,7 @@ export const getHeaderStyles = ({ theme }: { theme: Theme }) => css` border-bottom: 1px solid ${color[theme].border.secondary.default}; transition-property: box-shadow; transition-duration: ${transitionDuration.faster}ms; - transition-timing-function: ease-in-out; + transition-timing-function: ${TRANSITION_TIMING_FUNCTION}; `; const baseChildrenContainerStyles = css` @@ -263,3 +283,16 @@ export const innerChildrenContainerStyles = cx( baseInnerChildrenContainerStyles, scrollContainerStyles, ); + +export const getResizerStyles = ({ + resizerClassName, +}: { + resizerClassName?: string; +}) => + cx( + css` + position: absolute; + left: 0; + `, + resizerClassName, + ); diff --git a/packages/drawer/src/Drawer/Drawer.tsx b/packages/drawer/src/Drawer/Drawer.tsx index 17aa1162d7..88e37cf785 100644 --- a/packages/drawer/src/Drawer/Drawer.tsx +++ b/packages/drawer/src/Drawer/Drawer.tsx @@ -12,22 +12,26 @@ import LeafyGreenProvider, { useDarkMode, } from '@leafygreen-ui/leafygreen-provider'; import { usePolymorphic } from '@leafygreen-ui/polymorphic'; +import { Position, useResizable } from '@leafygreen-ui/resizable'; import { BaseFontSize } from '@leafygreen-ui/tokens'; import { Body } from '@leafygreen-ui/typography'; +import { TRANSITION_DURATION } from '../constants'; +import { useDrawerLayoutContext } from '../DrawerLayout'; import { useDrawerStackContext } from '../DrawerStackContext'; import { getLgIds } from '../utils'; import { - drawerTransitionDuration, getChildrenContainerStyles, getDrawerShadowStyles, getDrawerStyles, getHeaderStyles, getInnerContainerStyles, + getResizerStyles, innerChildrenContainerStyles, } from './Drawer.styles'; import { DisplayMode, DrawerProps } from './Drawer.types'; +import { getResolvedDrawerSizes, useResolvedDrawerProps } from './Drawer.utils'; /** * A drawer is a panel that slides in from the right side of the screen (not customizable). Because the user can use the Drawer without navigating away from the current page, tasks can be completed more efficiently while not changing page context. @@ -38,24 +42,50 @@ export const Drawer = forwardRef( children, className, 'data-lgid': dataLgId, - displayMode = DisplayMode.Overlay, + displayMode: displayModeProp, id: idProp, - onClose, - open = false, + onClose: onCloseProp, + open: openProp, title, ...rest }, fwdRef, ) => { const { darkMode, theme } = useDarkMode(); - const { Component } = usePolymorphic<'dialog' | 'div'>( - displayMode === DisplayMode.Overlay ? 'dialog' : 'div', - ); const { getDrawerIndex, registerDrawer, unregisterDrawer } = useDrawerStackContext(); + const { + isDrawerOpen, + resizable, + displayMode: displayModeContextProp, + onClose: onCloseContextProp, + hasToolbar, + setIsDrawerResizing, + setDrawerWidth, + } = useDrawerLayoutContext(); const [shouldAnimate, setShouldAnimate] = useState(false); const ref = useRef(null); - const drawerRef = useMergeRefs([fwdRef, ref]); + + // Returns the resolved displayMode, open state, and onClose function based on the component and context props. + const { displayMode, open, onClose } = useResolvedDrawerProps({ + componentDisplayMode: displayModeProp, + contextDisplayMode: displayModeContextProp, + componentOpen: openProp, + contextOpen: isDrawerOpen, + componentOnClose: onCloseProp, + contextOnClose: onCloseContextProp, + }); + + // Returns the resolved drawer sizes based on whether a toolbar is present. + const { initialSize, resizableMinWidth, resizableMaxWidth } = + getResolvedDrawerSizes(hasToolbar); + + const isEmbedded = displayMode === DisplayMode.Embedded; + const isOverlay = displayMode === DisplayMode.Overlay; + const isResizable = isEmbedded && !!resizable && open; + const { Component } = usePolymorphic<'dialog' | 'div'>( + isOverlay ? 'dialog' : 'div', + ); const lgIds = getLgIds(dataLgId); const id = useIdAllocator({ prefix: 'drawer', id: idProp }); @@ -90,7 +120,7 @@ export const Drawer = forwardRef( if (open) { registerDrawer(id); } else { - setTimeout(() => unregisterDrawer(id), drawerTransitionDuration); + setTimeout(() => unregisterDrawer(id), TRANSITION_DURATION); } }, [id, open, registerDrawer, unregisterDrawer]); @@ -114,6 +144,37 @@ export const Drawer = forwardRef( } }; + // Enables resizable functionality if the drawer is resizable and in embedded mode. + const { + resizableRef, + size: drawerSize, + getResizerProps, + isResizing, + } = useResizable({ + enabled: resizable && isEmbedded, + initialSize: open ? initialSize : 0, + minSize: resizableMinWidth, + maxSize: resizableMaxWidth, + position: Position.Right, + }); + + // The parent grid container controls the drawer width with grid-template-columns, so we pass the width to the context where it is read by the parent grid container. + useEffect(() => { + if (isEmbedded) { + setIsDrawerResizing(isResizing); + setDrawerWidth(drawerSize); + } + }, [ + isEmbedded, + drawerSize, + isResizing, + setDrawerWidth, + setIsDrawerResizing, + ]); + + const resizerProps = getResizerProps(); + const drawerRef = useMergeRefs([fwdRef, ref, resizableRef]); + return ( ( className, displayMode, zIndex: 1000 + drawerIndex, + hasToolbar, })} data-lgid={lgIds.root} data-testid={lgIds.root} @@ -135,6 +197,14 @@ export const Drawer = forwardRef( inert={!open ? 'inert' : undefined} {...rest} > + {isResizable && ( +
+ )}
` element that takes up the full parent container height and on the same elevation as container page content. It is recommended to wrap an embedded drawer within the `DrawerLayout` container - * @param Overlay will display a drawer as a `` element that takes up the full parent container height and elevated above container page content. It is recommended to wrap an overlay drawer within the `DrawerLayout` container + * @param Overlay will display a drawer as a `` element that takes up the full parent container height and elevated above container page content. It is recommended to wrap an overlay drawer within the `DrawerLayout` container. + * If wrapping the Drawer in a `DrawerLayout`, this prop is not needed. The Drawer read the `displayMode` prop from `DrawerLayout`. */ displayMode?: DisplayMode; /** - * Determines if the Drawer is open or closed - * @defaultValue false + * Determines if the Drawer is open or closed. If wrapping the Drawer in a `DrawerLayout`, this prop is not needed. The Drawer read the `isDrawerOpen` prop from `DrawerLayout`. */ open?: boolean; diff --git a/packages/drawer/src/Drawer/Drawer.utils.ts b/packages/drawer/src/Drawer/Drawer.utils.ts new file mode 100644 index 0000000000..43fb5583ef --- /dev/null +++ b/packages/drawer/src/Drawer/Drawer.utils.ts @@ -0,0 +1,57 @@ +import { DRAWER_WIDTH, DRAWER_WITH_TOOLBAR_WIDTH } from '../constants'; + +import { + DRAWER_MAX_WIDTH, + DRAWER_MAX_WIDTH_WITH_TOOLBAR, + DRAWER_MIN_WIDTH, + DRAWER_MIN_WIDTH_WITH_TOOLBAR, +} from './Drawer.constants'; +import { DisplayMode } from './Drawer.types'; + +/** + * Resolves the drawer props based on the component and context props. + * @returns An object containing the resolved displayMode, open state, and onClose function. + */ +export const useResolvedDrawerProps = ({ + componentDisplayMode, + contextDisplayMode, + componentOpen, + contextOpen, + componentOnClose, + contextOnClose, +}: { + componentDisplayMode?: DisplayMode; + contextDisplayMode?: DisplayMode; + componentOpen?: boolean; + contextOpen?: boolean; + componentOnClose?: React.MouseEventHandler; + contextOnClose?: React.MouseEventHandler; +}) => { + // If the component has a displayMode prop, use that. Otherwise, use the context displayMode. + const displayMode = + componentDisplayMode ?? contextDisplayMode ?? DisplayMode.Overlay; + + // If the component has an open prop, use that. Otherwise, use the context open state. + const open = componentOpen ?? contextOpen ?? false; + + // If the component has an onClose prop, use that. Otherwise, use the context onClose function. + const onClose = componentOnClose ?? contextOnClose ?? undefined; + + return { displayMode, open, onClose }; +}; + +/** + * Returns the resolved drawer sizes based on whether a toolbar is present. + * @returns + */ +export const getResolvedDrawerSizes = (hasToolbar?: boolean) => { + const initialSize = hasToolbar ? DRAWER_WITH_TOOLBAR_WIDTH : DRAWER_WIDTH; + const resizableMinWidth = hasToolbar + ? DRAWER_MIN_WIDTH_WITH_TOOLBAR + : DRAWER_MIN_WIDTH; + const resizableMaxWidth = hasToolbar + ? DRAWER_MAX_WIDTH_WITH_TOOLBAR + : DRAWER_MAX_WIDTH; + + return { initialSize, resizableMinWidth, resizableMaxWidth }; +}; diff --git a/packages/drawer/src/DrawerLayout/DrawerLayout.spec.tsx b/packages/drawer/src/DrawerLayout/DrawerLayout.spec.tsx index ff0a26edc2..a075173710 100644 --- a/packages/drawer/src/DrawerLayout/DrawerLayout.spec.tsx +++ b/packages/drawer/src/DrawerLayout/DrawerLayout.spec.tsx @@ -2,20 +2,28 @@ import React from 'react'; import { DrawerLayout } from '.'; -describe('packages/chip', () => { +describe('packages/DrawerLayout', () => { // eslint-disable-next-line jest/no-disabled-tests test.skip('types behave as expected', () => { <> + {/* ❌ */} {/* @ts-expect-error - Missing children */} - - {'children'} - - {'children'} - - {'children'} - + {/* @ts-expect-error - Resizable should not be true with displayMode overlay */} + drawer} displayMode="overlay" resizable> + {'children'} + + {/* @ts-expect-error - ToolbarData should not be passed with isDrawerOpen */} + {'children'} + + {/* @ts-expect-error - drawer should not be passed with toolbarData */} + drawer} toolbarData={[ { id: 'code', @@ -31,16 +39,54 @@ describe('packages/chip', () => { > {'children'} - - + {/* ✅ */} + {/* Without Toolbar */} + drawer}>{'children'} + drawer} displayMode="embedded"> + {'children'} + + drawer} displayMode="embedded" resizable> {'children'} - - {/* @ts-expect-error - ToolbarData should not be passed with isDrawerOpen */} drawer} + displayMode="embedded" + resizable={false} + > + {'children'} + + drawer} displayMode="overlay"> + {'children'} + + drawer} + displayMode="overlay" + resizable={undefined} + > + {'children'} + + drawer} isDrawerOpen={false} displayMode="embedded" - toolbarData={[]} + > + {'children'} + + + {/* With Toolbar */} + hey

', + label: 'the label', + title: 'the title', + }, + ]} + onClose={() => {}} + displayMode="overlay" + darkMode > {'children'}
diff --git a/packages/drawer/src/DrawerLayout/DrawerLayout.tsx b/packages/drawer/src/DrawerLayout/DrawerLayout.tsx index 71bd5820d8..1f4e90d816 100644 --- a/packages/drawer/src/DrawerLayout/DrawerLayout.tsx +++ b/packages/drawer/src/DrawerLayout/DrawerLayout.tsx @@ -6,6 +6,7 @@ import { DisplayMode } from '../Drawer/Drawer.types'; import { DrawerToolbarLayout } from '../DrawerToolbarLayout'; import { LayoutComponent } from '../LayoutComponent'; +import { DrawerLayoutProvider } from './DrawerLayoutContext/DrawerLayoutContext'; import { DrawerLayoutProps } from './DrawerLayout.types'; /** @@ -19,39 +20,42 @@ export const DrawerLayout = forwardRef( children, displayMode = DisplayMode.Overlay, isDrawerOpen = false, + resizable = false, + onClose, ...rest }: DrawerLayoutProps, forwardedRef, ) => { - // If there is data, we render the DrawerToolbarLayout. - if (toolbarData) { - return ( - - {children} - + const hasToolbar = toolbarData && toolbarData.length > 0; + + if (!hasToolbar) { + consoleOnce.warn( + 'Using a Drawer without a toolbar is not recommended. To include a toolbar, pass a toolbarData prop containing the desired toolbar items.', ); } - consoleOnce.warn( - 'Using a Drawer without a toolbar is not recommended. To include a toolbar, pass a toolbarData prop containing the desired toolbar items.', - ); - - // If there is no data, we render the LayoutComponent. - // The LayoutComponent will read the displayMode and render the appropriate layout. return ( - - {children} - + {toolbarData ? ( + + {children} + + ) : ( + + {children} + + )} + ); }, ); diff --git a/packages/drawer/src/DrawerLayout/DrawerLayout.types.ts b/packages/drawer/src/DrawerLayout/DrawerLayout.types.ts index c76b0d33e3..db302fe866 100644 --- a/packages/drawer/src/DrawerLayout/DrawerLayout.types.ts +++ b/packages/drawer/src/DrawerLayout/DrawerLayout.types.ts @@ -2,45 +2,90 @@ import { DarkModeProps, HTMLElementProps } from '@leafygreen-ui/lib'; import { DrawerProps } from '../Drawer/Drawer.types'; import { DrawerToolbarLayoutProps } from '../DrawerToolbarLayout'; -import { LayoutComponentProps } from '../LayoutComponent'; -export type PickedDrawerProps = Pick; +type PickedDrawerProps = Pick; -export interface BaseDrawerLayoutProps - extends PickedDrawerProps, - HTMLElementProps<'div'>, - DarkModeProps { +export interface BaseDrawerLayoutPropsWithoutDisplayMode + extends HTMLElementProps<'div'>, + DarkModeProps, + PickedDrawerProps { children: React.ReactNode; } -export type DrawerLayoutPropsWithoutToolbar = Omit< - LayoutComponentProps, - 'displayMode' | 'isDrawerOpen' -> & { +export interface BaseDrawerLayoutEmbeddedProps + extends BaseDrawerLayoutPropsWithoutDisplayMode { /** - * An array of data that will be used to render the toolbar items and the drawer content. + * Options to display the drawer element + * @param Embedded will display a drawer as a `
` element that takes up the full parent container height and on the same elevation as container page content. It is recommended to wrap an embedded drawer within the `DrawerLayout` container + * @param Overlay will display a drawer as a `` element that takes up the full parent container height and elevated above container page content. + * */ - toolbarData?: never; + displayMode?: 'embedded'; + + /** + * Determines if the drawer is resizable. This is only recommended for individual drawers, not stacked drawers. + * + * @defaultValue false + */ + resizable?: boolean; +} + +export interface BaseDrawerLayoutOverlayProps + extends BaseDrawerLayoutPropsWithoutDisplayMode { + /** + * Options to display the drawer element + * @param Embedded will display a drawer as a `
` element that takes up the full parent container height and on the same elevation as container page content. It is recommended to wrap an embedded drawer within the `DrawerLayout` container + * @param Overlay will display a drawer as a `` element that takes up the full parent container height and elevated above container page content. + * + */ + displayMode?: 'overlay'; + + /** + * Determines if the drawer is resizable. This is only recommended for individual drawers, not stacked drawers. + * + * @defaultValue false + */ + resizable?: never; +} +export type BaseDrawerLayoutProps = + | BaseDrawerLayoutEmbeddedProps + | BaseDrawerLayoutOverlayProps; + +export type DrawerLayoutPropsWithoutToolbar = { /** - * Event handler called on close button click. If provided, a close button will be rendered in the Drawer header. + * An array of data that will be used to render the toolbar items and the drawer content. */ - onClose?: never; + toolbarData?: never; /** * Determines if the Drawer is open. This is only needed if using the Drawer without a toolbar. This will shift the layout to the right by the width of the drawer if `displayMode` is set to 'embedded'. */ isDrawerOpen?: boolean; + + /** + * The drawer component to be rendered in the layout. This is only needed if using the Drawer without a toolbar. + * If using the Drawer with a toolbar, the drawer is rendered internally. + */ + drawer?: React.ReactNode; } & BaseDrawerLayoutProps; -export type DrawerLayoutPropsWithToolbar = Omit< - DrawerToolbarLayoutProps, - 'displayMode' -> & { +export type DrawerLayoutPropsWithToolbar = { + /** + * An array of data that will be used to render the toolbar items and the drawer content. + */ + toolbarData: DrawerToolbarLayoutProps['toolbarData']; + /** * Determines if the Drawer is open. This is only needed if using the Drawer without a toolbar. This will shift the layout to the right by the width of the drawer if `displayMode` is set to 'embedded'. */ isDrawerOpen?: never; + + /** + * The drawer component to be rendered in the layout. This is only needed if using the Drawer without a toolbar. + * If using the Drawer with a toolbar, the drawer is rendered internally. + */ + drawer?: never; } & BaseDrawerLayoutProps; export type DrawerLayoutProps = diff --git a/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.spec.tsx b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.spec.tsx new file mode 100644 index 0000000000..72329f05a8 --- /dev/null +++ b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.spec.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { act } from '@testing-library/react'; + +import { renderHook } from '@leafygreen-ui/testing-lib'; + +import { DisplayMode } from '../../Drawer/Drawer.types'; + +import { + DrawerLayoutProvider, + useDrawerLayoutContext, +} from './DrawerLayoutContext'; + +describe('useDrawerLayoutContext', () => { + test('returns default values', () => { + const { result } = renderHook(() => useDrawerLayoutContext()); + expect(result.current.isDrawerOpen).toBe(false); + expect(result.current.resizable).toBe(false); + expect(result.current.displayMode).toBe(DisplayMode.Overlay); + expect(result.current.onClose).toBeUndefined(); + expect(result.current.hasToolbar).toBe(false); + expect(result.current.isDrawerResizing).toBe(false); + expect(result.current.drawerWidth).toBe(0); + expect(typeof result.current.setIsDrawerOpen).toBe('function'); + expect(typeof result.current.setDrawerWidth).toBe('function'); + expect(typeof result.current.setIsDrawerResizing).toBe('function'); + }); + + test('Returns the value passed to the provider', () => { + const { result } = renderHook(() => useDrawerLayoutContext(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + expect(result.current.isDrawerOpen).toBe(true); + expect(result.current.resizable).toBe(true); + expect(result.current.displayMode).toBe(DisplayMode.Embedded); + expect(result.current.hasToolbar).toBe(true); + }); + + test('Updates isDrawerOpen when setIsDrawerOpen is called', () => { + const { result } = renderHook(() => useDrawerLayoutContext(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current.isDrawerOpen).toBe(true); + act(() => { + result?.current?.setIsDrawerOpen?.(false); + }); + expect(result.current.isDrawerOpen).toBe(false); + }); + + test('Updates drawerWidth when setDrawerWidth is called', () => { + const { result } = renderHook(() => useDrawerLayoutContext(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current.drawerWidth).toBe(0); + act(() => { + result?.current?.setDrawerWidth?.(100); + }); + expect(result.current.drawerWidth).toBe(100); + }); + + test('Updates isDrawerResizing when setIsDrawerResizing is called', () => { + const { result } = renderHook(() => useDrawerLayoutContext(), { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(result.current.isDrawerResizing).toBe(false); + act(() => { + result?.current?.setIsDrawerResizing?.(true); + }); + expect(result.current.isDrawerResizing).toBe(true); + }); +}); diff --git a/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.tsx b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.tsx new file mode 100644 index 0000000000..8c0b3ba151 --- /dev/null +++ b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.tsx @@ -0,0 +1,70 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + useEffect, +} from 'react'; + +import { DisplayMode } from '../../Drawer/Drawer.types'; + +import { + DrawerLayoutContextType, + DrawerLayoutProviderProps, +} from './DrawerLayoutContext.types'; + +export const DrawerLayoutContext = createContext({ + resizable: false, + isDrawerOpen: false, + displayMode: DisplayMode.Overlay, + onClose: undefined, + hasToolbar: false, + isDrawerResizing: false, + drawerWidth: 0, + setIsDrawerOpen: () => {}, + setDrawerWidth: () => {}, + setIsDrawerResizing: () => {}, +}); + +/** + * The DrawerLayoutProvider is used to provide the drawer layout context to the children components. + * It is used to determine if the drawer is open, if it is resizable, and the display mode of the drawer. + */ +export const DrawerLayoutProvider = ({ + children, + isDrawerOpen: isDrawerOpenProp = false, + resizable, + displayMode, + onClose, + hasToolbar, +}: PropsWithChildren) => { + const [isDrawerOpen, setIsDrawerOpen] = React.useState(isDrawerOpenProp); + const [isDrawerResizing, setIsDrawerResizing] = React.useState(false); + const [drawerWidth, setDrawerWidth] = React.useState(0); + + useEffect(() => { + setIsDrawerOpen(isDrawerOpenProp); + }, [isDrawerOpenProp]); + + const value = { + resizable, + isDrawerOpen, + displayMode, + onClose, + hasToolbar, + setIsDrawerOpen, + isDrawerResizing, + setIsDrawerResizing, + drawerWidth, + setDrawerWidth, + }; + + return ( + + {children} + + ); +}; + +export const useDrawerLayoutContext = () => { + return useContext(DrawerLayoutContext); +}; diff --git a/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.types.ts b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.types.ts new file mode 100644 index 0000000000..839cc8f636 --- /dev/null +++ b/packages/drawer/src/DrawerLayout/DrawerLayoutContext/DrawerLayoutContext.types.ts @@ -0,0 +1,55 @@ +import { DrawerProps } from '../../Drawer/Drawer.types'; + +export interface DrawerLayoutProviderProps { + /** + * Whether the drawer is currently open. + */ + isDrawerOpen?: boolean; + + /** + * Whether the drawer is resizable. + */ + resizable?: boolean; + + /** + * The display mode of the drawer. + */ + displayMode?: DrawerProps['displayMode']; + + /** + * Whether the drawer has a toolbar. + */ + hasToolbar?: boolean; + + /** + * Callback function to be called when the drawer is closed. + */ + onClose?: DrawerProps['onClose']; +} + +export interface DrawerLayoutContextType extends DrawerLayoutProviderProps { + /** + * The width of the drawer. This is used to update grid-template-columns in the DrawerLayout. + */ + drawerWidth: number; + + /** + * Determines if the drawer is currently being resized. + */ + isDrawerResizing: boolean; + + /** + * Function to set the drawer open state. + */ + setIsDrawerOpen: React.Dispatch>; + + /** + * Function to set the drawer width. + */ + setDrawerWidth: React.Dispatch>; + + /** + * Function to set the drawer resizing state. + */ + setIsDrawerResizing: React.Dispatch>; +} diff --git a/packages/drawer/src/DrawerLayout/index.ts b/packages/drawer/src/DrawerLayout/index.ts index 232d6e05d9..7e4bb9723f 100644 --- a/packages/drawer/src/DrawerLayout/index.ts +++ b/packages/drawer/src/DrawerLayout/index.ts @@ -1,2 +1,6 @@ export { DrawerLayout } from './DrawerLayout'; export type { DrawerLayoutProps } from './DrawerLayout.types'; +export { + DrawerLayoutProvider, + useDrawerLayoutContext, +} from './DrawerLayoutContext/DrawerLayoutContext'; diff --git a/packages/drawer/src/DrawerToolbarContext/DrawerToolbarContext.spec.tsx b/packages/drawer/src/DrawerToolbarContext/DrawerToolbarContext.spec.tsx index 019aa77a72..dbf0a19137 100644 --- a/packages/drawer/src/DrawerToolbarContext/DrawerToolbarContext.spec.tsx +++ b/packages/drawer/src/DrawerToolbarContext/DrawerToolbarContext.spec.tsx @@ -3,7 +3,7 @@ import { act, waitFor } from '@testing-library/react'; import { renderHook } from '@leafygreen-ui/testing-lib'; -import { drawerTransitionDuration } from '../Drawer/Drawer.styles'; +import { TRANSITION_DURATION } from '../constants'; import { DrawerToolbarProvider, @@ -216,9 +216,7 @@ describe('useDrawerToolbarContext', () => { expect(result.current.getActiveDrawerContent()).toEqual(mockData[0]); act(() => result.current.closeDrawer()); - await new Promise(resolve => - setTimeout(resolve, drawerTransitionDuration), - ); + await new Promise(resolve => setTimeout(resolve, TRANSITION_DURATION)); await waitFor(() => expect(result.current.getActiveDrawerContent()).toBeUndefined(), ); @@ -254,7 +252,7 @@ describe('useDrawerToolbarContext', () => { act(() => result.current.closeDrawer()); act(() => result.current.closeDrawer()); // Call close multiple times - await new Promise(resolve => setTimeout(resolve, drawerTransitionDuration)); + await new Promise(resolve => setTimeout(resolve, TRANSITION_DURATION)); await waitFor(() => expect(result.current.getActiveDrawerContent()).toBeUndefined(), ); diff --git a/packages/drawer/src/DrawerToolbarContext/DrawerToolbarContext.tsx b/packages/drawer/src/DrawerToolbarContext/DrawerToolbarContext.tsx index 162646d238..76df3fb0cd 100644 --- a/packages/drawer/src/DrawerToolbarContext/DrawerToolbarContext.tsx +++ b/packages/drawer/src/DrawerToolbarContext/DrawerToolbarContext.tsx @@ -6,7 +6,7 @@ import React, { useState, } from 'react'; -import { drawerTransitionDuration } from '../Drawer/Drawer.styles'; +import { TRANSITION_DURATION } from '../constants'; import { ContextData, @@ -50,7 +50,7 @@ export const DrawerToolbarProvider = ({ // Delay the removal of the content to allow the drawer to close before removing the content setTimeout(() => { setContent(undefined); - }, drawerTransitionDuration); + }, TRANSITION_DURATION); setIsDrawerOpen(false); }, [setContent]); diff --git a/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx b/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx index cc0b293e1f..44aa1e3da0 100644 --- a/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx +++ b/packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx @@ -12,6 +12,7 @@ import { spacing } from '@leafygreen-ui/tokens'; import { Body } from '@leafygreen-ui/typography'; import { DisplayMode, Drawer } from '../Drawer'; +import { DrawerLayoutProvider } from '../DrawerLayout'; import { useDrawerToolbarContext } from '../DrawerToolbarContext'; import { getTestUtils } from '../testing'; @@ -23,6 +24,12 @@ import { DrawerToolbarLayoutProps } from './DrawerToolbarLayout.types'; // Setting a delay of 1 second allows the tooltip to be in the correct position const TOOLTIP_SNAPSHOT_DELAY = 1000; // ms +// For testing purposes. displayMode is read from the context, so we need to +// pass it down to the DrawerToolbarLayoutProps. +type DrawerToolbarLayoutPropsWithDisplayMode = DrawerToolbarLayoutProps & { + displayMode?: DisplayMode; +}; + const SEED = 0; faker.seed(SEED); @@ -112,9 +119,9 @@ export default { ], }; -const Template: StoryFn = ({ +const Template: StoryFn = ({ displayMode = DisplayMode.Embedded, -}: DrawerToolbarLayoutProps) => { +}: DrawerToolbarLayoutPropsWithDisplayMode) => { const MainContent = () => { const { openDrawer } = useDrawerToolbarContext(); @@ -139,17 +146,16 @@ const Template: StoryFn = ({ width: 100%; `} > - - - + + + + +
); }; -export const OverlayOpensFirstToolbarItem: StoryObj = +export const OverlayOpensFirstToolbarItem: StoryObj = { render: (args: DrawerToolbarLayoutProps) =>