From 43aed96bb33270c9a1e0b4510be5a6c4ae46480f Mon Sep 17 00:00:00 2001 From: devadula-nandan Date: Mon, 28 Apr 2025 16:00:39 +0530 Subject: [PATCH 1/7] chore: resize bar initial commit --- examples/react/ResizeBar/index.html | 22 + examples/react/ResizeBar/package.json | 26 ++ examples/react/ResizeBar/src/App.tsx | 17 + examples/react/ResizeBar/src/index.scss | 9 + examples/react/ResizeBar/src/main.tsx | 19 + examples/react/ResizeBar/src/vite-env.d.ts | 10 + examples/react/ResizeBar/tsconfig.json | 25 ++ examples/react/ResizeBar/tsconfig.node.json | 10 + examples/react/ResizeBar/vite.config.ts | 16 + .../react/src/components/ResizeBar/README.md | 34 ++ .../ResizeBar/__stories__/ResizeBar.mdx | 64 +++ .../__stories__/ResizeBar.stories.js | 239 +++++++++++ .../ResizeBar/__tests__/ResizeBar.test.js | 23 ++ .../ResizeBar/components/ResizeBar.tsx | 390 ++++++++++++++++++ .../ResizeBar/components/resize-bar.scss | 66 +++ .../react/src/components/ResizeBar/index.ts | 9 + .../src/components/ResizeBar/package.json | 27 ++ .../src/components/ResizeBar/tsconfig.json | 8 + .../__stories__/SplitPanel.stories.js | 11 +- 19 files changed, 1015 insertions(+), 10 deletions(-) create mode 100644 examples/react/ResizeBar/index.html create mode 100644 examples/react/ResizeBar/package.json create mode 100644 examples/react/ResizeBar/src/App.tsx create mode 100644 examples/react/ResizeBar/src/index.scss create mode 100644 examples/react/ResizeBar/src/main.tsx create mode 100644 examples/react/ResizeBar/src/vite-env.d.ts create mode 100644 examples/react/ResizeBar/tsconfig.json create mode 100644 examples/react/ResizeBar/tsconfig.node.json create mode 100644 examples/react/ResizeBar/vite.config.ts create mode 100644 packages/react/src/components/ResizeBar/README.md create mode 100644 packages/react/src/components/ResizeBar/__stories__/ResizeBar.mdx create mode 100644 packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js create mode 100644 packages/react/src/components/ResizeBar/__tests__/ResizeBar.test.js create mode 100644 packages/react/src/components/ResizeBar/components/ResizeBar.tsx create mode 100644 packages/react/src/components/ResizeBar/components/resize-bar.scss create mode 100644 packages/react/src/components/ResizeBar/index.ts create mode 100644 packages/react/src/components/ResizeBar/package.json create mode 100644 packages/react/src/components/ResizeBar/tsconfig.json diff --git a/examples/react/ResizeBar/index.html b/examples/react/ResizeBar/index.html new file mode 100644 index 000000000..7bcd6db30 --- /dev/null +++ b/examples/react/ResizeBar/index.html @@ -0,0 +1,22 @@ + + + + + + + + + @carbon-labs/react-resize-bar stackblitz + + +
+ + + diff --git a/examples/react/ResizeBar/package.json b/examples/react/ResizeBar/package.json new file mode 100644 index 000000000..521dc3cd3 --- /dev/null +++ b/examples/react/ResizeBar/package.json @@ -0,0 +1,26 @@ +{ + "name": "carbon-labs-react-resize-bar-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@carbon/react": "^1.78.2", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@carbon-labs/react-resize-bar": "latest", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "sass": "~1.83.0", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} diff --git a/examples/react/ResizeBar/src/App.tsx b/examples/react/ResizeBar/src/App.tsx new file mode 100644 index 000000000..7e81cce42 --- /dev/null +++ b/examples/react/ResizeBar/src/App.tsx @@ -0,0 +1,17 @@ +/** + * @license + * + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { ResizeBar } from '@carbon-labs/react-resize-bar/es/index'; + +function App() { + return ; +} + +export default App; diff --git a/examples/react/ResizeBar/src/index.scss b/examples/react/ResizeBar/src/index.scss new file mode 100644 index 000000000..ad2b3bcbf --- /dev/null +++ b/examples/react/ResizeBar/src/index.scss @@ -0,0 +1,9 @@ +/** + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +@use '@carbon/react'; +@use '@carbon-labs/react-resize-bar/scss/resize-bar'; diff --git a/examples/react/ResizeBar/src/main.tsx b/examples/react/ResizeBar/src/main.tsx new file mode 100644 index 000000000..e52d23cb8 --- /dev/null +++ b/examples/react/ResizeBar/src/main.tsx @@ -0,0 +1,19 @@ +/** + * @license + * + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.tsx'; +import './index.scss'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/examples/react/ResizeBar/src/vite-env.d.ts b/examples/react/ResizeBar/src/vite-env.d.ts new file mode 100644 index 000000000..d246f8157 --- /dev/null +++ b/examples/react/ResizeBar/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/** + * @license + * + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +/// diff --git a/examples/react/ResizeBar/tsconfig.json b/examples/react/ResizeBar/tsconfig.json new file mode 100644 index 000000000..a7fc6fbf2 --- /dev/null +++ b/examples/react/ResizeBar/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/examples/react/ResizeBar/tsconfig.node.json b/examples/react/ResizeBar/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/examples/react/ResizeBar/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/examples/react/ResizeBar/vite.config.ts b/examples/react/ResizeBar/vite.config.ts new file mode 100644 index 000000000..0188763d2 --- /dev/null +++ b/examples/react/ResizeBar/vite.config.ts @@ -0,0 +1,16 @@ +/** + * @license + * + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}); diff --git a/packages/react/src/components/ResizeBar/README.md b/packages/react/src/components/ResizeBar/README.md new file mode 100644 index 000000000..1100f7289 --- /dev/null +++ b/packages/react/src/components/ResizeBar/README.md @@ -0,0 +1,34 @@ +

+ + Carbon Design System + +

+

+ Carbon Labs +

+ +> A community-driven incubation space enabling rapid prototyping, development, +> and deployment of Carbon-based components. + +## Getting started + +Install `@carbon-labs/ResizeBar` to use this package. + +### Storybook + +You can view the current state of the +[component](https://labs.carbondesignsystem.com/react/). + +## 📝 License + +Licensed under the +[Apache 2.0 License](https://github.com/carbon-design-system/carbon-labs/blob/main/LICENSE). + +## IBM Telemetry IBM Telemetry + +This package uses IBM Telemetry to collect de-identified and anonymized metrics +data. By installing this package as a dependency you are agreeing to telemetry +collection. To opt out, see +[Opting out of IBM Telemetry data collection](https://github.com/ibm-telemetry/telemetry-js/tree/main#opting-out-of-ibm-telemetry-data-collection). +For more information on the data being collected, please see the +[IBM Telemetry documentation](https://github.com/ibm-telemetry/telemetry-js/tree/main#ibm-telemetry-collection-basics). diff --git a/packages/react/src/components/ResizeBar/__stories__/ResizeBar.mdx b/packages/react/src/components/ResizeBar/__stories__/ResizeBar.mdx new file mode 100644 index 000000000..4b1321039 --- /dev/null +++ b/packages/react/src/components/ResizeBar/__stories__/ResizeBar.mdx @@ -0,0 +1,64 @@ +import { ArgTypes, Canvas, Meta } from '@storybook/blocks'; +import * as ResizeBarStories from './ResizeBar.stories'; + + + +# ResizeBar + +- **Initiative owner(s):** Nandan Devadula +- **Status:** Draft +- **Target library:** TBD +- **Target library maintainer(s) / PR Reviewer(s):** N/A +- **Support channel:** `#carbon-labs` + +{/* */} +{/* */} + +> 💡 Check our +> [Stackblitz](https://stackblitz.com/github/carbon-design-system/carbon-labs/tree/main/examples/react/ResizeBar) +> example implementation. + +[![Edit carbon-labs](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/carbon-design-system/carbon-labs/tree/main/examples/react/ResizeBar) + +## Table of Contents + +- [Overview](#overview) +- [Getting started](#getting-started) + +{/* */} + +## Overview + +`ResizeBar` is an atomic component extracted from `SplitPanel` and made into an independent component. which can be reused where ever there is a resizing requirement in the ui. + + + +## Getting started + +Here's a quick example to get you started. + +```bash +yarn add @carbon/react +yarn add @carbon-labs/react-resize-bar +``` + +### JS (via import) + +```javascript +import { ResizeBar } from '@carbon-labs/react-resize-bar/es/index'; + +function App() { + return ; +} + +export default App; +``` + +### SCSS + +In your styles file import + +``` +@use '@carbon/react'; +@use '@carbon-labs/react-resize-bar/scss/resize-bar'; +``` diff --git a/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js b/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js new file mode 100644 index 000000000..aa3f72523 --- /dev/null +++ b/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js @@ -0,0 +1,239 @@ +/** + * @license + * + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import mdx from './ResizeBar.mdx'; +import { ResizeBar } from '../components/ResizeBar'; +import '../components/resize-bar.scss'; + +export default { + title: 'Components/ResizeBar', + component: ResizeBar, + parameters: { + docs: { + page: mdx, + }, + }, +}; + +/** + * Default story for ResizeBar + */ +// export const Default = () => ; +export const SinglePanelNoBoundaries = () => ( + <> + +
+
+

+ Single Panel (no boundaries) +

+

+ This is a basic resizable panel that can be adjusted vertically using + the resize handle below. The panel takes height according to the content, but can also be pre set. +

+
+ +
+ +); + +export const SinglePanelBounded = () => ( + <> + +
+
+
+

Single Panel (bounded)

+

+ This panel demonstrates how resizing can be constrained within fixed + boundaries. The panel is contained within a 600x400 pixel container, + ensuring that the resizing behavior remains within these defined + limits. +

+
+ +
+
+ +); + +export const SinglePanelOverlay = () => ( + <> + +
+
+

Main Content

+

+ This is the main content area that remains fixed in the background. It + demonstrates how content can be organized in layers, with the overlay + panel providing additional context or controls when needed. +

+
+
+ +
+

Overlay Panel

+

+ This sliding panel overlays the main content and can be resized from + the top edge. It's useful for displaying additional information or + controls while maintaining access to the main content above. +

+
+
+
+ +); + +export const TwoPanelsHorizontal = () => ( + <> + +
+
+

Top Panel

+

+ The top panel in this vertically stacked layout can be adjusted using + the horizontal resize handle below. This arrangement is particularly + useful for interfaces that need to display different levels of + information, such as a preview area above and details below. +

+
+ +
+

Bottom Panel

+

+ The bottom panel adapts its size in response to the top panel's + resizing, maintaining a fluid and responsive layout. This setup works + well for scenarios like log viewers, console outputs, or supplementary + information displays. +

+
+
+ +); + +export const TwoPanelsVertical = () => ( + <> + +
+
+

Left Panel

+

+ This panel forms the left section of a two-panel layout. The vertical + resize handle between panels allows for horizontal adjustment, making + it perfect for side-by-side content organization like navigation menus + and main content areas. +

+
+ +
+

Right Panel

+

+ The right panel complements the left panel, creating a flexible + workspace. This arrangement is ideal for applications requiring + concurrent view of related content, such as code editors with preview + panes or document comparison tools. +

+
+
+ +); \ No newline at end of file diff --git a/packages/react/src/components/ResizeBar/__tests__/ResizeBar.test.js b/packages/react/src/components/ResizeBar/__tests__/ResizeBar.test.js new file mode 100644 index 000000000..24f1c310f --- /dev/null +++ b/packages/react/src/components/ResizeBar/__tests__/ResizeBar.test.js @@ -0,0 +1,23 @@ +/** + * @license + * + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; + +import { ResizeBar } from '../components/ResizeBar'; +jest.mock('./resize-bar.scss', () => ({})); +describe('ResizeBar', () => { + describe('renders as expected - Component API', () => { + it('should match snapshot', () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/react/src/components/ResizeBar/components/ResizeBar.tsx b/packages/react/src/components/ResizeBar/components/ResizeBar.tsx new file mode 100644 index 000000000..af4485d90 --- /dev/null +++ b/packages/react/src/components/ResizeBar/components/ResizeBar.tsx @@ -0,0 +1,390 @@ +/** + * @license + * + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +import React, { useRef, useEffect } from 'react'; +import { usePrefix } from '@carbon-labs/utilities/es/index.js'; +import cx from 'classnames'; + +/** Primary UI component for user interaction */ + +interface ResizeBarProps { + orientation: 'horizontal' | 'vertical'; + + /** + * Mode for announcing. + * + * - "pixels" enables announcing in pixel values. + * - "percentage" enables announcing in percentage values. + * - "none" disables announcing mode. + * + * Note: Percentages will not work for single panels as there is no comparison value to derive the percentage. + */ + mode?: 'pixels' | 'percentage' | 'none'; + + onResize?: (delta: number, isKeyboardEvent: boolean) => void; + onResizeEnd?: () => void; + onDoubleClick?: () => string | void; + + // Any other additional props + [key: string]: any; +} + +export const ResizeBar = ({ + orientation, + mode = 'pixels', + onResize, + onResizeEnd, + onDoubleClick, + ...rest +}: ResizeBarProps) => { + const prefix = usePrefix(); + const blockClass = `${prefix}--resize-bar`; + + const ref = useRef(null); + const isResizing = useRef(false); + const startPos = useRef({ x: 0, y: 0 }); + const currentSizes = useRef({ + prev: { width: 0, height: 0 }, + next: { width: 0, height: 0 }, + }); + const initialSizes = useRef({ + prev: { width: 0, height: 0 }, + next: { width: 0, height: 0 }, + }); + + useEffect(() => { + if (!ref.current) { + return; + } + + const prev = ref.current.previousElementSibling as HTMLElement; + const next = ref.current.nextElementSibling as HTMLElement; + const rect = (el: Element) => el?.getBoundingClientRect(); + + initialSizes.current = { + prev: prev + ? { width: rect(prev).width, height: rect(prev).height } + : { width: 0, height: 0 }, + next: next + ? { width: rect(next).width, height: rect(next).height } + : { width: 0, height: 0 }, + }; + }, []); + + // a11y effect + useEffect(() => { + if (!ref.current || mode === 'none' || onDoubleClick) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + const prev = ref.current?.previousElementSibling as HTMLElement; + const next = ref.current?.nextElementSibling as HTMLElement; + const prop = orientation === 'horizontal' ? 'height' : 'width'; + let message = ''; + + if (mode === 'pixels') { + if (prev) { + const size = Math.round(prev.getBoundingClientRect()[prop]); + message = `${ + orientation === 'horizontal' ? 'Top' : 'Left' + } panel: ${size}px`; + } + if (next) { + const size = Math.round(next.getBoundingClientRect()[prop]); + if (prev) { + message += `, ${ + orientation === 'horizontal' ? 'Bottom' : 'Right' + } panel: ${size}px`; + } else { + message = `${ + orientation === 'horizontal' ? 'Bottom' : 'Right' + } panel: ${size}px`; + } + } + } else if (mode === 'percentage') { + const container = prev?.parentElement || next?.parentElement; + if (!container) { + return; + } + + const totalSize = container.getBoundingClientRect()[prop]; + + if (prev) { + const percentage = Math.round( + (prev.getBoundingClientRect()[prop] / totalSize) * 100 + ); + message = `${ + orientation === 'horizontal' ? 'Top' : 'Left' + } panel: ${percentage}%`; + } + if (next) { + const percentage = Math.round( + (next.getBoundingClientRect()[prop] / totalSize) * 100 + ); + if (prev) { + message += `, ${ + orientation === 'horizontal' ? 'Bottom' : 'Right' + } panel: ${percentage}%`; + } else { + message = `${ + orientation === 'horizontal' ? 'Bottom' : 'Right' + } panel: ${percentage}%`; + } + } + } + + ref.current?.setAttribute('aria-label', message); + }); + + const prev = ref.current.previousElementSibling as HTMLElement; + const next = ref.current.nextElementSibling as HTMLElement; + + prev && resizeObserver.observe(prev); + next && resizeObserver.observe(next); + + return () => { + resizeObserver?.disconnect(); + }; + }, [orientation, mode, onDoubleClick]); + + const updateSizes = (delta: number, isKeyboardEvent: boolean) => { + if (!ref.current) { + return; + } + + if (onResize) { + onResize(delta, isKeyboardEvent); + return; + } + + const prev = ref.current.previousElementSibling as HTMLElement; + const next = ref.current.nextElementSibling as HTMLElement; + const prop = orientation === 'horizontal' ? 'height' : 'width'; + + if (prev) { + const newSize = currentSizes.current.prev[prop] + delta; + prev.style[prop] = `${newSize}px`; + } + if (next) { + const newSize = currentSizes.current.next[prop] - delta; + next.style[prop] = `${newSize}px`; + } + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing.current) { + return; + } + e.preventDefault(); + + const delta = + orientation === 'horizontal' + ? e.clientY - startPos.current.y + : e.clientX - startPos.current.x; + updateSizes(delta); + }; + + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault(); + if (!ref.current) { + return; + } + + const prev = ref.current.previousElementSibling as HTMLElement; + const next = ref.current.nextElementSibling as HTMLElement; + const rect = (el: Element) => el?.getBoundingClientRect(); + prev && (prev.style.transition = 'none'); + next && (next.style.transition = 'none'); + + isResizing.current = true; + startPos.current = { x: e.clientX, y: e.clientY }; + currentSizes.current = { + prev: prev + ? { width: rect(prev).width, height: rect(prev).height } + : { width: 0, height: 0 }, + next: next + ? { width: rect(next).width, height: rect(next).height } + : { width: 0, height: 0 }, + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + }; + + const handleMouseUp = () => { + isResizing.current = false; + if (onResizeEnd) { + onResizeEnd(); + } + const prev = ref.current.previousElementSibling as HTMLElement; + const next = ref.current.nextElementSibling as HTMLElement; + if (prev) { + prev.style.transition = ''; + } + if (next) { + next.style.transition = ''; + } + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + ![ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Home', + 'End', + ].includes(e.key) + ) + return; + e.preventDefault(); + + if (!ref.current) { + return; + } + const prev = ref.current.previousElementSibling as HTMLElement; + const next = ref.current.nextElementSibling as HTMLElement; + const rect = (el: Element) => el?.getBoundingClientRect(); + + currentSizes.current = { + prev: prev + ? { width: rect(prev).width, height: rect(prev).height } + : { width: 0, height: 0 }, + next: next + ? { width: rect(next).width, height: rect(next).height } + : { width: 0, height: 0 }, + }; + + const step = e.shiftKey ? 25 : 5; + let delta = 0; + + if (orientation === 'horizontal') { + if (e.key === 'ArrowUp') { + delta = -step; + } + if (e.key === 'ArrowDown') { + delta = step; + } + if (e.key === 'Home') { + delta = -currentSizes.current.prev.height; + } + if (e.key === 'End') { + delta = currentSizes.current.next?.height || 0; + } + } else { + if (e.key === 'ArrowLeft') { + delta = -step; + } + if (e.key === 'ArrowRight') { + delta = step; + } + if (e.key === 'Home') { + delta = -currentSizes.current.prev.width; + } + if (e.key === 'End') { + delta = currentSizes.current.next?.width || 0; + } + } + + updateSizes(delta, true); + }; + + const handleDoubleClick = (e: MouseEvent) => { + e.preventDefault(); + if (!ref.current) { + return; + } + + const prev = ref.current.previousElementSibling as HTMLElement; + const next = ref.current.nextElementSibling as HTMLElement; + + if (onDoubleClick) { + onDoubleClick(); + } else { + const prop = orientation === 'horizontal' ? 'height' : 'width'; + if (prev) { + prev.style[prop] = `${initialSizes.current.prev[prop]}px`; + } + if (next) { + next.style[prop] = `${initialSizes.current.next[prop]}px`; + } + + if (mode === 'pixels') { + const message = + prev && next + ? `Reset to initial size. ${ + orientation === 'horizontal' ? 'Top' : 'Left' + } panel: ${Math.round(initialSizes.current.prev[prop])}px, ${ + orientation === 'horizontal' ? 'Bottom' : 'Right' + } panel: ${Math.round(initialSizes.current.next[prop])}px` + : prev + ? `Reset to initial size. Panel: ${Math.round( + initialSizes.current.prev[prop] + )}px` + : `Reset to initial size. Panel: ${Math.round( + initialSizes.current.next[prop] + )}px`; + ref.current.setAttribute('aria-label', message); + } else if (mode === 'percentage') { + const container = prev?.parentElement || next?.parentElement; + if (container) { + const totalSize = container.getBoundingClientRect()[prop]; + const prevPercentage = prev + ? Math.round((initialSizes.current.prev[prop] / totalSize) * 100) + : 0; + const nextPercentage = next + ? Math.round((initialSizes.current.next[prop] / totalSize) * 100) + : 0; + + const message = + prev && next + ? `Reset to initial size. ${ + orientation === 'horizontal' ? 'Top' : 'Left' + } panel: ${prevPercentage}%, ${ + orientation === 'horizontal' ? 'Bottom' : 'Right' + } panel: ${nextPercentage}%` + : prev + ? `Reset to initial size. Panel: ${prevPercentage}%` + : `Reset to initial size. Panel: ${nextPercentage}%`; + ref.current.setAttribute('aria-label', message); + } + } + } + + requestAnimationFrame(() => { + ref.current?.focus(); + }); + }; + + return ( +
+ + Use arrow keys to resize, hold Shift for larger steps. Double-click to + reset. + +
+ ); +}; diff --git a/packages/react/src/components/ResizeBar/components/resize-bar.scss b/packages/react/src/components/ResizeBar/components/resize-bar.scss new file mode 100644 index 000000000..f08f7dccd --- /dev/null +++ b/packages/react/src/components/ResizeBar/components/resize-bar.scss @@ -0,0 +1,66 @@ +/** + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +// Carbon setting imports +@use '@carbon/styles/scss/theme' as *; +@use '@carbon/styles/scss/colors' as *; +@use '@carbon/styles/scss/spacing' as *; +@use '@carbon/styles/scss/components/button' as *; + +$prefix: 'clabs' !default; + +.#{$prefix}--resize-bar { + background-color: $border-subtle-01; + flex: none; + position: relative; + + &:hover { + background-color: $border-interactive; + transition: background-color 150ms ease; + } + + &:focus { + background-color: $border-interactive; + outline: none; + } + + &:active { + background-color: $border-interactive; + } + + &:focus:not(:focus-visible) { + outline: none; + box-shadow: none; + } + + &--horizontal { + block-size: 0.3rem; + cursor: ns-resize; + } + + &--vertical { + cursor: ew-resize; + inline-size: 0.3rem; + } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +// add transitions to panel resizes +.smooth-resize { + transition: all 150ms linear; + } \ No newline at end of file diff --git a/packages/react/src/components/ResizeBar/index.ts b/packages/react/src/components/ResizeBar/index.ts new file mode 100644 index 000000000..43d7cb46a --- /dev/null +++ b/packages/react/src/components/ResizeBar/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * + * Copyright IBM Corp. 2025 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ +export { ResizeBar } from './components/ResizeBar.js'; diff --git a/packages/react/src/components/ResizeBar/package.json b/packages/react/src/components/ResizeBar/package.json new file mode 100644 index 000000000..fc917a84e --- /dev/null +++ b/packages/react/src/components/ResizeBar/package.json @@ -0,0 +1,27 @@ +{ + "name": "@carbon-labs/react-resize-bar", + "version": "0.0.1", + "author": "Your Name ", + "publishConfig": { + "access": "public", + "provenance": true + }, + "type": "module", + "description": "Carbon Labs - Resize Bar", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/carbon-design-system/carbon-labs", + "directory": "src/components/ResizeBar" + }, + "main": "./src/index.js", + "module": "./src/index.js", + "files": ["es", "lib", "scss"], + "scripts": { + "build": "node ../../../tasks/build.js", + "clean": "rm -rf {es,lib,scss}" + }, + "devDependencies": { + "@carbon-labs/utilities": "canary" + } +} diff --git a/packages/react/src/components/ResizeBar/tsconfig.json b/packages/react/src/components/ResizeBar/tsconfig.json new file mode 100644 index 000000000..10a83e84d --- /dev/null +++ b/packages/react/src/components/ResizeBar/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "typescript-config-carbon/tsconfig.base.json", + "compilerOptions": { + // TODO: Turn back on once stricter typings for internal utitlies are complete + "noImplicitAny": false + }, + "exclude": ["**/__stories__/*.stories.js", "**/__tests__/*", "**/*d.ts"] +} diff --git a/packages/react/src/components/SplitPanel/__stories__/SplitPanel.stories.js b/packages/react/src/components/SplitPanel/__stories__/SplitPanel.stories.js index 35a058ca5..1c08b4e73 100644 --- a/packages/react/src/components/SplitPanel/__stories__/SplitPanel.stories.js +++ b/packages/react/src/components/SplitPanel/__stories__/SplitPanel.stories.js @@ -92,16 +92,7 @@ export const Horizontal = (args) => ( margin: '16px', }} onChange={(splitValue) => actionSplitValue(splitValue)} - childrenBeforeSplit={ -
-

