diff --git a/src/components/common/context.ts b/src/components/common/context.ts index b31c3bafb..f41f8ebe7 100644 --- a/src/components/common/context.ts +++ b/src/components/common/context.ts @@ -1,5 +1,6 @@ import { createContext } from '@lit/context'; import type { Ref } from 'lit/directives/ref.js'; +import type { IgcSplitterComponent } from '../../index.js'; import type IgcCarouselComponent from '../carousel/carousel.js'; import type { ChatState } from '../chat/chat-state.js'; import type IgcTileManagerComponent from '../tile-manager/tile-manager.js'; @@ -24,9 +25,14 @@ const chatUserInputContext = createContext( Symbol('chat-user-input-context') ); +const splitterContext = createContext( + Symbol('splitter-context') +); + export { carouselContext, tileManagerContext, chatContext, chatUserInputContext, + splitterContext, }; diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 4d3e03119..a3314b4c1 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -56,6 +56,7 @@ import IgcRangeSliderComponent from '../../slider/range-slider.js'; import IgcSliderComponent from '../../slider/slider.js'; import IgcSliderLabelComponent from '../../slider/slider-label.js'; import IgcSnackbarComponent from '../../snackbar/snackbar.js'; +import IgcSplitterComponent from '../../splitter/splitter.js'; import IgcStepComponent from '../../stepper/step.js'; import IgcStepperComponent from '../../stepper/stepper.js'; import IgcTabComponent from '../../tabs/tab.js'; @@ -134,6 +135,7 @@ const allComponents: IgniteComponent[] = [ IgcCircularGradientComponent, IgcSnackbarComponent, IgcDateTimeInputComponent, + IgcSplitterComponent, IgcStepperComponent, IgcStepComponent, IgcTextareaComponent, diff --git a/src/components/resize-container/resize-controller.ts b/src/components/resize-container/resize-controller.ts index dd5636347..9f030189d 100644 --- a/src/components/resize-container/resize-controller.ts +++ b/src/components/resize-container/resize-controller.ts @@ -21,6 +21,7 @@ class ResizeController implements ReactiveController { private readonly _options: ResizeControllerConfiguration = { enabled: true, + updateTarget: true, layer: getDefaultLayer, }; @@ -166,7 +167,9 @@ class ResizeController implements ReactiveController { const parameters = { event, state: this._stateParameters }; this._options.resize?.call(this._host, parameters); this._state.current = parameters.state.current; - this._updatePosition(this._isDeferred ? this._ghost : this._resizeTarget); + if (this._options.updateTarget) { + this._updatePosition(this._isDeferred ? this._ghost : this._resizeTarget); + } } private _handlePointerEnd(event: PointerEvent): void { @@ -175,7 +178,9 @@ class ResizeController implements ReactiveController { this._options.end?.call(this._host, parameters); this._state.current = parameters.state.current; - parameters.state.commit?.() ?? this._updatePosition(this._resizeTarget); + if (this._options.updateTarget) { + parameters.state.commit?.() ?? this._updatePosition(this._resizeTarget); + } this.dispose(); } diff --git a/src/components/resize-container/types.ts b/src/components/resize-container/types.ts index 29dc520d8..fefae198f 100644 --- a/src/components/resize-container/types.ts +++ b/src/components/resize-container/types.ts @@ -24,6 +24,7 @@ export type ResizeControllerConfiguration = { enabled?: boolean; ref?: Ref[]; mode?: ResizeMode; + updateTarget?: boolean; deferredFactory?: ResizeGhostFactory; layer?: () => HTMLElement; /** Callback invoked at the start of a resize operation. */ diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts new file mode 100644 index 000000000..c8eda8ad1 --- /dev/null +++ b/src/components/splitter/splitter.spec.ts @@ -0,0 +1,996 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; +import { spy } from 'sinon'; +import { + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + ctrlKey, +} from '../common/controllers/key-bindings.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { roundPrecise } from '../common/util.js'; +import { + simulateKeyboard, + simulateLostPointerCapture, + simulatePointerDown, + simulatePointerMove, +} from '../common/utils.spec.js'; +import type { SplitterOrientation } from '../types.js'; +import IgcSplitterComponent from './splitter.js'; + +describe('Splitter', () => { + before(() => { + defineComponents(IgcSplitterComponent); + }); + + let splitter: IgcSplitterComponent; + + beforeEach(async () => { + splitter = await fixture(createSplitter()); + await elementUpdated(splitter); + }); + + describe('Rendering', () => { + it('should render', () => { + expect(splitter).to.exist; + expect(splitter).to.be.instanceOf(IgcSplitterComponent); + }); + + it('is accessible', async () => { + await expect(splitter).to.be.accessible(); + await expect(splitter).shadowDom.to.be.accessible(); + }); + + it('should render start and end slots', async () => { + let slot = getSplitterSlot(splitter, 'start'); + let elements = slot.assignedElements(); + expect(elements).to.have.lengthOf(1); + expect(elements[0].textContent).to.equal('Pane 1'); + + slot = getSplitterSlot(splitter, 'end'); + elements = slot.assignedElements(); + expect(elements).to.have.lengthOf(1); + expect(elements[0].textContent).to.equal('Pane 2'); + }); + + it('should render splitter bar between start and end parts', async () => { + const base = getSplitterPart(splitter, 'base'); + const startPart = getSplitterPart(splitter, 'start-pane'); + const endPart = getSplitterPart(splitter, 'end-pane'); + const bar = getSplitterPart(splitter, 'bar'); + + expect(base).to.exist; + expect(startPart).to.exist; + expect(endPart).to.exist; + expect(bar).to.exist; + + expect(base.contains(startPart)).to.be.true; + expect(base.contains(endPart)).to.be.true; + expect(base.contains(bar)).to.be.true; + + expect(startPart.nextElementSibling).to.equal(bar); + expect(bar.nextElementSibling).to.equal(endPart); + }); + + it('should render splitter bar parts', async () => { + const bar = getSplitterPart(splitter, 'bar'); + const expanderStart = getSplitterPart(splitter, 'start-expander'); + const barHandle = getSplitterPart(splitter, 'handle'); + const expanderEnd = getSplitterPart(splitter, 'end-expander'); + + expect(expanderStart).to.exist; + expect(barHandle).to.exist; + expect(expanderEnd).to.exist; + + expect(bar.contains(expanderStart)).to.be.true; + expect(bar.contains(expanderEnd)).to.be.true; + expect(bar.contains(barHandle)).to.be.true; + + expect(expanderStart.nextElementSibling).to.equal(barHandle); + expect(barHandle.nextElementSibling).to.equal(expanderEnd); + }); + + it('should not display the bar elements if the splitter is nonCollapsible', async () => { + splitter.nonCollapsible = true; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, 'bar'); + expect(bar.children).to.have.lengthOf(0); + }); + + it('should have default horizontal orientation', () => { + expect(splitter.orientation).to.equal('horizontal'); + expect(splitter.hasAttribute('orientation')).to.be.true; + expect(splitter.getAttribute('orientation')).to.equal('horizontal'); + }); + + it('should change orientation to vertical', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + expect(splitter.orientation).to.equal('vertical'); + expect(splitter.getAttribute('orientation')).to.equal('vertical'); + }); + + it('should render nested splitters correctly', async () => { + const nestedSplitter = await fixture( + createNestedSplitter() + ); + await elementUpdated(nestedSplitter); + + const outerStartSlot = getSplitterSlot(nestedSplitter, 'start'); + const startElements = outerStartSlot.assignedElements(); + expect(startElements).to.have.lengthOf(1); + expect(startElements[0].tagName.toLowerCase()).to.equal( + IgcSplitterComponent.tagName.toLowerCase() + ); + + const outerEndSlot = getSplitterSlot(nestedSplitter, 'end'); + const endElements = outerEndSlot.assignedElements(); + expect(endElements).to.have.lengthOf(1); + expect(endElements[0].tagName.toLowerCase()).to.equal( + IgcSplitterComponent.tagName.toLowerCase() + ); + + const innerStartSlot1 = getSplitterSlot( + startElements[0] as IgcSplitterComponent, + 'start' + ); + expect(innerStartSlot1.assignedElements()[0].textContent).to.equal( + 'Top Left Pane' + ); + + const innerEndSlot1 = getSplitterSlot( + startElements[0] as IgcSplitterComponent, + 'end' + ); + expect(innerEndSlot1.assignedElements()[0].textContent).to.equal( + 'Bottom Left Pane' + ); + + const innerStartSlot2 = getSplitterSlot( + endElements[0] as IgcSplitterComponent, + 'start' + ); + expect(innerStartSlot2.assignedElements()[0].textContent).to.equal( + 'Top Right Pane' + ); + + const innerEndSlot2 = getSplitterSlot( + endElements[0] as IgcSplitterComponent, + 'end' + ); + expect(innerEndSlot2.assignedElements()[0].textContent).to.equal( + 'Bottom Right Pane' + ); + }); + + it('should set a default cursor on the bar in case splitter is not resizable or any pane is collapsed', async () => { + const bar = getSplitterPart(splitter, 'bar'); + + const style = getComputedStyle(bar); + expect(style.cursor).to.equal('col-resize'); + + splitter.nonResizable = true; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('default'); + + splitter.nonResizable = false; + splitter.endCollapsed = true; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('default'); + + splitter.endCollapsed = false; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('col-resize'); + }); + + it('should change the bar cursor based on the orientation', async () => { + const bar = getSplitterPart(splitter, 'bar'); + + const style = getComputedStyle(bar); + expect(style.cursor).to.equal('col-resize'); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('row-resize'); + }); + }); + + describe('Properties', () => { + it('should reset pane sizes when orientation changes', async () => { + splitter.startSize = '200px'; + await elementUpdated(splitter); + + const startPart = getSplitterPart(splitter, 'start-pane'); + const style = getComputedStyle(startPart); + expect(style.flex).to.equal('0 0 200px'); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('auto'); + expect(style.flex).to.equal('1 1 auto'); + }); + + // TODO: verify the attribute type, default value, reflection + it('should properly set default min/max values when not specified', async () => { + await elementUpdated(splitter); + + const startPart = getSplitterPart(splitter, 'start-pane'); + const style = getComputedStyle(startPart); + expect(style.flex).to.equal('1 1 auto'); + + expect(splitter.startSize).to.equal('auto'); + expect(style.minWidth).to.equal('0px'); + expect(style.maxWidth).to.equal('100%'); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + expect(style.minHeight).to.equal('0px'); + expect(style.maxHeight).to.equal('100%'); + }); + + it('should apply minSize and maxSize to panes for horizontal orientation', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startMinSize: '100px', + startMaxSize: '500px', + }) + ); + + await elementUpdated(splitter); + + const startPane = getSplitterPart(splitter, 'start-pane'); + const style = getComputedStyle(startPane); + expect(style.minWidth).to.equal('100px'); + expect(style.maxWidth).to.equal('500px'); + }); + + it('should apply minSize and maxSize to panes for vertical orientation', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startMinSize: '100px', + startMaxSize: '500px', + orientation: 'vertical', + }) + ); + await elementUpdated(splitter); + + const startPane = getSplitterPart(splitter, 'start-pane'); + const style = getComputedStyle(startPane); + expect(style.minHeight).to.equal('100px'); + expect(style.maxHeight).to.equal('500px'); + }); + + it('should handle percentage sizes', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '30%', + endSize: '70%', + startMinSize: '20%', + startMaxSize: '80%', + }) + ); + await elementUpdated(splitter); + + const startPane = getSplitterPart(splitter, 'start-pane'); + const style1 = getComputedStyle(startPane); + + const endPane = getSplitterPart(splitter, 'end-pane'); + const style2 = getComputedStyle(endPane); + + expect(splitter.startSize).to.equal('30%'); + expect(splitter.endSize).to.equal('70%'); + expect(style1.flex).to.equal('0 1 30%'); + expect(style2.flex).to.equal('0 1 70%'); + + expect(splitter.startMinSize).to.equal('20%'); + expect(splitter.startMaxSize).to.equal('80%'); + expect(style1.minWidth).to.equal('20%'); + expect(style1.maxWidth).to.equal('80%'); + + // TODO: test with drag; add constraints to second pane + }); + + it('should handle mixed px and % constraints', async () => { + const mixedConstraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startMinSize: '100px', + startMaxSize: '50%', + }) + ); + await elementUpdated(mixedConstraintSplitter); + + const startPane = getSplitterPart(mixedConstraintSplitter, 'start-pane'); + const style = getComputedStyle(startPane); + + expect(mixedConstraintSplitter.startMinSize).to.equal('100px'); + expect(mixedConstraintSplitter.startMaxSize).to.equal('50%'); + expect(style.minWidth).to.equal('100px'); + expect(style.maxWidth).to.equal('50%'); + + // TODO: test with drag + }); + }); + + describe('Methods, Events & Interactions', () => { + it('should expand/collapse panes when toggle is invoked', async () => { + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.true; + + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.false; + + splitter.toggle('end'); + await elementUpdated(splitter); + expect(splitter.endCollapsed).to.be.true; + + // edge case: supports collapsing both at a time? + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.true; + }); + + it('should toggle the next pane when the bar expander-end is clicked', async () => { + const expanderStart = getSplitterPart(splitter, 'start-expander'); + const expanderEnd = getSplitterPart(splitter, 'end-expander'); + + simulatePointerDown(expanderEnd, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + expect(expanderStart.hidden).to.be.false; + expect(expanderEnd.hidden).to.be.true; + + simulatePointerDown(expanderStart, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + expect(expanderStart.hidden).to.be.false; + expect(expanderEnd.hidden).to.be.false; + }); + + it('should toggle the previous pane when the bar expander-start is clicked', async () => { + const expanderStart = getSplitterPart(splitter, 'start-expander'); + const expanderEnd = getSplitterPart(splitter, 'end-expander'); + + simulatePointerDown(expanderStart, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + expect(expanderStart.hidden).to.be.true; + expect(expanderEnd.hidden).to.be.false; + + simulatePointerDown(expanderEnd, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + expect(expanderStart.hidden).to.be.false; + expect(expanderEnd.hidden).to.be.false; + }); + + it('should resize horizontally in both directions', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const previousSizes = getPanesSizes(splitter, 'width'); + let deltaX = 100; + + await resize(splitter, deltaX, 0); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaX); + expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaX); + checkResizeEvents(eventSpy); + + deltaX *= -1; + await resize(splitter, deltaX, 0); + + currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize); + expect(currentSizes.endSize).to.equal(previousSizes.endSize); + checkResizeEvents(eventSpy); + }); + + it('should resize vertically in both directions', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + const previousSizes = getPanesSizes(splitter, 'height'); + let deltaY = 100; + + await resize(splitter, 0, deltaY); + + let currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaY); + expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaY); + checkResizeEvents(eventSpy); + + deltaY *= -1; + await resize(splitter, 0, deltaY); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal(previousSizes.startSize); + expect(currentSizes.endSize).to.equal(previousSizes.endSize); + checkResizeEvents(eventSpy); + }); + + it('should resize horizontally by 10px delta with left/right arrow keys', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const bar = getSplitterPart(splitter, 'bar'); + let previousSizes = getPanesSizes(splitter, 'width'); + const resizeDelta = 10; + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta + ); + checkResizeEvents(eventSpy); + + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta * 2 + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta * 2 + ); + checkResizeEvents(eventSpy); + + previousSizes = getPanesSizes(splitter, 'width'); + simulateKeyboard(bar, arrowLeft); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal( + previousSizes.startSize - resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize + resizeDelta + ); + checkResizeEvents(eventSpy); + }); + + it('should resize vertically by 10px delta with up/down arrow keys', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, 'bar'); + let previousSizes = getPanesSizes(splitter, 'height'); + const resizeDelta = 10; + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, arrowDown); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta + ); + checkResizeEvents(eventSpy); + + simulateKeyboard(bar, arrowDown); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta * 2 + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta * 2 + ); + checkResizeEvents(eventSpy); + + previousSizes = getPanesSizes(splitter, 'height'); + simulateKeyboard(bar, arrowUp); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal( + previousSizes.startSize - resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize + resizeDelta + ); + checkResizeEvents(eventSpy); + }); + + it('should not resize with left/right keys when in vertical orientation', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + const eventSpy = spy(splitter, 'emitEvent'); + const bar = getSplitterPart(splitter, 'bar'); + const previousSizes = getPanesSizes(splitter, 'height'); + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, arrowLeft); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes).to.deep.equal(previousSizes); + + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes).to.deep.equal(previousSizes); + expect(eventSpy.called).to.be.false; + }); + + it('should not resize with up/down keys when in horizontal orientation', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const bar = getSplitterPart(splitter, 'bar'); + const previousSizes = getPanesSizes(splitter, 'width'); + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, arrowUp); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes).to.deep.equal(previousSizes); + + simulateKeyboard(bar, arrowDown); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes).to.deep.equal(previousSizes); + expect(eventSpy.called).to.be.false; + }); + + // TODO: should there be events on expand/collapse? + it('should expand/collapse panes with Ctrl + left/right arrow keys in horizontal orientation', async () => { + const bar = getSplitterPart(splitter, 'bar'); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + const splitterSize = splitter.getBoundingClientRect().width; + const barSize = bar.getBoundingClientRect().width; + + expect(currentSizes.startSize).to.equal(0); + expect(currentSizes.endSize).to.equal(splitterSize - barSize); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.be.greaterThan(0); + expect(currentSizes.endSize).to.equal( + splitterSize - barSize - currentSizes.startSize + ); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal(splitterSize - barSize); + expect(currentSizes.endSize).to.equal(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal( + splitterSize - barSize - currentSizes.endSize + ); + expect(currentSizes.endSize).to.be.greaterThan(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + }); + + it('should expand/collapse panes with Ctrl + up/down arrow keys in vertical orientation', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, 'bar'); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowUp]); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'height'); + const splitterSize = splitter.getBoundingClientRect().height; + const barSize = bar.getBoundingClientRect().height; + + expect(currentSizes.startSize).to.equal(0); + expect(currentSizes.endSize).to.equal(splitterSize - barSize); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowDown]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.be.greaterThan(0); + expect(currentSizes.endSize).to.equal( + splitterSize - barSize - currentSizes.startSize + ); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowDown]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal(splitterSize - barSize); + expect(currentSizes.endSize).to.equal(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + simulateKeyboard(bar, [ctrlKey, arrowUp]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal( + splitterSize - barSize - currentSizes.endSize + ); + expect(currentSizes.endSize).to.be.greaterThan(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + }); + + it('should not resize when nonResizable is true', async () => { + splitter.nonResizable = true; + await elementUpdated(splitter); + + const eventSpy = spy(splitter, 'emitEvent'); + let previousSizes = getPanesSizes(splitter, 'width'); + const bar = getSplitterPart(splitter, 'bar'); + + await resize(splitter, 100, 0); + + let currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes).to.deep.equal(previousSizes); + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + await nextFrame(); + + currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes).to.deep.equal(previousSizes); + expect(eventSpy.called).to.be.false; + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + previousSizes = getPanesSizes(splitter, 'height'); + + await resize(splitter, 0, 100); + + currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes).to.deep.equal(previousSizes); + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, arrowDown); + await elementUpdated(splitter); + await nextFrame(); + + currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes).to.deep.equal(previousSizes); + + expect(eventSpy.called).to.be.false; + }); + + it('should not expand/collapse panes with Ctrl + arrow keys when nonCollapsible is true', async () => { + splitter.nonCollapsible = true; + await elementUpdated(splitter); + + expect(splitter.nonCollapsible).to.be.true; + expect(splitter.hasAttribute('non-collapsible')).to.be.true; + + const bar = getSplitterPart(splitter, 'bar'); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + await nextFrame(); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + await nextFrame(); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowUp]); + await elementUpdated(splitter); + await nextFrame(); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowDown]); + await elementUpdated(splitter); + await nextFrame(); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + }); + }); + + // TODO: test when the slots have assigned sizes/min sizes + edge cases + describe('Resizing with constraints and edge cases', () => { + it('panes should not exceed splitter size when set in px and horizontally resizing to end', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '500px', + endSize: '500px', + }) + ); + const totalSplitterSize = 800; + splitter.style.width = `${totalSplitterSize}px`; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, 'bar'); + const barSize = bar.getBoundingClientRect().width; + const previousSizes = getPanesSizes(splitter, 'width'); + const deltaX = 100; + + await resize(splitter, deltaX, 0); + + const currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaX); + // end pane size should be decreased to fit the splitter width + expect(currentSizes.endSize).to.equal( + totalSplitterSize - barSize - currentSizes.startSize + ); + checkPanesAreWithingBounds( + splitter, + currentSizes.startSize, + currentSizes.endSize, + 'x' + ); + }); + + it('panes should not exceed splitter size when set in px and vertically resizing to end', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation: 'vertical', + startSize: '500px', + endSize: '500px', + }) + ); + const totalSplitterSize = 800; + splitter.style.height = `${totalSplitterSize}px`; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, 'bar'); + const barSize = bar.getBoundingClientRect().height; + const previousSizes = getPanesSizes(splitter, 'height'); + const deltaY = 100; + + await resize(splitter, 0, deltaY); + + const currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaY); + // end pane size should be decreased to fit the splitter height + expect(currentSizes.endSize).to.equal( + totalSplitterSize - barSize - currentSizes.startSize + ); + checkPanesAreWithingBounds( + splitter, + currentSizes.startSize, + currentSizes.endSize, + 'y' + ); + }); + }); +}); + +function createSplitter() { + return html` + +
Pane 1
+
Pane 2
+
+ `; +} + +function createNestedSplitter() { + return html` + + +
Top Left Pane
+
Bottom Left Pane
+
+ +
Top Right Pane
+
Bottom Right Pane
+
+
+ `; +} + +type SplitterTestSizesAndConstraints = { + startSize?: string; + endSize?: string; + startMinSize?: string; + startMaxSize?: string; + endMinSize?: string; + endMaxSize?: string; + orientation?: SplitterOrientation; +}; + +function createTwoPanesWithSizesAndConstraints( + config: SplitterTestSizesAndConstraints +) { + return html` + +
Pane 1
+
Pane 2
+
+ `; +} + +function getSplitterSlot( + splitter: IgcSplitterComponent, + which: 'start' | 'end' +) { + return splitter.renderRoot.querySelector( + `slot[name="${which}"]` + ) as HTMLSlotElement; +} + +// TODO: more parts and names? +type SplitterParts = + | 'start-pane' + | 'end-pane' + | 'bar' + | 'base' + | 'start-expander' + | 'end-expander' + | 'handle'; + +function getSplitterPart(splitter: IgcSplitterComponent, which: SplitterParts) { + return splitter.shadowRoot!.querySelector( + `[part~="${which}"]` + ) as HTMLElement; +} + +async function resize( + splitter: IgcSplitterComponent, + deltaX: number, + deltaY: number +) { + const bar = getSplitterPart(splitter, 'bar'); + const barRect = bar.getBoundingClientRect(); + + simulatePointerDown(bar, { + clientX: barRect.left, + clientY: barRect.top, + }); + await elementUpdated(splitter); + + simulatePointerMove( + bar, + { + clientX: barRect.left, + clientY: barRect.top, + }, + { x: deltaX, y: deltaY } + ); + await elementUpdated(splitter); + + simulateLostPointerCapture(bar); + await elementUpdated(splitter); + await nextFrame(); +} + +function getPanesSizes( + splitter: IgcSplitterComponent, + dimension: 'width' | 'height' = 'width' +) { + const startPane = getSplitterPart(splitter, 'start-pane'); + const endPane = getSplitterPart(splitter, 'end-pane'); + + return { + startSize: roundPrecise(startPane.getBoundingClientRect()[dimension]), + endSize: roundPrecise(endPane.getBoundingClientRect()[dimension]), + }; +} + +function checkResizeEvents(eventSpy: sinon.SinonSpy) { + expect(eventSpy.calledWith('igcResizeStart')).to.be.true; + expect(eventSpy.calledWith('igcResizing')).to.be.true; + expect(eventSpy.calledWith('igcResizeEnd')).to.be.true; + eventSpy.resetHistory(); +} + +function checkPanesAreWithingBounds( + splitter: IgcSplitterComponent, + startSize: number, + endSize: number, + dimension: 'x' | 'y' +) { + const splitterSize = + splitter.getBoundingClientRect()[dimension === 'x' ? 'width' : 'height']; + expect(startSize + endSize).to.be.at.most(splitterSize); +} diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts new file mode 100644 index 000000000..09bc2c5e4 --- /dev/null +++ b/src/components/splitter/splitter.ts @@ -0,0 +1,655 @@ +import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import { addInternalsController } from '../common/controllers/internals.js'; +import { + addKeybindings, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + ctrlKey, +} from '../common/controllers/key-bindings.js'; +import { addSlotController, setSlots } from '../common/controllers/slot.js'; +import { watch } from '../common/decorators/watch.js'; +import { registerComponent } from '../common/definitions/register.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { isLTR } from '../common/util.js'; +import { addResizeController } from '../resize-container/resize-controller.js'; +import type { SplitterOrientation } from '../types.js'; +import { styles } from './themes/splitter.base.css.js'; + +export interface IgcSplitterBarResizeEventArgs { + pane: HTMLElement; + sibling: HTMLElement; +} + +export interface IgcSplitterComponentEventMap { + igcResizeStart: CustomEvent; + igcResizing: CustomEvent; + igcResizeEnd: CustomEvent; +} + +interface PaneResizeState { + initialSize: number; + isPercentageBased: boolean; +} + +interface SplitterResizeState { + startPane: PaneResizeState; + endPane: PaneResizeState; +} + +/** + * The Splitter component provides a framework for a simple layout, splitting the view horizontally or vertically + * into multiple smaller resizable and collapsible areas. + * + * @element igc-splitter + * * + * @fires igcResizeStart - Emitted when resizing starts. + * @fires igcResizing - Emitted while resizing. + * @fires igcResizeEnd - Emitted when resizing ends. + * + * @csspart ... - ... . + */ +export default class IgcSplitterComponent extends EventEmitterMixin< + IgcSplitterComponentEventMap, + Constructor +>(LitElement) { + public static readonly tagName = 'igc-splitter'; + public static styles = [styles]; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcSplitterComponent); + } + + //#region Private Properties + + private readonly _barRef = createRef(); + private _startPaneInternalStyles: StyleInfo = {}; + private _endPaneInternalStyles: StyleInfo = {}; + private _barInternalStyles: StyleInfo = {}; + private _startSize = 'auto'; + private _endSize = 'auto'; + private _resizeState: SplitterResizeState | null = null; + + private readonly _internals = addInternalsController(this, { + initialARIA: { + ariaOrientation: 'horizontal', + }, + }); + + @query('[part~="base"]', true) + private readonly _base!: HTMLElement; + + @query('[part~="start-pane"]', true) + private readonly _startPane!: HTMLElement; + + @query('[part~="end-pane"]', true) + private readonly _endPane!: HTMLElement; + + @query('[part~="bar"]', true) + private readonly _bar!: HTMLElement; + + private get _startFlex() { + const grow = this._isAutoSize('start') ? 1 : 0; + const shrink = + this._isAutoSize('start') || this._isPercentageSize('start') ? 1 : 0; + return `${grow} ${shrink} ${this._startSize}`; + } + + private get _endFlex() { + const grow = this._isAutoSize('end') ? 1 : 0; + const shrink = + this._isAutoSize('end') || this._isPercentageSize('end') ? 1 : 0; + return `${grow} ${shrink} ${this._endSize}`; + } + + private get _resizeDisallowed() { + return this.nonResizable || this.startCollapsed || this.endCollapsed; + } + + private get _barCursor(): string { + if (this._resizeDisallowed) { + return 'default'; + } + return this.orientation === 'horizontal' ? 'col-resize' : 'row-resize'; + } + + //#endregion + + //#region Public Properties + + /** Gets/Sets the orientation of the splitter. + * + * @remarks + * Default value is `horizontal`. + */ + @property({ reflect: true }) + public orientation: SplitterOrientation = 'horizontal'; + + /** + * Sets the visibility of the handle and expanders in the splitter bar. + * @remarks + * Default value is `false`. + * @attr + */ + @property({ type: Boolean, attribute: 'non-collapsible', reflect: true }) + public nonCollapsible = false; + + /** + * Defines if the splitter is resizable or not. + * @attr + */ + @property({ type: Boolean, reflect: true, attribute: 'non-resizable' }) + public nonResizable = false; + + /** + * The minimum size of the start pane. + * @attr + */ + @property({ attribute: 'start-min-size', reflect: true }) + public startMinSize: string | undefined; + + /** + * The minimum size of the end pane. + * @attr + */ + @property({ attribute: 'end-min-size', reflect: true }) + public endMinSize: string | undefined; + + /** + * The maximum size of the start pane. + * @attr + */ + @property({ attribute: 'start-max-size', reflect: true }) + public startMaxSize: string | undefined; + + /** + * The maximum size of the end pane. + * @attr + */ + @property({ attribute: 'end-max-size', reflect: true }) + public endMaxSize: string | undefined; + + /** + * The size of the start pane. + * @attr + */ + @property({ attribute: 'start-size', reflect: true }) + public set startSize(value: string) { + this._startSize = value; + Object.assign(this._startPaneInternalStyles, { + flex: this._startFlex, + }); + } + + public get startSize(): string | undefined { + return this._startSize; + } + + /** + * The size of the end pane. + * @attr + */ + @property({ attribute: 'end-size', reflect: true }) + public set endSize(value: string) { + this._endSize = value; + Object.assign(this._endPaneInternalStyles, { + flex: this._endFlex, + }); + } + + public get endSize(): string | undefined { + return this._endSize; + } + + /** + * Collapsed state of the start pane. + * @attr + */ + @property({ type: Boolean, attribute: 'start-collapsed', reflect: true }) + public startCollapsed = false; + + /** + * Collapsed state of the end pane. + * @attr + */ + @property({ type: Boolean, attribute: 'end-collapsed', reflect: true }) + public endCollapsed = false; + + //#endregion + + //#region Watchers + + @watch('orientation', { waitUntilFirstUpdate: true }) + protected _orientationChange(): void { + this._internals.setARIA({ ariaOrientation: this.orientation }); + Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); + this._resetPanes(); + } + + @watch('nonResizable') + protected _changeCursor(): void { + Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); + } + + @watch('startCollapsed', { waitUntilFirstUpdate: true }) + @watch('endCollapsed', { waitUntilFirstUpdate: true }) + protected _collapsedChange(): void { + this.startSize = 'auto'; + this.endSize = 'auto'; + this._changeCursor(); + } + + //#endregion + + //#region Lifecycle + + protected override willUpdate(changed: PropertyValues) { + super.willUpdate(changed); + + if ( + changed.has('startMinSize') || + changed.has('startMaxSize') || + changed.has('endMinSize') || + changed.has('endMaxSize') + ) { + this._initPanes(); + } + } + + constructor() { + super(); + + addSlotController(this, { + slots: setSlots('start', 'end'), + }); + addKeybindings(this, { + ref: this._barRef, + }) + .set(arrowUp, () => this._handleResizePanes(-1, 'vertical')) + .set(arrowDown, () => this._handleResizePanes(1, 'vertical')) + .set(arrowLeft, () => this._handleResizePanes(-1, 'horizontal')) + .set(arrowRight, () => this._handleResizePanes(1, 'horizontal')) + .set([ctrlKey, arrowUp], () => + this._handleArrowsExpandCollapse('start', 'vertical') + ) + .set([ctrlKey, arrowDown], () => + this._handleArrowsExpandCollapse('end', 'vertical') + ) + .set([ctrlKey, arrowLeft], () => + this._handleArrowsExpandCollapse('start', 'horizontal') + ) + .set([ctrlKey, arrowRight], () => + this._handleArrowsExpandCollapse('end', 'horizontal') + ); + + addResizeController(this, { + ref: [this._barRef], + mode: 'immediate', + updateTarget: false, + resizeTarget: () => { + return this._startPane; + }, + start: () => { + if (this._resizeDisallowed) { + return false; + } + this._resizeStart(); + return true; + }, + resize: ({ state }) => { + const isHorizontal = this.orientation === 'horizontal'; + const delta = isHorizontal ? state.deltaX : state.deltaY; + if (delta !== 0) { + this._resizing(delta); + } + }, + end: ({ state }) => { + const isHorizontal = this.orientation === 'horizontal'; + const delta = isHorizontal ? state.deltaX : state.deltaY; + if (delta !== 0) { + this._resizeEnd(delta); + } + }, + cancel: () => {}, + }); + } + + protected override firstUpdated() { + this._initPanes(); + } + + //#endregion + + //#region Public Methods + + /** Toggles the collapsed state of the pane. */ + public toggle(position: 'start' | 'end') { + if (position === 'start') { + this.startCollapsed = !this.startCollapsed; + } else { + this.endCollapsed = !this.endCollapsed; + } + } + + //#endregion + + //#region Internal API + + private _isPercentageSize(which: 'start' | 'end') { + const targetSize = which === 'start' ? this._startSize : this._endSize; + return !!targetSize && targetSize.indexOf('%') !== -1; + } + + private _isAutoSize(which: 'start' | 'end') { + const targetSize = which === 'start' ? this._startSize : this._endSize; + return !!targetSize && targetSize === 'auto'; + } + + private _handleResizePanes( + direction: -1 | 1, + validOrientation: 'horizontal' | 'vertical' + ) { + if (this._resizeDisallowed || this.orientation !== validOrientation) { + return; + } + const delta = 10 * direction * (isLTR(this) ? 1 : -1); + + this._resizeStart(); + this._resizing(delta); + this._resizeEnd(delta); + return true; + } + + private _handleExpanderStartAction() { + const target = this.endCollapsed ? 'end' : 'start'; + this.toggle(target); + } + + private _handleExpanderEndAction() { + const target = this.startCollapsed ? 'start' : 'end'; + this.toggle(target); + } + + private _handleArrowsExpandCollapse( + target: 'start' | 'end', + validOrientation: 'horizontal' | 'vertical' + ) { + if (this.nonCollapsible || this.orientation !== validOrientation) { + return; + } + target === 'start' + ? this._handleExpanderStartAction() + : this._handleExpanderEndAction(); + } + + private _resizeStart() { + const [startSize, endSize] = this._rectSize(); + + this._resizeState = { + startPane: this._createPaneState('start', startSize), + endPane: this._createPaneState('end', endSize), + }; + // TODO: are these event args needed? + this.emitEvent('igcResizeStart', { + detail: { pane: this._startPane, sibling: this._endPane }, + }); + } + + private _createPaneState( + pane: 'start' | 'end', + size: number + ): PaneResizeState { + return { + initialSize: size, + isPercentageBased: this._isPercentageSize(pane) || this._isAutoSize(pane), + }; + } + + private _resizing(delta: number) { + let [paneSize, siblingSize] = this._calcNewSizes(delta); + const totalSize = this.getTotalSize(); + [paneSize, siblingSize] = this._fitInSplitter( + totalSize, + paneSize, + siblingSize, + delta + ); + + this.startSize = `${paneSize}px`; + this.endSize = `${siblingSize}px`; + + this.emitEvent('igcResizing', { + detail: { pane: this._startPane, sibling: this._endPane }, + }); + } + + private _resizeEnd(delta: number) { + if (!this._resizeState) return; + let [paneSize, siblingSize] = this._calcNewSizes(delta); + const totalSize = this.getTotalSize(); + + [paneSize, siblingSize] = this._fitInSplitter( + totalSize, + paneSize, + siblingSize, + delta + ); + + if (this._resizeState.startPane.isPercentageBased) { + // handle % resizes + const percentPaneSize = (paneSize / totalSize) * 100; + this.startSize = `${percentPaneSize}%`; + } else { + // px resize + this.startSize = `${paneSize}px`; + } + + if (this._resizeState.endPane.isPercentageBased) { + // handle % resizes + const percentSiblingSize = (siblingSize / totalSize) * 100; + this.endSize = `${percentSiblingSize}%`; + } else { + // px resize + this.endSize = `${siblingSize}px`; + } + this.emitEvent('igcResizeEnd', { + detail: { pane: this._startPane, sibling: this._endPane }, + }); + this._resizeState = null; + } + + private _rectSize() { + const relevantDimension = + this.orientation === 'horizontal' ? 'width' : 'height'; + const startPaneRect = this._startPane.getBoundingClientRect(); + const endPaneRect = this._endPane.getBoundingClientRect(); + return [startPaneRect[relevantDimension], endPaneRect[relevantDimension]]; + } + + private _fitInSplitter( + total: number, + startSize: number, + endSize: number, + delta: number + ): [number, number] { + let newStartSize = startSize; + let newEndSize = endSize; + if (startSize + endSize > total && delta > 0) { + newEndSize = total - newStartSize; + } else if (newStartSize + newEndSize > total && delta < 0) { + newStartSize = total - newEndSize; + } + return [newStartSize, newEndSize]; + } + + // TODO: handle RTL + private _calcNewSizes(delta: number): [number, number] { + if (!this._resizeState) return [0, 0]; + + let finalDelta: number; + const min = Number.parseInt(this.startMinSize ?? '0', 10) || 0; + const minSibling = Number.parseInt(this.endMinSize ?? '0', 10) || 0; + const max = + Number.parseInt(this.startMaxSize ?? '0', 10) || + this._resizeState.startPane.initialSize + + this._resizeState.endPane.initialSize - + minSibling; + const maxSibling = + Number.parseInt(this.endMaxSize ?? '0', 10) || + this._resizeState.startPane.initialSize + + this._resizeState.endPane.initialSize - + min; + + if (delta < 0) { + const maxPossibleDelta = Math.min( + this._resizeState.startPane.initialSize - min, + maxSibling - this._resizeState.endPane.initialSize + ); + finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)) * -1; + } else { + const maxPossibleDelta = Math.min( + max - this._resizeState.startPane.initialSize, + this._resizeState.endPane.initialSize - minSibling + ); + finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)); + } + return [ + this._resizeState.startPane.initialSize + finalDelta, + this._resizeState.endPane.initialSize - finalDelta, + ]; + } + + private getTotalSize() { + if (!this._base) { + return 0; + } + + const barSize = this._bar + ? Number.parseInt( + getComputedStyle(this._bar).getPropertyValue('--bar-size').trim(), + 10 + ) || 0 + : 0; + + const rect = this._base.getBoundingClientRect(); + const size = this.orientation === 'horizontal' ? rect.width : rect.height; + return size - barSize; + } + + private _resetPanes() { + this.startSize = 'auto'; + this.endSize = 'auto'; + const commonStyles = { + minWidth: 0, + maxWidth: '100%', + minHeight: 0, + maxHeight: '100%', + }; + Object.assign(this._startPaneInternalStyles, { + ...commonStyles, + flex: this._startFlex, + }); + Object.assign(this._endPaneInternalStyles, { + ...commonStyles, + flex: this._endFlex, + }); + } + + private _initPanes() { + const isHorizontal = this.orientation === 'horizontal'; + const minProp = isHorizontal ? 'minWidth' : 'minHeight'; + const maxProp = isHorizontal ? 'maxWidth' : 'maxHeight'; + + const sizes = { + [minProp]: this.startMinSize ?? 0, + [maxProp]: this.startMaxSize ?? '100%', + }; + + Object.assign(this._startPaneInternalStyles, { + ...sizes, + flex: this._startFlex, + }); + Object.assign(this._endPaneInternalStyles, { + ...sizes, + flex: this._endFlex, + }); + this.requestUpdate(); + } + + private _handleExpanderClick(pane: 'start' | 'end', event: PointerEvent) { + // Prevent resize controller from starting + event.stopPropagation(); + + pane === 'start' + ? this._handleExpanderStartAction() + : this._handleExpanderEndAction(); + } + + //#endregion + + //#region Rendering + + private _getExpanderHiddenState() { + return { + prevButtonHidden: !!(this.startCollapsed && !this.endCollapsed), + nextButtonHidden: !!(this.endCollapsed && !this.startCollapsed), + }; + } + + private _renderBarControls() { + if (this.nonCollapsible) { + return nothing; + } + const { prevButtonHidden, nextButtonHidden } = + this._getExpanderHiddenState(); + return html` +
+ this._handleExpanderClick('start', e)} + >
+
+
this._handleExpanderClick('end', e)} + >
+ `; + } + + protected override render() { + return html` +
+
+ +
+
+ ${this._renderBarControls()} +
+
+ +
+
+ `; + } + + //#endregion +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-splitter': IgcSplitterComponent; + } +} diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss new file mode 100644 index 000000000..ae4e46eb0 --- /dev/null +++ b/src/components/splitter/themes/splitter.base.scss @@ -0,0 +1,118 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: flex; + width: 100%; + height: 100%; + + [part='base'] { + width: 100%; + height: 100%; + display: flex; + color: var(--ig-gray-900); + background: var(--ig-gray-100); + border: 1px solid var(--ig-gray-200); + user-select: none; + } + + // Styles for the pane wrapper divs (startPane/endPane) + [part~='start-pane'], + [part~='end-pane'] { + display: flex; + flex: 1 1 auto; + width: 100%; + height: 100%; + overflow: auto; + box-sizing: border-box; + } + + ::slotted(*) { + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + box-sizing: border-box; + } + + // Bar styles (moved from splitter-bar.base.scss) + [part='bar'] { + --bar-size: 5px; + + display: flex; + background-color: var(--ig-gray-200); + justify-content: center; + cursor: var(--cursor, default); + + &:hover { + background-color: var(--ig-gray-400); + } + + [part='start-expander'], + [part='end-expander'] { + cursor: pointer; + width: 5px; + height: 5px; + } + + [part='start-expander'] { + background-color: red; + } + + [part='end-expander'] { + background-color: green; + } + + [part='handle'] { + background-color: yellow; + } + } +} + +// Horizontal orientation (default) +:host(:not([orientation='vertical'])) { + [part='base'] { + flex-direction: row; + } + + [part='bar'] { + flex-direction: column; + width: var(--bar-size); + height: 100%; + + [part='handle'] { + height: 50px; + } + } +} + +// Vertical orientation +:host([orientation='vertical']) { + [part='base'] { + flex-direction: column; + } + + [part='bar'] { + flex-direction: row; + width: 100%; + height: var(--bar-size); + + [part='handle'] { + width: 50px; + } + } +} + +// Collapsed states +:host([start-collapsed]) { + [part='start-pane'] { + display: none; + } +} + +:host([end-collapsed]) { + [part~='end-pane'] { + display: none; + } +} diff --git a/src/components/types.ts b/src/components/types.ts index ab829fcdd..8f213462c 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -47,6 +47,7 @@ export type MaskInputValueMode = 'raw' | 'withFormatting'; export type NavDrawerPosition = 'start' | 'end' | 'top' | 'bottom' | 'relative'; export type SliderTickLabelRotation = 0 | 90 | -90; export type SliderTickOrientation = 'end' | 'mirror' | 'start'; +export type SplitterOrientation = 'horizontal' | 'vertical'; export type StepperOrientation = 'horizontal' | 'vertical'; export type StepperStepType = 'full' | 'indicator' | 'title'; export type StepperTitlePosition = 'auto' | 'bottom' | 'top' | 'end' | 'start'; diff --git a/src/index.ts b/src/index.ts index 3a0e3a6e5..0d294b78e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ export { default as IgcSwitchComponent } from './components/checkbox/switch.js'; export { default as IgcTextareaComponent } from './components/textarea/textarea.js'; export { default as IgcTreeComponent } from './components/tree/tree.js'; export { default as IgcTreeItemComponent } from './components/tree/tree-item.js'; +export { default as IgcSplitterComponent } from './components/splitter/splitter.js'; export { default as IgcStepperComponent } from './components/stepper/stepper.js'; export { default as IgcStepComponent } from './components/stepper/step.js'; export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts new file mode 100644 index 000000000..e2f172c08 --- /dev/null +++ b/stories/splitter.stories.ts @@ -0,0 +1,194 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; + +import { defineComponents } from 'igniteui-webcomponents'; +import IgcSplitterComponent from '../src/components/splitter/splitter.js'; +import { disableStoryControls } from './story.js'; + +defineComponents(IgcSplitterComponent); + +const metadata: Meta = { + title: 'Splitter', + component: 'igc-splitter', + parameters: { + docs: { + description: { + component: + 'The Splitter lays out panes with draggable bars rendered between each pair of panes.', + }, + }, + actions: { + handles: ['igcResizeStart', 'igcResizing', 'igcResizeEnd'], + }, + }, + argTypes: { + orientation: { + options: ['horizontal', 'vertical'], + control: { type: 'inline-radio' }, + description: 'Orientation of the splitter.', + table: { defaultValue: { summary: 'horizontal' } }, + }, + nonCollapsible: { + type: 'boolean', + description: 'Disables pane collapsing.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + nonResizable: { + type: 'boolean', + description: 'Disables pane resizing.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + startCollapsed: { + type: 'boolean', + description: 'Collapses the start pane.', + table: { defaultValue: { summary: 'false' } }, + }, + endCollapsed: { + type: 'boolean', + description: 'Collapses the end pane.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + startSize: { + control: { type: 'text' }, + description: 'Size of the start pane (e.g., "200px", "50%", "auto").', + }, + endSize: { + control: { type: 'text' }, + description: 'Size of the end pane (e.g., "200px", "50%", "auto").', + }, + startMinSize: { + control: { type: 'text' }, + description: 'Minimum size of the start pane.', + }, + startMaxSize: { + control: { type: 'text' }, + description: 'Maximum size of the start pane.', + }, + endMinSize: { + control: { type: 'text' }, + description: 'Minimum size of the end pane.', + }, + endMaxSize: { + control: { type: 'text' }, + description: 'Maximum size of the end pane.', + }, + }, + args: { + orientation: 'horizontal', + nonCollapsible: false, + nonResizable: false, + startCollapsed: false, + endCollapsed: false, + }, +}; + +export default metadata; + +interface IgcSplitterArgs { + orientation: 'horizontal' | 'vertical'; + nonCollapsible: boolean; + nonResizable: boolean; + startCollapsed: boolean; + endCollapsed: boolean; + startSize?: string; + endSize?: string; + startMinSize?: string; + startMaxSize?: string; + endMinSize?: string; + endMaxSize?: string; +} + +type Story = StoryObj; + +function changePaneMinMaxSizes() { + const splitter = document.querySelector('igc-splitter'); + if (!splitter) { + return; + } + splitter.startMinSize = '50px'; + splitter.startMaxSize = '200px'; + splitter.endMinSize = '100px'; + splitter.endMaxSize = '300px'; +} + +export const Default: Story = { + render: ({ + orientation, + nonCollapsible, + nonResizable, + startCollapsed, + endCollapsed, + startSize, + endSize, + startMinSize, + startMaxSize, + endMinSize, + endMaxSize, + }) => html` + + +
+ +
Pane 1
+
Pane 2
+
+
+ Change All Panes Min/Max Sizes + `, +}; + +export const NestedSplitters: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` + + + +
+ +
Top Left Pane
+ +
Bottom Left Pane
+
+
+ +
+ +
Top Right Pane
+
Bottom Right Pane
+
+
+
+ `, +};