diff --git a/packages/components/src/components/Resizable/Resizable.mdx b/packages/components/src/components/Resizable/Resizable.mdx new file mode 100644 index 00000000..d101af59 --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.mdx @@ -0,0 +1,114 @@ +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 + + + +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 + + + +## 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. + +## 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. + + + +## 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-hovered`, `data-focused`, +`data-focus-visible`, `data-resizing`, 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 new file mode 100644 index 00000000..a776f35b --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.module.css @@ -0,0 +1,89 @@ +.base { + 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'] +) { + 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..ffd047e6 --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.stories.tsx @@ -0,0 +1,189 @@ +import { useState } from 'react'; + +import { clsx } 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'; +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 CustomHandle: Story = { + render: (args) => ( + <> + + + + Hover over the right edge + ( + + )} + > + Resize + + + + ), +}; + +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..0abfbc76 --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.test.tsx @@ -0,0 +1,499 @@ +import { createRef, type ComponentProps } from 'react'; + +import type * as ReactCore from '@koobiq/react-core'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +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('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) => { + 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.mouseEnter(getHandle()); + fireEvent.focus(getHandle()); + 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'); + 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', () => { + 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'); + expect(getHandle()).not.toHaveAttribute('aria-keyshortcuts'); + }); + + 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()).not.toHaveAttribute('aria-orientation'); + expect(getHandle()).toHaveAttribute('aria-controls', getRoot().id); + }); + + it('localizes default accessible labels', () => { + render( + + + + + + ); + + expect(getHandle()).toHaveAttribute('aria-label', 'Изменить высоту'); + }); + + it('exposes physical direction through data attributes', () => { + renderResizable([-1, 1]); + + 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); + + 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..92d8601a --- /dev/null +++ b/packages/components/src/components/Resizable/Resizable.tsx @@ -0,0 +1,81 @@ +'use client'; + +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 { ResizableBaseProps } from './types'; + +const ResizableComponent = polymorphicForwardRef<'div', ResizableBaseProps>( + (props, forwardedRef) => { + const { + as: Tag = 'div', + size: sizeProp, + defaultSize: defaultSizeProp, + minSize, + maxSize, + isDisabled = false, + onResize, + onResizeStart, + onResizeEnd, + id, + className, + style: styleProp, + children, + ...other + } = props; + + const state = useResizableState({ + size: sizeProp, + defaultSize: defaultSizeProp, + minSize, + maxSize, + isDisabled, + onResize, + onResizeStart, + onResizeEnd, + }); + + const { + resizableProps: { + ref: targetRef, + style: resizableStyle, + ...behaviorProps + }, + contextValue, + } = useResizable( + { + id, + minSize, + }, + state + ); + + 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..bffee7a6 --- /dev/null +++ b/packages/components/src/components/Resizable/ResizableContext.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from 'react'; + +import type { ResizableContextValue } from './hooks'; + +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..e972cd75 --- /dev/null +++ b/packages/components/src/components/Resizable/ResizableHandle.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { clsx, mergeProps, polymorphicForwardRef } from '@koobiq/react-core'; + +import { isResizableHandleDirection, useResizableHandle } from './hooks'; +import s from './Resizable.module.css'; +import { useResizableContext } from './ResizableContext'; +import type { ResizableHandleBaseProps } from './types'; + +/** A draggable handle that changes the size of its parent Resizable. */ +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/__stories__/styles.module.css b/packages/components/src/components/Resizable/__stories__/styles.module.css new file mode 100644 index 00000000..1a294530 --- /dev/null +++ b/packages/components/src/components/Resizable/__stories__/styles.module.css @@ -0,0 +1,29 @@ +.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([data-hovered], [data-focus-visible], [data-resizing]) { + background-color: var(--kbq-states-background-theme-active); +} 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..36d47359 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/index.ts @@ -0,0 +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/hooks/intl.json b/packages/components/src/components/Resizable/hooks/intl.json new file mode 100644 index 00000000..db8e36c0 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/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/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 new file mode 100644 index 00000000..c3adc7e5 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/useResizable.ts @@ -0,0 +1,116 @@ +'use client'; + +import type { CSSProperties } from 'react'; +import { useCallback, useId, useMemo } from 'react'; + +import { useElementSize } from '@koobiq/react-core'; + +import type { + ResizableHandleDirection, + ResizableMoveEvent, + ResizableSizeConstraints, +} from './types'; +import type { ResizableState } from './useResizableState'; +import { clampResizableSize } from './utils'; + +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 = { + id?: string; + minSize?: ResizableSizeConstraints; +}; + +/** 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..edbba249 --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/useResizableHandle.ts @@ -0,0 +1,137 @@ +'use client'; + +import { + type DOMAttributes, + mergeProps, + useFocusRing, + useHover, + useLocalizedStringFormatter, + useMove, +} from '@koobiq/react-core'; + +import intlMessages from './intl.json'; +import type { ResizableHandleDirection } from './types'; +import type { ResizableContextValue } from './useResizable'; +import { getDirectionKey } from './utils'; + +type ResizableHandleDOMProps = DOMAttributes & { + [key: `data-${string}`]: string | number | boolean | undefined; +}; + +export type UseResizableHandleProps = { + direction: ResizableHandleDirection; + 'aria-label'?: string; + tabIndex?: number; +}; + +/** 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, isFocused, isFocusVisible } = useFocusRing({ + isTextInput: false, + autoFocus: false, + }); + + 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; + + if (isDisabled) { + tabIndex = -1; + } + + const accessibilityProps: ResizableHandleDOMProps = { + tabIndex, + 'aria-label': ariaLabel ?? defaultLabel, + 'aria-controls': rootId, + 'aria-disabled': isDisabled || undefined, + '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, + }; + + 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 }; +}; 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..37de259a --- /dev/null +++ b/packages/components/src/components/Resizable/hooks/useResizableState.ts @@ -0,0 +1,155 @@ +'use client'; + +import { useCallback, useMemo, useRef, useState } from 'react'; + +import { useControlledState } from '@koobiq/react-core'; + +import type { + ResizableHandleDirection, + ResizableMoveEvent, + ResizableSize, + ResizableSizeConstraints, +} from './types'; +import { + clampResizableSize, + getDirectionKey, + getResizableBounds, + normalizeResizableSize, +} from './utils'; + +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 = { + 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 = ( + 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, + ] + ); +}; 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/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/types.ts b/packages/components/src/components/Resizable/types.ts new file mode 100644 index 00000000..356a8f61 --- /dev/null +++ b/packages/components/src/components/Resizable/types.ts @@ -0,0 +1,78 @@ +import type { + ComponentPropsWithRef, + CSSProperties, + ElementType, + ReactNode, +} from 'react'; + +import type { AsProps } from '@koobiq/react-core'; + +import type { + ResizableHandleDirection, + ResizableSize, + ResizableSizeConstraints, +} from './hooks'; + +export { resizableHandleDirections } from './hooks'; +export type { + ResizableHandleDirection, + ResizableSize, + ResizableSizeConstraints, +} from './hooks'; + +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. */ + 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; + /** 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 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/index.ts b/packages/components/src/components/index.ts index 9837688b..81d735bc 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -56,6 +56,7 @@ export * from './TreeSelect'; export * from './EmptyState'; export * from './Flag'; export * from './Sidebar'; +export * from './Resizable'; export * from './layout'; export { useListData, diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 205f5c09..8484bfc6 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -38,6 +38,7 @@ "ProgressSpinner", "Provider", "RadioGroup", + "Resizable", "SearchInput", "Select", "Sidebar", 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..97edbb65 --- /dev/null +++ b/tools/public_api_guard/components/Resizable.api.md @@ -0,0 +1,68 @@ +## 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 { AsProps } from '@koobiq/react-core'; +import type { ComponentPropsWithRef } 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 +// +// @public +export const Resizable: CompoundedComponent; + +// @public (undocumented) +export type ResizableBaseProps = { + size?: ResizableSize; + defaultSize?: ResizableSize; + minSize?: ResizableSizeConstraints; + maxSize?: ResizableSizeConstraints; + isDisabled?: boolean; + 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; + height: number; +}; + +// @public (undocumented) +export type ResizableSizeConstraints = Partial; + +// (No @packageDocumentation comment for this package) + +```