Children before split

-
    -
  • One
  • -
  • Two
  • -
  • Three
  • -
-
- } + childrenAfterSplit={

Children after split

From 9e9ef2f15246f95b318ed5ac46d61aa6dee7768a Mon Sep 17 00:00:00 2001 From: devadula-nandan Date: Mon, 28 Apr 2025 17:13:43 +0530 Subject: [PATCH 2/7] chore: add more examples --- .../__stories__/ResizeBar.stories.js | 211 +++++++++++++++++- 1 file changed, 207 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js b/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js index aa3f72523..995ac1a1a 100644 --- a/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js +++ b/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js @@ -7,7 +7,7 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useRef, useState, useEffect } from 'react'; import mdx from './ResizeBar.mdx'; import { ResizeBar } from '../components/ResizeBar'; import '../components/resize-bar.scss'; @@ -52,7 +52,8 @@ export const SinglePanelNoBoundaries = () => (

This is a basic resizable panel that can be adjusted vertically using - the resize handle below. The panel takes height according to the content, but can also be pre set. + the resize handle below. The panel takes height according to the + content, but can also be pre set.

@@ -83,7 +84,9 @@ export const SinglePanelBounded = () => (
-

Single Panel (bounded)

+

+ Single Panel (bounded) +

This panel demonstrates how resizing can be constrained within fixed boundaries. The panel is contained within a 600x400 pixel container, @@ -236,4 +239,204 @@ export const TwoPanelsVertical = () => (

-); \ No newline at end of file +); + +export const FourPanels = () => ( + <> + +
+
+
+

Top Left Panel

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Laborum + voluptatum asperiores harum non quidem quasi labore ducimus, commodi + nam minima? +

+
+ +
+

Bottom Left Panel

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Nobis sed + earum mollitia beatae. Doloremque quos sapiente facere repellendus + magnam cumque. +

+
+
+ +
+
+

Top Right Panel

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Hic aliquam + temporibus fugiat placeat illo voluptas earum perferendis soluta + minima quibusdam! +

+
+ +
+

Bottom Right Panel

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Animi + accusamus quod culpa perferendis natus autem officia tenetur libero + consectetur praesentium? +

+
+
+
+ +); + +export const TwoPanelsVerticalGrid = () => { + // fully controlled example + const getAriaLabel = (fraction) => + `Left panel: ${Math.round(fraction * 100)}%, Right panel: ${Math.round( + (1 - fraction) * 100 + )}%`; + + const clampFraction = (value) => + Math.max(0.0806723, Math.min(0.919328, value)); + + const clampWidth = (width, totalWidth) => + Math.max(48, Math.min(totalWidth - 48, width)); + const containerRef = useRef(null); + const startWidthRef = useRef(0); + const currentFractionRef = useRef(0.5); + const initialFraction = 0.5; + + const [isKeyboard, setIsKeyboard] = useState(false); + const [ariaLabel, setAriaLabel] = useState(getAriaLabel(initialFraction)); + + useEffect(() => { + const container = containerRef.current; + if (container) { + container.style.transition = isKeyboard ? '' : 'unset'; + } + }, [isKeyboard]); + + const handleResize = (delta, isKeyboardEvent) => { + const container = containerRef.current; + if (!container) return; + + const totalWidth = container.offsetWidth - 5; + let newFraction = currentFractionRef.current; + + if (isKeyboardEvent) { + setIsKeyboard(true); + const step = delta / totalWidth; + newFraction = clampFraction(currentFractionRef.current + step); + } else { + setIsKeyboard(false); + const leftPanelWidth = container.firstElementChild?.clientWidth ?? 0; + if (startWidthRef.current === 0) { + startWidthRef.current = leftPanelWidth; + } + const newWidth = clampWidth(startWidthRef.current + delta, totalWidth); + newFraction = newWidth / totalWidth; + } + + currentFractionRef.current = newFraction; + container.style.gridTemplateColumns = `${newFraction}fr auto ${ + 1 - newFraction + }fr`; + // this cause re-renders. need to find a better way, should probably move this to handleResizeEnd to announce on end + setAriaLabel(getAriaLabel(newFraction)); + }; + + const handleResizeEnd = () => { + const container = containerRef.current; + startWidthRef.current = 0; + container.style.transition = isKeyboard ? '' : 'unset'; + }; + + const handleDoubleClick = () => { + const container = containerRef.current; + if (!container) return; + + currentFractionRef.current = initialFraction; + container.style.gridTemplateColumns = `${initialFraction}fr auto ${ + 1 - initialFraction + }fr`; + setAriaLabel(`Reset to initial size. ${getAriaLabel(initialFraction)}`); + }; + + return ( + <> + +
+
+

Left Panel

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Qui, ex. + Non esse ullam hic, laboriosam nesciunt optio repellat fugiat saepe? +

+
+ + + +
+

Right Panel

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. + Necessitatibus quos, inventore minus sunt consectetur id iure fuga + cum ab optio. +

+
+
+ + ); +}; From d3e94e2ac92daf4ab5bcca51aa7d9996fb9794ca Mon Sep 17 00:00:00 2001 From: devadula-nandan Date: Mon, 28 Apr 2025 17:35:52 +0530 Subject: [PATCH 3/7] revert: split panel story --- .../SplitPanel/__stories__/SplitPanel.stories.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/SplitPanel/__stories__/SplitPanel.stories.js b/packages/react/src/components/SplitPanel/__stories__/SplitPanel.stories.js index 1c08b4e73..35a058ca5 100644 --- a/packages/react/src/components/SplitPanel/__stories__/SplitPanel.stories.js +++ b/packages/react/src/components/SplitPanel/__stories__/SplitPanel.stories.js @@ -92,7 +92,16 @@ export const Horizontal = (args) => ( margin: '16px', }} onChange={(splitValue) => actionSplitValue(splitValue)} - + childrenBeforeSplit={ +
+

Children before split

+
    +
  • One
  • +
  • Two
  • +
  • Three
  • +
+
+ } childrenAfterSplit={

Children after split

From 01d914b2c296cbf636cb5e5cf449bfae2788473f Mon Sep 17 00:00:00 2001 From: devadula-nandan Date: Mon, 28 Apr 2025 17:39:16 +0530 Subject: [PATCH 4/7] chore: run yarn --- packages/react/src/components/ResizeBar/package.json | 6 +++++- yarn.lock | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/ResizeBar/package.json b/packages/react/src/components/ResizeBar/package.json index fc917a84e..3f4d2d07d 100644 --- a/packages/react/src/components/ResizeBar/package.json +++ b/packages/react/src/components/ResizeBar/package.json @@ -16,7 +16,11 @@ }, "main": "./src/index.js", "module": "./src/index.js", - "files": ["es", "lib", "scss"], + "files": [ + "es", + "lib", + "scss" + ], "scripts": { "build": "node ../../../tasks/build.js", "clean": "rm -rf {es,lib,scss}" diff --git a/yarn.lock b/yarn.lock index 02331ef41..deda5279a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2634,6 +2634,14 @@ __metadata: languageName: unknown linkType: soft +"@carbon-labs/react-resize-bar@workspace:packages/react/src/components/ResizeBar": + version: 0.0.0-use.local + resolution: "@carbon-labs/react-resize-bar@workspace:packages/react/src/components/ResizeBar" + dependencies: + "@carbon-labs/utilities": "npm:canary" + languageName: unknown + linkType: soft + "@carbon-labs/react-split-panel@workspace:packages/react/src/components/SplitPanel": version: 0.0.0-use.local resolution: "@carbon-labs/react-split-panel@workspace:packages/react/src/components/SplitPanel" From 33e8ec138aeb33716604e0a230f27f255f52ff2a Mon Sep 17 00:00:00 2001 From: devadula-nandan Date: Mon, 28 Apr 2025 17:48:42 +0530 Subject: [PATCH 5/7] chore: run yarn lint and format --- .../__stories__/ResizeBar.stories.js | 87 ++++++++++++++----- .../ResizeBar/components/resize-bar.scss | 6 +- 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js b/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js index 995ac1a1a..b51554fbb 100644 --- a/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js +++ b/packages/react/src/components/ResizeBar/__stories__/ResizeBar.stories.js @@ -61,6 +61,9 @@ export const SinglePanelNoBoundaries = () => ( ); +/** + * + */ export const SinglePanelBounded = () => ( <>