diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 832f0f87a..66d728f99 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'; @@ -136,6 +137,7 @@ const allComponents: IgniteComponent[] = [ IgcCircularGradientComponent, IgcSnackbarComponent, IgcDateTimeInputComponent, + IgcSplitterComponent, IgcStepperComponent, IgcStepComponent, IgcTextareaComponent, diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts new file mode 100644 index 000000000..b08a4a591 --- /dev/null +++ b/src/components/splitter/splitter.spec.ts @@ -0,0 +1,3079 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; +import { spy } from 'sinon'; +import { + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + ctrlKey, + endKey, + homeKey, +} from '../common/controllers/key-bindings.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { roundPrecise } from '../common/util.js'; +import { + simulateKeyboard, + simulatePointerDown, + simulatePointerMove, + simulatePointerUp, +} from '../common/utils.spec.js'; +import IgcTreeComponent from '../tree/tree.js'; +import IgcTreeItemComponent from '../tree/tree-item.js'; +import type { SplitterOrientation } from '../types.js'; +import IgcSplitterComponent, { + type IgcSplitterResizeEventDetail, +} from './splitter.js'; + +const BAR_PART = 'splitter-bar'; +const START_PART = 'start-panel'; +const END_PART = 'end-panel'; +const START_EXPANDER_PART = 'start-expand-btn'; +const END_EXPANDER_PART = 'end-expand-btn'; +const START_COLLAPSE_PART = 'start-collapse-btn'; +const END_COLLAPSE_PART = 'end-collapse-btn'; +const DRAG_HANDLE_PART = 'drag-handle'; + +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(); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.getAttribute('role')).to.equal('separator'); + }); + + it('should render both panes with equal sizes if no explicit sizes set', async () => { + const startPart = getSplitterPart(splitter, START_PART); + const endPart = getSplitterPart(splitter, END_PART); + + const startStyle = getComputedStyle(startPart); + const endStyle = getComputedStyle(endPart); + + const totalWidth = getTotalSize(splitter, 'width'); + const startWidth = Number.parseFloat(startStyle.width); + const endWidth = Number.parseFloat(endStyle.width); + + expect(startWidth).to.be.closeTo(totalWidth / 2, 1); + expect(endWidth).to.be.closeTo(totalWidth / 2, 1); + }); + + it('should render both panes with equal sizes if no explicit sizes set - vertical', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + const startPart = getSplitterPart(splitter, START_PART); + const endPart = getSplitterPart(splitter, END_PART); + + const startStyle = getComputedStyle(startPart); + const endStyle = getComputedStyle(endPart); + + const totalHeight = getTotalSize(splitter, 'height'); + const startHeight = Number.parseFloat(startStyle.height); + const endHeight = Number.parseFloat(endStyle.height); + + expect(startHeight).to.be.closeTo(totalHeight / 2, 1); + expect(endHeight).to.be.closeTo(totalHeight / 2, 1); + }); + + it('should render splitter bar between start and end parts', async () => { + const base = getSplitterPart(splitter, 'base'); + const startPart = getSplitterPart(splitter, START_PART); + const endPart = getSplitterPart(splitter, END_PART); + const bar = getSplitterPart(splitter, BAR_PART); + + 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_PART); + const expanderStartCollapseBtn = getSplitterPart( + splitter, + START_COLLAPSE_PART + ); + const barHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + const expanderEndCollapseBtn = getSplitterPart( + splitter, + END_COLLAPSE_PART + ); + + expect(expanderStartCollapseBtn).to.exist; + expect(barHandle).to.exist; + expect(expanderEndCollapseBtn).to.exist; + + expect(bar.contains(expanderStartCollapseBtn)).to.be.true; + expect(bar.contains(expanderEndCollapseBtn)).to.be.true; + expect(bar.contains(barHandle)).to.be.true; + + expect(expanderStartCollapseBtn.nextElementSibling).to.equal(barHandle); + expect(barHandle.nextElementSibling).to.equal(expanderEndCollapseBtn); + }); + + it('should not display the collapse/expand button parts if disableCollapse is true', async () => { + splitter.disableCollapse = true; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.children).to.have.lengthOf(3); + + let startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + let endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + let dragHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + + expect(startCollapseBtn.hidden).to.be.true; + expect(endCollapseBtn.hidden).to.be.true; + expect(dragHandle.hidden).to.be.false; + + // verify this with programmatic expand/collapse as well + splitter.toggle('start'); + await elementUpdated(splitter); + + startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + dragHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + + const startExpandButton = getSplitterPart(splitter, START_EXPANDER_PART); + + expect(startExpandButton.hidden).to.be.true; + expect(startCollapseBtn.hidden).to.be.true; + expect(endCollapseBtn).to.be.null; + expect(dragHandle.hidden).to.be.false; + }); + + it('should not display the collapse/expand button parts if hideCollapseButtons is true', async () => { + splitter.hideCollapseButtons = true; + await elementUpdated(splitter); + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.children).to.have.lengthOf(3); + + let startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + let endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + let dragHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + + expect(startCollapseBtn.hidden).to.be.true; + expect(endCollapseBtn.hidden).to.be.true; + expect(dragHandle.hidden).to.be.false; + + splitter.toggle('end'); + await elementUpdated(splitter); + + startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + dragHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + + const startExpandButton = getSplitterPart(splitter, START_EXPANDER_PART); + + expect(startExpandButton).to.be.null; + expect(startCollapseBtn).to.be.null; + expect(endCollapseBtn.hidden).to.be.true; + expect(dragHandle.hidden).to.be.false; + }); + + it('should not display bar handle if disableResize is true', async () => { + splitter.disableResize = true; + await elementUpdated(splitter); + + const barHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + const startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + const endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + expect(barHandle.hidden).to.be.true; + expect(startCollapseBtn.hidden).to.be.false; + expect(endCollapseBtn.hidden).to.be.false; + }); + + it('should not display bar handle if hideDragHandle is true', async () => { + splitter.hideDragHandle = true; + await elementUpdated(splitter); + + const barHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + const startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + const endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + expect(barHandle.hidden).to.be.true; + expect(startCollapseBtn.hidden).to.be.false; + expect(endCollapseBtn.hidden).to.be.false; + + // Splitter bar is still focusable + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + expect(splitter.shadowRoot!.activeElement).to.equal(bar); + + const previousSizes = getPanesSizes(splitter, 'width'); + const resizeDelta = 10; + + // Splitter bar is still interactive for resizing + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + const currentSizes = getPanesSizes(splitter, 'width'); + const newStart = previousSizes.startSize + resizeDelta; + const newEnd = previousSizes.endSize - resizeDelta; + expect(currentSizes.startSize).to.equal(newStart); + expect(currentSizes.endSize).to.equal(newEnd); + }); + + 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'); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.getAttribute('aria-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'); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.getAttribute('aria-orientation')).to.equal('vertical'); + }); + + 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_PART); + + const style = getComputedStyle(bar); + expect(style.cursor).to.equal('col-resize'); + + splitter.disableResize = true; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('default'); + + splitter.disableResize = 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_PART); + + 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'); + }); + + it('should reset sizes when pane is initially collapsed.', async () => { + splitter = await fixture( + createSplitterWithCollapsedPane() + ); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('auto'); + expect(splitter.endSize).to.equal('auto'); + }); + + it('should reset sizes when pane is runtime collapsed.', async () => { + splitter.startSize = '200px'; + splitter.endSize = '30%'; + await elementUpdated(splitter); + + splitter.toggle('start'); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('auto'); + expect(splitter.endSize).to.equal('auto'); + }); + }); + + describe('Slotted content', () => { + 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 update content when slot content changes', async () => { + let slot = getSplitterSlot(splitter, 'start'); + let elements = slot.assignedElements(); + expect(elements[0].textContent).to.equal('Pane 1'); + + elements[0].textContent = 'Updated Pane 1'; + await elementUpdated(splitter); + + slot = getSplitterSlot(splitter, 'start'); + elements = slot.assignedElements(); + expect(elements[0].textContent).to.equal('Updated Pane 1'); + }); + + it('should render complex content (forms, tables, etc.) correctly', async () => { + splitter = await fixture( + createSplitterWithComplexContent() + ); + await elementUpdated(splitter); + + const startSlot = getSplitterSlot(splitter, 'start'); + let elements = startSlot.assignedElements(); + let slottedDiv = elements.find( + (el) => el.tagName.toLowerCase() === 'div' + )!; + let children = slottedDiv.children!; + expect(children).to.have.lengthOf(3); + expect(children[0].tagName.toLowerCase()).to.equal('h1'); + expect(children[1].tagName.toLowerCase()).to.equal('form'); + const formElements = children[1].children; + expect(formElements).to.have.lengthOf(2); + expect(formElements[0].tagName.toLowerCase()).to.equal('input'); + expect(formElements[1].tagName.toLowerCase()).to.equal('button'); + expect(children[2].tagName.toLowerCase()).to.equal('button'); + + const endSlot = getSplitterSlot(splitter, 'end'); + elements = endSlot.assignedElements(); + slottedDiv = elements.find((el) => el.tagName.toLowerCase() === 'div')!; + children = slottedDiv.children!; + expect(children).to.have.lengthOf(2); + expect(children[0].tagName.toLowerCase()).to.equal('h1'); + expect(children[1].tagName.toLowerCase()).to.equal('igc-tree'); + }); + + describe('Custom icons', () => { + beforeEach(async () => { + splitter = await fixture( + createSplitterWithCustomSlots() + ); + }); + + it('should render custom drag handle via drag-handle slot', async () => { + const dragHandlePart = getSplitterPart(splitter, DRAG_HANDLE_PART); + const dragHandleSlot = getSplitterSlot(splitter, 'drag-handle'); + const assignedElements = dragHandleSlot.assignedElements(); + expect(assignedElements).to.have.lengthOf(1); + + const customIcon = assignedElements[0] as HTMLElement; + expect(customIcon.tagName.toLowerCase()).to.equal('span'); + expect(customIcon.textContent?.trim()).to.equal('drag-handle-icon'); + expect(dragHandlePart.contains(dragHandleSlot)).to.be.true; + }); + + it('should render custom expand icons via start-expand and end-expand slots', async () => { + splitter.toggle('start'); + await elementUpdated(splitter); + + const startExpandPart = getSplitterPart(splitter, START_EXPANDER_PART); + const startExpandSlot = getSplitterSlot(splitter, 'start-expand'); + const startAssignedElements = startExpandSlot.assignedElements(); + + expect(startAssignedElements).to.have.lengthOf(1); + const startCustomIcon = startAssignedElements[0] as HTMLElement; + expect(startCustomIcon.tagName.toLowerCase()).to.equal('span'); + expect(startCustomIcon.classList.contains('custom-icon')).to.be.true; + expect(startCustomIcon.textContent?.trim()).to.equal( + 'start-expand-icon' + ); + expect(startExpandPart.contains(startExpandSlot)).to.be.true; + + splitter.toggle('start'); + await elementUpdated(splitter); + splitter.toggle('end'); + await elementUpdated(splitter); + + const endExpandPart = getSplitterPart(splitter, END_EXPANDER_PART); + const endExpandSlot = getSplitterSlot(splitter, 'end-expand'); + const endAssignedElements = endExpandSlot.assignedElements(); + + expect(endAssignedElements).to.have.lengthOf(1); + const endCustomIcon = endAssignedElements[0] as HTMLElement; + expect(endCustomIcon.tagName.toLowerCase()).to.equal('span'); + expect(endCustomIcon.classList.contains('custom-icon')).to.be.true; + expect(endCustomIcon.textContent?.trim()).to.equal('end-expand-icon'); + expect(endExpandPart.contains(endExpandSlot)).to.be.true; + }); + + it('should render custom collapse icons via start-collapse and end-collapse slots', async () => { + const startCollapsePart = getSplitterPart( + splitter, + START_COLLAPSE_PART + ); + const startCollapseSlot = getSplitterSlot(splitter, 'start-collapse'); + const startAssignedElements = startCollapseSlot.assignedElements(); + + expect(startAssignedElements).to.have.lengthOf(1); + const startCustomIcon = startAssignedElements[0] as HTMLElement; + expect(startCustomIcon.tagName.toLowerCase()).to.equal('span'); + expect(startCustomIcon.classList.contains('custom-icon')).to.be.true; + expect(startCustomIcon.textContent?.trim()).to.equal( + 'start-collapse-icon' + ); + expect(startCollapsePart.contains(startCollapseSlot)).to.be.true; + + const endCollapsePart = getSplitterPart(splitter, END_COLLAPSE_PART); + const endCollapseSlot = getSplitterSlot(splitter, 'end-collapse'); + const endAssignedElements = endCollapseSlot.assignedElements(); + + expect(endAssignedElements).to.have.lengthOf(1); + const endCustomIcon = endAssignedElements[0] as HTMLElement; + expect(endCustomIcon.tagName.toLowerCase()).to.equal('span'); + expect(endCustomIcon.classList.contains('custom-icon')).to.be.true; + expect(endCustomIcon.textContent?.trim()).to.equal('end-collapse-icon'); + expect(endCollapsePart.contains(endCollapseSlot)).to.be.true; + }); + }); + }); + + describe('Properties', () => { + it('should change panels state from the startExpanded and endExpanded properties', async () => { + splitter.startCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + splitter.endCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + splitter.startCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + }); + + it('should reset pane sizes when orientation changes', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation: 'horizontal', + startSize: '200px', + startMinSize: '100px', + startMaxSize: '300px', + endSize: '100px', + endMinSize: '100px', + endMaxSize: '300px', + }) + ); + await elementUpdated(splitter); + + const startPart = getSplitterPart(splitter, START_PART); + const style = getComputedStyle(startPart); + expect(style.flex).to.equal('0 1 200px'); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('auto'); + expect(style.flex).to.equal('1 1 0px'); + + expect(splitter.startMinSize).to.be.undefined; + expect(splitter.startMaxSize).to.be.undefined; + + expect(style.minHeight).to.equal('0px'); + expect(style.maxHeight).to.equal('100%'); + expect(style.minWidth).to.equal('0px'); + expect(style.maxWidth).to.equal('100%'); + }); + + it('should set pane sizes to alternative CSS units such as em, rem, etc.', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '5em', + endSize: '2rem', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + document.body.style.fontSize = '16px'; + splitter.style.fontSize = '10px'; + await elementUpdated(splitter); + + const startPart = getSplitterPart(splitter, START_PART); + const style = getComputedStyle(startPart); + + const expectedStartSizeInPixels = 5 * 10; // 5em with font size of 10px + expect(style.flex).to.equal( + `0 1 ${expectedStartSizeInPixels.toString()}px` + ); + + const endPart = getSplitterPart(splitter, END_PART); + const style2 = getComputedStyle(endPart); + + const expectedEndSizeInPixels = 2 * 16; // 2rem with root font size of 16px + expect(style2.flex).to.equal( + `0 1 ${expectedEndSizeInPixels.toString()}px` + ); + }); + + it('should properly set default min/max values when not specified', async () => { + await elementUpdated(splitter); + + const startPart = getSplitterPart(splitter, START_PART); + const style = getComputedStyle(startPart); + expect(style.flex).to.equal('1 1 0px'); + + 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_PART); + 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_PART); + const style = getComputedStyle(startPane); + expect(style.minHeight).to.equal('100px'); + expect(style.maxHeight).to.equal('500px'); + }); + + it('should handle percentage sizes - horizontal and vertical', async () => { + const testPercentageSizes = async (orientation: SplitterOrientation) => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '30%', + endSize: '70%', + startMinSize: '20%', + startMaxSize: '80%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + const totalAvailable = getTotalSize( + splitter, + orientation === 'horizontal' ? 'width' : 'height' + ); + const startPane = getSplitterPart(splitter, START_PART); + const style1 = getComputedStyle(startPane); + + const endPane = getSplitterPart(splitter, END_PART); + const style2 = getComputedStyle(endPane); + const sizes = getPanesSizes( + splitter, + orientation === 'horizontal' ? 'width' : 'height' + ); + + expect(sizes.startSize).to.be.closeTo(totalAvailable * 0.3, 2); + expect(sizes.endSize).to.be.closeTo(totalAvailable * 0.7, 2); + + 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%'); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.getAttribute('aria-valuenow')).to.equal('30'); + expect(bar.getAttribute('aria-valuemin')).to.equal('20'); + expect(bar.getAttribute('aria-valuemax')).to.equal('80'); + + const minProp = orientation === 'horizontal' ? 'minWidth' : 'minHeight'; + expect(style1[minProp]).to.equal('20%'); + const maxProp = orientation === 'horizontal' ? 'maxWidth' : 'maxHeight'; + expect(style1[maxProp]).to.equal('80%'); + }; + + await testPercentageSizes('horizontal'); + await testPercentageSizes('vertical'); + }); + + it('should handle mixed % and auto size - horizontal and vertical', async () => { + const testMixedSizes = async (orientation: SplitterOrientation) => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + endSize: '30%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + const totalAvailable = getTotalSize( + splitter, + orientation === 'horizontal' ? 'width' : 'height' + ); + + const startPart = getSplitterPart(splitter, START_PART); + const style = getComputedStyle(startPart); + expect(style.flex).to.equal('1 1 0px'); + + const sizes = getPanesSizes( + splitter, + orientation === 'horizontal' ? 'width' : 'height' + ); + const expectedEndSize = roundPrecise((30 / 100) * totalAvailable, 2); + expect(sizes.endSize).to.be.closeTo(expectedEndSize, 2); + + // When only one size is set, the other panel fills remaining space + expect(sizes.startSize).to.be.closeTo( + totalAvailable - expectedEndSize, + 2 + ); + + const endPart = getSplitterPart(splitter, END_PART); + const styleEnd = getComputedStyle(endPart); + expect(styleEnd.flex).to.equal('0 1 30%'); + }; + await testMixedSizes('horizontal'); + await testMixedSizes('vertical'); + }); + }); + + 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; + + // Single collapsed pane constraint + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + splitter.toggle('end'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + }); + + it('should toggle the next pane when the bar expander-end parts are clicked', async () => { + let parts = getButtonParts(splitter); + expect(parts.endCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse end pane' + ); + + simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + parts = getButtonParts(splitter); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + expect(parts.startCollapseBtn).to.be.null; + expect(parts.endCollapseBtn.hidden).to.be.true; + expect(parts.startExpander).to.be.null; + expect(parts.endExpander.hidden).to.be.false; + expect(parts.endExpander.getAttribute('aria-label')).to.equal( + 'Expand end pane' + ); + + simulatePointerDown(parts.endExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.hidden).to.be.false; + expect(parts.endCollapseBtn.hidden).to.be.false; + expect(parts.startExpander).to.be.null; + expect(parts.endExpander).to.be.null; + expect(parts.startCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse start pane' + ); + expect(parts.endCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse end pane' + ); + }); + + it('should toggle the previous pane when the bar expander-start parts are clicked', async () => { + let parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse start pane' + ); + + simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + parts = getButtonParts(splitter); + + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + expect(parts.startCollapseBtn.hidden).to.be.true; + expect(parts.startExpander.hidden).to.be.false; + expect(parts.endCollapseBtn).to.be.null; + expect(parts.endExpander).to.be.null; + expect(parts.startExpander.getAttribute('aria-label')).to.equal( + 'Expand start pane' + ); + + simulatePointerDown(parts.startExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + parts = getButtonParts(splitter); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + expect(parts.startCollapseBtn.hidden).to.be.false; + expect(parts.endCollapseBtn.hidden).to.be.false; + expect(parts.startExpander).to.be.null; + expect(parts.endExpander).to.be.null; + expect(parts.startCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse start pane' + ); + expect(parts.endCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse end pane' + ); + }); + + it('should set tabindex correctly on the bar based on interactivity', async () => { + const bar = getSplitterPart(splitter, BAR_PART); + + expect(bar.getAttribute('tabindex')).to.equal('0'); + + splitter.disableResize = true; + await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('0'); + + splitter.disableResize = false; + splitter.disableCollapse = true; + await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('0'); + + splitter.disableResize = true; + await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('-1'); + }); + + 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); + + const newStart = previousSizes.startSize + deltaX; + const newEnd = previousSizes.endSize - deltaX; + + let startArgs = { + startPanelSize: previousSizes.startSize, + endPanelSize: previousSizes.endSize, + }; + let resizingArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + delta: deltaX, + }; + let endArgs = resizingArgs; + + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); + + 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); + + startArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + }; + resizingArgs = { + startPanelSize: currentSizes.startSize, + endPanelSize: currentSizes.endSize, + delta: deltaX, + }; + endArgs = resizingArgs; + + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); + }); + + 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); + + const newStart = previousSizes.startSize + deltaY; + const newEnd = previousSizes.endSize - deltaY; + + let startArgs = { + startPanelSize: previousSizes.startSize, + endPanelSize: previousSizes.endSize, + }; + let resizingArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + delta: deltaY, + }; + let endArgs = resizingArgs; + + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); + + 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); + startArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + }; + resizingArgs = { + startPanelSize: currentSizes.startSize, + endPanelSize: previousSizes.endSize, + delta: deltaY, + }; + endArgs = resizingArgs; + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); + }); + + it('should respect minSize and maxSize constraints when resizing with arrows', async () => { + splitter.style.width = '1000px'; + splitter.startMinSize = '100px'; + splitter.startMaxSize = '400px'; + splitter.endMinSize = '50px'; + splitter.startSize = '250px'; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, BAR_PART); + let barSize = bar.getBoundingClientRect().width; + bar.focus(); + await elementUpdated(splitter); + + // resize below minSize + for (let i = 0; i < 20; i++) { + simulateKeyboard(bar, arrowLeft); + await elementUpdated(splitter); + } + + let currentSizes = getPanesSizes(splitter, 'width'); + // should stop at minSize (100px) + expect(currentSizes.startSize).to.be.closeTo(100, 1); + + splitter.startSize = '250px'; + await elementUpdated(splitter); + + // resize beyond maxSize + for (let i = 0; i < 20; i++) { + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + } + + currentSizes = getPanesSizes(splitter, 'width'); + // should stop at maxSize (400px) + expect(currentSizes.startSize).to.be.closeTo(400 - barSize, 2); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + splitter.startMinSize = '150px'; + splitter.startMaxSize = '350px'; + splitter.endMinSize = '50px'; + splitter.style.height = '1000px'; + splitter.startSize = '250px'; + await elementUpdated(splitter); + + barSize = bar.getBoundingClientRect().height; + bar.focus(); + await elementUpdated(splitter); + + for (let i = 0; i < 15; i++) { + simulateKeyboard(bar, arrowUp); + await elementUpdated(splitter); + } + + currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.be.closeTo(150, 2); + + splitter.startSize = '250px'; + await elementUpdated(splitter); + + for (let i = 0; i < 15; i++) { + simulateKeyboard(bar, arrowDown); + await elementUpdated(splitter); + } + + currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.be.closeTo(350, 2); + }); + + it('should resize horizontally by 10px delta with left/right arrow keys', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const bar = getSplitterPart(splitter, BAR_PART); + let previousSizes = getPanesSizes(splitter, 'width'); + const resizeDelta = 10; + + bar.focus(); + await elementUpdated(splitter); + + expect(bar.getAttribute('tabindex')).to.equal('0'); + + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + let newStart = previousSizes.startSize + resizeDelta; + let newEnd = previousSizes.endSize - resizeDelta; + expect(currentSizes.startSize).to.equal(newStart); + expect(currentSizes.endSize).to.equal(newEnd); + + // Focus is not lost during resize + expect(bar.getAttribute('tabindex')).to.equal('0'); + + let startArgs = { + startPanelSize: previousSizes.startSize, + endPanelSize: previousSizes.endSize, + }; + let resizingArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + delta: resizeDelta, + }; + let endArgs = resizingArgs; + + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); + + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + newStart = previousSizes.startSize + resizeDelta * 2; + newEnd = previousSizes.endSize - resizeDelta * 2; + expect(currentSizes.startSize).to.equal(newStart); + expect(currentSizes.endSize).to.equal(newEnd); + + expect(bar.getAttribute('tabindex')).to.equal('0'); + + startArgs = { + startPanelSize: previousSizes.startSize + resizeDelta, + endPanelSize: previousSizes.endSize - resizeDelta, + }; + resizingArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + delta: resizeDelta, + }; + endArgs = resizingArgs; + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); + + previousSizes = getPanesSizes(splitter, 'width'); + simulateKeyboard(bar, arrowLeft); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + newStart = previousSizes.startSize - resizeDelta; + newEnd = previousSizes.endSize + resizeDelta; + expect(currentSizes.startSize).to.equal(newStart); + expect(currentSizes.endSize).to.equal(newEnd); + + 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_PART); + 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 set start pane size to minSize/maxSize with Home/End key in horizontal orientation', async () => { + splitter.startMinSize = '100px'; + splitter.startMaxSize = '80%'; + await elementUpdated(splitter); + + const totalAvailable = getTotalSize(splitter, 'width'); + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, homeKey); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('100px'); + expect(splitter.endSize).to.equal(`${totalAvailable - 100}px`); + + const minPercent = Math.round((100 / totalAvailable) * 100); + expect(bar.getAttribute('aria-valuenow')).to.equal(minPercent.toString()); + + simulateKeyboard(bar, endKey); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('80%'); + expect(splitter.endSize).to.equal('20%'); + + expect(bar.getAttribute('aria-valuenow')).to.equal('80'); + expect(bar.getAttribute('aria-valuemax')).to.equal('80'); + }); + + it('should set start pane size to minSize/maxSize with Home/End key in vertical orientation', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + const totalAvailable = getTotalSize(splitter, 'height'); + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, homeKey); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('0px'); + expect(splitter.endSize).to.equal(`${totalAvailable}px`); + + simulateKeyboard(bar, endKey); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('100%'); + expect(splitter.endSize).to.equal('0%'); + }); + + 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_PART); + 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_PART); + 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_PART); + bar.focus(); + await elementUpdated(splitter); + + expect(bar.getAttribute('tabindex')).to.equal('0'); + + 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; + + // Focus is not lost during collapse + expect(bar.getAttribute('tabindex')).to.equal('0'); + + 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; + expect(bar.getAttribute('tabindex')).to.equal('0'); + + 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; + expect(bar.getAttribute('tabindex')).to.equal('0'); + }); + + 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_PART); + bar.focus(); + await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('0'); + + 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; + expect(bar.getAttribute('tabindex')).to.equal('0'); + + 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; + expect(bar.getAttribute('tabindex')).to.equal('0'); + + 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 disableResize is true', async () => { + splitter.disableResize = true; + await elementUpdated(splitter); + + const eventSpy = spy(splitter, 'emitEvent'); + let previousSizes = getPanesSizes(splitter, 'width'); + const bar = getSplitterPart(splitter, BAR_PART); + // Splitter bar is still visible but non-interactive + expect(bar).to.exist; + expect(bar.hidden).to.be.false; + // Bar handle is hidden + const barHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + expect(barHandle).to.exist; + expect(barHandle.hidden).to.be.true; + + 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 disableCollapse is true', async () => { + splitter.disableCollapse = true; + await elementUpdated(splitter); + + expect(splitter.disableCollapse).to.be.true; + expect(splitter.hasAttribute('disable-collapse')).to.be.true; + + const bar = getSplitterPart(splitter, BAR_PART); + 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; + }); + + it('should expand/collapse panes via keyboard and API when hideCollapseButtons is true', async () => { + splitter.hideCollapseButtons = true; + await elementUpdated(splitter); + + expect(splitter.hideCollapseButtons).to.be.true; + expect(splitter.hasAttribute('hide-collapse-buttons')).to.be.true; + + const bar = getSplitterPart(splitter, BAR_PART); + + 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; + + splitter.toggle('start'); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, '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; + }); + + it('should not be able to resize a pane when it is collapsed', async () => { + splitter.toggle('start'); + await elementUpdated(splitter); + const previousSizes = getPanesSizes(splitter, 'width'); + + await resize(splitter, 100, 0); + const currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes).to.deep.equal(previousSizes); + }); + }); + + describe('Resizing with constraints', () => { + const testMinMaxConstraintsPx = async ( + orientation: SplitterOrientation + ) => { + const mixedConstraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '200px', + startMinSize: '100px', + startMaxSize: '300px', + endSize: '200px', + endMinSize: '100px', + endMaxSize: '300px', + }) + ); + await elementUpdated(mixedConstraintSplitter); + + const isX = orientation === 'horizontal'; + let delta = 1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + let sizes = getPanesSizes( + mixedConstraintSplitter, + isX ? 'width' : 'height' + ); + expect(sizes.startSize).to.equal(300); + + delta = -1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + sizes = getPanesSizes(mixedConstraintSplitter, isX ? 'width' : 'height'); + expect(sizes.startSize).to.equal(100); + + delta = 1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + sizes = getPanesSizes(mixedConstraintSplitter, isX ? 'width' : 'height'); + expect(sizes.endSize).to.equal(100); + + delta = -1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + sizes = getPanesSizes(mixedConstraintSplitter, isX ? 'width' : 'height'); + expect(sizes.endSize).to.equal(300); + }; + + const testMinMaxConstraintsInPercentage = async ( + orientation: SplitterOrientation + ) => { + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '30%', + startMinSize: '10%', + startMaxSize: '80%', + endSize: '70%', + endMinSize: '20%', + endMaxSize: '90%', + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); + + let delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + let sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + const expectedStartMax = Math.round((totalAvailable * 80) / 100); + expect(sizes.startSize).to.be.closeTo(expectedStartMax, 2); + + delta = -1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + const expectedStartMin = Math.round((totalAvailable * 10) / 100); + expect(sizes.startSize).to.be.closeTo(expectedStartMin, 2); + + delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + const expectedEndMin = Math.round((totalAvailable * 20) / 100); + expect(sizes.endSize).to.be.closeTo(expectedEndMin, 2); + + delta = -1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + const expectedEndMax = Math.round((totalAvailable * 90) / 100); + expect(sizes.endSize).to.be.closeTo(expectedEndMax, 2); + }; + + const testConflictingConstraintsInPx = async ( + orientation: SplitterOrientation + ) => { + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '200px', + startMinSize: '100px', + startMaxSize: '400px', + endSize: '200px', + endMinSize: '150px', + endMaxSize: '350px', + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); + + const initialSizes = getPanesSizes( + constraintSplitter, + isX ? 'width' : 'height' + ); + const initialCombinedSize = initialSizes.startSize + initialSizes.endSize; + + // Try to grow start pane to max, but end pane has min (150px) + // Result: Start pane can only grow as much as end pane allows + const delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + + // Start pane can only grow until end pane hits its minSize + // Within the initial combined size of 400px - because of flex basis + expect(sizes.startSize).to.equal(250); + expect(sizes.endSize).to.equal(150); + + expect(sizes.startSize + sizes.endSize).to.equal(initialCombinedSize); + expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + }; + + const testConflictingConstraintsInPercentage = async ( + orientation: SplitterOrientation + ) => { + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '40%', + startMinSize: '20%', + startMaxSize: '80%', + endSize: '60%', + endMinSize: '30%', + endMaxSize: '70%', + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); + + const initialSizes = getPanesSizes( + constraintSplitter, + isX ? 'width' : 'height' + ); + const initialCombinedSize = initialSizes.startSize + initialSizes.endSize; + + // Try to grow start pane to max (80%), but end pane has min (30%) + // Result: Start pane can only grow as much as end pane allows + const delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + + // Start pane can only grow until end pane hits its minSize (30% of 495px) + // Within the initial combined size - because of flex basis + const expectedEndMin = Math.round((totalAvailable * 30) / 100); + const expectedStartAfterResize = initialCombinedSize - expectedEndMin; + + expect(sizes.startSize).to.be.closeTo(expectedStartAfterResize, 2); + expect(sizes.endSize).to.be.closeTo(expectedEndMin, 2); + + expect(sizes.startSize + sizes.endSize).to.be.closeTo( + initialCombinedSize, + 2 + ); + expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + }; + + const testMixedConstraintsPxAndPercentage = async ( + orientation: SplitterOrientation + ) => { + const mixedConstraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startMinSize: '100px', + startMaxSize: '50%', + }) + ); + await elementUpdated(mixedConstraintSplitter); + + const startPane = getSplitterPart(mixedConstraintSplitter, START_PART); + const style = getComputedStyle(startPane); + + expect(mixedConstraintSplitter.startMinSize).to.equal('100px'); + expect(mixedConstraintSplitter.startMaxSize).to.equal('50%'); + const targetMinProp = + orientation === 'horizontal' ? 'minWidth' : 'minHeight'; + const targetMaxProp = + orientation === 'horizontal' ? 'maxWidth' : 'maxHeight'; + expect(style[targetMinProp]).to.equal('100px'); + expect(style[targetMaxProp]).to.equal('50%'); + + const isX = orientation === 'horizontal'; + const totalAvailable = getTotalSize( + mixedConstraintSplitter, + isX ? 'width' : 'height' + ); + const expectedEndMax = Math.round((totalAvailable * 50) / 100); + + let delta = 1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes( + mixedConstraintSplitter, + isX ? 'width' : 'height' + ); + expect(sizes.startSize).to.be.closeTo(totalAvailable - expectedEndMax, 2); + expect(sizes.endSize).to.be.closeTo(expectedEndMax, 2); + + delta = -1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizesAfterSecondResize = getPanesSizes( + mixedConstraintSplitter, + isX ? 'width' : 'height' + ); + expect(sizesAfterSecondResize.startSize).to.equal(100); + expect(sizesAfterSecondResize.endSize).to.equal(totalAvailable - 100); + }; + + const testConstraintsPxAndAutoSizes = async ( + orientation: SplitterOrientation + ) => { + const startMaxSize = 400; + const startMinSize = 100; + const endMaxSize = 350; + const endMinSize = 150; + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + endSize: '200px', + startMinSize: `${startMinSize}px`, + startMaxSize: `${startMaxSize}px`, + endMinSize: `${endMinSize}px`, + endMaxSize: `${endMaxSize}px`, + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); + + const initialSizes = getPanesSizes( + constraintSplitter, + isX ? 'width' : 'height' + ); + const initialCombinedSize = initialSizes.startSize + initialSizes.endSize; + + // Try to grow start pane to max, but end pane has min (150px) + // Result: Start pane can only grow as much as end pane allows + const delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + + // Start pane can only grow until end pane hits its minSize + expect(sizes.startSize).to.equal(totalAvailable - endMinSize); + expect(sizes.endSize).to.equal(endMinSize); + + expect(sizes.startSize + sizes.endSize).to.equal(initialCombinedSize); + expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + }; + + const testConstraintsPercentAndAutoSizes = async ( + orientation: SplitterOrientation + ) => { + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + endSize: '40%', + startMinSize: '20%', + startMaxSize: '80%', + endMinSize: '30%', + endMaxSize: '70%', + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); + + const initialSizes = getPanesSizes( + constraintSplitter, + isX ? 'width' : 'height' + ); + const initialCombinedSize = initialSizes.startSize + initialSizes.endSize; + + // Try to grow start pane to max (80%), but end pane has min (30%) + // Result: Start pane can only grow as much as end pane allows + const delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + + // Start pane can only grow until end pane hits its minSize (30% of total) + const expectedEndMin = Math.round((totalAvailable * 30) / 100); + const expectedStartAfterResize = totalAvailable - expectedEndMin; + + expect(sizes.startSize).to.be.closeTo(expectedStartAfterResize, 2); + expect(sizes.endSize).to.be.closeTo(expectedEndMin, 2); + + expect(sizes.startSize + sizes.endSize).to.be.closeTo( + initialCombinedSize, + 2 + ); + expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + }; + + describe('Horizontal orientation', () => { + it('should honor minSize and maxSize constraints when resizing, constraints in px', async () => { + await testMinMaxConstraintsPx('horizontal'); + }); + + it('should honor minSize and maxSize constraints when resizing, constraints in %', async () => { + await testMinMaxConstraintsInPercentage('horizontal'); + }); + + it('should respect both panes constraints when they conflict during resize in px', async () => { + await testConflictingConstraintsInPx('horizontal'); + }); + + it('should respect both panes constraints when they conflict during resize in %', async () => { + await testConflictingConstraintsInPercentage('horizontal'); + }); + + it('should handle mixed px and % constraints - start in px; end in %', async () => { + await testMixedConstraintsPxAndPercentage('horizontal'); + }); + + it('should handle resize with mixed % and auto size', async () => { + await testConstraintsPercentAndAutoSizes('horizontal'); + }); + + it('should handle mixed px and auto size', async () => { + await testConstraintsPxAndAutoSizes('horizontal'); + }); + }); + + describe('Vertical orientation', () => { + it('should honor minSize and maxSize constraints when resizing - constraints in px - vertical', async () => { + await testMinMaxConstraintsPx('vertical'); + }); + + it('should honor minSize and maxSize constraints when resizing, constraints in % - vertical', async () => { + await testMinMaxConstraintsInPercentage('vertical'); + }); + + it('should respect both panes constraints when they conflict during resize in px - vertical', async () => { + await testConflictingConstraintsInPx('vertical'); + }); + + it('should respect both panes constraints when they conflict during resize in % - vertical', async () => { + await testConflictingConstraintsInPercentage('vertical'); + }); + + it('should handle mixed px and % constraints - start in px; end in %', async () => { + await testMixedConstraintsPxAndPercentage('vertical'); + }); + + it('should handle resize with mixed % and auto size - vertical', async () => { + await testConstraintsPercentAndAutoSizes('vertical'); + }); + + it('should handle resize with mixed px and auto size - vertical', async () => { + await testConstraintsPxAndAutoSizes('vertical'); + }); + }); + + it('should result in % sizes after resize when the panes size is auto', async () => { + const previousSizes = getPanesSizes(splitter, 'width'); + const deltaX = 100; + + await resize(splitter, deltaX, 0); + await elementUpdated(splitter); + + const currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaX); + expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaX); + expect(splitter.startSize).to.contain('%'); + expect(splitter.endSize).to.contain('%'); + }); + + 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_PART); + 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_PART); + 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' + ); + }); + + it('should properly resize after switching orientation (horizontal -> vertical -> horizontal) w/ constraints', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation: 'horizontal', + startSize: '100px', + startMinSize: '100px', + startMaxSize: '300px', + endSize: '100px', + endMinSize: '100px', + endMaxSize: '300px', + }) + ); + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + splitter.orientation = 'horizontal'; + await elementUpdated(splitter); + + const previousSizes = getPanesSizes(splitter, 'width'); + const deltaX = 100; + + await resize(splitter, deltaX, 0); + await elementUpdated(splitter); + + const currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaX); + expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaX); + }); + }); + + describe('Behavior on splitter/container resize', () => { + it('should maintain panes sizes in px on splitter resize', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '200px', + endSize: '200px', + splitterWidth: '600px', + }) + ); + await elementUpdated(splitter); + + splitter.style.width = '800px'; + await elementUpdated(splitter); + + const newSizes = getPanesSizes(splitter, 'width'); + + expect(newSizes.startSize).to.equal(200); + expect(newSizes.endSize).to.equal(200); + }); + + it('should handle panes sizes in % on window resize', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '20%', + endSize: '20%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + splitter.style.width = '800px'; + await elementUpdated(splitter); + + const newSizes = getPanesSizes(splitter, 'width'); + + expect(newSizes.startSize).to.equal(0.2 * 800); + expect(newSizes.endSize).to.equal(0.2 * 800); + }); + + it('should handle panes sizes with mixed px and % on window resize', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '200px', + endSize: '20%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + splitter.style.width = '800px'; + await elementUpdated(splitter); + + const newSizes = getPanesSizes(splitter, 'width'); + const totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.equal(200); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.2, 2); + }); + + it('should handle sizes on window resize with auto and % sizes', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + endSize: '30%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + splitter.style.width = '800px'; + await elementUpdated(splitter); + + const newSizes = getPanesSizes(splitter, 'width'); + const totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.3, 2); + expect(newSizes.startSize).to.equal(totalAvailable - newSizes.endSize); + }); + + it('component adapts when container size changes', async () => { + const container = await fixture( + createSplitterInContainer({ + startSize: '300px', + endSize: '40%', + containerWidth: '800px', + }) + ); + const splitter = container.querySelector( + 'igc-splitter' + ) as IgcSplitterComponent; + await elementUpdated(splitter); + + container.style.width = '1200px'; + await elementUpdated(splitter); + + let newSizes = getPanesSizes(splitter, 'width'); + let totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.equal(300); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.4, 2); + + container.style.width = '600px'; + await elementUpdated(splitter); + + newSizes = getPanesSizes(splitter, 'width'); + totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.equal(300); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.4, 2); + expect(newSizes.startSize + newSizes.endSize).to.be.at.most( + totalAvailable + ); + }); + + it('relative sizes (percentages) update correctly', async () => { + const container = await fixture( + createSplitterInContainer({ + startSize: '25%', + endSize: '50%', + containerWidth: '1000px', + }) + ); + const splitter = container.querySelector( + 'igc-splitter' + ) as IgcSplitterComponent; + await elementUpdated(splitter); + + const initialSizes = getPanesSizes(splitter, 'width'); + const initialTotal = getTotalSize(splitter, 'width'); + + // increase tolerance to 3px to account for rounding differences in percentage calculations across browsers + expect(initialSizes.startSize).to.be.closeTo(initialTotal * 0.25, 3); + expect(initialSizes.endSize).to.be.closeTo(initialTotal * 0.5, 3); + + container.style.width = '1600px'; + await elementUpdated(splitter); + + let newSizes = getPanesSizes(splitter, 'width'); + let totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.be.closeTo(totalAvailable * 0.25, 3); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.5, 3); + + container.style.width = '500px'; + await elementUpdated(splitter); + + newSizes = getPanesSizes(splitter, 'width'); + totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.be.closeTo(totalAvailable * 0.25, 3); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.5, 3); + }); + + it('absolute sizes are maintained when possible', async () => { + const container = await fixture( + createSplitterInContainer({ + startSize: '250px', + endSize: '350px', + containerWidth: '800px', + }) + ); + const splitter = container.querySelector( + 'igc-splitter' + ) as IgcSplitterComponent; + await elementUpdated(splitter); + + const initialSizes = getPanesSizes(splitter, 'width'); + + expect(initialSizes.startSize).to.equal(250); + expect(initialSizes.endSize).to.equal(350); + + container.style.width = '1200px'; + await elementUpdated(splitter); + + let newSizes = getPanesSizes(splitter, 'width'); + + expect(newSizes.startSize).to.equal(250); + expect(newSizes.endSize).to.equal(350); + + container.style.width = '700px'; + await elementUpdated(splitter); + + newSizes = getPanesSizes(splitter, 'width'); + + expect(newSizes.startSize).to.equal(250); + expect(newSizes.endSize).to.equal(350); + }); + }); + + describe('RTL', () => { + beforeEach(async () => { + splitter.dir = 'rtl'; + await elementUpdated(splitter); + }); + + it('should resize correctly with pointer in RTL', async () => { + const previousSizes = getPanesSizes(splitter, 'width'); + let deltaX = 100; + + await resize(splitter, deltaX, 0); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + // In RTL, moving pointer to the right (positive mouse delta) decreases start pane size + expect(currentSizes.startSize).to.equal(previousSizes.startSize - deltaX); + expect(currentSizes.endSize).to.equal(previousSizes.endSize + deltaX); + + 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); + }); + + it('should resize correctly with keyboard in RTL', async () => { + const bar = getSplitterPart(splitter, BAR_PART); + let previousSizes = getPanesSizes(splitter, 'width'); + const resizeDelta = 10; + + bar.focus(); + await elementUpdated(splitter); + + // arrowLeft should increase start pane size in RTL, as opposed to LTR, where arrowLeft decreases it + simulateKeyboard(bar, arrowLeft); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta + ); + + simulateKeyboard(bar, arrowLeft); + 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 + ); + + previousSizes = getPanesSizes(splitter, 'width'); + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal( + previousSizes.startSize - resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize + resizeDelta + ); + }); + + it('should expand/collapse correctly with keyboard in RTL', async () => { + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowRight]); + 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, arrowLeft]); + 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, arrowLeft]); + 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, arrowRight]); + 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 the correct pane through the expander buttons in RTL', async () => { + let parts = getButtonParts(splitter); + + simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + const totalAvailable = getTotalSize(splitter, 'width'); + let currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal(0); + expect(currentSizes.endSize).to.equal(totalAvailable); + + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.hidden).to.be.true; + expect(parts.startExpander.hidden).to.be.false; + expect(parts.endCollapseBtn).to.be.null; + expect(parts.endExpander).to.be.null; + + simulatePointerDown(parts.startExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.be.greaterThan(0); + expect(currentSizes.endSize).to.equal( + totalAvailable - currentSizes.startSize + ); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.hidden).to.be.false; + expect(parts.startExpander).to.be.null; + expect(parts.endCollapseBtn.hidden).to.be.false; + expect(parts.endExpander).to.be.null; + + simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal(totalAvailable); + expect(currentSizes.endSize).to.equal(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn).to.be.null; + expect(parts.startExpander).to.be.null; + expect(parts.endCollapseBtn.hidden).to.be.true; + expect(parts.endExpander.hidden).to.be.false; + + simulatePointerDown(parts.endExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal( + totalAvailable - currentSizes.endSize + ); + expect(currentSizes.endSize).to.be.greaterThan(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.hidden).to.be.false; + expect(parts.startExpander).to.be.null; + expect(parts.endCollapseBtn.hidden).to.be.false; + expect(parts.endExpander).to.be.null; + }); + + it('direction should not affect interactions in vertical orientation', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + // 1. Resize with keyboard + const bar = getSplitterPart(splitter, BAR_PART); + let previousSizes = getPanesSizes(splitter, 'height'); + const resizeDelta = 10; + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, arrowUp); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize - resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize + resizeDelta + ); + + previousSizes = getPanesSizes(splitter, 'height'); + simulateKeyboard(bar, arrowDown); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta + ); + + // 2. Resize with pointer + previousSizes = getPanesSizes(splitter, 'height'); + let deltaY = 100; + + await resize(splitter, 0, deltaY); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaY); + expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaY); + + 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); + + // 3. Expand/collapse with keyboard + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowUp]); + await elementUpdated(splitter); + + 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; + }); + }); + + describe('Nested Splitters', () => { + let outerSplitter: IgcSplitterComponent; + let leftInnerSplitter: IgcSplitterComponent; + let rightInnerSplitter: IgcSplitterComponent; + let outerStartSlot: HTMLSlotElement; + let outerEndSlot: HTMLSlotElement; + + beforeEach(async () => { + outerSplitter = await fixture( + createNestedSplitter() + ); + await elementUpdated(outerSplitter); + + outerStartSlot = getSplitterSlot(outerSplitter, 'start'); + outerEndSlot = getSplitterSlot(outerSplitter, 'end'); + leftInnerSplitter = + outerStartSlot.assignedElements()[0] as IgcSplitterComponent; + rightInnerSplitter = + outerEndSlot.assignedElements()[0] as IgcSplitterComponent; + + await elementUpdated(leftInnerSplitter); + await elementUpdated(rightInnerSplitter); + }); + + it('should render nested splitters correctly', async () => { + const startElements = outerStartSlot.assignedElements(); + expect(startElements).to.have.lengthOf(1); + expect(startElements[0].tagName.toLowerCase()).to.equal( + IgcSplitterComponent.tagName.toLowerCase() + ); + + 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 maintain independent state in nested splitters', async () => { + outerSplitter.startSize = '60%'; + leftInnerSplitter.startSize = '40%'; + rightInnerSplitter.startSize = '30%'; + + await elementUpdated(outerSplitter); + await elementUpdated(leftInnerSplitter); + await elementUpdated(rightInnerSplitter); + + expect(outerSplitter.startSize).to.equal('60%'); + expect(outerSplitter.orientation).to.equal('horizontal'); + expect(leftInnerSplitter.startSize).to.equal('40%'); + expect(leftInnerSplitter.orientation).to.equal('vertical'); + expect(rightInnerSplitter.startSize).to.equal('30%'); + expect(rightInnerSplitter.orientation).to.equal('vertical'); + + outerSplitter.startCollapsed = true; + await elementUpdated(outerSplitter); + await elementUpdated(leftInnerSplitter); + + expect(outerSplitter.startCollapsed).to.be.true; + expect(leftInnerSplitter.startCollapsed).to.be.false; + expect(rightInnerSplitter.startCollapsed).to.be.false; + + leftInnerSplitter.startCollapsed = true; + await elementUpdated(leftInnerSplitter); + + expect(outerSplitter.startCollapsed).to.be.true; + expect(leftInnerSplitter.startCollapsed).to.be.true; + expect(rightInnerSplitter.startCollapsed).to.be.false; + + outerSplitter.startCollapsed = false; + await elementUpdated(outerSplitter); + await elementUpdated(leftInnerSplitter); + + expect(outerSplitter.startCollapsed).to.be.false; + expect(leftInnerSplitter.startCollapsed).to.be.true; + expect(rightInnerSplitter.startCollapsed).to.be.false; + }); + + it('should not interfere with parent/child resize operations', async () => { + const outerEventSpy = spy(outerSplitter, 'emitEvent'); + const innerEventSpy = spy(leftInnerSplitter, 'emitEvent'); + + await resize(outerSplitter, 50, 0); + + checkResizeEvents(outerEventSpy); + expect(innerEventSpy.called).to.be.false; + + await resize(leftInnerSplitter, 0, 30); + + checkResizeEvents(innerEventSpy); + expect(outerEventSpy.called).to.be.false; + }); + + it('should handle focus management correctly with nested splitters', async () => { + const outerBar = getSplitterPart(outerSplitter, BAR_PART); + const innerBar = getSplitterPart(leftInnerSplitter, BAR_PART); + const resizeDelta = 10; + + outerBar.focus(); + await elementUpdated(outerSplitter); + + expect(outerSplitter.shadowRoot!.activeElement).to.equal(outerBar); + + const outerPreviousSizes = getPanesSizes(outerSplitter, 'width'); + + simulateKeyboard(outerBar, arrowRight); + await elementUpdated(outerSplitter); + + const outerCurrentSizes = getPanesSizes(outerSplitter, 'width'); + expect(outerCurrentSizes.startSize).to.equal( + outerPreviousSizes.startSize + resizeDelta + ); + expect(outerSplitter.shadowRoot!.activeElement).to.equal(outerBar); + + innerBar.focus(); + await elementUpdated(leftInnerSplitter); + + expect(leftInnerSplitter.shadowRoot!.activeElement).to.equal(innerBar); + + const innerPreviousSizes = getPanesSizes(leftInnerSplitter, 'height'); + + simulateKeyboard(innerBar, arrowDown); + await elementUpdated(leftInnerSplitter); + + const innerCurrentSizes = getPanesSizes(leftInnerSplitter, 'height'); + expect(innerCurrentSizes.startSize).to.equal( + innerPreviousSizes.startSize + resizeDelta + ); + expect(leftInnerSplitter.shadowRoot!.activeElement).to.equal(innerBar); + + const outerFinalSizes = getPanesSizes(outerSplitter, 'width'); + expect(outerFinalSizes.startSize).to.equal(outerCurrentSizes.startSize); + }); + + it('should handle tabindex correctly for nested splitters', async () => { + const outerBar = getSplitterPart(outerSplitter, BAR_PART); + const leftInnerBar = getSplitterPart(leftInnerSplitter, BAR_PART); + const rightInnerBar = getSplitterPart(rightInnerSplitter, BAR_PART); + + expect(outerBar.tabIndex).to.equal(0); + expect(leftInnerBar.tabIndex).to.equal(0); + expect(rightInnerBar.tabIndex).to.equal(0); + + outerSplitter.disableResize = true; + outerSplitter.disableCollapse = true; + await elementUpdated(outerSplitter); + + expect(outerBar.tabIndex).to.equal(-1); + expect(leftInnerBar.tabIndex).to.equal(0); + expect(rightInnerBar.tabIndex).to.equal(0); + + outerSplitter.disableResize = false; + outerSplitter.disableCollapse = false; + leftInnerSplitter.disableResize = true; + leftInnerSplitter.disableCollapse = true; + await elementUpdated(outerSplitter); + await elementUpdated(leftInnerSplitter); + + expect(outerBar.tabIndex).to.equal(0); + expect(leftInnerBar.tabIndex).to.equal(-1); + expect(rightInnerBar.tabIndex).to.equal(0); + }); + }); + + describe('Edge scenarios', () => { + it('invalid size values should be handled gracefully', async () => { + // TODO: determine expected behavior for invalid size values (negative, non-numeric, etc.) + }); + + it('should handle invalid constraint values gracefully', async () => { + // TODO: determine how + }); + + it('should predictably handle the case where min sizes exceed container', async () => { + // TODO: determine how + }); + + it('should predictably handle the case where max sizes are smaller than container', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '250px', + endSize: '350px', + startMaxSize: '200px', + endMaxSize: '300px', + splitterWidth: '800px', + }) + ); + await elementUpdated(splitter); + + const sizes = getPanesSizes(splitter, 'width'); + const totalAvailable = getTotalSize(splitter, 'width'); + + expect(sizes.startSize).to.be.at.most(200); + expect(sizes.endSize).to.be.at.most(300); + + expect(sizes.startSize + sizes.endSize).to.be.lessThan(totalAvailable); + }); + + it('invalid orientation values should fall back to default', async () => { + splitter.orientation = 'diagonal' as unknown as SplitterOrientation; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar).to.exist; + + splitter.orientation = 'horizontal'; + await elementUpdated(splitter); + expect(splitter.orientation).to.equal('horizontal'); + expect(splitter.getAttribute('orientation')).to.equal('horizontal'); + }); + + it('missing slot content should not break rendering', async () => { + const emptySplitter = await fixture(html` + + `); + await elementUpdated(emptySplitter); + + expect(emptySplitter).to.exist; + + const startPane = getSplitterPart(emptySplitter, START_PART); + const endPane = getSplitterPart(emptySplitter, END_PART); + const bar = getSplitterPart(emptySplitter, BAR_PART); + + expect(startPane).to.exist; + expect(endPane).to.exist; + expect(bar).to.exist; + + const partialSplitter = await fixture(html` + +
Only Start Pane
+
+ `); + await elementUpdated(partialSplitter); + + expect(partialSplitter).to.exist; + + const partialStartPane = getSplitterPart(partialSplitter, START_PART); + const partialEndPane = getSplitterPart(partialSplitter, END_PART); + const partialBar = getSplitterPart(partialSplitter, BAR_PART); + + expect(partialStartPane).to.exist; + expect(partialEndPane).to.exist; + expect(partialBar).to.exist; + + partialSplitter.startSize = '300px'; + await elementUpdated(partialSplitter); + + expect(partialSplitter.startSize).to.equal('300px'); + }); + }); +}); + +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; + splitterWidth?: string; + splitterHeight?: string; + containerWidth?: string; + containerHeight?: string; +}; + +function createTwoPanesWithSizesAndConstraints( + config: SplitterTestSizesAndConstraints +) { + return html` + +
Pane 1
+
Pane 2
+
+ `; +} + +function createSplitterWithCollapsedPane() { + return html` + +
Pane 1
+
Pane 2
+
+ `; +} + +function createSplitterWithComplexContent() { + defineComponents(IgcTreeComponent, IgcTreeItemComponent); + return html` + +
+

Pane 1

+
+ + +
+ +
+
+

Pane 2

+ + Item 1 + Subitem 1.1 + Subitem 1.2 + + Item 2 + Subitem 2.1 + + Item 3 + +
+
+ `; +} + +function createSplitterWithCustomSlots() { + return html` +
Pane 1
+
Pane 2
+ drag-handle-icon + + start-expand-icon + start-collapse-icon + + end-expand-icon + end-collapse-icon +
`; +} + +function createSplitterInContainer( + config: SplitterTestSizesAndConstraints = {} +) { + return html` +
+ +
Pane 1
+
Pane 2
+
+
+ `; +} + +type SplitterSlot = + | 'start' + | 'end' + | 'drag-handle' + | 'start-expand' + | 'start-collapse' + | 'end-expand' + | 'end-collapse'; + +function getSplitterSlot(splitter: IgcSplitterComponent, which: SplitterSlot) { + return splitter.renderRoot.querySelector( + `slot[name="${which}"]` + ) as HTMLSlotElement; +} + +type SplitterParts = + | 'base' + | typeof START_PART + | typeof END_PART + | typeof BAR_PART + | typeof START_EXPANDER_PART + | typeof END_EXPANDER_PART + | typeof START_COLLAPSE_PART + | typeof END_COLLAPSE_PART + | typeof DRAG_HANDLE_PART; + +function getSplitterPart(splitter: IgcSplitterComponent, which: SplitterParts) { + return splitter.shadowRoot!.querySelector( + `[part~="${which}"]` + ) as HTMLElement; +} + +function getButtonParts(splitter: IgcSplitterComponent) { + return { + startExpander: getSplitterPart(splitter, START_EXPANDER_PART), + endExpander: getSplitterPart(splitter, END_EXPANDER_PART), + startCollapseBtn: getSplitterPart(splitter, START_COLLAPSE_PART), + endCollapseBtn: getSplitterPart(splitter, END_COLLAPSE_PART), + }; +} + +async function resize( + splitter: IgcSplitterComponent, + deltaX: number, + deltaY: number +) { + const bar = getSplitterPart(splitter, BAR_PART); + const barRect = bar.getBoundingClientRect(); + + simulatePointerDown(bar, { + clientX: barRect.left, + clientY: barRect.top, + pointerId: 1, + }); + await elementUpdated(splitter); + + simulatePointerMove( + bar, + { + clientX: barRect.left, + clientY: barRect.top, + pointerId: 1, + }, + { x: deltaX, y: deltaY } + ); + await elementUpdated(splitter); + + simulatePointerUp(bar, { + clientX: barRect.left + deltaX, + clientY: barRect.top + deltaY, + pointerId: 1, + }); + await elementUpdated(splitter); + await nextFrame(); +} + +function getPanesSizes( + splitter: IgcSplitterComponent, + dimension: 'width' | 'height' = 'width' +) { + const startPane = getSplitterPart(splitter, START_PART); + const endPane = getSplitterPart(splitter, END_PART); + + return { + startSize: roundPrecise(startPane.getBoundingClientRect()[dimension]), + endSize: roundPrecise(endPane.getBoundingClientRect()[dimension]), + }; +} + +function checkResizeEvents( + eventSpy: sinon.SinonSpy, + startArgs?: IgcSplitterResizeEventDetail, + resizingArgs?: IgcSplitterResizeEventDetail, + endArgs?: IgcSplitterResizeEventDetail +) { + expect(eventSpy.calledWith('igcResizeStart')).to.be.true; + expect(eventSpy.calledWith('igcResizing')).to.be.true; + expect(eventSpy.calledWith('igcResizeEnd')).to.be.true; + + if (startArgs) { + expect( + eventSpy.withArgs('igcResizeStart').lastCall.lastArg.detail + ).to.deep.equal(startArgs); + } + if (resizingArgs) { + expect( + eventSpy.withArgs('igcResizing').lastCall.lastArg.detail + ).to.deep.equal(resizingArgs); + } + if (endArgs) { + expect( + eventSpy.withArgs('igcResizeEnd').lastCall.lastArg.detail + ).to.deep.equal(endArgs); + } + 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); +} + +function getTotalSize( + splitter: IgcSplitterComponent, + dimension: 'width' | 'height' +) { + const bar = getSplitterPart(splitter, BAR_PART); + const barSize = bar.getBoundingClientRect()[dimension]; + const splitterSize = splitter.getBoundingClientRect()[dimension]; + const totalAvailable = splitterSize - barSize; + return totalAvailable; +} diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts new file mode 100644 index 000000000..86746f5be --- /dev/null +++ b/src/components/splitter/splitter.ts @@ -0,0 +1,927 @@ +import { html, LitElement, 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 { + addKeybindings, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + ctrlKey, + endKey, + homeKey, +} 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 type { SplitterOrientation } from '../types.js'; +import { styles } from './themes/splitter.base.css.js'; + +export interface IgcSplitterResizeEventDetail { + /** The current size of the start panel in pixels */ + startPanelSize: number; + /** The current size of the end panel in pixels */ + endPanelSize: number; + /** The change in size since the resize operation started (only for igcResizing and igcResizeEnd) */ + delta?: number; +} + +export interface IgcSplitterComponentEventMap { + igcResizeStart: CustomEvent; + igcResizing: CustomEvent; + igcResizeEnd: CustomEvent; +} + +interface PaneResizeState { + initialSize: number; + isPercentageBased: boolean; + minSizePx?: number; + maxSizePx?: number; +} + +interface SplitterResizeState { + startPane: PaneResizeState; + endPane: PaneResizeState; +} + +interface ExpanderConfig { + slotName: string; + partName: string; + label: string; + hidden: boolean; +} + +/** + * 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. + * + * @slot start - Content for the start pane. + * @slot end - Content for the end pane. + * @slot drag-handle - Optional slot for custom cursor content (not visually rendered, can be used for cursor customization). + * @slot start-expand - Optional slot to customize the icon for expanding the start panel. + * @slot start-collapse - Optional slot to customize the icon for collapsing the start panel. + * @slot end-expand - Optional slot to customize the icon for expanding the end panel. + * @slot end-collapse - Optional slot to customize the icon for collapsing the end panel. + * + * @csspart splitter-bar - The resizable bar element between the two panels. + * @csspart drag-handle - The drag handle icon/element on the splitter bar. + * @csspart start-panel - The container for the start panel content. + * @csspart end-panel - The container for the end panel content. + * @csspart start-collapse-btn - The button to collapse the start panel. + * @csspart end-collapse-btn - The button to collapse the end panel. + * @csspart start-expand-btn - The button to expand the start panel when collapsed. + * @csspart end-expand-btn - The button to expand the end panel when collapsed. + */ +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 _startCollapsed = false; + private _endCollapsed = false; + private _startSize = 'auto'; + private _endSize = 'auto'; + private _resizeState: SplitterResizeState | null = null; + private _isDragging = false; + private _dragPointerId = -1; + private _dragStartPosition = { x: 0, y: 0 }; + + @query('[part~="base"]', true) + private readonly _base!: HTMLElement; + + @query('[part~="start-panel"]', true) + private readonly _startPane!: HTMLElement; + + @query('[part~="end-panel"]', true) + private readonly _endPane!: HTMLElement; + + @query('[part~="splitter-bar"]', true) + private readonly _bar!: HTMLElement; + + private get _resizeDisallowed() { + return this.disableResize || this.startCollapsed || this.endCollapsed; + } + + private get _barCursor(): string { + if (this._resizeDisallowed) { + return 'default'; + } + return this.orientation === 'horizontal' ? 'col-resize' : 'row-resize'; + } + + private get _barTabIndex(): number { + return this.disableCollapse && this.disableResize ? -1 : 0; + } + + //#endregion + + //#region Public Properties + + /** Gets/Sets the orientation of the splitter. + * + * @remarks + * Default value is `horizontal`. + */ + @property({ reflect: true }) + public orientation: SplitterOrientation = 'horizontal'; + + /** + * Sets whether the user can resize the panels by interacting with the splitter bar. + * @remarks + * Default value is `false`. + * @attr + */ + @property({ type: Boolean, attribute: 'disable-collapse', reflect: true }) + public disableCollapse = false; + + /** + * Sets whether the user can resize the panels by interacting with the splitter bar. + * @attr + */ + @property({ type: Boolean, reflect: true, attribute: 'disable-resize' }) + public disableResize = false; + + /** + * Controls the visibility of the expand/collapse buttons on the splitter bar. + * @remarks + * Default value is `false`. + * @attr + */ + @property({ + type: Boolean, + attribute: 'hide-collapse-buttons', + reflect: true, + }) + public hideCollapseButtons = false; + + /** + * Controls the visibility of the drag handle on the splitter bar. + * @remarks + * Default value is `false`. + * @attr + */ + @property({ + type: Boolean, + attribute: 'hide-drag-handle', + reflect: true, + }) + public hideDragHandle = 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 | undefined) { + this._startSize = value ? value : 'auto'; + this._setPaneFlex(this._startPaneInternalStyles, this._getFlex('start')); + } + + 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 | undefined) { + this._endSize = value ? value : 'auto'; + this._setPaneFlex(this._endPaneInternalStyles, this._getFlex('end')); + } + + public get endSize(): string | undefined { + return this._endSize; + } + + /** + * Collapsed state of the start pane. + * @attr + */ + @property({ type: Boolean, attribute: 'start-collapsed', reflect: true }) + public get startCollapsed() { + return this._startCollapsed; + } + + public set startCollapsed(value: boolean) { + this._startCollapsed = value; + if (this._startCollapsed && this._endCollapsed) { + this.endCollapsed = false; + } + this._collapsedChange(); + } + + /** + * Collapsed state of the end pane. + * @attr + */ + @property({ type: Boolean, attribute: 'end-collapsed', reflect: true }) + public get endCollapsed() { + return this._endCollapsed; + } + + public set endCollapsed(value: boolean) { + this._endCollapsed = value; + if (this._startCollapsed && this._endCollapsed) { + this.startCollapsed = false; + } + this._collapsedChange(); + } + + //#endregion + + //#region Watchers + + @watch('orientation', { waitUntilFirstUpdate: true }) + protected _orientationChange(): void { + Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); + this._resetPanes(); + } + + @watch('disableResize') + protected _changeCursor(): void { + Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); + } + + //#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', + 'drag-handle', + 'start-expand', + 'start-collapse', + 'end-expand', + 'end-collapse' + ), + }); + 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(homeKey, () => this._handleMinMaxResize('min')) + .set(endKey, () => this._handleMinMaxResize('max')) + .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') + ); + } + + protected override firstUpdated() { + this._initPanes(); + } + + //#endregion + + //#region Resize Event Handlers + + private _handleBarPointerDown(e: PointerEvent) { + if (e.button !== 0 || this._resizeDisallowed) { + return; + } + + e.preventDefault(); + + this._isDragging = true; + this._dragPointerId = e.pointerId; + this._dragStartPosition = { x: e.clientX, y: e.clientY }; + + this._resizeStart(); + this._bar.setPointerCapture(this._dragPointerId); + } + + private _handleBarPointerMove(e: PointerEvent) { + if (!this._isDragging || e.pointerId !== this._dragPointerId) { + return; + } + + const deltaX = e.clientX - this._dragStartPosition.x; + const deltaY = e.clientY - this._dragStartPosition.y; + const delta = this._resolveDelta(deltaX, deltaY); + + if (delta !== 0) { + this._resizing(delta); + } + } + + private _handleEndDrag(e: PointerEvent) { + if (!this._isDragging || e.pointerId !== this._dragPointerId) { + return; + } + + const deltaX = e.clientX - this._dragStartPosition.x; + const deltaY = e.clientY - this._dragStartPosition.y; + const delta = this._resolveDelta(deltaX, deltaY); + + if (delta !== 0) { + this._resizeEnd(delta); + } + + this._endDrag(); + } + + private _handleBarPointerCancel() { + if (!this._isDragging) { + return; + } + + this._resizeState = null; + this._endDrag(); + } + + private _endDrag() { + if (this._isDragging && this._dragPointerId !== -1) { + this._bar.releasePointerCapture(this._dragPointerId); + } + this._isDragging = false; + this._dragPointerId = -1; + } + + //#endregion + + //#region Public Methods + + /** Toggles the collapsed state of the pane. */ + public toggle(position: 'start' | 'end') { + // TODO: determine behavior when disableCollapsed is true + if (position === 'start') { + this.startCollapsed = !this.startCollapsed; + } else { + this.endCollapsed = !this.endCollapsed; + } + + if (!this.startCollapsed || !this.endCollapsed) { + this.updateComplete.then(() => this.requestUpdate()); + } + } + + //#endregion + + //#region Internal API + + private _sizeToPercent(sizeValue: string | undefined): number { + const totalSize = this._getTotalSize(); + if (totalSize === 0) { + return 0; + } + + if (!sizeValue || sizeValue === 'auto') { + const [startSize] = this._rectSize(); + return Math.round((startSize / totalSize) * 100); + } + + if (sizeValue.indexOf('%') !== -1) { + return Number.parseInt(sizeValue, 10) || 0; + } + + const pxValue = Number.parseInt(sizeValue, 10) || 0; + return Math.round((pxValue / totalSize) * 100); + } + + private _getStartPaneSizePercent(): number { + if (!this._startPane || this.startCollapsed) { + return 0; + } + if (this.endCollapsed) { + return 100; + } + return this._sizeToPercent(this.startSize); + } + + private _getMinMaxAsPercent(type: 'min' | 'max'): number { + const value = type === 'min' ? this.startMinSize : this.startMaxSize; + const defaultValue = type === 'min' ? 0 : 100; + + return value ? this._sizeToPercent(value) : defaultValue; + } + + 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 _getFlex(which: 'start' | 'end'): string { + const grow = this._isAutoSize(which) ? 1 : 0; + const shrink = 1; + const size = this._isAutoSize(which) + ? '0px' + : which === 'start' + ? this._startSize + : this._endSize; + return `${grow} ${shrink} ${size}`; + } + + private _collapsedChange(): void { + this.startSize = 'auto'; + this.endSize = 'auto'; + this._changeCursor(); + } + + private _handleResizePanes( + direction: -1 | 1, + validOrientation: 'horizontal' | 'vertical' + ) { + if (this._resizeDisallowed || this.orientation !== validOrientation) { + return; + } + const delta = this._resolveDelta(10, 10, direction); + + this._resizeStart(); + this._resizing(delta); + this._resizeEnd(delta); + return true; + } + + private _resolveDelta( + deltaX: number, + deltaY: number, + direction?: -1 | 1 + ): number { + const isHorizontal = this.orientation === 'horizontal'; + const rtlMultiplier = isHorizontal && !isLTR(this) ? -1 : 1; + const delta = isHorizontal ? deltaX : deltaY; + return delta * rtlMultiplier * (direction ?? 1); + } + + private _handleMinMaxResize(type: 'min' | 'max') { + if (this._resizeDisallowed) { + return; + } + + const totalSize = this._getTotalSize(); + const boundaryValue = + type === 'min' ? this.startMinSize : this.startMaxSize; + const isPercentage = boundaryValue + ? boundaryValue.includes('%') + : type === 'max'; + + const targetStartSizePx = + this._setMinMaxInPx('start', type) ?? (type === 'min' ? 0 : totalSize); + const targetEndSizePx = totalSize - targetStartSizePx; + + if (isPercentage) { + this.startSize = `${(targetStartSizePx / totalSize) * 100}%`; + this.endSize = `${(targetEndSizePx / totalSize) * 100}%`; + } else { + this.startSize = `${targetStartSizePx}px`; + this.endSize = `${targetEndSizePx}px`; + } + } + + // TODO: should there be events on expand/collapse - existing resize events or others? + 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.disableCollapse || this.orientation !== validOrientation) { + return; + } + let effectiveTarget = target; + if (validOrientation === 'horizontal' && !isLTR(this)) { + effectiveTarget = target === 'start' ? 'end' : 'start'; + } + + effectiveTarget === 'start' + ? this._handleExpanderStartAction() + : this._handleExpanderEndAction(); + } + + private _resizeStart() { + const [startSize, endSize] = this._rectSize(); + + this._resizeState = { + startPane: this._createPaneState('start', startSize), + endPane: this._createPaneState('end', endSize), + }; + this.emitEvent('igcResizeStart', { + detail: { startPanelSize: startSize, endPanelSize: endSize }, + }); + } + + private _createPaneState( + pane: 'start' | 'end', + size: number + ): PaneResizeState { + return { + initialSize: size, + isPercentageBased: this._isPercentageSize(pane) || this._isAutoSize(pane), + minSizePx: this._setMinMaxInPx(pane, 'min'), + maxSizePx: this._setMinMaxInPx(pane, 'max'), + }; + } + + private _setMinMaxInPx( + pane: 'start' | 'end', + type: 'min' | 'max' + ): number | undefined { + let value: string | undefined; + if (type === 'max') { + value = pane === 'start' ? this.startMaxSize : this.endMaxSize; + } else { + value = pane === 'start' ? this.startMinSize : this.endMinSize; + } + if (!value) { + return undefined; + } + const totalSize = this._getTotalSize(); + let result: number; + if (value.indexOf('%') !== -1) { + const percentageValue = Number.parseInt(value, 10) || 0; + result = (percentageValue / 100) * totalSize; + } else { + result = Number.parseInt(value, 10) || 0; + } + return result; + } + + private _resizing(delta: number) { + const [startPaneSize, endPaneSize] = this._calcNewSizes(delta); + + this.startSize = `${startPaneSize}px`; + this.endSize = `${endPaneSize}px`; + + this.emitEvent('igcResizing', { + detail: { + startPanelSize: startPaneSize, + endPanelSize: endPaneSize, + delta, + }, + }); + } + + private _computeSize(pane: PaneResizeState, paneSize: number): string { + const totalSize = this._getTotalSize(); + if (pane.isPercentageBased) { + const percentPaneSize = (paneSize / totalSize) * 100; + return `${percentPaneSize}%`; + } + return `${paneSize}px`; + } + + private _resizeEnd(delta: number) { + if (!this._resizeState) return; + const [startPaneSize, endPaneSize] = this._calcNewSizes(delta); + + this.startSize = this._computeSize( + this._resizeState.startPane, + startPaneSize + ); + this.endSize = this._computeSize(this._resizeState.endPane, endPaneSize); + + this.emitEvent('igcResizeEnd', { + detail: { + startPanelSize: startPaneSize, + endPanelSize: endPaneSize, + delta, + }, + }); + 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 _calcNewSizes(delta: number): [number, number] { + if (!this._resizeState) return [0, 0]; + + const start = this._resizeState.startPane; + const end = this._resizeState.endPane; + const minStart = start.minSizePx || 0; + const minEnd = end.minSizePx || 0; + const maxStart = + start.maxSizePx || start.initialSize + end.initialSize - minEnd; + const maxEnd = + end.maxSizePx || start.initialSize + end.initialSize - minStart; + + let finalDelta: number; + + if (delta < 0) { + const maxPossibleDelta = Math.min( + start.initialSize - minStart, + maxEnd - end.initialSize + ); + finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)) * -1; + } else { + const maxPossibleDelta = Math.min( + maxStart - start.initialSize, + end.initialSize - minEnd + ); + finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)); + } + return [start.initialSize + finalDelta, end.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'; + this.startMinSize = undefined; + this.startMaxSize = undefined; + this.endMinSize = undefined; + this.endMaxSize = undefined; + + this._setPaneFlex(this._startPaneInternalStyles, this._getFlex('start')); + this._setPaneMinMaxSizes(this._startPaneInternalStyles, '0', '100%'); + this._setPaneFlex(this._endPaneInternalStyles, this._getFlex('end')); + this._setPaneMinMaxSizes(this._endPaneInternalStyles, '0', '100%'); + } + + private _initPanes() { + if (this.startCollapsed || this.endCollapsed) { + this._resetPanes(); + } else { + this._setPaneMinMaxSizes( + this._startPaneInternalStyles, + this.startMinSize, + this.startMaxSize + ); + this._setPaneMinMaxSizes( + this._endPaneInternalStyles, + this.endMinSize, + this.endMaxSize + ); + } + + this._setPaneFlex(this._startPaneInternalStyles, this._getFlex('start')); + this._setPaneFlex(this._endPaneInternalStyles, this._getFlex('end')); + this.requestUpdate(); + } + + private _setPaneMinMaxSizes( + styles: StyleInfo, + minSize?: string, + maxSize?: string + ) { + const isHorizontal = this.orientation === 'horizontal'; + + const min = minSize ?? 0; + const max = maxSize ?? '100%'; + + const sizes = isHorizontal + ? { + minWidth: min, + maxWidth: max, + minHeight: 0, + maxHeight: '100%', + } + : { + minWidth: 0, + maxWidth: '100%', + minHeight: min, + maxHeight: max, + }; + + Object.assign(styles, { + ...sizes, + }); + } + + private _setPaneFlex(styles: StyleInfo, flex: string) { + Object.assign(styles, { + flex: flex, + }); + } + + private _handleExpanderClick(pane: 'start' | 'end', event: PointerEvent) { + // Prevent resize action being initiated + event.stopPropagation(); + + pane === 'start' + ? this._handleExpanderStartAction() + : this._handleExpanderEndAction(); + } + + //#endregion + + //#region Rendering + + private _getExpanderHiddenState() { + const hidden = this.disableCollapse || this.hideCollapseButtons; + return { + prevButtonHidden: hidden || !!(this.startCollapsed && !this.endCollapsed), + nextButtonHidden: hidden || !!(this.endCollapsed && !this.startCollapsed), + }; + } + + private _getExpanderConfig(position: 'start' | 'end'): ExpanderConfig { + const { prevButtonHidden, nextButtonHidden } = + this._getExpanderHiddenState(); + + if (position === 'start') { + const isExpandingEnd = this.endCollapsed; + return { + slotName: isExpandingEnd ? 'end-expand' : 'start-collapse', + partName: isExpandingEnd ? 'end-expand-btn' : 'start-collapse-btn', + label: isExpandingEnd ? 'Expand end pane' : 'Collapse start pane', + hidden: prevButtonHidden, + }; + } + + const isExpandingStart = this.startCollapsed; + return { + slotName: isExpandingStart ? 'start-expand' : 'end-collapse', + partName: isExpandingStart ? 'start-expand-btn' : 'end-collapse-btn', + label: isExpandingStart ? 'Expand start pane' : 'Collapse end pane', + hidden: nextButtonHidden, + }; + } + + private _renderBarControls() { + const dragHandleHidden = this.hideDragHandle || this.disableResize; + const startConfig = this._getExpanderConfig('start'); + const endConfig = this._getExpanderConfig('end'); + + return html` +
+ this._handleExpanderClick('start', e)} + > + +
+
+ +
+
this._handleExpanderClick('end', e)} + > + +
+ `; + } + + protected override render() { + return html` +
+
+ +
+
e.preventDefault()} + @contextmenu=${(e: PointerEvent) => e.preventDefault()} + @pointerdown=${this._handleBarPointerDown} + @pointermove=${this._handleBarPointerMove} + @pointerup=${this._handleEndDrag} + @lostpointercapture=${this._handleEndDrag} + @pointercancel=${this._handleBarPointerCancel} + > + ${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..c31914b01 --- /dev/null +++ b/src/components/splitter/themes/splitter.base.scss @@ -0,0 +1,121 @@ +@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-panel'], + [part~='end-panel'] { + 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='splitter-bar'] { + --bar-size: 5px; + + display: flex; + flex-shrink: 0; + background-color: var(--ig-gray-200); + justify-content: center; + cursor: var(--cursor, default); + + &:hover { + background-color: var(--ig-gray-400); + } + + [part*='collapse-btn'], + [part*='expand-btn'] { + cursor: pointer; + width: 5px; + height: 5px; + } + + [part='start-collapse-btn'], + [part='start-expand-btn'] { + background-color: red; + } + + [part='end-collapse-btn'], + [part='end-expand-btn'] { + background-color: green; + } + + [part='drag-handle'] { + background-color: yellow; + } + } +} + +// Horizontal orientation (default) +:host(:not([orientation='vertical'])) { + [part='base'] { + flex-direction: row; + } + + [part='splitter-bar'] { + flex-direction: column; + width: var(--bar-size); + height: 100%; + + [part='drag-handle'] { + height: 50px; + } + } +} + +// Vertical orientation +:host([orientation='vertical']) { + [part='base'] { + flex-direction: column; + } + + [part='splitter-bar'] { + flex-direction: row; + width: 100%; + height: var(--bar-size); + + [part='drag-handle'] { + width: 50px; + } + } +} + +// Collapsed states +:host([start-collapsed]) { + [part='start-panel'] { + display: none; + } +} + +:host([end-collapsed]) { + [part~='end-panel'] { + 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 e79d5309b..cbb57efa9 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/card.stories.ts b/stories/card.stories.ts index a61d776ed..649bc83b0 100644 --- a/stories/card.stories.ts +++ b/stories/card.stories.ts @@ -36,14 +36,15 @@ const metadata: Meta = { docs: { description: { component: - 'A container which wraps different elements related to a single subject', + 'A container component that wraps different elements related to a single subject.\nThe card component provides a flexible container for organizing content such as headers,\nmedia, text content, and actions.', }, }, }, argTypes: { elevated: { type: 'boolean', - description: 'Sets card elevated style, otherwise card looks outlined.', + description: + 'Sets the card to have an elevated appearance with shadow.\nWhen false, the card uses an outlined style with a border.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -54,7 +55,10 @@ const metadata: Meta = { export default metadata; interface IgcCardArgs { - /** Sets card elevated style, otherwise card looks outlined. */ + /** + * Sets the card to have an elevated appearance with shadow. + * When false, the card uses an outlined style with a border. + */ elevated: boolean; } type Story = StoryObj; diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts new file mode 100644 index 000000000..effecab9a --- /dev/null +++ b/stories/splitter.stories.ts @@ -0,0 +1,334 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; + +import { defineComponents, IgcIconComponent } from 'igniteui-webcomponents'; +import IgcSplitterComponent from '../src/components/splitter/splitter.js'; +import { disableStoryControls } from './story.js'; + +defineComponents(IgcSplitterComponent, IgcIconComponent); + +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' } }, + }, + disableCollapse: { + type: 'boolean', + description: 'Disables pane collapsing.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + hideCollapseButtons: { + type: 'boolean', + description: 'Hides the collapse buttons on the splitter bar.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + hideDragHandle: { + type: 'boolean', + description: 'Hides the drag handle on the splitter bar.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + disableResize: { + 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', + disableCollapse: false, + hideCollapseButtons: false, + hideDragHandle: false, + disableResize: false, + startCollapsed: false, + endCollapsed: false, + }, +}; + +export default metadata; + +interface IgcSplitterArgs { + orientation: 'horizontal' | 'vertical'; + disableCollapse: boolean; + hideCollapseButtons: boolean; + hideDragHandle: boolean; + disableResize: boolean; + startCollapsed: boolean; + endCollapsed: boolean; + startSize?: string; + endSize?: string; + startMinSize?: string; + startMaxSize?: string; + endMinSize?: string; + endMaxSize?: string; +} + +type Story = StoryObj; + +function changePaneMinMaxSizesPx() { + const splitter = document.querySelector('igc-splitter'); + if (!splitter) { + return; + } + splitter.startMinSize = '50px'; + splitter.startMaxSize = '200px'; + splitter.endMinSize = '100px'; + splitter.endMaxSize = '300px'; +} + +function changePaneMinMaxSizesPercent() { + const splitter = document.querySelector('igc-splitter'); + if (!splitter) { + return; + } + splitter.startMinSize = '10%'; + splitter.startMaxSize = '80%'; + splitter.endMinSize = '20%'; + splitter.endMaxSize = '90%'; + splitter.startSize = '30%'; + splitter.endSize = '70%'; +} + +export const Default: Story = { + render: ({ + orientation, + disableCollapse, + hideCollapseButtons, + hideDragHandle, + disableResize, + startCollapsed, + endCollapsed, + startSize, + endSize, + startMinSize, + startMaxSize, + endMinSize, + endMaxSize, + }) => { + document.addEventListener('DOMContentLoaded', () => { + // const splitter = document.getElementById( + // 'splitter' + // ) as IgcSplitterComponent; + // splitter.addEventListener('igcResizeStart', (event) => + // console.log(event.detail) + // ); + // splitter.addEventListener('igcResizing', (event) => + // console.log(event.detail) + // ); + // splitter.addEventListener('igcResizeEnd', (event) => + // console.log(event.detail) + // ); + }); + + return html` + + +
+ +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Pellentesque scelerisque elementum ante, et tincidunt eros ultrices + sit amet. Mauris non consectetur nunc. In hac habitasse platea + dictumst. Pellentesque ornare et tellus sit amet varius. Nulla in + augue rhoncus, finibus mauris semper, tincidunt sem. Cras vitae + semper neque, eget tempus massa. Maecenas gravida turpis quis + interdum bibendum. Nam quis ultricies est. Fusce ante erat, iaculis + quis iaculis ut, iaculis sed nunc. Cras iaculis condimentum lacus + nec tempus. Nam ex massa, mattis vitae iaculis in, suscipit ut nibh. +
+
+ Maecenas sit amet ipsum non ipsum scelerisque varius. Maecenas + scelerisque nisl scelerisque nulla ultricies eleifend. Aliquam sit + amet velit mauris. Duis at nulla vitae risus condimentum semper. Nam + ornare arcu vitae euismod pharetra. Morbi facilisis tincidunt lorem + at consequat. Aliquam varius quam non eros suscipit, ac tincidunt + sapien porttitor. Sed sed lorem quam. Praesent blandit aliquam arcu + a vestibulum. Mauris porta faucibus ex in vehicula. Pellentesque ut + risus quis felis molestie facilisis eget et est. Proin interdum urna + vitae porttitor suscipit. Curabitur lobortis aliquet dolor sit amet + varius. Proin a semper velit, non molestie libero. Suspendisse + potenti. Aliquam vestibulum dui id lacus suscipit, eget posuere + justo venenatis. Vestibulum id velit ac dui posuere pretium. +
+
+
+ Change All Panes Min/Max Sizes (px) + 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
+
+
+
+ `, +}; + +export const Slots: Story = { + render: ({ + orientation, + disableCollapse, + disableResize, + hideCollapseButtons, + hideDragHandle, + startCollapsed, + endCollapsed, + }) => html` + + + +
Start panel with custom icons
+
End panel with custom icons
+ + + + ${orientation === 'horizontal' ? '⋮' : '⋯'} + + + + + ${orientation === 'horizontal' ? '➡️' : '⬇️'} + + + ${orientation === 'horizontal' ? '⬅️' : '⬆️'} + + + + + ${orientation === 'horizontal' ? '🔙' : '🔼'} + + + ${orientation === 'horizontal' ? '🔜' : '🔽'} + +
+ `, +};