From d80aa6a206cd974b40cf8886dd33a8547f5eccc2 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 16 Jul 2026 17:54:38 +0300 Subject: [PATCH 1/6] chore(Resizable): add resizable container with compound handles --- .../src/components/Resizable/Resizable.mdx | 81 ++++ .../components/Resizable/Resizable.module.css | 40 ++ .../Resizable/Resizable.stories.tsx | 127 +++++ .../components/Resizable/Resizable.test.tsx | 436 ++++++++++++++++++ .../src/components/Resizable/Resizable.tsx | 208 +++++++++ .../components/Resizable/ResizableContext.ts | 36 ++ .../components/Resizable/ResizableHandle.tsx | 121 +++++ .../Resizable/__stories__/styles.module.css | 35 ++ .../src/components/Resizable/index.ts | 2 + .../src/components/Resizable/intl.json | 12 + .../src/components/Resizable/types.ts | 51 ++ .../src/components/Resizable/utils.ts | 109 +++++ packages/components/src/components/index.ts | 1 + tools/api-extractor/config.json | 1 + .../components/Resizable.api.md | 50 ++ 15 files changed, 1310 insertions(+) create mode 100644 packages/components/src/components/Resizable/Resizable.mdx create mode 100644 packages/components/src/components/Resizable/Resizable.module.css create mode 100644 packages/components/src/components/Resizable/Resizable.stories.tsx create mode 100644 packages/components/src/components/Resizable/Resizable.test.tsx create mode 100644 packages/components/src/components/Resizable/Resizable.tsx create mode 100644 packages/components/src/components/Resizable/ResizableContext.ts create mode 100644 packages/components/src/components/Resizable/ResizableHandle.tsx create mode 100644 packages/components/src/components/Resizable/__stories__/styles.module.css create mode 100644 packages/components/src/components/Resizable/index.ts create mode 100644 packages/components/src/components/Resizable/intl.json create mode 100644 packages/components/src/components/Resizable/types.ts create mode 100644 packages/components/src/components/Resizable/utils.ts create mode 100644 tools/public_api_guard/components/Resizable.api.md diff --git a/packages/components/src/components/Resizable/Resizable.mdx b/packages/components/src/components/Resizable/Resizable.mdx new file mode 100644 index 00000000..dc37fc8f --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.mdx @@ -0,0 +1,81 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './Resizable.stories'; + + + +# Resizable + + + +Resizable changes an element's width and height using one or more composed handles. +The element stays in place while its size changes. + +## Import + +```tsx +import { Resizable } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## State + +Use `defaultSize` for uncontrolled state, or combine `size` and `onResize` to control the size. +All values are measured in CSS pixels. + + + +### Intrinsic size + +When both `size` and `defaultSize` are omitted, Resizable preserves its natural CSS size. +The measured size is fixed in pixels only after the first resize interaction. + + + +## Direction + +`Resizable.Handle` accepts a physical `[x, y]` direction. Each axis supports `-1`, `0`, and `1`: + +- `x`: `-1` resizes from the left, `0` disables horizontal resizing, and `1` resizes from the right. +- `y`: `-1` resizes from the top, `0` disables vertical resizing, and `1` resizes from the bottom. +- `[0, 0]` is not a valid direction. + +The direction remains physical in right-to-left layouts. + + + +## Constraints + +Use `minSize` and `maxSize` to constrain either dimension independently. The default minimum is zero, +and dimensions without a maximum are unbounded. If a minimum exceeds a maximum, the minimum wins. + +## Keyboard interaction + +Handles are focusable. Use the arrow keys to resize by one pixel, or hold Shift to resize +by ten pixels. Edge handles expose the ARIA separator pattern; corner handles expose an accessible +two-dimensional resize control. + +## Disabled + +Set `isDisabled` to remove all handles from the tab order and disable resize interactions. + + + +## Styling handles + +Handles provide positioning, hit areas, cursors, and state attributes, but no visible grip or line. +Use `className` and the `data-direction-x`, `data-direction-y`, `data-focus-visible`, `data-resizing`, +and `data-disabled` attributes to provide an application-specific indicator. The hit area can be +adjusted with the `--kbq-resizable-handle-size` custom property on Resizable. diff --git a/packages/components/src/components/Resizable/Resizable.module.css b/packages/components/src/components/Resizable/Resizable.module.css new file mode 100644 index 00000000..807f1a90 --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.module.css @@ -0,0 +1,40 @@ +.base { + --kbq-resizable-handle-size: var(--kbq-size-l); + + position: relative; + box-sizing: border-box; +} + +.handle { + position: absolute; + z-index: 1; + box-sizing: border-box; + touch-action: none; + background: transparent; +} + +.handle[data-direction-x='0'] { + cursor: ns-resize; +} + +.handle[data-direction-y='0'] { + cursor: ew-resize; +} + +.handle:is( + [data-direction-x='-1'][data-direction-y='-1'], + [data-direction-x='1'][data-direction-y='1'] +) { + cursor: nwse-resize; +} + +.handle:is( + [data-direction-x='1'][data-direction-y='-1'], + [data-direction-x='-1'][data-direction-y='1'] +) { + cursor: nesw-resize; +} + +.handle[data-disabled] { + cursor: default; +} diff --git a/packages/components/src/components/Resizable/Resizable.stories.tsx b/packages/components/src/components/Resizable/Resizable.stories.tsx new file mode 100644 index 00000000..55ead5e3 --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.stories.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; + +import { clsx } from '@koobiq/react-core'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { Typography } from '../Typography'; + +import s from './__stories__/styles.module.css'; +import { Resizable } from './Resizable'; +import type { ResizableProps, ResizableSize } from './types'; + +const meta = { + title: 'Components/Resizable', + component: Resizable, + subcomponents: { + 'Resizable.Handle': Resizable.Handle, + }, + parameters: { + layout: 'centered', + }, + tags: ['status:new', 'date:2026-07-16'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const handleClassName = s.handle; + +function Handles() { + return ( + <> + + + + + + + + + + ); +} + +export const Base: Story = { + render: function Render(args) { + const [size, setSize] = useState({ + width: 400, + height: 300, + }); + + return ( + + + {Math.round(size.width)} × {Math.round(size.height)} px + + + + ); + }, +}; + +export const Uncontrolled: Story = { + render: (args) => ( + + Uncontrolled size + + + ), +}; + +export const IntrinsicSize: Story = { + render: (args) => ( + + + This element keeps its intrinsic size until a handle is moved + + + + ), +}; + +export const SingleDirection: Story = { + render: (args) => ( + + Resize from the right edge + + + ), +}; + +export const Disabled: Story = { + render: (args) => ( + + Resizing is disabled + + + ), +}; diff --git a/packages/components/src/components/Resizable/Resizable.test.tsx b/packages/components/src/components/Resizable/Resizable.test.tsx new file mode 100644 index 00000000..cbf77df9 --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.test.tsx @@ -0,0 +1,436 @@ +import { createRef, type ComponentProps } from 'react'; + +import type * as ReactCore from '@koobiq/react-core'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Provider } from '../Provider'; + +import { + Resizable, + resizableHandleDirections, + type ResizableHandleDirection, + type ResizableSize, +} from './index.js'; + +const mocks = vi.hoisted(() => ({ + observedSize: { width: 300, height: 200 }, +})); + +vi.mock('@koobiq/react-core', async () => { + const React = await import('react'); + const actual = await vi.importActual('@koobiq/react-core'); + + return { + ...actual, + useElementSize: () => ({ + ref: React.useRef(null), + width: mocks.observedSize.width, + height: mocks.observedSize.height, + }), + }; +}); + +const getRoot = () => screen.getByTestId('resizable'); +const getHandle = () => screen.getByTestId('handle'); + +const renderResizable = ( + direction: ResizableHandleDirection = [1, 1], + props: Partial> = {} +) => + render( + + content + + + ); + +const drag = (deltaX: number, deltaY: number) => { + fireEvent.mouseDown(getHandle(), { button: 0, clientX: 10, clientY: 10 }); + + fireEvent.mouseMove(window, { + button: 0, + clientX: 10 + deltaX, + clientY: 10 + deltaY, + }); + + fireEvent.mouseUp(window, { + button: 0, + clientX: 10 + deltaX, + clientY: 10 + deltaY, + }); +}; + +describe('Resizable', () => { + beforeEach(() => { + mocks.observedSize = { width: 300, height: 200 }; + vi.stubGlobal('PointerEvent', undefined); + + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation( + function getBoundingClientRect(this: HTMLElement) { + const width = Number.parseFloat(this.style.width); + const height = Number.parseFloat(this.style.height); + + const resolvedWidth = Number.isNaN(width) + ? mocks.observedSize.width + : width; + + const resolvedHeight = Number.isNaN(height) + ? mocks.observedSize.height + : height; + + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: resolvedWidth, + bottom: resolvedHeight, + width: resolvedWidth, + height: resolvedHeight, + toJSON: () => ({}), + }; + } + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('forwards refs and merges consumer props', () => { + const rootRef = createRef(); + const handleRef = createRef(); + const onKeyDown = vi.fn(); + + render( + + + + ); + + fireEvent.keyDown(getHandle(), { key: 'ArrowRight' }); + + expect(rootRef.current).toBe(getRoot()); + expect(handleRef.current).toBe(getHandle()); + expect(getRoot()).toHaveClass('custom-root'); + expect(getRoot().style.color).toBe('red'); + expect(getHandle()).toHaveClass('custom-handle'); + expect(onKeyDown).toHaveBeenCalledOnce(); + }); + + it.each(resizableHandleDirections)( + 'resizes in direction [%s, %s] from the drag snapshot', + (x, y) => { + renderResizable([x, y] as ResizableHandleDirection); + + drag(20, 15); + + expect(getRoot()).toHaveStyle({ + width: `${300 + 20 * x}px`, + height: `${200 + 15 * y}px`, + }); + + expect(getRoot().style.top).toBe(''); + expect(getRoot().style.left).toBe(''); + expect(getRoot().style.transform).toBe(''); + } + ); + + it('accumulates movement without applying total deltas more than once', () => { + renderResizable([1, 1]); + + fireEvent.mouseDown(getHandle(), { button: 0, clientX: 10, clientY: 10 }); + + fireEvent.mouseMove(window, { + button: 0, + clientX: 30, + clientY: 20, + }); + + fireEvent.mouseMove(window, { + button: 0, + clientX: 40, + clientY: 30, + }); + + fireEvent.mouseUp(window, { button: 0, clientX: 40, clientY: 30 }); + + expect(getRoot()).toHaveStyle({ width: '330px', height: '220px' }); + }); + + it('clamps sizes and preserves the drag relationship beyond a boundary', () => { + renderResizable([1, 0], { + minSize: { width: 280 }, + maxSize: { width: 320 }, + }); + + fireEvent.mouseDown(getHandle(), { button: 0, clientX: 0, clientY: 0 }); + fireEvent.mouseMove(window, { button: 0, clientX: 50, clientY: 0 }); + expect(getRoot()).toHaveStyle({ width: '320px', height: '200px' }); + + fireEvent.mouseMove(window, { button: 0, clientX: 40, clientY: 0 }); + expect(getRoot()).toHaveStyle({ width: '320px', height: '200px' }); + + fireEvent.mouseMove(window, { button: 0, clientX: 10, clientY: 0 }); + expect(getRoot()).toHaveStyle({ width: '310px', height: '200px' }); + }); + + it('lets minimum constraints win over maximum constraints', () => { + renderResizable([1, 0], { + defaultSize: { width: 150, height: 200 }, + minSize: { width: 200 }, + maxSize: { width: 100 }, + }); + + expect(getRoot()).toHaveStyle({ + width: '200px', + minWidth: '200px', + maxWidth: '200px', + }); + }); + + it('normalizes negative and non-finite sizes to the minimum', () => { + renderResizable([1, 0], { + defaultSize: { width: Number.NaN, height: -20 }, + minSize: { width: 40, height: 30 }, + }); + + expect(getRoot()).toHaveStyle({ width: '40px', height: '30px' }); + }); + + it('supports controlled state without optimistically changing the DOM size', () => { + const onResize = vi.fn(); + const initialSize = { width: 300, height: 200 }; + + const { rerender } = render( + + + + ); + + drag(20, 10); + + expect(onResize).toHaveBeenLastCalledWith({ width: 320, height: 210 }); + expect(getRoot()).toHaveStyle({ width: '300px', height: '200px' }); + + const nextSize = onResize.mock.lastCall?.[0] as ResizableSize; + + rerender( + + + + ); + + expect(getRoot()).toHaveStyle({ width: '320px', height: '210px' }); + }); + + it('preserves intrinsic sizing until the first resize', () => { + mocks.observedSize = { width: 260, height: 140 }; + renderResizable([1, 0], { defaultSize: undefined }); + + expect(getRoot().style.width).toBe(''); + expect(getRoot().style.height).toBe(''); + expect(getHandle()).toHaveAttribute('aria-valuenow', '260'); + + fireEvent.keyDown(getHandle(), { key: 'ArrowRight' }); + + expect(getRoot()).toHaveStyle({ width: '261px', height: '140px' }); + }); + + it('calls lifecycle callbacks with the snapshot and final size', () => { + const onResizeStart = vi.fn(); + const onResizeEnd = vi.fn(); + + renderResizable([1, 1], { onResizeStart, onResizeEnd }); + drag(25, 10); + + expect(onResizeStart).toHaveBeenCalledOnce(); + expect(onResizeStart).toHaveBeenCalledWith({ width: 300, height: 200 }); + expect(onResizeEnd).toHaveBeenCalledOnce(); + expect(onResizeEnd).toHaveBeenCalledWith({ width: 325, height: 210 }); + expect(getRoot()).not.toHaveAttribute('data-resizing'); + }); + + it('supports Pointer Events and exposes the active resize state', () => { + vi.stubGlobal('PointerEvent', MouseEvent); + renderResizable([1, 1]); + + fireEvent.pointerDown(getHandle(), { + button: 0, + pointerId: 1, + pointerType: 'touch', + clientX: 10, + clientY: 10, + }); + + fireEvent.pointerMove(window, { + button: 0, + pointerId: 1, + pointerType: 'touch', + clientX: 30, + clientY: 25, + }); + + expect(getRoot()).toHaveStyle({ width: '320px', height: '215px' }); + expect(getRoot()).toHaveAttribute('data-resizing', 'true'); + expect(getHandle()).toHaveAttribute('data-resizing', 'true'); + + fireEvent.pointerUp(window, { + button: 0, + pointerId: 1, + pointerType: 'touch', + clientX: 30, + clientY: 25, + }); + + expect(getRoot()).not.toHaveAttribute('data-resizing'); + }); + + it('supports touch events when Pointer Events are unavailable', () => { + renderResizable([-1, -1]); + + fireEvent.touchStart(getHandle(), { + changedTouches: [{ identifier: 7, pageX: 20, pageY: 20 }], + }); + + fireEvent.touchMove(window, { + changedTouches: [{ identifier: 7, pageX: 30, pageY: 25 }], + }); + + fireEvent.touchEnd(window, { + changedTouches: [{ identifier: 7, pageX: 30, pageY: 25 }], + }); + + expect(getRoot()).toHaveStyle({ width: '290px', height: '195px' }); + }); + + it('resizes by one pixel with arrows and ten pixels with Shift', () => { + renderResizable([1, 0]); + + fireEvent.keyDown(getHandle(), { key: 'ArrowRight' }); + expect(getRoot()).toHaveStyle({ width: '301px', height: '200px' }); + + fireEvent.keyDown(getHandle(), { key: 'ArrowRight', shiftKey: true }); + expect(getRoot()).toHaveStyle({ width: '311px', height: '200px' }); + }); + + it('disables interactions and removes handles from the tab order', () => { + const onResize = vi.fn(); + renderResizable([1, 0], { isDisabled: true, onResize }); + + fireEvent.keyDown(getHandle(), { key: 'ArrowRight' }); + drag(20, 0); + + expect(onResize).not.toHaveBeenCalled(); + expect(getRoot()).toHaveAttribute('data-disabled', 'true'); + expect(getHandle()).toHaveAttribute('data-disabled', 'true'); + expect(getHandle()).toHaveAttribute('aria-disabled', 'true'); + expect(getHandle()).toHaveAttribute('tabindex', '-1'); + }); + + it('exposes separator semantics for edge handles', () => { + renderResizable([1, 0], { + minSize: { width: 200 }, + maxSize: { width: 500 }, + }); + + expect(getHandle()).toHaveAttribute('role', 'separator'); + expect(getHandle()).toHaveAttribute('aria-orientation', 'vertical'); + expect(getHandle()).toHaveAttribute('aria-valuenow', '300'); + expect(getHandle()).toHaveAttribute('aria-valuemin', '200'); + expect(getHandle()).toHaveAttribute('aria-valuemax', '500'); + expect(getHandle()).toHaveAttribute('aria-controls', getRoot().id); + expect(getHandle()).toHaveAttribute('aria-label', 'Resize width'); + }); + + it('exposes button semantics for corner handles and allows a custom label', () => { + render( + + + + ); + + expect(getHandle()).toHaveAttribute('role', 'button'); + expect(getHandle()).toHaveAttribute('aria-label', 'Resize preview'); + + expect(getHandle()).toHaveAttribute( + 'aria-keyshortcuts', + 'ArrowUp ArrowDown ArrowLeft ArrowRight' + ); + + expect(getHandle()).not.toHaveAttribute('aria-valuenow'); + expect(getHandle()).toHaveAttribute('aria-controls', getRoot().id); + }); + + it('localizes default accessible labels', () => { + render( + + + + + + ); + + expect(getHandle()).toHaveAttribute('aria-label', 'Изменить высоту'); + }); + + it('positions handles using physical coordinates', () => { + renderResizable([-1, 1]); + + expect(getHandle().style.left).toBe('0px'); + expect(getHandle().style.bottom).toBe('0px'); + expect(getHandle().style.transform).toBe('translate(-50%, 50%)'); + expect(getHandle()).toHaveAttribute('data-direction-x', '-1'); + expect(getHandle()).toHaveAttribute('data-direction-y', '1'); + }); + + it('throws when a handle is rendered without Resizable', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => render()).toThrow( + 'Resizable.Handle must be rendered inside Resizable.' + ); + }); + + it('throws for an invalid runtime direction', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(() => + render( + + + + ) + ).toThrow('eight non-zero [x, y] directions'); + }); +}); diff --git a/packages/components/src/components/Resizable/Resizable.tsx b/packages/components/src/components/Resizable/Resizable.tsx new file mode 100644 index 00000000..f313b9e1 --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.tsx @@ -0,0 +1,208 @@ +'use client'; + +import type { CSSProperties } from 'react'; +import { + forwardRef, + useCallback, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { + clsx, + mergeRefs, + useControlledState, + useElementSize, +} from '@koobiq/react-core'; + +import s from './Resizable.module.css'; +import { ResizableContext, type ResizableMoveEvent } from './ResizableContext'; +import { ResizableHandle } from './ResizableHandle'; +import type { + ResizableHandleDirection, + ResizableProps, + ResizableSize, +} from './types'; +import { + clampResizableSize, + getDirectionKey, + getResizableBounds, + normalizeResizableSize, +} from './utils'; + +const ResizableComponent = forwardRef( + (props, forwardedRef) => { + const { + size: sizeProp, + defaultSize: defaultSizeProp, + minSize, + maxSize, + isDisabled = false, + onResize, + onResizeStart, + onResizeEnd, + id: idProp, + className, + style: styleProp, + children, + ...other + } = props; + + const generatedId = useId(); + const rootId = idProp ?? generatedId; + + const { + ref: elementRef, + width, + height, + } = useElementSize({ box: 'border-box' }); + + const bounds = useMemo( + () => getResizableBounds(minSize, maxSize), + [minSize?.width, minSize?.height, maxSize?.width, maxSize?.height] + ); + + const controlledSize = useMemo( + () => normalizeResizableSize(sizeProp, bounds), + [sizeProp?.width, sizeProp?.height, bounds] + ); + + const defaultSize = useMemo( + () => normalizeResizableSize(defaultSizeProp, bounds), + [defaultSizeProp?.width, defaultSizeProp?.height, bounds] + ); + + const [managedSize, setManagedSize] = useControlledState< + ResizableSize | null, + ResizableSize + >(controlledSize, defaultSize ?? null, onResize); + + const observedSize = clampResizableSize({ width, height }, bounds); + const currentSize = managedSize ?? observedSize; + + const startSizeRef = useRef(currentSize); + const lastSizeRef = useRef(currentSize); + const accumulatedRef = useRef({ x: 0, y: 0 }); + const directionRef = useRef([1, 1]); + const [activeDirection, setActiveDirection] = useState(null); + + const handleMoveStart = useCallback( + (direction: ResizableHandleDirection) => { + const rect = elementRef.current?.getBoundingClientRect(); + + const startSize = clampResizableSize( + rect ? { width: rect.width, height: rect.height } : currentSize, + bounds + ); + + startSizeRef.current = startSize; + lastSizeRef.current = startSize; + accumulatedRef.current = { x: 0, y: 0 }; + directionRef.current = direction; + setActiveDirection(getDirectionKey(direction)); + onResizeStart?.(startSize); + }, + [bounds, currentSize, elementRef, onResizeStart] + ); + + const handleMove = useCallback( + (event: ResizableMoveEvent) => { + const multiplier = + event.pointerType === 'keyboard' && event.shiftKey ? 10 : 1; + + accumulatedRef.current.x += event.deltaX * multiplier; + accumulatedRef.current.y += event.deltaY * multiplier; + + const [x, y] = directionRef.current; + const startSize = startSizeRef.current; + + const nextSize = clampResizableSize( + { + width: + x === 0 + ? startSize.width + : startSize.width + accumulatedRef.current.x * x, + height: + y === 0 + ? startSize.height + : startSize.height + accumulatedRef.current.y * y, + }, + bounds + ); + + lastSizeRef.current = nextSize; + setManagedSize(nextSize); + }, + [bounds, setManagedSize] + ); + + const handleMoveEnd = useCallback(() => { + setActiveDirection(null); + onResizeEnd?.(lastSizeRef.current); + }, [onResizeEnd]); + + const contextValue = useMemo( + () => ({ + rootId, + size: currentSize, + bounds, + isDisabled, + activeDirection, + onMoveStart: handleMoveStart, + onMove: handleMove, + onMoveEnd: handleMoveEnd, + }), + [ + rootId, + currentSize, + bounds, + isDisabled, + activeDirection, + handleMoveStart, + handleMove, + handleMoveEnd, + ] + ); + + const style = { + ...styleProp, + ...(minSize?.width !== undefined && { minWidth: bounds.minWidth }), + ...(minSize?.height !== undefined && { minHeight: bounds.minHeight }), + ...(Number.isFinite(bounds.maxWidth) && { maxWidth: bounds.maxWidth }), + ...(Number.isFinite(bounds.maxHeight) && { maxHeight: bounds.maxHeight }), + ...(managedSize && { + width: managedSize.width, + height: managedSize.height, + }), + } satisfies CSSProperties; + + return ( + +
+ {children} +
+
+ ); + } +); + +ResizableComponent.displayName = 'Resizable'; + +type CompoundedComponent = typeof ResizableComponent & { + Handle: typeof ResizableHandle; +}; + +/** An element whose width and height can be changed with composed handles. */ +export const Resizable = ResizableComponent as CompoundedComponent; + +Resizable.Handle = ResizableHandle; diff --git a/packages/components/src/components/Resizable/ResizableContext.ts b/packages/components/src/components/Resizable/ResizableContext.ts new file mode 100644 index 00000000..b56eb047 --- /dev/null +++ b/packages/components/src/components/Resizable/ResizableContext.ts @@ -0,0 +1,36 @@ +import { createContext, useContext } from 'react'; + +import type { ResizableHandleDirection, ResizableSize } from './types'; +import type { ResizableBounds } from './utils'; + +export type ResizableMoveEvent = { + deltaX: number; + deltaY: number; + pointerType: string; + shiftKey: boolean; +}; + +export type ResizableContextValue = { + rootId: string; + size: ResizableSize; + bounds: ResizableBounds; + isDisabled: boolean; + activeDirection: string | null; + onMoveStart: (direction: ResizableHandleDirection) => void; + onMove: (event: ResizableMoveEvent) => void; + onMoveEnd: () => void; +}; + +export const ResizableContext = createContext( + null +); + +export const useResizableContext = () => { + const context = useContext(ResizableContext); + + if (!context) { + throw new Error('Resizable.Handle must be rendered inside Resizable.'); + } + + return context; +}; diff --git a/packages/components/src/components/Resizable/ResizableHandle.tsx b/packages/components/src/components/Resizable/ResizableHandle.tsx new file mode 100644 index 00000000..8e3098d7 --- /dev/null +++ b/packages/components/src/components/Resizable/ResizableHandle.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { forwardRef } from 'react'; + +import { + clsx, + mergeProps, + useFocusRing, + useLocalizedStringFormatter, + useMove, +} from '@koobiq/react-core'; + +import intlMessages from './intl.json'; +import s from './Resizable.module.css'; +import { useResizableContext } from './ResizableContext'; +import type { ResizableHandleProps } from './types'; +import { + getDirectionKey, + getHandlePositionStyle, + isResizableHandleDirection, +} from './utils'; + +const UNBOUNDED_ARIA_MAX = Number.MAX_SAFE_INTEGER; + +/** A draggable handle that changes the size of its parent Resizable. */ +export const ResizableHandle = forwardRef( + (props, ref) => { + const { + direction, + className, + style: styleProp, + tabIndex: tabIndexProp, + 'aria-label': ariaLabelProp, + ...other + } = props; + + if (!isResizableHandleDirection(direction)) { + throw new Error( + 'Resizable.Handle direction must be one of the eight non-zero [x, y] directions.' + ); + } + + const { + rootId, + size, + bounds, + isDisabled, + activeDirection, + onMoveStart, + onMove, + onMoveEnd, + } = useResizableContext(); + + const t = useLocalizedStringFormatter(intlMessages); + const [x, y] = direction; + const isWidthHandle = y === 0; + const isHeightHandle = x === 0; + const isCornerHandle = x !== 0 && y !== 0; + const isResizing = activeDirection === getDirectionKey(direction); + + const { moveProps } = useMove({ + onMoveStart: () => onMoveStart(direction), + onMove, + onMoveEnd, + }); + + const { focusProps, isFocusVisible } = useFocusRing({ + isTextInput: false, + autoFocus: false, + }); + + const interactionProps = isDisabled + ? other + : mergeProps(moveProps, focusProps, other); + + const defaultLabel = isWidthHandle + ? t.format('resize width') + : isHeightHandle + ? t.format('resize height') + : t.format('resize'); + + const value = isWidthHandle ? size.width : size.height; + const minValue = isWidthHandle ? bounds.minWidth : bounds.minHeight; + const maxValue = isWidthHandle ? bounds.maxWidth : bounds.maxHeight; + + return ( +
+ ); + } +); + +ResizableHandle.displayName = 'Resizable.Handle'; diff --git a/packages/components/src/components/Resizable/__stories__/styles.module.css b/packages/components/src/components/Resizable/__stories__/styles.module.css new file mode 100644 index 00000000..1cbd1c3f --- /dev/null +++ b/packages/components/src/components/Resizable/__stories__/styles.module.css @@ -0,0 +1,35 @@ +.content { + box-sizing: border-box; + border: 1px solid var(--kbq-line-contrast-less); + border-radius: var(--kbq-size-s); + background-color: var(--kbq-background-card); + padding: var(--kbq-size-l); + display: flex; + align-items: center; + justify-content: center; +} + +.intrinsic { + display: inline-flex; + max-inline-size: 480px; +} + +.handle { + background-color: var(--kbq-background-transparent); + opacity: 0.25; + outline: none; + border-radius: var(--kbq-size-xxs); + transition: + background-color var(--kbq-transition-slow), + opacity var(--kbq-transition-slow); +} + +.handle:is(:hover, [data-focus-visible], [data-resizing]) { + background-color: var(--kbq-states-background-theme-active); +} + +.layout { + min-inline-size: 640px; + min-block-size: 480px; + padding: var(--kbq-size-3xl); +} diff --git a/packages/components/src/components/Resizable/index.ts b/packages/components/src/components/Resizable/index.ts new file mode 100644 index 00000000..45800000 --- /dev/null +++ b/packages/components/src/components/Resizable/index.ts @@ -0,0 +1,2 @@ +export * from './Resizable'; +export * from './types'; diff --git a/packages/components/src/components/Resizable/intl.json b/packages/components/src/components/Resizable/intl.json new file mode 100644 index 00000000..db8e36c0 --- /dev/null +++ b/packages/components/src/components/Resizable/intl.json @@ -0,0 +1,12 @@ +{ + "ru-RU": { + "resize": "Изменить размер", + "resize width": "Изменить ширину", + "resize height": "Изменить высоту" + }, + "en-US": { + "resize": "Resize", + "resize width": "Resize width", + "resize height": "Resize height" + } +} diff --git a/packages/components/src/components/Resizable/types.ts b/packages/components/src/components/Resizable/types.ts new file mode 100644 index 00000000..390d80bf --- /dev/null +++ b/packages/components/src/components/Resizable/types.ts @@ -0,0 +1,51 @@ +import type { ComponentPropsWithRef } from 'react'; + +export type ResizableSize = { + /** Width in CSS pixels. */ + width: number; + /** Height in CSS pixels. */ + height: number; +}; + +export type ResizableSizeConstraints = Partial; + +export const resizableHandleDirections = [ + [-1, -1], + [0, -1], + [1, -1], + [-1, 0], + [1, 0], + [-1, 1], + [0, 1], + [1, 1], +] as const; + +export type ResizableHandleDirection = + (typeof resizableHandleDirections)[number]; + +export type ResizableProps = Omit, 'onResize'> & { + /** The controlled size of the element, in CSS pixels. */ + size?: ResizableSize; + /** The initial size of the element when uncontrolled, in CSS pixels. */ + defaultSize?: ResizableSize; + /** The minimum allowed size. Omitted dimensions default to zero. */ + minSize?: ResizableSizeConstraints; + /** The maximum allowed size. Omitted dimensions have no upper limit. */ + maxSize?: ResizableSizeConstraints; + /** Whether resizing is disabled. */ + isDisabled?: boolean; + /** Handler called whenever the size changes. */ + onResize?: (size: ResizableSize) => void; + /** Handler called when a resize interaction starts. */ + onResizeStart?: (size: ResizableSize) => void; + /** Handler called when a resize interaction ends. */ + onResizeEnd?: (size: ResizableSize) => void; +}; + +export type ResizableHandleProps = ComponentPropsWithRef<'div'> & { + /** + * Physical resize direction: `-1` means left/up, `0` disables the axis, + * and `1` means right/down. + */ + direction: ResizableHandleDirection; +}; diff --git a/packages/components/src/components/Resizable/utils.ts b/packages/components/src/components/Resizable/utils.ts new file mode 100644 index 00000000..b0e988a5 --- /dev/null +++ b/packages/components/src/components/Resizable/utils.ts @@ -0,0 +1,109 @@ +import type { CSSProperties } from 'react'; + +import type { + ResizableHandleDirection, + ResizableSize, + ResizableSizeConstraints, +} from './types'; + +export type ResizableBounds = { + minWidth: number; + maxWidth: number; + minHeight: number; + maxHeight: number; +}; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +export const getResizableBounds = ( + minSize?: ResizableSizeConstraints, + maxSize?: ResizableSizeConstraints +): ResizableBounds => { + const minWidth = Math.max( + 0, + isFiniteNumber(minSize?.width) ? minSize.width : 0 + ); + + const minHeight = Math.max( + 0, + isFiniteNumber(minSize?.height) ? minSize.height : 0 + ); + + return { + minWidth, + minHeight, + maxWidth: isFiniteNumber(maxSize?.width) + ? Math.max(minWidth, maxSize.width) + : Number.POSITIVE_INFINITY, + maxHeight: isFiniteNumber(maxSize?.height) + ? Math.max(minHeight, maxSize.height) + : Number.POSITIVE_INFINITY, + }; +}; + +const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); + +export const clampResizableSize = ( + size: ResizableSize, + bounds: ResizableBounds +): ResizableSize => ({ + width: clamp( + isFiniteNumber(size.width) ? size.width : bounds.minWidth, + bounds.minWidth, + bounds.maxWidth + ), + height: clamp( + isFiniteNumber(size.height) ? size.height : bounds.minHeight, + bounds.minHeight, + bounds.maxHeight + ), +}); + +export const normalizeResizableSize = ( + size: ResizableSize | undefined, + bounds: ResizableBounds +) => (size ? clampResizableSize(size, bounds) : undefined); + +export const getDirectionKey = ([x, y]: ResizableHandleDirection) => + `${x}:${y}`; + +export const isResizableHandleDirection = ( + direction: readonly number[] +): direction is ResizableHandleDirection => { + const [x, y] = direction; + + return ( + direction.length === 2 && + (x === -1 || x === 0 || x === 1) && + (y === -1 || y === 0 || y === 1) && + (x !== 0 || y !== 0) + ); +}; + +const handleSize = 'var(--kbq-resizable-handle-size)'; + +export const getHandlePositionStyle = ([ + x, + y, +]: ResizableHandleDirection): CSSProperties => { + const style: CSSProperties = { + width: x === 0 ? '100%' : handleSize, + height: y === 0 ? '100%' : handleSize, + }; + + const translateX = x === -1 ? '-50%' : x === 1 ? '50%' : '0'; + const translateY = y === -1 ? '-50%' : y === 1 ? '50%' : '0'; + + if (x === -1) style.left = 0; + if (x === 0) style.left = 0; + if (x === 1) style.right = 0; + if (y === -1) style.top = 0; + if (y === 0) style.top = 0; + if (y === 1) style.bottom = 0; + + style.transform = `translate(${translateX}, ${translateY})`; + + return style; +}; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index b02814d8..b230c71e 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -55,6 +55,7 @@ export * from './Tree'; export * from './TreeSelect'; export * from './EmptyState'; export * from './Flag'; +export * from './Resizable'; export * from './layout'; export { useListData, diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 4faa8ef5..3914b5d4 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -38,6 +38,7 @@ "ProgressSpinner", "Provider", "RadioGroup", + "Resizable", "SearchInput", "Select", "SidePanel", diff --git a/tools/public_api_guard/components/Resizable.api.md b/tools/public_api_guard/components/Resizable.api.md new file mode 100644 index 00000000..91c523c3 --- /dev/null +++ b/tools/public_api_guard/components/Resizable.api.md @@ -0,0 +1,50 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ComponentPropsWithRef } from 'react'; +import { ForwardRefExoticComponent } from 'react'; +import { RefAttributes } from 'react'; + +// Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts +// +// @public +export const Resizable: CompoundedComponent; + +// @public (undocumented) +export type ResizableHandleDirection = (typeof resizableHandleDirections)[number]; + +// @public (undocumented) +export const resizableHandleDirections: readonly [readonly [-1, -1], readonly [0, -1], readonly [1, -1], readonly [-1, 0], readonly [1, 0], readonly [-1, 1], readonly [0, 1], readonly [1, 1]]; + +// @public (undocumented) +export type ResizableHandleProps = ComponentPropsWithRef<'div'> & { + direction: ResizableHandleDirection; +}; + +// @public (undocumented) +export type ResizableProps = Omit, 'onResize'> & { + size?: ResizableSize; + defaultSize?: ResizableSize; + minSize?: ResizableSizeConstraints; + maxSize?: ResizableSizeConstraints; + isDisabled?: boolean; + onResize?: (size: ResizableSize) => void; + onResizeStart?: (size: ResizableSize) => void; + onResizeEnd?: (size: ResizableSize) => void; +}; + +// @public (undocumented) +export type ResizableSize = { + width: number; + height: number; +}; + +// @public (undocumented) +export type ResizableSizeConstraints = Partial; + +// (No @packageDocumentation comment for this package) + +``` From b74936a99d1df951ea8176494ee2126c87527063 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 16 Jul 2026 18:14:26 +0300 Subject: [PATCH 2/6] chore(Resizable): extract reusable resize hooks --- .../src/components/Resizable/Resizable.tsx | 184 +++--------------- .../components/Resizable/ResizableContext.ts | 21 +- .../components/Resizable/ResizableHandle.tsx | 94 ++------- .../src/components/Resizable/hooks/index.ts | 3 + .../Resizable/hooks/useResizable.ts | 110 +++++++++++ .../Resizable/hooks/useResizableHandle.ts | 99 ++++++++++ .../Resizable/hooks/useResizableState.ts | 162 +++++++++++++++ 7 files changed, 417 insertions(+), 256 deletions(-) create mode 100644 packages/components/src/components/Resizable/hooks/index.ts create mode 100644 packages/components/src/components/Resizable/hooks/useResizable.ts create mode 100644 packages/components/src/components/Resizable/hooks/useResizableHandle.ts create mode 100644 packages/components/src/components/Resizable/hooks/useResizableState.ts diff --git a/packages/components/src/components/Resizable/Resizable.tsx b/packages/components/src/components/Resizable/Resizable.tsx index f313b9e1..15625f1a 100644 --- a/packages/components/src/components/Resizable/Resizable.tsx +++ b/packages/components/src/components/Resizable/Resizable.tsx @@ -1,36 +1,14 @@ 'use client'; -import type { CSSProperties } from 'react'; -import { - forwardRef, - useCallback, - useId, - useMemo, - useRef, - useState, -} from 'react'; +import { forwardRef } from 'react'; -import { - clsx, - mergeRefs, - useControlledState, - useElementSize, -} from '@koobiq/react-core'; +import { clsx, mergeRefs } from '@koobiq/react-core'; +import { useResizable, useResizableState } from './hooks'; import s from './Resizable.module.css'; -import { ResizableContext, type ResizableMoveEvent } from './ResizableContext'; +import { ResizableContext } from './ResizableContext'; import { ResizableHandle } from './ResizableHandle'; -import type { - ResizableHandleDirection, - ResizableProps, - ResizableSize, -} from './types'; -import { - clampResizableSize, - getDirectionKey, - getResizableBounds, - normalizeResizableSize, -} from './utils'; +import type { ResizableProps } from './types'; const ResizableComponent = forwardRef( (props, forwardedRef) => { @@ -43,151 +21,47 @@ const ResizableComponent = forwardRef( onResize, onResizeStart, onResizeEnd, - id: idProp, + id, className, style: styleProp, children, ...other } = props; - const generatedId = useId(); - const rootId = idProp ?? generatedId; + const state = useResizableState({ + size: sizeProp, + defaultSize: defaultSizeProp, + minSize, + maxSize, + isDisabled, + onResize, + onResizeStart, + onResizeEnd, + }); const { - ref: elementRef, - width, - height, - } = useElementSize({ box: 'border-box' }); - - const bounds = useMemo( - () => getResizableBounds(minSize, maxSize), - [minSize?.width, minSize?.height, maxSize?.width, maxSize?.height] - ); - - const controlledSize = useMemo( - () => normalizeResizableSize(sizeProp, bounds), - [sizeProp?.width, sizeProp?.height, bounds] - ); - - const defaultSize = useMemo( - () => normalizeResizableSize(defaultSizeProp, bounds), - [defaultSizeProp?.width, defaultSizeProp?.height, bounds] - ); - - const [managedSize, setManagedSize] = useControlledState< - ResizableSize | null, - ResizableSize - >(controlledSize, defaultSize ?? null, onResize); - - const observedSize = clampResizableSize({ width, height }, bounds); - const currentSize = managedSize ?? observedSize; - - const startSizeRef = useRef(currentSize); - const lastSizeRef = useRef(currentSize); - const accumulatedRef = useRef({ x: 0, y: 0 }); - const directionRef = useRef([1, 1]); - const [activeDirection, setActiveDirection] = useState(null); - - const handleMoveStart = useCallback( - (direction: ResizableHandleDirection) => { - const rect = elementRef.current?.getBoundingClientRect(); - - const startSize = clampResizableSize( - rect ? { width: rect.width, height: rect.height } : currentSize, - bounds - ); - - startSizeRef.current = startSize; - lastSizeRef.current = startSize; - accumulatedRef.current = { x: 0, y: 0 }; - directionRef.current = direction; - setActiveDirection(getDirectionKey(direction)); - onResizeStart?.(startSize); + resizableProps: { + ref: targetRef, + style: resizableStyle, + ...behaviorProps }, - [bounds, currentSize, elementRef, onResizeStart] - ); - - const handleMove = useCallback( - (event: ResizableMoveEvent) => { - const multiplier = - event.pointerType === 'keyboard' && event.shiftKey ? 10 : 1; - - accumulatedRef.current.x += event.deltaX * multiplier; - accumulatedRef.current.y += event.deltaY * multiplier; - - const [x, y] = directionRef.current; - const startSize = startSizeRef.current; - - const nextSize = clampResizableSize( - { - width: - x === 0 - ? startSize.width - : startSize.width + accumulatedRef.current.x * x, - height: - y === 0 - ? startSize.height - : startSize.height + accumulatedRef.current.y * y, - }, - bounds - ); - - lastSizeRef.current = nextSize; - setManagedSize(nextSize); + contextValue, + } = useResizable( + { + id, + minSize, }, - [bounds, setManagedSize] + state ); - const handleMoveEnd = useCallback(() => { - setActiveDirection(null); - onResizeEnd?.(lastSizeRef.current); - }, [onResizeEnd]); - - const contextValue = useMemo( - () => ({ - rootId, - size: currentSize, - bounds, - isDisabled, - activeDirection, - onMoveStart: handleMoveStart, - onMove: handleMove, - onMoveEnd: handleMoveEnd, - }), - [ - rootId, - currentSize, - bounds, - isDisabled, - activeDirection, - handleMoveStart, - handleMove, - handleMoveEnd, - ] - ); - - const style = { - ...styleProp, - ...(minSize?.width !== undefined && { minWidth: bounds.minWidth }), - ...(minSize?.height !== undefined && { minHeight: bounds.minHeight }), - ...(Number.isFinite(bounds.maxWidth) && { maxWidth: bounds.maxWidth }), - ...(Number.isFinite(bounds.maxHeight) && { maxHeight: bounds.maxHeight }), - ...(managedSize && { - width: managedSize.width, - height: managedSize.height, - }), - } satisfies CSSProperties; - return (
{children}
diff --git a/packages/components/src/components/Resizable/ResizableContext.ts b/packages/components/src/components/Resizable/ResizableContext.ts index b56eb047..bffee7a6 100644 --- a/packages/components/src/components/Resizable/ResizableContext.ts +++ b/packages/components/src/components/Resizable/ResizableContext.ts @@ -1,25 +1,6 @@ import { createContext, useContext } from 'react'; -import type { ResizableHandleDirection, ResizableSize } from './types'; -import type { ResizableBounds } from './utils'; - -export type ResizableMoveEvent = { - deltaX: number; - deltaY: number; - pointerType: string; - shiftKey: boolean; -}; - -export type ResizableContextValue = { - rootId: string; - size: ResizableSize; - bounds: ResizableBounds; - isDisabled: boolean; - activeDirection: string | null; - onMoveStart: (direction: ResizableHandleDirection) => void; - onMove: (event: ResizableMoveEvent) => void; - onMoveEnd: () => void; -}; +import type { ResizableContextValue } from './hooks'; export const ResizableContext = createContext( null diff --git a/packages/components/src/components/Resizable/ResizableHandle.tsx b/packages/components/src/components/Resizable/ResizableHandle.tsx index 8e3098d7..2a33876d 100644 --- a/packages/components/src/components/Resizable/ResizableHandle.tsx +++ b/packages/components/src/components/Resizable/ResizableHandle.tsx @@ -2,25 +2,13 @@ import { forwardRef } from 'react'; -import { - clsx, - mergeProps, - useFocusRing, - useLocalizedStringFormatter, - useMove, -} from '@koobiq/react-core'; +import { clsx, mergeProps } from '@koobiq/react-core'; -import intlMessages from './intl.json'; +import { useResizableHandle } from './hooks'; import s from './Resizable.module.css'; import { useResizableContext } from './ResizableContext'; import type { ResizableHandleProps } from './types'; -import { - getDirectionKey, - getHandlePositionStyle, - isResizableHandleDirection, -} from './utils'; - -const UNBOUNDED_ARIA_MAX = Number.MAX_SAFE_INTEGER; +import { getHandlePositionStyle, isResizableHandleDirection } from './utils'; /** A draggable handle that changes the size of its parent Resizable. */ export const ResizableHandle = forwardRef( @@ -40,79 +28,23 @@ export const ResizableHandle = forwardRef( ); } - const { - rootId, - size, - bounds, - isDisabled, - activeDirection, - onMoveStart, - onMove, - onMoveEnd, - } = useResizableContext(); - - const t = useLocalizedStringFormatter(intlMessages); - const [x, y] = direction; - const isWidthHandle = y === 0; - const isHeightHandle = x === 0; - const isCornerHandle = x !== 0 && y !== 0; - const isResizing = activeDirection === getDirectionKey(direction); - - const { moveProps } = useMove({ - onMoveStart: () => onMoveStart(direction), - onMove, - onMoveEnd, - }); + const state = useResizableContext(); - const { focusProps, isFocusVisible } = useFocusRing({ - isTextInput: false, - autoFocus: false, - }); - - const interactionProps = isDisabled - ? other - : mergeProps(moveProps, focusProps, other); - - const defaultLabel = isWidthHandle - ? t.format('resize width') - : isHeightHandle - ? t.format('resize height') - : t.format('resize'); - - const value = isWidthHandle ? size.width : size.height; - const minValue = isWidthHandle ? bounds.minWidth : bounds.minHeight; - const maxValue = isWidthHandle ? bounds.maxWidth : bounds.maxHeight; + const { handleProps } = useResizableHandle( + { + direction, + 'aria-label': ariaLabelProp, + tabIndex: tabIndexProp, + }, + state + ); return (
); } diff --git a/packages/components/src/components/Resizable/hooks/index.ts b/packages/components/src/components/Resizable/hooks/index.ts new file mode 100644 index 00000000..5c172748 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useResizable'; +export * from './useResizableHandle'; +export * from './useResizableState'; diff --git a/packages/components/src/components/Resizable/hooks/useResizable.ts b/packages/components/src/components/Resizable/hooks/useResizable.ts new file mode 100644 index 00000000..ddacd7f2 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/useResizable.ts @@ -0,0 +1,110 @@ +'use client'; + +import type { CSSProperties } from 'react'; +import { useCallback, useId, useMemo } from 'react'; + +import { useElementSize } from '@koobiq/react-core'; + +import type { ResizableHandleDirection, ResizableProps } from '../types'; +import { clampResizableSize } from '../utils'; + +import type { ResizableMoveEvent, ResizableState } from './useResizableState'; + +export type ResizableContextValue = { + rootId: string; + size: NonNullable; + bounds: ResizableState['bounds']; + isDisabled: boolean; + activeDirection: string | null; + onMoveStart: (direction: ResizableHandleDirection) => void; + onMove: (event: ResizableMoveEvent) => void; + onMoveEnd: () => void; +}; + +export type UseResizableProps = Pick; + +/** Provides DOM props and observed sizing for a resizable target element. */ +export const useResizable = ( + props: UseResizableProps, + state: ResizableState +) => { + const { id: idProp, minSize } = props; + + const { + size: managedSize, + bounds, + isDisabled, + activeDirection, + startResize, + resize, + endResize, + } = state; + + const generatedId = useId(); + const rootId = idProp ?? generatedId; + + const { + ref: targetRef, + width, + height, + } = useElementSize({ box: 'border-box' }); + + const observedSize = clampResizableSize({ width, height }, bounds); + const currentSize = managedSize ?? observedSize; + + const handleMoveStart = useCallback( + (direction: ResizableHandleDirection) => { + const rect = targetRef.current?.getBoundingClientRect(); + + startResize( + direction, + rect ? { width: rect.width, height: rect.height } : currentSize + ); + }, + [currentSize, startResize, targetRef] + ); + + const contextValue = useMemo( + () => ({ + rootId, + size: currentSize, + bounds, + isDisabled, + activeDirection, + onMoveStart: handleMoveStart, + onMove: resize, + onMoveEnd: endResize, + }), + [ + rootId, + currentSize, + bounds, + isDisabled, + activeDirection, + handleMoveStart, + resize, + endResize, + ] + ); + + const style = { + ...(minSize?.width !== undefined && { minWidth: bounds.minWidth }), + ...(minSize?.height !== undefined && { minHeight: bounds.minHeight }), + ...(Number.isFinite(bounds.maxWidth) && { maxWidth: bounds.maxWidth }), + ...(Number.isFinite(bounds.maxHeight) && { maxHeight: bounds.maxHeight }), + ...(managedSize && { + width: managedSize.width, + height: managedSize.height, + }), + } satisfies CSSProperties; + + const resizableProps = { + ref: targetRef, + id: rootId, + style, + 'data-resizing': activeDirection ? true : undefined, + 'data-disabled': isDisabled || undefined, + }; + + return { resizableProps, contextValue }; +}; diff --git a/packages/components/src/components/Resizable/hooks/useResizableHandle.ts b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts new file mode 100644 index 00000000..92f99b28 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts @@ -0,0 +1,99 @@ +'use client'; + +import { + mergeProps, + useFocusRing, + useLocalizedStringFormatter, + useMove, +} from '@koobiq/react-core'; + +import intlMessages from '../intl.json'; +import type { ResizableHandleProps } from '../types'; +import { getDirectionKey } from '../utils'; + +import type { ResizableContextValue } from './useResizable'; + +const UNBOUNDED_ARIA_MAX = Number.MAX_SAFE_INTEGER; + +export type UseResizableHandleProps = Pick< + ResizableHandleProps, + 'direction' | 'aria-label' | 'tabIndex' +>; + +/** Provides interaction and accessibility props for a resize handle. */ +export const useResizableHandle = ( + props: UseResizableHandleProps, + state: ResizableContextValue +) => { + const { direction, 'aria-label': ariaLabel, tabIndex: tabIndexProp } = props; + + const { + rootId, + size, + bounds, + isDisabled, + activeDirection, + onMoveStart, + onMove, + onMoveEnd, + } = state; + + const t = useLocalizedStringFormatter(intlMessages); + const [x, y] = direction; + const isWidthHandle = y === 0; + const isHeightHandle = x === 0; + const isCornerHandle = x !== 0 && y !== 0; + const isResizing = activeDirection === getDirectionKey(direction); + + const { moveProps } = useMove({ + onMoveStart: () => onMoveStart(direction), + onMove, + onMoveEnd, + }); + + const { focusProps, isFocusVisible } = useFocusRing({ + isTextInput: false, + autoFocus: false, + }); + + const defaultLabel = isWidthHandle + ? t.format('resize width') + : isHeightHandle + ? t.format('resize height') + : t.format('resize'); + + const value = isWidthHandle ? size.width : size.height; + const minValue = isWidthHandle ? bounds.minWidth : bounds.minHeight; + const maxValue = isWidthHandle ? bounds.maxWidth : bounds.maxHeight; + + const accessibilityProps = { + role: isCornerHandle ? ('button' as const) : ('separator' as const), + tabIndex: isDisabled ? -1 : (tabIndexProp ?? 0), + 'aria-label': ariaLabel ?? defaultLabel, + 'aria-controls': rootId, + 'aria-disabled': isDisabled || undefined, + 'aria-keyshortcuts': 'ArrowUp ArrowDown ArrowLeft ArrowRight', + 'aria-orientation': isCornerHandle + ? undefined + : isWidthHandle + ? ('vertical' as const) + : ('horizontal' as const), + 'aria-valuenow': isCornerHandle ? undefined : Math.round(value), + 'aria-valuemin': isCornerHandle ? undefined : Math.round(minValue), + 'aria-valuemax': isCornerHandle + ? undefined + : Math.round(Number.isFinite(maxValue) ? maxValue : UNBOUNDED_ARIA_MAX), + 'aria-valuetext': isCornerHandle ? undefined : `${Math.round(value)} px`, + 'data-direction-x': x, + 'data-direction-y': y, + 'data-focus-visible': isFocusVisible || undefined, + 'data-resizing': isResizing || undefined, + 'data-disabled': isDisabled || undefined, + }; + + return { + handleProps: isDisabled + ? accessibilityProps + : mergeProps(moveProps, focusProps, accessibilityProps), + }; +}; diff --git a/packages/components/src/components/Resizable/hooks/useResizableState.ts b/packages/components/src/components/Resizable/hooks/useResizableState.ts new file mode 100644 index 00000000..078cb6cd --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/useResizableState.ts @@ -0,0 +1,162 @@ +'use client'; + +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { useControlledState } from '@koobiq/react-core'; + +import type { + ResizableHandleDirection, + ResizableProps, + ResizableSize, +} from '../types'; +import { + clampResizableSize, + getDirectionKey, + getResizableBounds, + normalizeResizableSize, +} from '../utils'; + +export type ResizableMoveEvent = { + deltaX: number; + deltaY: number; + pointerType: string; + shiftKey: boolean; +}; + +export type ResizableState = { + size: ResizableSize | null; + bounds: ReturnType; + isDisabled: boolean; + activeDirection: string | null; + startResize: ( + direction: ResizableHandleDirection, + startSize: ResizableSize + ) => void; + resize: (event: ResizableMoveEvent) => void; + endResize: () => void; +}; + +export type UseResizableStateProps = Pick< + ResizableProps, + | 'size' + | 'defaultSize' + | 'minSize' + | 'maxSize' + | 'isDisabled' + | 'onResize' + | 'onResizeStart' + | 'onResizeEnd' +>; + +/** Provides state and calculations for a resizable element. */ +export const useResizableState = ( + props: UseResizableStateProps +): ResizableState => { + const { + size: sizeProp, + defaultSize: defaultSizeProp, + minSize, + maxSize, + isDisabled = false, + onResize, + onResizeStart, + onResizeEnd, + } = props; + + const bounds = useMemo( + () => getResizableBounds(minSize, maxSize), + [minSize?.width, minSize?.height, maxSize?.width, maxSize?.height] + ); + + const controlledSize = useMemo( + () => normalizeResizableSize(sizeProp, bounds), + [sizeProp?.width, sizeProp?.height, bounds] + ); + + const defaultSize = useMemo( + () => normalizeResizableSize(defaultSizeProp, bounds), + [defaultSizeProp?.width, defaultSizeProp?.height, bounds] + ); + + const [managedSize, setManagedSize] = useControlledState< + ResizableSize | null, + ResizableSize + >(controlledSize, defaultSize ?? null, onResize); + + const startSizeRef = useRef({ width: 0, height: 0 }); + const lastSizeRef = useRef({ width: 0, height: 0 }); + const accumulatedRef = useRef({ x: 0, y: 0 }); + const directionRef = useRef([1, 1]); + const [activeDirection, setActiveDirection] = useState(null); + + const startResize = useCallback( + (direction: ResizableHandleDirection, startSize: ResizableSize) => { + const clampedStartSize = clampResizableSize(startSize, bounds); + + startSizeRef.current = clampedStartSize; + lastSizeRef.current = clampedStartSize; + accumulatedRef.current = { x: 0, y: 0 }; + directionRef.current = direction; + setActiveDirection(getDirectionKey(direction)); + onResizeStart?.(clampedStartSize); + }, + [bounds, onResizeStart] + ); + + const resize = useCallback( + (event: ResizableMoveEvent) => { + const multiplier = + event.pointerType === 'keyboard' && event.shiftKey ? 10 : 1; + + accumulatedRef.current.x += event.deltaX * multiplier; + accumulatedRef.current.y += event.deltaY * multiplier; + + const [x, y] = directionRef.current; + const startSize = startSizeRef.current; + + const nextSize = clampResizableSize( + { + width: + x === 0 + ? startSize.width + : startSize.width + accumulatedRef.current.x * x, + height: + y === 0 + ? startSize.height + : startSize.height + accumulatedRef.current.y * y, + }, + bounds + ); + + lastSizeRef.current = nextSize; + setManagedSize(nextSize); + }, + [bounds, setManagedSize] + ); + + const endResize = useCallback(() => { + setActiveDirection(null); + onResizeEnd?.(lastSizeRef.current); + }, [onResizeEnd]); + + return useMemo( + () => ({ + size: managedSize, + bounds, + isDisabled, + activeDirection, + startResize, + resize, + endResize, + }), + [ + managedSize, + bounds, + isDisabled, + activeDirection, + startResize, + resize, + endResize, + ] + ); +}; From 39e144ccb520aec57cef254719127eedae069f1c Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 16 Jul 2026 18:41:42 +0300 Subject: [PATCH 3/6] refactor(Resizable): extract resize hooks and add polymorphic support --- .../src/components/Resizable/Resizable.mdx | 10 +++ .../components/Resizable/Resizable.test.tsx | 27 ++++++ .../src/components/Resizable/Resizable.tsx | 15 ++-- .../components/Resizable/ResizableHandle.tsx | 84 +++++++++---------- .../src/components/Resizable/hooks/index.ts | 2 + .../Resizable/{ => hooks}/intl.json | 0 .../src/components/Resizable/hooks/types.ts | 36 ++++++++ .../Resizable/hooks/useResizable.ts | 16 ++-- .../Resizable/hooks/useResizableHandle.ts | 16 ++-- .../Resizable/hooks/useResizableState.ts | 35 ++++---- .../src/components/Resizable/hooks/utils.ts | 75 +++++++++++++++++ .../src/components/Resizable/types.ts | 73 +++++++++++----- .../src/components/Resizable/utils.ts | 82 +----------------- .../components/Resizable.api.md | 46 ++++++---- 14 files changed, 315 insertions(+), 202 deletions(-) rename packages/components/src/components/Resizable/{ => hooks}/intl.json (100%) create mode 100644 packages/components/src/components/Resizable/hooks/types.ts create mode 100644 packages/components/src/components/Resizable/hooks/utils.ts diff --git a/packages/components/src/components/Resizable/Resizable.mdx b/packages/components/src/components/Resizable/Resizable.mdx index dc37fc8f..0766b177 100644 --- a/packages/components/src/components/Resizable/Resizable.mdx +++ b/packages/components/src/components/Resizable/Resizable.mdx @@ -26,6 +26,16 @@ import { Resizable } from '@koobiq/react-components'; +Both `Resizable` and `Resizable.Handle` render a `div` by default. Use the `as` prop +to render another element while preserving its native props and ref type. + +```tsx + + + + +``` + ## Props diff --git a/packages/components/src/components/Resizable/Resizable.test.tsx b/packages/components/src/components/Resizable/Resizable.test.tsx index cbf77df9..257ff8f3 100644 --- a/packages/components/src/components/Resizable/Resizable.test.tsx +++ b/packages/components/src/components/Resizable/Resizable.test.tsx @@ -136,6 +136,33 @@ describe('Resizable', () => { expect(onKeyDown).toHaveBeenCalledOnce(); }); + it('supports polymorphic root and handle elements with typed refs', () => { + const rootRef = createRef(); + const handleRef = createRef(); + + render( + + + + ); + + expect(getRoot().tagName).toBe('SECTION'); + expect(getHandle().tagName).toBe('BUTTON'); + expect(rootRef.current).toBe(getRoot()); + expect(handleRef.current).toBe(getHandle()); + }); + it.each(resizableHandleDirections)( 'resizes in direction [%s, %s] from the drag snapshot', (x, y) => { diff --git a/packages/components/src/components/Resizable/Resizable.tsx b/packages/components/src/components/Resizable/Resizable.tsx index 15625f1a..92d8601a 100644 --- a/packages/components/src/components/Resizable/Resizable.tsx +++ b/packages/components/src/components/Resizable/Resizable.tsx @@ -1,18 +1,17 @@ 'use client'; -import { forwardRef } from 'react'; - -import { clsx, mergeRefs } from '@koobiq/react-core'; +import { clsx, mergeRefs, polymorphicForwardRef } from '@koobiq/react-core'; import { useResizable, useResizableState } from './hooks'; import s from './Resizable.module.css'; import { ResizableContext } from './ResizableContext'; import { ResizableHandle } from './ResizableHandle'; -import type { ResizableProps } from './types'; +import type { ResizableBaseProps } from './types'; -const ResizableComponent = forwardRef( +const ResizableComponent = polymorphicForwardRef<'div', ResizableBaseProps>( (props, forwardedRef) => { const { + as: Tag = 'div', size: sizeProp, defaultSize: defaultSizeProp, minSize, @@ -46,7 +45,7 @@ const ResizableComponent = forwardRef( ...behaviorProps }, contextValue, - } = useResizable( + } = useResizable( { id, minSize, @@ -56,7 +55,7 @@ const ResizableComponent = forwardRef( return ( -
( style={{ ...styleProp, ...resizableStyle }} > {children} -
+
); } diff --git a/packages/components/src/components/Resizable/ResizableHandle.tsx b/packages/components/src/components/Resizable/ResizableHandle.tsx index 2a33876d..c7c9e0d6 100644 --- a/packages/components/src/components/Resizable/ResizableHandle.tsx +++ b/packages/components/src/components/Resizable/ResizableHandle.tsx @@ -1,53 +1,53 @@ 'use client'; -import { forwardRef } from 'react'; +import { clsx, mergeProps, polymorphicForwardRef } from '@koobiq/react-core'; -import { clsx, mergeProps } from '@koobiq/react-core'; - -import { useResizableHandle } from './hooks'; +import { isResizableHandleDirection, useResizableHandle } from './hooks'; import s from './Resizable.module.css'; import { useResizableContext } from './ResizableContext'; -import type { ResizableHandleProps } from './types'; -import { getHandlePositionStyle, isResizableHandleDirection } from './utils'; +import type { ResizableHandleBaseProps } from './types'; +import { getHandlePositionStyle } from './utils'; /** A draggable handle that changes the size of its parent Resizable. */ -export const ResizableHandle = forwardRef( - (props, ref) => { - const { - direction, - className, - style: styleProp, - tabIndex: tabIndexProp, - 'aria-label': ariaLabelProp, - ...other - } = props; - - if (!isResizableHandleDirection(direction)) { - throw new Error( - 'Resizable.Handle direction must be one of the eight non-zero [x, y] directions.' - ); - } - - const state = useResizableContext(); - - const { handleProps } = useResizableHandle( - { - direction, - 'aria-label': ariaLabelProp, - tabIndex: tabIndexProp, - }, - state - ); - - return ( -
+export const ResizableHandle = polymorphicForwardRef< + 'div', + ResizableHandleBaseProps +>((props, ref) => { + const { + as: Tag = 'div', + direction, + className, + style: styleProp, + tabIndex: tabIndexProp, + 'aria-label': ariaLabelProp, + ...other + } = props; + + if (!isResizableHandleDirection(direction)) { + throw new Error( + 'Resizable.Handle direction must be one of the eight non-zero [x, y] directions.' ); } -); + + const state = useResizableContext(); + + const { handleProps } = useResizableHandle( + { + direction, + 'aria-label': ariaLabelProp, + tabIndex: tabIndexProp, + }, + state + ); + + return ( + + ); +}); ResizableHandle.displayName = 'Resizable.Handle'; diff --git a/packages/components/src/components/Resizable/hooks/index.ts b/packages/components/src/components/Resizable/hooks/index.ts index 5c172748..36d47359 100644 --- a/packages/components/src/components/Resizable/hooks/index.ts +++ b/packages/components/src/components/Resizable/hooks/index.ts @@ -1,3 +1,5 @@ +export * from './types'; export * from './useResizable'; export * from './useResizableHandle'; export * from './useResizableState'; +export * from './utils'; diff --git a/packages/components/src/components/Resizable/intl.json b/packages/components/src/components/Resizable/hooks/intl.json similarity index 100% rename from packages/components/src/components/Resizable/intl.json rename to packages/components/src/components/Resizable/hooks/intl.json diff --git a/packages/components/src/components/Resizable/hooks/types.ts b/packages/components/src/components/Resizable/hooks/types.ts new file mode 100644 index 00000000..05309896 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/types.ts @@ -0,0 +1,36 @@ +export type ResizableSize = { + /** Width in CSS pixels. */ + width: number; + /** Height in CSS pixels. */ + height: number; +}; + +export type ResizableSizeConstraints = Partial; + +export const resizableHandleDirections = [ + [-1, -1], + [0, -1], + [1, -1], + [-1, 0], + [1, 0], + [-1, 1], + [0, 1], + [1, 1], +] as const; + +export type ResizableHandleDirection = + (typeof resizableHandleDirections)[number]; + +export type ResizableBounds = { + minWidth: number; + maxWidth: number; + minHeight: number; + maxHeight: number; +}; + +export type ResizableMoveEvent = { + deltaX: number; + deltaY: number; + pointerType: string; + shiftKey: boolean; +}; diff --git a/packages/components/src/components/Resizable/hooks/useResizable.ts b/packages/components/src/components/Resizable/hooks/useResizable.ts index ddacd7f2..c3adc7e5 100644 --- a/packages/components/src/components/Resizable/hooks/useResizable.ts +++ b/packages/components/src/components/Resizable/hooks/useResizable.ts @@ -5,10 +5,13 @@ import { useCallback, useId, useMemo } from 'react'; import { useElementSize } from '@koobiq/react-core'; -import type { ResizableHandleDirection, ResizableProps } from '../types'; -import { clampResizableSize } from '../utils'; - -import type { ResizableMoveEvent, ResizableState } from './useResizableState'; +import type { + ResizableHandleDirection, + ResizableMoveEvent, + ResizableSizeConstraints, +} from './types'; +import type { ResizableState } from './useResizableState'; +import { clampResizableSize } from './utils'; export type ResizableContextValue = { rootId: string; @@ -21,7 +24,10 @@ export type ResizableContextValue = { onMoveEnd: () => void; }; -export type UseResizableProps = Pick; +export type UseResizableProps = { + id?: string; + minSize?: ResizableSizeConstraints; +}; /** Provides DOM props and observed sizing for a resizable target element. */ export const useResizable = ( diff --git a/packages/components/src/components/Resizable/hooks/useResizableHandle.ts b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts index 92f99b28..cae746e8 100644 --- a/packages/components/src/components/Resizable/hooks/useResizableHandle.ts +++ b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts @@ -7,18 +7,18 @@ import { useMove, } from '@koobiq/react-core'; -import intlMessages from '../intl.json'; -import type { ResizableHandleProps } from '../types'; -import { getDirectionKey } from '../utils'; - +import intlMessages from './intl.json'; +import type { ResizableHandleDirection } from './types'; import type { ResizableContextValue } from './useResizable'; +import { getDirectionKey } from './utils'; const UNBOUNDED_ARIA_MAX = Number.MAX_SAFE_INTEGER; -export type UseResizableHandleProps = Pick< - ResizableHandleProps, - 'direction' | 'aria-label' | 'tabIndex' ->; +export type UseResizableHandleProps = { + direction: ResizableHandleDirection; + 'aria-label'?: string; + tabIndex?: number; +}; /** Provides interaction and accessibility props for a resize handle. */ export const useResizableHandle = ( diff --git a/packages/components/src/components/Resizable/hooks/useResizableState.ts b/packages/components/src/components/Resizable/hooks/useResizableState.ts index 078cb6cd..37de259a 100644 --- a/packages/components/src/components/Resizable/hooks/useResizableState.ts +++ b/packages/components/src/components/Resizable/hooks/useResizableState.ts @@ -6,22 +6,16 @@ import { useControlledState } from '@koobiq/react-core'; import type { ResizableHandleDirection, - ResizableProps, + ResizableMoveEvent, ResizableSize, -} from '../types'; + ResizableSizeConstraints, +} from './types'; import { clampResizableSize, getDirectionKey, getResizableBounds, normalizeResizableSize, -} from '../utils'; - -export type ResizableMoveEvent = { - deltaX: number; - deltaY: number; - pointerType: string; - shiftKey: boolean; -}; +} from './utils'; export type ResizableState = { size: ResizableSize | null; @@ -36,17 +30,16 @@ export type ResizableState = { endResize: () => void; }; -export type UseResizableStateProps = Pick< - ResizableProps, - | 'size' - | 'defaultSize' - | 'minSize' - | 'maxSize' - | 'isDisabled' - | 'onResize' - | 'onResizeStart' - | 'onResizeEnd' ->; +export type UseResizableStateProps = { + size?: ResizableSize; + defaultSize?: ResizableSize; + minSize?: ResizableSizeConstraints; + maxSize?: ResizableSizeConstraints; + isDisabled?: boolean; + onResize?: (size: ResizableSize) => void; + onResizeStart?: (size: ResizableSize) => void; + onResizeEnd?: (size: ResizableSize) => void; +}; /** Provides state and calculations for a resizable element. */ export const useResizableState = ( diff --git a/packages/components/src/components/Resizable/hooks/utils.ts b/packages/components/src/components/Resizable/hooks/utils.ts new file mode 100644 index 00000000..ac52a654 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/utils.ts @@ -0,0 +1,75 @@ +import type { + ResizableBounds, + ResizableHandleDirection, + ResizableSize, + ResizableSizeConstraints, +} from './types'; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); + +export const getResizableBounds = ( + minSize?: ResizableSizeConstraints, + maxSize?: ResizableSizeConstraints +): ResizableBounds => { + const minWidth = Math.max( + 0, + isFiniteNumber(minSize?.width) ? minSize.width : 0 + ); + + const minHeight = Math.max( + 0, + isFiniteNumber(minSize?.height) ? minSize.height : 0 + ); + + return { + minWidth, + minHeight, + maxWidth: isFiniteNumber(maxSize?.width) + ? Math.max(minWidth, maxSize.width) + : Number.POSITIVE_INFINITY, + maxHeight: isFiniteNumber(maxSize?.height) + ? Math.max(minHeight, maxSize.height) + : Number.POSITIVE_INFINITY, + }; +}; + +const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); + +export const clampResizableSize = ( + size: ResizableSize, + bounds: ResizableBounds +): ResizableSize => ({ + width: clamp( + isFiniteNumber(size.width) ? size.width : bounds.minWidth, + bounds.minWidth, + bounds.maxWidth + ), + height: clamp( + isFiniteNumber(size.height) ? size.height : bounds.minHeight, + bounds.minHeight, + bounds.maxHeight + ), +}); + +export const normalizeResizableSize = ( + size: ResizableSize | undefined, + bounds: ResizableBounds +) => (size ? clampResizableSize(size, bounds) : undefined); + +export const getDirectionKey = ([x, y]: ResizableHandleDirection) => + `${x}:${y}`; + +export const isResizableHandleDirection = ( + direction: readonly number[] +): direction is ResizableHandleDirection => { + const [x, y] = direction; + + return ( + direction.length === 2 && + (x === -1 || x === 0 || x === 1) && + (y === -1 || y === 0 || y === 1) && + (x !== 0 || y !== 0) + ); +}; diff --git a/packages/components/src/components/Resizable/types.ts b/packages/components/src/components/Resizable/types.ts index 390d80bf..356a8f61 100644 --- a/packages/components/src/components/Resizable/types.ts +++ b/packages/components/src/components/Resizable/types.ts @@ -1,29 +1,26 @@ -import type { ComponentPropsWithRef } from 'react'; +import type { + ComponentPropsWithRef, + CSSProperties, + ElementType, + ReactNode, +} from 'react'; -export type ResizableSize = { - /** Width in CSS pixels. */ - width: number; - /** Height in CSS pixels. */ - height: number; -}; - -export type ResizableSizeConstraints = Partial; +import type { AsProps } from '@koobiq/react-core'; -export const resizableHandleDirections = [ - [-1, -1], - [0, -1], - [1, -1], - [-1, 0], - [1, 0], - [-1, 1], - [0, 1], - [1, 1], -] as const; +import type { + ResizableHandleDirection, + ResizableSize, + ResizableSizeConstraints, +} from './hooks'; -export type ResizableHandleDirection = - (typeof resizableHandleDirections)[number]; +export { resizableHandleDirections } from './hooks'; +export type { + ResizableHandleDirection, + ResizableSize, + ResizableSizeConstraints, +} from './hooks'; -export type ResizableProps = Omit, 'onResize'> & { +export type ResizableBaseProps = { /** The controlled size of the element, in CSS pixels. */ size?: ResizableSize; /** The initial size of the element when uncontrolled, in CSS pixels. */ @@ -40,12 +37,42 @@ export type ResizableProps = Omit, 'onResize'> & { onResizeStart?: (size: ResizableSize) => void; /** Handler called when a resize interaction ends. */ onResizeEnd?: (size: ResizableSize) => void; + /** The id of the resizable target. */ + id?: string; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** The content of the component. */ + children?: ReactNode; }; -export type ResizableHandleProps = ComponentPropsWithRef<'div'> & { +export type ResizableProps = AsProps< + As, + ResizableBaseProps, + ComponentPropsWithRef +>; + +export type ResizableHandleBaseProps = { /** * Physical resize direction: `-1` means left/up, `0` disables the axis, * and `1` means right/down. */ direction: ResizableHandleDirection; + /** The accessible name of the handle. */ + 'aria-label'?: string; + /** Overrides the handle's position in the tab order. */ + tabIndex?: number; + /** Additional CSS-classes. */ + className?: string; + /** Inline styles. */ + style?: CSSProperties; + /** The content of the handle. */ + children?: ReactNode; }; + +export type ResizableHandleProps = AsProps< + As, + ResizableHandleBaseProps, + ComponentPropsWithRef +>; diff --git a/packages/components/src/components/Resizable/utils.ts b/packages/components/src/components/Resizable/utils.ts index b0e988a5..6cebf48b 100644 --- a/packages/components/src/components/Resizable/utils.ts +++ b/packages/components/src/components/Resizable/utils.ts @@ -1,86 +1,6 @@ import type { CSSProperties } from 'react'; -import type { - ResizableHandleDirection, - ResizableSize, - ResizableSizeConstraints, -} from './types'; - -export type ResizableBounds = { - minWidth: number; - maxWidth: number; - minHeight: number; - maxHeight: number; -}; - -const isFiniteNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value); - -export const getResizableBounds = ( - minSize?: ResizableSizeConstraints, - maxSize?: ResizableSizeConstraints -): ResizableBounds => { - const minWidth = Math.max( - 0, - isFiniteNumber(minSize?.width) ? minSize.width : 0 - ); - - const minHeight = Math.max( - 0, - isFiniteNumber(minSize?.height) ? minSize.height : 0 - ); - - return { - minWidth, - minHeight, - maxWidth: isFiniteNumber(maxSize?.width) - ? Math.max(minWidth, maxSize.width) - : Number.POSITIVE_INFINITY, - maxHeight: isFiniteNumber(maxSize?.height) - ? Math.max(minHeight, maxSize.height) - : Number.POSITIVE_INFINITY, - }; -}; - -const clamp = (value: number, min: number, max: number) => - Math.max(min, Math.min(max, value)); - -export const clampResizableSize = ( - size: ResizableSize, - bounds: ResizableBounds -): ResizableSize => ({ - width: clamp( - isFiniteNumber(size.width) ? size.width : bounds.minWidth, - bounds.minWidth, - bounds.maxWidth - ), - height: clamp( - isFiniteNumber(size.height) ? size.height : bounds.minHeight, - bounds.minHeight, - bounds.maxHeight - ), -}); - -export const normalizeResizableSize = ( - size: ResizableSize | undefined, - bounds: ResizableBounds -) => (size ? clampResizableSize(size, bounds) : undefined); - -export const getDirectionKey = ([x, y]: ResizableHandleDirection) => - `${x}:${y}`; - -export const isResizableHandleDirection = ( - direction: readonly number[] -): direction is ResizableHandleDirection => { - const [x, y] = direction; - - return ( - direction.length === 2 && - (x === -1 || x === 0 || x === 1) && - (y === -1 || y === 0 || y === 1) && - (x !== 0 || y !== 0) - ); -}; +import type { ResizableHandleDirection } from './hooks'; const handleSize = 'var(--kbq-resizable-handle-size)'; diff --git a/tools/public_api_guard/components/Resizable.api.md b/tools/public_api_guard/components/Resizable.api.md index 91c523c3..97edbb65 100644 --- a/tools/public_api_guard/components/Resizable.api.md +++ b/tools/public_api_guard/components/Resizable.api.md @@ -4,9 +4,12 @@ ```ts +import type { AsProps } from '@koobiq/react-core'; import type { ComponentPropsWithRef } from 'react'; -import { ForwardRefExoticComponent } from 'react'; -import { RefAttributes } from 'react'; +import type { CSSProperties } from 'react'; +import { ElementType } from 'react'; +import { PolyForwardComponent } from '@koobiq/react-core'; +import type { ReactNode } from 'react'; // Warning: (ae-forgotten-export) The symbol "CompoundedComponent" needs to be exported by the entry point index.d.ts // @@ -14,18 +17,7 @@ import { RefAttributes } from 'react'; export const Resizable: CompoundedComponent; // @public (undocumented) -export type ResizableHandleDirection = (typeof resizableHandleDirections)[number]; - -// @public (undocumented) -export const resizableHandleDirections: readonly [readonly [-1, -1], readonly [0, -1], readonly [1, -1], readonly [-1, 0], readonly [1, 0], readonly [-1, 1], readonly [0, 1], readonly [1, 1]]; - -// @public (undocumented) -export type ResizableHandleProps = ComponentPropsWithRef<'div'> & { - direction: ResizableHandleDirection; -}; - -// @public (undocumented) -export type ResizableProps = Omit, 'onResize'> & { +export type ResizableBaseProps = { size?: ResizableSize; defaultSize?: ResizableSize; minSize?: ResizableSizeConstraints; @@ -34,8 +26,34 @@ export type ResizableProps = Omit, 'onResize'> & { onResize?: (size: ResizableSize) => void; onResizeStart?: (size: ResizableSize) => void; onResizeEnd?: (size: ResizableSize) => void; + id?: string; + className?: string; + style?: CSSProperties; + children?: ReactNode; +}; + +// @public (undocumented) +export type ResizableHandleBaseProps = { + direction: ResizableHandleDirection; + 'aria-label'?: string; + tabIndex?: number; + className?: string; + style?: CSSProperties; + children?: ReactNode; }; +// @public (undocumented) +export type ResizableHandleDirection = (typeof resizableHandleDirections)[number]; + +// @public (undocumented) +export const resizableHandleDirections: readonly [readonly [-1, -1], readonly [0, -1], readonly [1, -1], readonly [-1, 0], readonly [1, 0], readonly [-1, 1], readonly [0, 1], readonly [1, 1]]; + +// @public (undocumented) +export type ResizableHandleProps = AsProps>; + +// @public (undocumented) +export type ResizableProps = AsProps>; + // @public (undocumented) export type ResizableSize = { width: number; From df4d0a76945d17322c9feb32a4faef50b149b721 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 16 Jul 2026 19:28:27 +0300 Subject: [PATCH 4/6] docs(Resizable): add nested panels example --- .../src/components/Resizable/Resizable.mdx | 7 ++ .../Resizable/Resizable.stories.tsx | 57 ++++++++- .../Resizable/__stories__/styles.module.css | 116 ++++++++++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/Resizable/Resizable.mdx b/packages/components/src/components/Resizable/Resizable.mdx index 0766b177..faede07b 100644 --- a/packages/components/src/components/Resizable/Resizable.mdx +++ b/packages/components/src/components/Resizable/Resizable.mdx @@ -71,6 +71,13 @@ The direction remains physical in right-to-left layouts. Use `minSize` and `maxSize` to constrain either dimension independently. The default minimum is zero, and dimensions without a maximum are unbounded. If a minimum exceeds a maximum, the minimum wins. +## Nested panels + +Resizable components can be nested to build layouts with independently adjustable regions. In this +example, the outer handle changes the sidebar width and the inner handle changes the filters height. + + + ## Keyboard interaction Handles are focusable. Use the arrow keys to resize by one pixel, or hold Shift to resize diff --git a/packages/components/src/components/Resizable/Resizable.stories.tsx b/packages/components/src/components/Resizable/Resizable.stories.tsx index 55ead5e3..25f08fdc 100644 --- a/packages/components/src/components/Resizable/Resizable.stories.tsx +++ b/packages/components/src/components/Resizable/Resizable.stories.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { clsx } from '@koobiq/react-core'; +import { clsx, useElementSize } from '@koobiq/react-core'; import type { Meta, StoryObj } from '@storybook/react'; import { Typography } from '../Typography'; @@ -112,6 +112,61 @@ export const SingleDirection: Story = { ), }; +export const NestedPanels: Story = { + parameters: { + layout: 'padded', + }, + render: function Render(args) { + const { + ref: workspaceRef, + width: workspaceWidth, + height: workspaceHeight, + } = useElementSize({ box: 'border-box' }); + + return ( +
+
+ Content +
+ + + + + +
+ Filters +
+ +
+ +
+ Data +
+
+
+ ); + }, +}; + export const Disabled: Story = { render: (args) => ( Date: Fri, 17 Jul 2026 10:32:06 +0300 Subject: [PATCH 5/6] chore(Resizable): support custom handle geometry --- .../src/components/Resizable/Resizable.mdx | 26 +++++++- .../components/Resizable/Resizable.module.css | 53 +++++++++++++++- .../Resizable/Resizable.stories.tsx | 62 +++++++++++++++++++ .../components/Resizable/Resizable.test.tsx | 20 ++++-- .../components/Resizable/ResizableHandle.tsx | 3 +- .../src/components/Resizable/utils.ts | 29 --------- 6 files changed, 154 insertions(+), 39 deletions(-) delete mode 100644 packages/components/src/components/Resizable/utils.ts diff --git a/packages/components/src/components/Resizable/Resizable.mdx b/packages/components/src/components/Resizable/Resizable.mdx index faede07b..938f06ee 100644 --- a/packages/components/src/components/Resizable/Resizable.mdx +++ b/packages/components/src/components/Resizable/Resizable.mdx @@ -71,6 +71,15 @@ The direction remains physical in right-to-left layouts. Use `minSize` and `maxSize` to constrain either dimension independently. The default minimum is zero, and dimensions without a maximum are unbounded. If a minimum exceeds a maximum, the minimum wins. +## Custom handle + +Handles can be styled without changing their hit area or resize behavior. This example adds a small +indicator outside the right edge and composes the handle with a tooltip. See +[Styling handles](#styling-handles) for available state attributes and [CSS Variables](#css-variables) +for geometry customization. + + + ## Nested panels Resizable components can be nested to build layouts with independently adjustable regions. In this @@ -94,5 +103,18 @@ Set `isDisabled` to remove all handles from the tab order and disable resize int Handles provide positioning, hit areas, cursors, and state attributes, but no visible grip or line. Use `className` and the `data-direction-x`, `data-direction-y`, `data-focus-visible`, `data-resizing`, -and `data-disabled` attributes to provide an application-specific indicator. The hit area can be -adjusted with the `--kbq-resizable-handle-size` custom property on Resizable. +and `data-disabled` attributes to provide an application-specific indicator. + +## CSS Variables + +Use CSS variables to customize the handle geometry without inline styles. + +| Variable | +| ---------------------------------- | +| `--kbq-resizable-handle-width` | +| `--kbq-resizable-handle-height` | +| `--kbq-resizable-handle-top` | +| `--kbq-resizable-handle-right` | +| `--kbq-resizable-handle-bottom` | +| `--kbq-resizable-handle-left` | +| `--kbq-resizable-handle-transform` | diff --git a/packages/components/src/components/Resizable/Resizable.module.css b/packages/components/src/components/Resizable/Resizable.module.css index 807f1a90..a776f35b 100644 --- a/packages/components/src/components/Resizable/Resizable.module.css +++ b/packages/components/src/components/Resizable/Resizable.module.css @@ -1,26 +1,75 @@ .base { - --kbq-resizable-handle-size: var(--kbq-size-l); - position: relative; box-sizing: border-box; } .handle { + --resizable-handle-width: var(--kbq-size-l); + --resizable-handle-height: var(--kbq-size-l); + --resizable-handle-top: auto; + --resizable-handle-right: auto; + --resizable-handle-bottom: auto; + --resizable-handle-left: auto; + --resizable-handle-translate-x: 0; + --resizable-handle-translate-y: 0; + position: absolute; z-index: 1; box-sizing: border-box; + inline-size: var(--kbq-resizable-handle-width, var(--resizable-handle-width)); + block-size: var( + --kbq-resizable-handle-height, + var(--resizable-handle-height) + ); + inset: var(--kbq-resizable-handle-top, var(--resizable-handle-top)) + var(--kbq-resizable-handle-right, var(--resizable-handle-right)) + var(--kbq-resizable-handle-bottom, var(--resizable-handle-bottom)) + var(--kbq-resizable-handle-left, var(--resizable-handle-left)); touch-action: none; background: transparent; + transform: var( + --kbq-resizable-handle-transform, + translate( + var(--resizable-handle-translate-x), + var(--resizable-handle-translate-y) + ) + ); +} + +.handle[data-direction-x='-1'] { + --resizable-handle-left: 0; + --resizable-handle-translate-x: -50%; } .handle[data-direction-x='0'] { + --resizable-handle-width: 100%; + --resizable-handle-left: 0; + cursor: ns-resize; } +.handle[data-direction-x='1'] { + --resizable-handle-right: 0; + --resizable-handle-translate-x: 50%; +} + +.handle[data-direction-y='-1'] { + --resizable-handle-top: 0; + --resizable-handle-translate-y: -50%; +} + .handle[data-direction-y='0'] { + --resizable-handle-height: 100%; + --resizable-handle-top: 0; + cursor: ew-resize; } +.handle[data-direction-y='1'] { + --resizable-handle-bottom: 0; + --resizable-handle-translate-y: 50%; +} + .handle:is( [data-direction-x='-1'][data-direction-y='-1'], [data-direction-x='1'][data-direction-y='1'] diff --git a/packages/components/src/components/Resizable/Resizable.stories.tsx b/packages/components/src/components/Resizable/Resizable.stories.tsx index 25f08fdc..89fe809f 100644 --- a/packages/components/src/components/Resizable/Resizable.stories.tsx +++ b/packages/components/src/components/Resizable/Resizable.stories.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { clsx, useElementSize } from '@koobiq/react-core'; import type { Meta, StoryObj } from '@storybook/react'; +import { Tooltip } from '../Tooltip'; import { Typography } from '../Typography'; import s from './__stories__/styles.module.css'; @@ -112,6 +113,67 @@ export const SingleDirection: Story = { ), }; +export const CustomHandle: Story = { + render: (args) => ( + <> + + + + Hover over the right edge + ( + + )} + > + Resize + + + + ), +}; + export const NestedPanels: Story = { parameters: { layout: 'padded', diff --git a/packages/components/src/components/Resizable/Resizable.test.tsx b/packages/components/src/components/Resizable/Resizable.test.tsx index 257ff8f3..081de3e4 100644 --- a/packages/components/src/components/Resizable/Resizable.test.tsx +++ b/packages/components/src/components/Resizable/Resizable.test.tsx @@ -429,16 +429,28 @@ describe('Resizable', () => { expect(getHandle()).toHaveAttribute('aria-label', 'Изменить высоту'); }); - it('positions handles using physical coordinates', () => { + it('exposes physical direction through data attributes', () => { renderResizable([-1, 1]); - expect(getHandle().style.left).toBe('0px'); - expect(getHandle().style.bottom).toBe('0px'); - expect(getHandle().style.transform).toBe('translate(-50%, 50%)'); + expect(getHandle()).not.toHaveAttribute('style'); expect(getHandle()).toHaveAttribute('data-direction-x', '-1'); expect(getHandle()).toHaveAttribute('data-direction-y', '1'); }); + it('preserves custom handle positioning styles', () => { + render( + + + + ); + + expect(getHandle()).toHaveStyle({ transform: 'translateX(100%)' }); + }); + it('throws when a handle is rendered without Resizable', () => { vi.spyOn(console, 'error').mockImplementation(() => undefined); diff --git a/packages/components/src/components/Resizable/ResizableHandle.tsx b/packages/components/src/components/Resizable/ResizableHandle.tsx index c7c9e0d6..e972cd75 100644 --- a/packages/components/src/components/Resizable/ResizableHandle.tsx +++ b/packages/components/src/components/Resizable/ResizableHandle.tsx @@ -6,7 +6,6 @@ import { isResizableHandleDirection, useResizableHandle } from './hooks'; import s from './Resizable.module.css'; import { useResizableContext } from './ResizableContext'; import type { ResizableHandleBaseProps } from './types'; -import { getHandlePositionStyle } from './utils'; /** A draggable handle that changes the size of its parent Resizable. */ export const ResizableHandle = polymorphicForwardRef< @@ -45,7 +44,7 @@ export const ResizableHandle = polymorphicForwardRef< {...mergeProps(other, handleProps)} ref={ref} className={clsx(s.handle, className)} - style={{ ...styleProp, ...getHandlePositionStyle(direction) }} + style={styleProp} /> ); }); diff --git a/packages/components/src/components/Resizable/utils.ts b/packages/components/src/components/Resizable/utils.ts deleted file mode 100644 index 6cebf48b..00000000 --- a/packages/components/src/components/Resizable/utils.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CSSProperties } from 'react'; - -import type { ResizableHandleDirection } from './hooks'; - -const handleSize = 'var(--kbq-resizable-handle-size)'; - -export const getHandlePositionStyle = ([ - x, - y, -]: ResizableHandleDirection): CSSProperties => { - const style: CSSProperties = { - width: x === 0 ? '100%' : handleSize, - height: y === 0 ? '100%' : handleSize, - }; - - const translateX = x === -1 ? '-50%' : x === 1 ? '50%' : '0'; - const translateY = y === -1 ? '-50%' : y === 1 ? '50%' : '0'; - - if (x === -1) style.left = 0; - if (x === 0) style.left = 0; - if (x === 1) style.right = 0; - if (y === -1) style.top = 0; - if (y === 0) style.top = 0; - if (y === 1) style.bottom = 0; - - style.transform = `translate(${translateX}, ${translateY})`; - - return style; -}; From d24c6cd06ca5e3770258f6213878ff2c969e3117 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Mon, 27 Jul 2026 18:49:15 +0300 Subject: [PATCH 6/6] chore(Resizable): expose handle interaction states --- .../src/components/Resizable/Resizable.mdx | 12 +- .../Resizable/Resizable.stories.tsx | 59 +-------- .../components/Resizable/Resizable.test.tsx | 24 ++++ .../Resizable/__stories__/styles.module.css | 124 +----------------- .../Resizable/hooks/useResizableHandle.ts | 98 +++++++++----- 5 files changed, 98 insertions(+), 219 deletions(-) diff --git a/packages/components/src/components/Resizable/Resizable.mdx b/packages/components/src/components/Resizable/Resizable.mdx index 938f06ee..d101af59 100644 --- a/packages/components/src/components/Resizable/Resizable.mdx +++ b/packages/components/src/components/Resizable/Resizable.mdx @@ -80,13 +80,6 @@ for geometry customization. -## Nested panels - -Resizable components can be nested to build layouts with independently adjustable regions. In this -example, the outer handle changes the sidebar width and the inner handle changes the filters height. - - - ## Keyboard interaction Handles are focusable. Use the arrow keys to resize by one pixel, or hold Shift to resize @@ -102,8 +95,9 @@ Set `isDisabled` to remove all handles from the tab order and disable resize int ## Styling handles Handles provide positioning, hit areas, cursors, and state attributes, but no visible grip or line. -Use `className` and the `data-direction-x`, `data-direction-y`, `data-focus-visible`, `data-resizing`, -and `data-disabled` attributes to provide an application-specific indicator. +Use `className` and the `data-direction-x`, `data-direction-y`, `data-hovered`, `data-focused`, +`data-focus-visible`, `data-resizing`, and `data-disabled` attributes to provide an +application-specific indicator. ## CSS Variables diff --git a/packages/components/src/components/Resizable/Resizable.stories.tsx b/packages/components/src/components/Resizable/Resizable.stories.tsx index 89fe809f..ffd047e6 100644 --- a/packages/components/src/components/Resizable/Resizable.stories.tsx +++ b/packages/components/src/components/Resizable/Resizable.stories.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { clsx, useElementSize } from '@koobiq/react-core'; +import { clsx } from '@koobiq/react-core'; import type { Meta, StoryObj } from '@storybook/react'; import { Tooltip } from '../Tooltip'; @@ -139,7 +139,7 @@ export const CustomHandle: Story = { } .custom-resizable-handle:is( - :hover, + [data-hovered], [data-focus-visible], [data-resizing] )::after { @@ -174,61 +174,6 @@ export const CustomHandle: Story = { ), }; -export const NestedPanels: Story = { - parameters: { - layout: 'padded', - }, - render: function Render(args) { - const { - ref: workspaceRef, - width: workspaceWidth, - height: workspaceHeight, - } = useElementSize({ box: 'border-box' }); - - return ( -
-
- Content -
- - - - - -
- Filters -
- -
- -
- Data -
-
-
- ); - }, -}; - export const Disabled: Story = { render: (args) => ( { const onResize = vi.fn(); renderResizable([1, 0], { isDisabled: true, onResize }); + fireEvent.mouseEnter(getHandle()); + fireEvent.focus(getHandle()); fireEvent.keyDown(getHandle(), { key: 'ArrowRight' }); drag(20, 0); @@ -371,6 +374,25 @@ describe('Resizable', () => { expect(getHandle()).toHaveAttribute('data-disabled', 'true'); expect(getHandle()).toHaveAttribute('aria-disabled', 'true'); expect(getHandle()).toHaveAttribute('tabindex', '-1'); + expect(getHandle()).not.toHaveAttribute('data-hovered'); + expect(getHandle()).not.toHaveAttribute('data-focused'); + }); + + it('exposes hover and focus states', async () => { + const user = userEvent.setup(); + + renderResizable([1, 0]); + + await user.hover(getHandle()); + expect(getHandle()).toHaveAttribute('data-hovered', 'true'); + + await user.tab(); + expect(getHandle()).toHaveAttribute('data-focused', 'true'); + + await user.unhover(getHandle()); + await user.tab(); + expect(getHandle()).not.toHaveAttribute('data-hovered'); + expect(getHandle()).not.toHaveAttribute('data-focused'); }); it('exposes separator semantics for edge handles', () => { @@ -386,6 +408,7 @@ describe('Resizable', () => { expect(getHandle()).toHaveAttribute('aria-valuemax', '500'); expect(getHandle()).toHaveAttribute('aria-controls', getRoot().id); expect(getHandle()).toHaveAttribute('aria-label', 'Resize width'); + expect(getHandle()).not.toHaveAttribute('aria-keyshortcuts'); }); it('exposes button semantics for corner handles and allows a custom label', () => { @@ -411,6 +434,7 @@ describe('Resizable', () => { ); expect(getHandle()).not.toHaveAttribute('aria-valuenow'); + expect(getHandle()).not.toHaveAttribute('aria-orientation'); expect(getHandle()).toHaveAttribute('aria-controls', getRoot().id); }); diff --git a/packages/components/src/components/Resizable/__stories__/styles.module.css b/packages/components/src/components/Resizable/__stories__/styles.module.css index 5008a8b1..1a294530 100644 --- a/packages/components/src/components/Resizable/__stories__/styles.module.css +++ b/packages/components/src/components/Resizable/__stories__/styles.module.css @@ -24,128 +24,6 @@ opacity var(--kbq-transition-slow); } -.handle:is(:hover, [data-focus-visible], [data-resizing]) { +.handle:is([data-hovered], [data-focus-visible], [data-resizing]) { background-color: var(--kbq-states-background-theme-active); } - -.layout { - min-inline-size: 640px; - min-block-size: 480px; - padding: var(--kbq-size-3xl); -} - -.workspace { - position: relative; - display: flex; - box-sizing: border-box; - inline-size: 100%; - block-size: 520px; - overflow: hidden; - background-color: var(--kbq-background-card); -} - -.workspace::after { - position: absolute; - z-index: 1; - inset: 0; - border: 1px solid var(--kbq-line-contrast-less); - content: ''; - pointer-events: none; -} - -.workspaceSection { - display: flex; - box-sizing: border-box; - min-inline-size: 0; - min-block-size: 0; - overflow: hidden; - align-items: center; - justify-content: center; - text-transform: uppercase; -} - -.workspaceContent { - flex: 1; -} - -.workspaceSidebar { - flex: none; - display: flex; - inline-size: 50%; - block-size: 100%; - min-inline-size: 0; - min-block-size: 100%; - max-block-size: 100%; - flex-direction: column; - background-color: var(--kbq-background-card); -} - -.workspaceFilters { - flex: none; - inline-size: 100%; - block-size: 50%; - min-inline-size: 100%; - max-inline-size: 100%; - overflow: visible; -} - -.workspaceFiltersContent { - display: flex; - inline-size: 100%; - block-size: 100%; - min-block-size: 0; - overflow: hidden; - align-items: center; - justify-content: center; -} - -.workspaceData { - flex: 1; - min-block-size: 0; -} - -.panelHandle::after { - position: absolute; - background-color: var(--kbq-line-contrast-less); - content: ''; - pointer-events: none; - transition: background-color var(--kbq-transition-slow); -} - -.panelHandle { - outline: none; -} - -.panelHandle[data-direction-y='0']::after { - inset-block: 0; - inset-inline-start: 50%; - inline-size: 1px; - transform: translateX(-50%); -} - -.panelHandle[data-direction-x='0']::after { - inset-block-start: 50%; - inset-inline: 0; - block-size: 1px; - transform: translateY(-50%); -} - -.panelHandle[data-direction-y='0']:is( - :hover, - [data-focus-visible], - [data-resizing] - )::after { - inline-size: 2px; - min-inline-size: 2px; - background-color: var(--kbq-background-theme); -} - -.panelHandle[data-direction-x='0']:is( - :hover, - [data-focus-visible], - [data-resizing] - )::after { - block-size: 2px; - min-block-size: 2px; - background-color: var(--kbq-background-theme); -} diff --git a/packages/components/src/components/Resizable/hooks/useResizableHandle.ts b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts index cae746e8..edbba249 100644 --- a/packages/components/src/components/Resizable/hooks/useResizableHandle.ts +++ b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts @@ -1,8 +1,10 @@ 'use client'; import { + type DOMAttributes, mergeProps, useFocusRing, + useHover, useLocalizedStringFormatter, useMove, } from '@koobiq/react-core'; @@ -12,7 +14,9 @@ import type { ResizableHandleDirection } from './types'; import type { ResizableContextValue } from './useResizable'; import { getDirectionKey } from './utils'; -const UNBOUNDED_ARIA_MAX = Number.MAX_SAFE_INTEGER; +type ResizableHandleDOMProps = DOMAttributes & { + [key: `data-${string}`]: string | number | boolean | undefined; +}; export type UseResizableHandleProps = { direction: ResizableHandleDirection; @@ -51,49 +55,83 @@ export const useResizableHandle = ( onMoveEnd, }); - const { focusProps, isFocusVisible } = useFocusRing({ + const { focusProps, isFocused, isFocusVisible } = useFocusRing({ isTextInput: false, autoFocus: false, }); - const defaultLabel = isWidthHandle - ? t.format('resize width') - : isHeightHandle - ? t.format('resize height') - : t.format('resize'); + const { hoverProps, isHovered } = useHover({ isDisabled }); + + let defaultLabel = t.format('resize'); + + if (isWidthHandle) { + defaultLabel = t.format('resize width'); + } else if (isHeightHandle) { + defaultLabel = t.format('resize height'); + } + + let tabIndex = tabIndexProp ?? 0; - const value = isWidthHandle ? size.width : size.height; - const minValue = isWidthHandle ? bounds.minWidth : bounds.minHeight; - const maxValue = isWidthHandle ? bounds.maxWidth : bounds.maxHeight; + if (isDisabled) { + tabIndex = -1; + } - const accessibilityProps = { - role: isCornerHandle ? ('button' as const) : ('separator' as const), - tabIndex: isDisabled ? -1 : (tabIndexProp ?? 0), + const accessibilityProps: ResizableHandleDOMProps = { + tabIndex, 'aria-label': ariaLabel ?? defaultLabel, 'aria-controls': rootId, 'aria-disabled': isDisabled || undefined, - 'aria-keyshortcuts': 'ArrowUp ArrowDown ArrowLeft ArrowRight', - 'aria-orientation': isCornerHandle - ? undefined - : isWidthHandle - ? ('vertical' as const) - : ('horizontal' as const), - 'aria-valuenow': isCornerHandle ? undefined : Math.round(value), - 'aria-valuemin': isCornerHandle ? undefined : Math.round(minValue), - 'aria-valuemax': isCornerHandle - ? undefined - : Math.round(Number.isFinite(maxValue) ? maxValue : UNBOUNDED_ARIA_MAX), - 'aria-valuetext': isCornerHandle ? undefined : `${Math.round(value)} px`, 'data-direction-x': x, 'data-direction-y': y, + 'data-hovered': isHovered || undefined, + 'data-focused': isFocused || undefined, 'data-focus-visible': isFocusVisible || undefined, 'data-resizing': isResizing || undefined, 'data-disabled': isDisabled || undefined, }; - return { - handleProps: isDisabled - ? accessibilityProps - : mergeProps(moveProps, focusProps, accessibilityProps), - }; + if (isCornerHandle) { + accessibilityProps.role = 'button'; + + accessibilityProps['aria-keyshortcuts'] = + 'ArrowUp ArrowDown ArrowLeft ArrowRight'; + } else { + let value = size.height; + let minValue = bounds.minHeight; + let maxValue = bounds.maxHeight; + let orientation: 'horizontal' | 'vertical' = 'horizontal'; + + if (isWidthHandle) { + value = size.width; + minValue = bounds.minWidth; + maxValue = bounds.maxWidth; + orientation = 'vertical'; + } + + let ariaMaxValue = Number.MAX_SAFE_INTEGER; + + if (Number.isFinite(maxValue)) { + ariaMaxValue = maxValue; + } + + accessibilityProps.role = 'separator'; + accessibilityProps['aria-orientation'] = orientation; + accessibilityProps['aria-valuenow'] = Math.round(value); + accessibilityProps['aria-valuemin'] = Math.round(minValue); + accessibilityProps['aria-valuemax'] = Math.round(ariaMaxValue); + accessibilityProps['aria-valuetext'] = `${Math.round(value)} px`; + } + + let handleProps = accessibilityProps; + + if (!isDisabled) { + handleProps = mergeProps( + moveProps, + hoverProps, + focusProps, + accessibilityProps + ); + } + + return { handleProps }; };