diff --git a/CHANGELOG.md b/CHANGELOG.md index 079f27ab2..07ae8c72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Optional branding logo via `logo-src`, with `logo-size` and `logo-margin` controlling how much of the code's safe, scannable area it covers; the error correction level is raised automatically to accommodate it unless explicitly set. - `dot-style` and `square-style` customize the shape of the data modules and finder-pattern corners (`square`, `circle`, `rounded`). - Themable via the `--ig-qr-code-background`, `--ig-qr-code-dark-color`, `--ig-qr-code-corner-square-color`, and `--ig-qr-code-corner-dot-color` CSS custom properties, and exposes `background`, `dots`, `corner-square`, and `corner-dot` CSS parts. +- #### Splitter + - `startCollapsed` and `endCollapsed` properties for reading and programmatically setting the collapsed state of each pane. + - `igcLayoutChanged` event, emitted after a user-driven resize or expansion change, with a snapshot of the current layout (`startSize`, `endSize`, `startCollapsed`, `endCollapsed`). - #### Virtual Scroll - Added the new `igc-virtual-scroll` component. It efficiently renders large or unbounded lists by only rendering the items currently within the viewport, plus a configurable `overScan`. [#2222](https://github.com/IgniteUI/igniteui-webcomponents/pull/2222) - Supports both `vertical` and `horizontal` orientation, including RTL layouts. diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 820f34131..91f5019b9 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -597,6 +597,79 @@ describe('Splitter', () => { await testMixedSizes('horizontal'); await testMixedSizes('vertical'); }); + + it('should get/set startCollapsed and endCollapsed', async () => { + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + splitter.startCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + expect(splitter.matches(':state(start-collapsed)')).to.be.true; + + splitter.startCollapsed = false; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.matches(':state(start-collapsed)')).to.be.false; + + splitter.endCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.endCollapsed).to.be.true; + expect(splitter.startCollapsed).to.be.false; + expect(splitter.matches(':state(end-collapsed)')).to.be.true; + }); + + it('should enforce mutual exclusivity when setting startCollapsed/endCollapsed directly', async () => { + splitter.endCollapsed = true; + await elementUpdated(splitter); + + 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 be a no-op when setting startCollapsed/endCollapsed to their current value', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + + splitter.startCollapsed = false; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.false; + expect(eventSpy.called).to.be.false; + + splitter.startCollapsed = true; + await elementUpdated(splitter); + eventSpy.resetHistory(); + + splitter.startCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.true; + expect(eventSpy.called).to.be.false; + }); + + it('should reflect startCollapsed/endCollapsed as attributes', async () => { + splitter.startCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.hasAttribute('start-collapsed')).to.be.true; + expect(splitter.hasAttribute('end-collapsed')).to.be.false; + + splitter.startCollapsed = false; + splitter.endCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.hasAttribute('start-collapsed')).to.be.false; + expect(splitter.hasAttribute('end-collapsed')).to.be.true; + }); }); describe('Methods, Events & Interactions', () => { @@ -631,6 +704,22 @@ describe('Splitter', () => { expect(splitter.matches(':state(start-collapsed)')).to.be.true; }); + it('should update startCollapsed/endCollapsed when toggle is invoked, without emitting igcLayoutChanged', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + splitter.toggle('end'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + expect(eventSpy.calledWith('igcLayoutChanged')).to.be.false; + }); + it('should restore pane sizes as percentages after collapse then expand', async () => { splitter.startSize = '200px'; splitter.endSize = '30%'; @@ -638,7 +727,7 @@ describe('Splitter', () => { const totalSize = getTotalSize(splitter, 'width'); const { startSize: initialStart } = getPanesSizes(splitter, 'width'); - const expectedStartPercent = `${roundPrecise((initialStart / totalSize) * 100, 0)}%`; + const expectedStartPercent = `${roundPrecise((initialStart / totalSize) * 100, 2)}%`; splitter.toggle('start'); await elementUpdated(splitter); @@ -652,7 +741,7 @@ describe('Splitter', () => { expect(splitter.startSize).to.equal(expectedStartPercent); const { endSize: currentEnd } = getPanesSizes(splitter, 'width'); - const expectedEndPercent = `${roundPrecise((currentEnd / totalSize) * 100, 0)}%`; + const expectedEndPercent = `${roundPrecise((currentEnd / totalSize) * 100, 2)}%`; splitter.toggle('end'); await elementUpdated(splitter); @@ -666,6 +755,31 @@ describe('Splitter', () => { expect(splitter.endSize).to.equal(expectedEndPercent); }); + it('should not leave panes at a degenerate 0% size when expanding after a pane was collapsed via a property set before the first render', async () => { + const preCollapsed = await fixture(html` + +
Pane 1
+
Pane 2
+
+ `); + await elementUpdated(preCollapsed); + + expect(preCollapsed.endCollapsed).to.be.true; + + preCollapsed.endCollapsed = false; + await elementUpdated(preCollapsed); + + expect(preCollapsed.startSize).to.equal('auto'); + expect(preCollapsed.endSize).to.equal('auto'); + + const sizes = getPanesSizes(preCollapsed, 'width'); + expect(sizes.startSize).to.be.greaterThan(0); + expect(sizes.endSize).to.be.greaterThan(0); + }); + it('should toggle the next pane when the bar expander-end parts are clicked', async () => { let parts = getButtonParts(splitter); @@ -721,6 +835,85 @@ describe('Splitter', () => { expect(parts.endExpander).to.be.null; }); + it('should emit igcLayoutChanged when a collapse/expand button is clicked', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const totalSize = getTotalSize(splitter, 'width'); + let parts = getButtonParts(splitter); + + let { startSize: preCollapseStart, endSize: preCollapseEnd } = + getPanesSizes(splitter, 'width'); + let expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 2)}%`; + let expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 2)}%`; + + simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: expectedStartPercent, + endSize: expectedEndPercent, + startCollapsed: true, + endCollapsed: false, + }, + }); + expect(splitter.startCollapsed).to.be.true; + + eventSpy.resetHistory(); + parts = getButtonParts(splitter); + + simulatePointerDown(parts.startExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: splitter.startSize, + endSize: splitter.endSize, + startCollapsed: false, + endCollapsed: false, + }, + }); + expect(splitter.startCollapsed).to.be.false; + + eventSpy.resetHistory(); + parts = getButtonParts(splitter); + + ({ startSize: preCollapseStart, endSize: preCollapseEnd } = getPanesSizes( + splitter, + 'width' + )); + expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 2)}%`; + expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 2)}%`; + + simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: expectedStartPercent, + endSize: expectedEndPercent, + startCollapsed: false, + endCollapsed: true, + }, + }); + expect(splitter.endCollapsed).to.be.true; + }); + + it('should not emit igcLayoutChanged when startCollapsed/endCollapsed are set directly', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + + splitter.startCollapsed = true; + await elementUpdated(splitter); + splitter.startCollapsed = false; + await elementUpdated(splitter); + splitter.endCollapsed = true; + await elementUpdated(splitter); + + expect(eventSpy.calledWith('igcLayoutChanged')).to.be.false; + }); + it('should set tabindex correctly on the bar based on interactivity', async () => { const bar = getSplitterPart(splitter, BAR_PART); @@ -841,6 +1034,32 @@ describe('Splitter', () => { checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); }); + it('should still emit igcResizeEnd and igcLayoutChanged when a drag ends with zero net delta', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const previousSizes = getPanesSizes(splitter, 'width'); + + await resize(splitter, 0, 0); + + expect(eventSpy).calledWith('igcResizeEnd', { + detail: { + startPanelSize: previousSizes.startSize, + endPanelSize: previousSizes.endSize, + delta: 0, + }, + }); + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: splitter.startSize, + endSize: splitter.endSize, + startCollapsed: false, + endCollapsed: false, + }, + }); + + const currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes).to.deep.equal(previousSizes); + }); + it('should respect minSize and maxSize constraints when resizing with arrows', async () => { splitter.style.width = '1000px'; splitter.startMinSize = '100px'; @@ -1087,6 +1306,57 @@ describe('Splitter', () => { expect(splitter.endSize).to.equal('0%'); }); + it('should emit resize and layout changed events with Home/End keys', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + const previousSizes = getPanesSizes(splitter, 'width'); + const totalAvailable = getTotalSize(splitter, 'width'); + + simulateKeyboard(bar, homeKey); + await elementUpdated(splitter); + + let delta = 0 - previousSizes.startSize; + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: splitter.startSize, + endSize: splitter.endSize, + startCollapsed: false, + endCollapsed: false, + }, + }); + checkResizeEvents( + eventSpy, + { + startPanelSize: previousSizes.startSize, + endPanelSize: previousSizes.endSize, + }, + { startPanelSize: 0, endPanelSize: totalAvailable, delta }, + { startPanelSize: 0, endPanelSize: totalAvailable, delta } + ); + + simulateKeyboard(bar, endKey); + await elementUpdated(splitter); + + delta = totalAvailable; + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: splitter.startSize, + endSize: splitter.endSize, + startCollapsed: false, + endCollapsed: false, + }, + }); + checkResizeEvents( + eventSpy, + { startPanelSize: 0, endPanelSize: totalAvailable }, + { startPanelSize: totalAvailable, endPanelSize: 0, delta }, + { startPanelSize: totalAvailable, endPanelSize: 0, delta } + ); + }); + it('should not resize with left/right keys when in vertical orientation', async () => { splitter.orientation = 'vertical'; await elementUpdated(splitter); @@ -1192,6 +1462,44 @@ describe('Splitter', () => { expect(bar.getAttribute('tabindex')).to.equal('0'); }); + it('should emit igcLayoutChanged when collapsing/expanding via Ctrl + arrow keys', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + const totalSize = getTotalSize(splitter, 'width'); + const { startSize: preCollapseStart, endSize: preCollapseEnd } = + getPanesSizes(splitter, 'width'); + const expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 2)}%`; + const expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 2)}%`; + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: expectedStartPercent, + endSize: expectedEndPercent, + startCollapsed: true, + endCollapsed: false, + }, + }); + + eventSpy.resetHistory(); + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: splitter.startSize, + endSize: splitter.endSize, + startCollapsed: false, + endCollapsed: false, + }, + }); + }); + it('should expand/collapse panes with Ctrl + up/down arrow keys in vertical orientation', async () => { splitter.orientation = 'vertical'; await elementUpdated(splitter); diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 81fac1d62..47a96e176 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -35,6 +35,7 @@ import { styles } from './themes/splitter.base.css.js'; import { all } from './themes/themes.js'; import type { IgcSplitterComponentEventMap, + IgcSplitterLayoutChangedEventArgs, IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, PanePosition, @@ -117,6 +118,8 @@ const DEFAULT_RESIZE_STATE: SplitterResizeState = { * @fires igcResizeStart - Emitted once when a resize operation begins (pointer drag or keyboard). * @fires igcResizing - Emitted continuously while a pane is being resized. * @fires igcResizeEnd - Emitted once when a resize operation completes. + * @fires igcLayoutChanged - Emitted after a user-driven resize or expansion change, with a full + * snapshot of the current layout (pane sizes and collapsed states). * * @slot start - Content projected into the start (left/top) panel. * @slot end - Content projected into the end (right/bottom) panel. @@ -148,8 +151,14 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private readonly _separatorRef = createRef(); - private _startPaneState: SplitterPaneState = { size: 'auto', styles: {} }; - private _endPaneState: SplitterPaneState = { size: 'auto', styles: {} }; + private readonly _startPaneState: SplitterPaneState = { + size: 'auto', + styles: {}, + }; + private readonly _endPaneState: SplitterPaneState = { + size: 'auto', + styles: {}, + }; @state() private _collapsedPane: PanePosition | null = null; @@ -157,7 +166,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< @state() private _resizeState: SplitterResizeState = { ...DEFAULT_RESIZE_STATE }; - @query('[part~="base"]', true) + @query('[part~="base"]') private readonly _base!: HTMLElement; @query('[part~="start-pane"]', true) @@ -191,7 +200,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /** * The orientation of the splitter, which determines the direction of resizing and collapsing. - * * @attr orientation * @default 'horizontal' */ @@ -199,9 +207,8 @@ export default class IgcSplitterComponent extends EventEmitterMixin< public orientation: SplitterOrientation = 'horizontal'; /** - * When true, prevents the user from collapsing either pane. - * This also hides the expand/collapse buttons on the splitter bar. - * + * Whether collapsing either pane is disabled. When `true`, this also hides + * the expand/collapse buttons on the splitter bar. * @attr disable-collapse * @default false */ @@ -209,9 +216,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< public disableCollapse = false; /** - * When true, prevents the user from resizing the panes by dragging the splitter bar or using keyboard shortcuts. - * This also hides the drag handle on the splitter bar. - * + * Whether resizing the panes by dragging the splitter bar or using keyboard + * shortcuts is disabled. When `true`, this also hides the drag handle on the + * splitter bar. * @attr disable-resize * @default false */ @@ -219,11 +226,10 @@ export default class IgcSplitterComponent extends EventEmitterMixin< public disableResize = false; /** - * When true, hides the expand/collapse buttons on the splitter bar. + * Whether the expand/collapse buttons on the splitter bar are hidden. * * Note that the buttons will also be hidden if `disable-collapse` is true or * if a pane is currently collapsed. - * * @attr hide-collapse-buttons * @default false */ @@ -235,10 +241,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< public hideCollapseButtons = false; /** - * When true, hides the drag handle on the splitter bar. + * Whether the drag handle on the splitter bar is hidden. * * Note that the drag handle will also be hidden if `disable-resize` is true. - * * @attr hide-drag-handle * @default false */ @@ -254,7 +259,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * * Accepts a CSS length, e.g. `100px` or `20%`. Setting `auto`, a negative * value, or a percentage above 100 removes the constraint. - * * @attr start-min-size */ @property({ attribute: 'start-min-size' }) @@ -271,7 +275,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * * Accepts a CSS length, e.g. `100px` or `20%`. Setting `auto`, a negative * value, or a percentage above 100 removes the constraint. - * * @attr end-min-size */ @property({ attribute: 'end-min-size' }) @@ -288,7 +291,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * * Accepts a CSS length, e.g. `500px` or `80%`. Setting `auto`, a negative * value, or a percentage above 100 removes the constraint. - * * @attr start-max-size */ @property({ attribute: 'start-max-size' }) @@ -305,7 +307,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * * Accepts a CSS length, e.g. `500px` or `80%`. Setting `auto`, a negative * value, or a percentage above 100 removes the constraint. - * * @attr end-max-size */ @property({ attribute: 'end-max-size' }) @@ -322,7 +323,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * * Accepts a CSS length, e.g. `200px` or `50%`. Setting `auto`, a negative * value, or a percentage above 100 falls back to automatic sizing. - * * @attr start-size */ @property({ attribute: 'start-size' }) @@ -339,7 +339,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * * Accepts a CSS length, e.g. `200px` or `50%`. Setting `auto`, a negative * value, or a percentage above 100 falls back to automatic sizing. - * * @attr end-size */ @property({ attribute: 'end-size' }) @@ -351,6 +350,36 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return this._endPaneState.size; } + /** + * Whether the start pane is currently collapsed. Set this property to + * collapse or expand the pane programmatically. + * @attr start-collapsed + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'start-collapsed' }) + public set startCollapsed(value: boolean) { + this._setCollapsed('start', value); + } + + public get startCollapsed(): boolean { + return this._isCollapsed('start'); + } + + /** + * Whether the end pane is currently collapsed. Set this property to + * collapse or expand the pane programmatically. + * @attr end-collapsed + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'end-collapsed' }) + public set endCollapsed(value: boolean) { + this._setCollapsed('end', value); + } + + public get endCollapsed(): boolean { + return this._isCollapsed('end'); + } + //#endregion //#region Lifecycle @@ -455,12 +484,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return; } - const delta = this._getDragDelta(e); - - if (delta !== 0) { - this._resizeEnd(delta); - } - + this._resizeEnd(this._getDragDelta(e)); this._endDrag(); } @@ -477,13 +501,21 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /** Toggles the collapsed state of the specified pane. */ public toggle(position: PanePosition): void { - if (this._collapsedPane === null) { + // If the requested pane is already collapsed, expand it (set to null) + // Otherwise, collapse the requested pane (this also handles switching from one collapsed pane to another) + this._applyCollapse(this._collapsedPane === position ? null : position); + } + + //#endregion + + //#region Internal API + + private _applyCollapse(target: PanePosition | null): void { + if (this._collapsedPane === null && target !== null) { this._savePaneSizes(); } - // If the requested pane is already collapsed, expand it (set to null) - // Otherwise, collapse the requested pane (this also handles switching from one collapsed pane to another) - this._collapsedPane = this._collapsedPane === position ? null : position; + this._collapsedPane = target; this._internals.setState('start-collapsed', this._isCollapsed('start')); this._internals.setState('end-collapsed', this._isCollapsed('end')); @@ -491,13 +523,24 @@ export default class IgcSplitterComponent extends EventEmitterMixin< this._restoreSizesOnExpandCollapse(); } - //#endregion - - //#region Internal API + private _setCollapsed(pane: PanePosition, value: boolean): void { + if (this._isCollapsed(pane) === value) { + return; + } + this._applyCollapse(value ? pane : null); + } private _savePaneSizes(): void { - this._startPaneState.savedSize = `${this._paneRectAsPercent(0)}%`; - this._endPaneState.savedSize = `${this._paneRectAsPercent(1)}%`; + // Layout not measurable yet (e.g. collapsed state set before first render) - + // preserve the explicit size instead of losing it to the 'auto' reset below. + if (this._getTotalSize() === 0) { + this._startPaneState.savedSize = this._startPaneState.size; + this._endPaneState.savedSize = this._endPaneState.size; + return; + } + // Higher precision than the ARIA percent so restored layouts don't drift. + this._startPaneState.savedSize = `${this._paneRectAsPercent(0, 2)}%`; + this._endPaneState.savedSize = `${this._paneRectAsPercent(1, 2)}%`; } /* Reset sizes on collapse; restore saved sizes on expand */ @@ -513,12 +556,15 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } /** Measures the actual rendered size of a pane and returns it as a percentage of total size. */ - private _paneRectAsPercent(paneIndex: 0 | 1): number { + private _paneRectAsPercent(paneIndex: 0 | 1, precision = 0): number { const totalSize = this._getTotalSize(); if (totalSize === 0) { return 0; } - return roundPrecise(asPercent(this._rectSize()[paneIndex], totalSize), 0); + return roundPrecise( + asPercent(this._rectSize()[paneIndex], totalSize), + precision + ); } /** Converts a CSS size string (px or %) to a percentage of total size. */ @@ -652,6 +698,20 @@ export default class IgcSplitterComponent extends EventEmitterMixin< this._setMinMaxInPx('start', type) ?? (type === 'min' ? 0 : totalSize); const targetEndSizePx = totalSize - targetStartSizePx; + const [initialStart, initialEnd] = this._rectSize(); + const delta = targetStartSizePx - initialStart; + + this.emitEvent('igcResizeStart', { + detail: { startPanelSize: initialStart, endPanelSize: initialEnd }, + }); + this.emitEvent('igcResizing', { + detail: { + startPanelSize: targetStartSizePx, + endPanelSize: targetEndSizePx, + delta, + }, + }); + if (isPercentage) { this.startSize = `${roundPrecise(asPercent(targetStartSizePx, totalSize), 2)}%`; this.endSize = `${roundPrecise(asPercent(targetEndSizePx, totalSize), 2)}%`; @@ -659,11 +719,45 @@ export default class IgcSplitterComponent extends EventEmitterMixin< this.startSize = `${targetStartSizePx}px`; this.endSize = `${targetEndSizePx}px`; } + + this.emitEvent('igcResizeEnd', { + detail: { + startPanelSize: targetStartSizePx, + endPanelSize: targetEndSizePx, + delta, + }, + }); + this._emitLayoutChanged(); } private _handleExpanderAction(pane: PanePosition): void { const other: PanePosition = pane === 'start' ? 'end' : 'start'; - this.toggle(this._collapsedPane === other ? other : pane); + const target = this._collapsedPane === other ? other : pane; + this._toggleWithEvent(target); + } + + private _toggleWithEvent(position: PanePosition): void { + this.toggle(position); + this._emitLayoutChanged(); + } + + // While any pane is collapsed, both sizes are forced to 'auto' for rendering, + // so report the pre-collapse sizes instead - what a consumer needs to restore layout. + private _reportedSize(pane: PanePosition): string { + const state = this._getPaneState(pane); + return ( + (this._collapsedPane !== null ? state.savedSize : state.size) ?? 'auto' + ); + } + + private _emitLayoutChanged(): void { + const detail: IgcSplitterLayoutChangedEventArgs = { + startSize: this._reportedSize('start'), + endSize: this._reportedSize('end'), + startCollapsed: this.startCollapsed, + endCollapsed: this.endCollapsed, + }; + this.emitEvent('igcLayoutChanged', { detail }); } private _handleArrowsExpandCollapse( @@ -784,6 +878,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< delta, }, }); + this._emitLayoutChanged(); } private _rectSize(): [number, number] { @@ -1012,6 +1107,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< export type { IgcSplitterComponentEventMap, + IgcSplitterLayoutChangedEventArgs, IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, }; diff --git a/src/components/splitter/types.ts b/src/components/splitter/types.ts index 5077ab58e..bbec817f7 100644 --- a/src/components/splitter/types.ts +++ b/src/components/splitter/types.ts @@ -37,18 +37,32 @@ interface IgcSplitterResizeEventArgs { /** * @hidden - * @deprecated use IgcSplitterResizeEventArgs instead + * @deprecated since 7.1.0. Use the `IgcSplitterResizeEventArgs` type instead. */ interface IgcSplitterResizeEventDetail extends IgcSplitterResizeEventArgs {} +/* jsonAPIPlainObject */ +interface IgcSplitterLayoutChangedEventArgs { + /** The current size of the start pane */ + startSize: string; + /** The current size of the end pane */ + endSize: string; + /** Whether the start pane is currently collapsed */ + startCollapsed: boolean; + /** Whether the end pane is currently collapsed */ + endCollapsed: boolean; +} + interface IgcSplitterComponentEventMap { igcResizeStart: CustomEvent; igcResizing: CustomEvent; igcResizeEnd: CustomEvent; + igcLayoutChanged: CustomEvent; } export type { IgcSplitterComponentEventMap, + IgcSplitterLayoutChangedEventArgs, IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, PanePosition, diff --git a/src/index.ts b/src/index.ts index 97bb03c63..4e800dfa0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -193,6 +193,7 @@ export type { IgcRangeSliderValueEventArgs } from './components/slider/range-sli export type { IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, + IgcSplitterLayoutChangedEventArgs, } from './components/splitter/splitter.js'; export type { IgcActiveStepChangingEventArgs, diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 802edbd4f..b3cdd9d73 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -3,6 +3,7 @@ import { IgcSplitterComponent, defineComponents, } from 'igniteui-webcomponents'; +import type { IgcSplitterLayoutChangedEventArgs } from 'igniteui-webcomponents'; import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { disableStoryControls } from './story.js'; @@ -21,7 +22,14 @@ const metadata: Meta = { 'A splitter component that provides a resizable split-pane layout, dividing the view\ninto two panels — *start* and *end* — separated by a draggable bar.\n\nPanels can be resized by dragging the bar, using keyboard shortcuts, or collapsed/expanded\nusing the built-in collapse buttons or the programmatic `toggle()` API.\nNested splitters are supported for more complex layouts.', }, }, - actions: { handles: ['igcResizeStart', 'igcResizing', 'igcResizeEnd'] }, + actions: { + handles: [ + 'igcResizeStart', + 'igcResizing', + 'igcResizeEnd', + 'igcLayoutChanged', + ], + }, }, argTypes: { orientation: { @@ -35,28 +43,28 @@ const metadata: Meta = { disableCollapse: { type: 'boolean', description: - 'When true, prevents the user from collapsing either pane.\nThis also hides the expand/collapse buttons on the splitter bar.', + 'Whether collapsing either pane is disabled. When `true`, this also hides\nthe expand/collapse buttons on the splitter bar.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, disableResize: { type: 'boolean', description: - 'When true, prevents the user from resizing the panes by dragging the splitter bar or using keyboard shortcuts.\nThis also hides the drag handle on the splitter bar.', + 'Whether resizing the panes by dragging the splitter bar or using keyboard\nshortcuts is disabled. When `true`, this also hides the drag handle on the\nsplitter bar.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, hideCollapseButtons: { type: 'boolean', description: - 'When true, hides the expand/collapse buttons on the splitter bar.\n\nNote that the buttons will also be hidden if `disable-collapse` is true or\nif a pane is currently collapsed.', + 'Whether the expand/collapse buttons on the splitter bar are hidden.\n\nNote that the buttons will also be hidden if `disable-collapse` is true or\nif a pane is currently collapsed.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, hideDragHandle: { type: 'boolean', description: - 'When true, hides the drag handle on the splitter bar.\n\nNote that the drag handle will also be hidden if `disable-resize` is true.', + 'Whether the drag handle on the splitter bar is hidden.\n\nNote that the drag handle will also be hidden if `disable-resize` is true.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -96,6 +104,20 @@ const metadata: Meta = { 'The size of the end pane.\n\nAccepts a CSS length, e.g. `200px` or `50%`. Setting `auto`, a negative\nvalue, or a percentage above 100 falls back to automatic sizing.', control: 'text', }, + startCollapsed: { + type: 'boolean', + description: + 'Whether the start pane is currently collapsed. Set this property to\ncollapse or expand the pane programmatically.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + endCollapsed: { + type: 'boolean', + description: + 'Whether the end pane is currently collapsed. Set this property to\ncollapse or expand the pane programmatically.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, }, args: { orientation: 'horizontal', @@ -103,6 +125,8 @@ const metadata: Meta = { disableResize: false, hideCollapseButtons: false, hideDragHandle: false, + startCollapsed: false, + endCollapsed: false, }, }; @@ -112,24 +136,25 @@ interface IgcSplitterArgs { /** The orientation of the splitter, which determines the direction of resizing and collapsing. */ orientation: 'horizontal' | 'vertical'; /** - * When true, prevents the user from collapsing either pane. - * This also hides the expand/collapse buttons on the splitter bar. + * Whether collapsing either pane is disabled. When `true`, this also hides + * the expand/collapse buttons on the splitter bar. */ disableCollapse: boolean; /** - * When true, prevents the user from resizing the panes by dragging the splitter bar or using keyboard shortcuts. - * This also hides the drag handle on the splitter bar. + * Whether resizing the panes by dragging the splitter bar or using keyboard + * shortcuts is disabled. When `true`, this also hides the drag handle on the + * splitter bar. */ disableResize: boolean; /** - * When true, hides the expand/collapse buttons on the splitter bar. + * Whether the expand/collapse buttons on the splitter bar are hidden. * * Note that the buttons will also be hidden if `disable-collapse` is true or * if a pane is currently collapsed. */ hideCollapseButtons: boolean; /** - * When true, hides the drag handle on the splitter bar. + * Whether the drag handle on the splitter bar is hidden. * * Note that the drag handle will also be hidden if `disable-resize` is true. */ @@ -176,6 +201,16 @@ interface IgcSplitterArgs { * value, or a percentage above 100 falls back to automatic sizing. */ endSize: string; + /** + * Whether the start pane is currently collapsed. Set this property to + * collapse or expand the pane programmatically. + */ + startCollapsed: boolean; + /** + * Whether the end pane is currently collapsed. Set this property to + * collapse or expand the pane programmatically. + */ + endCollapsed: boolean; } type Story = StoryObj; @@ -190,6 +225,8 @@ const LOREM_LONG = export const Default: Story = { render: ({ orientation, + startCollapsed, + endCollapsed, disableCollapse, hideCollapseButtons, hideDragHandle, @@ -211,6 +248,8 @@ export const Default: Story = { html` + + +
+ Start panel +

${LOREM}

+
+
+ End panel +

${LOREM_LONG}

+
+
+ `; + }, +}; + export const NestedSplitters: Story = { argTypes: disableStoryControls(metadata), parameters: { @@ -410,6 +515,9 @@ export const NestedSplitters: Story = { 'Each inner splitter fills its parent panel and can have its own orientation.', }, }, + actions: { + handles: [], + }, }, render: () => html`