From 98661a4ed3b068c86571c94e1e151150ce9b83d4 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Mon, 27 Jul 2026 09:58:26 +0300 Subject: [PATCH 1/5] feature(splitter): Added support for collapsed panes state Added support for collapsed panes state in the splitter component. This enhancement allows developers to programmatically control the collapsed state of panes, enabling more dynamic and interactive layouts. The feature includes methods to collapse and expand panes, as well as events to listen for state changes. Closes #2297 --- CHANGELOG.md | 3 + src/components/splitter/splitter.spec.ts | 187 +++++++++++++++++++++++ src/components/splitter/splitter.ts | 83 ++++++++-- src/components/splitter/types.ts | 10 ++ src/index.ts | 1 + stories/splitter.stories.ts | 83 +++++++++- 6 files changed, 355 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4e3cd7a..3c25f6ce3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - #### Icon - `registerIcon` and `registerIconFromText` now accept a `RegisterIconOptions` object as their third argument in addition to the existing plain collection string. Setting `stripMeta: true` removes `` and `<desc>` elements from the stored SVG, preventing the browser from displaying a native tooltip on hover. The title text is still captured and exposed as the `aria-label` of the host `<igc-icon>` element. Any `aria-labelledby` / `aria-describedby` references on the root `<svg>` that pointed to the stripped elements' IDs are cleaned up automatically. [#1822](https://github.com/IgniteUI/igniteui-webcomponents/issues/1822) +- #### Splitter + - `startCollapsed` and `endCollapsed` properties for reading and programmatically setting the collapsed state of each pane. + - `igcExpansionChanged` event, emitted when a pane's collapsed state changes as a result of user interaction (collapse/expand buttons or the Ctrl+Arrow keyboard shortcuts). ## [7.2.4] - 2026-06-29 ### Added diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 820f34131..67b766b30 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 igcExpansionChanged', 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('igcExpansionChanged')).to.be.false; + }); + it('should restore pane sizes as percentages after collapse then expand', async () => { splitter.startSize = '200px'; splitter.endSize = '30%'; @@ -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<IgcSplitterComponent>(html` + <igc-splitter + style="width: 500px; height: 500px;" + .endCollapsed=${true} + > + <div slot="start">Pane 1</div> + <div slot="end">Pane 2</div> + </igc-splitter> + `); + 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,57 @@ describe('Splitter', () => { expect(parts.endExpander).to.be.null; }); + it('should emit igcExpansionChanged when a collapse/expand button is clicked', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + let parts = getButtonParts(splitter); + + simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(eventSpy).calledOnceWith('igcExpansionChanged', { + detail: { pane: 'start', expanded: false }, + }); + expect(splitter.startCollapsed).to.be.true; + + eventSpy.resetHistory(); + parts = getButtonParts(splitter); + + simulatePointerDown(parts.startExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(eventSpy).calledOnceWith('igcExpansionChanged', { + detail: { pane: 'start', expanded: true }, + }); + expect(splitter.startCollapsed).to.be.false; + + eventSpy.resetHistory(); + parts = getButtonParts(splitter); + + simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + expect(eventSpy).calledOnceWith('igcExpansionChanged', { + detail: { pane: 'end', expanded: false }, + }); + expect(splitter.endCollapsed).to.be.true; + }); + + it('should not emit igcExpansionChanged 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('igcExpansionChanged')).to.be.false; + }); + it('should set tabindex correctly on the bar based on interactivity', async () => { const bar = getSplitterPart(splitter, BAR_PART); @@ -1192,6 +1357,28 @@ describe('Splitter', () => { expect(bar.getAttribute('tabindex')).to.equal('0'); }); + it('should emit igcExpansionChanged when collapsing/expanding via Ctrl + arrow keys', async () => { + const eventSpy = spy(splitter, 'emitEvent'); + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + + expect(eventSpy).calledOnceWith('igcExpansionChanged', { + detail: { pane: 'start', expanded: false }, + }); + + eventSpy.resetHistory(); + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + + expect(eventSpy).calledOnceWith('igcExpansionChanged', { + detail: { pane: 'start', expanded: true }, + }); + }); + 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 e1d6b08ee..cdef48153 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, + IgcSplitterExpansionChangedEventArgs, 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 igcExpansionChanged - Emitted when a pane's collapsed state changes due to user interaction + * (collapse/expand button click or Ctrl+Arrow keyboard shortcut). * * @slot start - Content projected into the start (left/top) panel. * @slot end - Content projected into the end (right/bottom) panel. @@ -157,13 +160,13 @@ 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) + @query('[part~="start-pane"]') private readonly _startPane!: HTMLElement; - @query('[part~="end-pane"]', true) + @query('[part~="end-pane"]') private readonly _endPane!: HTMLElement; private get _separator(): HTMLElement | undefined { @@ -333,6 +336,36 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return this._endPaneState.size; } + /** + * Gets/sets the collapsed state of the start pane. + * + * @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'); + } + + /** + * Gets/sets the collapsed state of the end pane. + * + * @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 @@ -459,13 +492,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')); @@ -473,11 +514,19 @@ 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 { + // Guard against saving degenerate sizes when the splitter hasn't been laid out yet + // (e.g. a collapsed state is set programmatically before the first render/layout). + if (this._getTotalSize() === 0) { + return; + } this._startPaneState.savedSize = `${this._paneRectAsPercent(0)}%`; this._endPaneState.savedSize = `${this._paneRectAsPercent(1)}%`; } @@ -645,7 +694,18 @@ export default class IgcSplitterComponent extends EventEmitterMixin< 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); + + const detail: IgcSplitterExpansionChangedEventArgs = { + pane: position, + expanded: !this._isCollapsed(position), + }; + this.emitEvent('igcExpansionChanged', { detail }); } private _handleArrowsExpandCollapse( @@ -994,6 +1054,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< export type { IgcSplitterComponentEventMap, + IgcSplitterExpansionChangedEventArgs, IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, }; diff --git a/src/components/splitter/types.ts b/src/components/splitter/types.ts index 5077ab58e..317a905bd 100644 --- a/src/components/splitter/types.ts +++ b/src/components/splitter/types.ts @@ -41,14 +41,24 @@ interface IgcSplitterResizeEventArgs { */ interface IgcSplitterResizeEventDetail extends IgcSplitterResizeEventArgs {} +/* jsonAPIPlainObject */ +interface IgcSplitterExpansionChangedEventArgs { + /** The pane whose collapsed state changed */ + pane: PanePosition; + /** Whether the pane is now expanded (true) or collapsed (false) */ + expanded: boolean; +} + interface IgcSplitterComponentEventMap { igcResizeStart: CustomEvent<IgcSplitterResizeEventArgs>; igcResizing: CustomEvent<IgcSplitterResizeEventArgs>; igcResizeEnd: CustomEvent<IgcSplitterResizeEventArgs>; + igcExpansionChanged: CustomEvent<IgcSplitterExpansionChangedEventArgs>; } export type { IgcSplitterComponentEventMap, + IgcSplitterExpansionChangedEventArgs, IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, PanePosition, diff --git a/src/index.ts b/src/index.ts index 7bfa5fdba..410d91b22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -182,6 +182,7 @@ export type { IgcRangeSliderValueEventArgs } from './components/slider/range-sli export type { IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, + IgcSplitterExpansionChangedEventArgs, } from './components/splitter/splitter.js'; export type { IgcActiveStepChangingEventArgs, diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 9284c4093..88fcaa655 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -23,7 +23,12 @@ const metadata: Meta<IgcSplitterComponent> = { }, }, actions: { - handles: ['igcResizeStart', 'igcResizing', 'igcResizeEnd'], + handles: [ + 'igcResizeStart', + 'igcResizing', + 'igcResizeEnd', + 'igcExpansionChanged', + ], }, }, argTypes: { @@ -34,6 +39,18 @@ const metadata: Meta<IgcSplitterComponent> = { 'The axis along which the panels are split. `horizontal` places start/end side‑by‑side; `vertical` stacks them.', table: { defaultValue: { summary: 'horizontal' } }, }, + startCollapsed: { + type: 'boolean', + description: 'Gets/sets the collapsed state of the start panel.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + endCollapsed: { + type: 'boolean', + description: 'Gets/sets the collapsed state of the end panel.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, disableCollapse: { type: 'boolean', description: @@ -91,6 +108,8 @@ const metadata: Meta<IgcSplitterComponent> = { }, args: { orientation: 'horizontal', + startCollapsed: false, + endCollapsed: false, disableCollapse: false, hideCollapseButtons: false, hideDragHandle: false, @@ -102,6 +121,8 @@ export default metadata; interface IgcSplitterArgs { orientation: 'horizontal' | 'vertical'; + startCollapsed: boolean; + endCollapsed: boolean; disableCollapse: boolean; hideCollapseButtons: boolean; hideDragHandle: boolean; @@ -125,6 +146,8 @@ const LOREM_LONG = export const Default: Story = { render: ({ orientation, + startCollapsed, + endCollapsed, disableCollapse, hideCollapseButtons, hideDragHandle, @@ -146,6 +169,8 @@ export const Default: Story = { <igc-splitter style="height: 400px;" .orientation=${orientation} + .startCollapsed=${startCollapsed} + .endCollapsed=${endCollapsed} .disableCollapse=${disableCollapse} .hideCollapseButtons=${hideCollapseButtons} .hideDragHandle=${hideDragHandle} @@ -335,6 +360,62 @@ export const ProgrammaticCollapse: Story = { }, }; +const PERSISTED_LAYOUT_KEY = 'igc-splitter-demo-layout'; + +export const PersistedLayout: Story = { + argTypes: disableStoryControls(metadata), + parameters: { + docs: { + description: { + story: + 'Demonstrates reading/writing the `startCollapsed`/`endCollapsed` properties and listening for ' + + '`igcExpansionChanged` to persist the collapsed state (e.g. in `localStorage`) and restore it on load.', + }, + }, + }, + render: () => { + const saved = localStorage.getItem(PERSISTED_LAYOUT_KEY); + const startCollapsed = saved ? JSON.parse(saved).startCollapsed : false; + const endCollapsed = saved ? JSON.parse(saved).endCollapsed : false; + + function persist(splitter: IgcSplitterComponent) { + localStorage.setItem( + PERSISTED_LAYOUT_KEY, + JSON.stringify({ + startCollapsed: splitter.startCollapsed, + endCollapsed: splitter.endCollapsed, + }) + ); + } + + return html` + <style> + .demo-pane { + padding: 1rem; + box-sizing: border-box; + } + </style> + + <igc-splitter + style="height: 400px;" + .startCollapsed=${startCollapsed} + .endCollapsed=${endCollapsed} + @igcExpansionChanged=${(e: CustomEvent) => + persist(e.target as IgcSplitterComponent)} + > + <div slot="start" class="demo-pane"> + <strong>Start panel</strong> + <p>${LOREM}</p> + </div> + <div slot="end" class="demo-pane"> + <strong>End panel</strong> + <p>${LOREM_LONG}</p> + </div> + </igc-splitter> + `; + }, +}; + export const NestedSplitters: Story = { argTypes: disableStoryControls(metadata), parameters: { From 3f8942dee19245a55141f4809010aa295b919d0a Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov <rkaraivanov@infragistics.com> Date: Mon, 27 Jul 2026 10:17:15 +0300 Subject: [PATCH 2/5] refactor: Revert query cache flags for splitter panes --- src/components/splitter/splitter.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index cdef48153..899e56a61 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -163,10 +163,10 @@ export default class IgcSplitterComponent extends EventEmitterMixin< @query('[part~="base"]') private readonly _base!: HTMLElement; - @query('[part~="start-pane"]') + @query('[part~="start-pane"]', true) private readonly _startPane!: HTMLElement; - @query('[part~="end-pane"]') + @query('[part~="end-pane"]', true) private readonly _endPane!: HTMLElement; private get _separator(): HTMLElement | undefined { From 87bb63bd28126a4840a0ada1ff807b5c7c006ec7 Mon Sep 17 00:00:00 2001 From: jsakamotoIGJP <102948935+jsakamotoIGJP@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:31:01 +0900 Subject: [PATCH 3/5] docs(splitter): Persist pane size in the persisted layout story (#2301) --- stories/splitter.stories.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 88fcaa655..9ab5537f2 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -368,20 +368,25 @@ export const PersistedLayout: Story = { docs: { description: { story: - 'Demonstrates reading/writing the `startCollapsed`/`endCollapsed` properties and listening for ' + - '`igcExpansionChanged` to persist the collapsed state (e.g. in `localStorage`) and restore it on load.', + 'Demonstrates reading/writing the `startSize` and `startCollapsed`/`endCollapsed` properties and listening for ' + + '`igcResizeEnd`/`igcExpansionChanged` to persist the pane size and collapsed state (e.g. in `localStorage`) ' + + 'and restore them on load.', }, }, }, render: () => { const saved = localStorage.getItem(PERSISTED_LAYOUT_KEY); - const startCollapsed = saved ? JSON.parse(saved).startCollapsed : false; - const endCollapsed = saved ? JSON.parse(saved).endCollapsed : false; + const layout = saved ? JSON.parse(saved) : null; + + const startSize = layout?.startSize ?? '50%'; + const startCollapsed = layout?.startCollapsed ?? false; + const endCollapsed = layout?.endCollapsed ?? false; function persist(splitter: IgcSplitterComponent) { localStorage.setItem( PERSISTED_LAYOUT_KEY, JSON.stringify({ + startSize: splitter.startSize, startCollapsed: splitter.startCollapsed, endCollapsed: splitter.endCollapsed, }) @@ -398,8 +403,11 @@ export const PersistedLayout: Story = { <igc-splitter style="height: 400px;" + .startSize=${startSize} .startCollapsed=${startCollapsed} .endCollapsed=${endCollapsed} + @igcResizeEnd=${(e: CustomEvent) => + persist(e.target as IgcSplitterComponent)} @igcExpansionChanged=${(e: CustomEvent) => persist(e.target as IgcSplitterComponent)} > From e9fea7cc166c68c77166f8ad684ae0b49f651ec1 Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov <rkaraivanov@infragistics.com> Date: Tue, 4 Aug 2026 12:16:11 +0300 Subject: [PATCH 4/5] feat(splitter): Replace igcExpansionChanged with unified igcLayoutChanged event - Removed the igcExpansionChanged event in favor of a single igcLayoutChanged event, fired after a user-driven resize (drag/keyboard) or a collapse/expand toggle, carrying a full layout snapshot (startSize, endSize, startCollapsed, endCollapsed). Simplifies persisting/restoring layout to one listener instead of two. - Fixed pane sizes collapsing to 'auto' when a pane is expanded after being collapsed programmatically before first render (e.g. restoring persisted state) - _savePaneSizes() now preserves the explicit size when layout can't be measured yet, instead of losing it. - Fixed igcLayoutChanged reporting 'auto' for the still-expanded pane's size while the other pane is collapsed - both panes' sizes are forced to 'auto' together, so both must report from the saved pre-collapse size. - Updated PersistedLayout story to use the single igcLayoutChanged listener and persist/restore startSize alongside the collapsed state. - Cleaned up JSDoc formatting on splitter properties to match repo convention (tags immediately follow description, no blank line; corrected @deprecated format on IgcSplitterResizeEventDetail). --- CHANGELOG.md | 2 +- src/components/splitter/splitter.spec.ts | 76 +++++++++++++++++++----- src/components/splitter/splitter.ts | 50 +++++++++------- src/components/splitter/types.ts | 20 ++++--- src/index.ts | 2 +- stories/splitter.stories.ts | 36 ++++++----- 6 files changed, 122 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0809f281c..e86113305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `registerIcon` and `registerIconFromText` now accept a `RegisterIconOptions` object as their third argument in addition to the existing plain collection string. Setting `stripMeta: true` removes `<title>` and `<desc>` elements from the stored SVG, preventing the browser from displaying a native tooltip on hover. The title text is still captured and exposed as the `aria-label` of the host `<igc-icon>` element. Any `aria-labelledby` / `aria-describedby` references on the root `<svg>` that pointed to the stripped elements' IDs are cleaned up automatically. [#1822](https://github.com/IgniteUI/igniteui-webcomponents/issues/1822) - #### Splitter - `startCollapsed` and `endCollapsed` properties for reading and programmatically setting the collapsed state of each pane. - - `igcExpansionChanged` event, emitted when a pane's collapsed state changes as a result of user interaction (collapse/expand buttons or the Ctrl+Arrow keyboard shortcuts). + - `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 67b766b30..bdb3dfd86 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -704,7 +704,7 @@ describe('Splitter', () => { expect(splitter.matches(':state(start-collapsed)')).to.be.true; }); - it('should update startCollapsed/endCollapsed when toggle is invoked, without emitting igcExpansionChanged', async () => { + it('should update startCollapsed/endCollapsed when toggle is invoked, without emitting igcLayoutChanged', async () => { const eventSpy = spy(splitter, 'emitEvent'); splitter.toggle('start'); @@ -717,7 +717,7 @@ describe('Splitter', () => { expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.true; - expect(eventSpy.calledWith('igcExpansionChanged')).to.be.false; + expect(eventSpy.calledWith('igcLayoutChanged')).to.be.false; }); it('should restore pane sizes as percentages after collapse then expand', async () => { @@ -835,16 +835,27 @@ describe('Splitter', () => { expect(parts.endExpander).to.be.null; }); - it('should emit igcExpansionChanged when a collapse/expand button is clicked', async () => { + 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, 0)}%`; + let expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 0)}%`; + simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); - expect(eventSpy).calledOnceWith('igcExpansionChanged', { - detail: { pane: 'start', expanded: false }, + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: expectedStartPercent, + endSize: expectedEndPercent, + startCollapsed: true, + endCollapsed: false, + }, }); expect(splitter.startCollapsed).to.be.true; @@ -855,25 +866,42 @@ describe('Splitter', () => { await elementUpdated(splitter); await nextFrame(); - expect(eventSpy).calledOnceWith('igcExpansionChanged', { - detail: { pane: 'start', expanded: true }, + 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, 0)}%`; + expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 0)}%`; + simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); - expect(eventSpy).calledOnceWith('igcExpansionChanged', { - detail: { pane: 'end', expanded: false }, + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: expectedStartPercent, + endSize: expectedEndPercent, + startCollapsed: false, + endCollapsed: true, + }, }); expect(splitter.endCollapsed).to.be.true; }); - it('should not emit igcExpansionChanged when startCollapsed/endCollapsed are set directly', async () => { + it('should not emit igcLayoutChanged when startCollapsed/endCollapsed are set directly', async () => { const eventSpy = spy(splitter, 'emitEvent'); splitter.startCollapsed = true; @@ -883,7 +911,7 @@ describe('Splitter', () => { splitter.endCollapsed = true; await elementUpdated(splitter); - expect(eventSpy.calledWith('igcExpansionChanged')).to.be.false; + expect(eventSpy.calledWith('igcLayoutChanged')).to.be.false; }); it('should set tabindex correctly on the bar based on interactivity', async () => { @@ -1357,25 +1385,41 @@ describe('Splitter', () => { expect(bar.getAttribute('tabindex')).to.equal('0'); }); - it('should emit igcExpansionChanged when collapsing/expanding via Ctrl + arrow keys', async () => { + 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, 0)}%`; + const expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 0)}%`; + simulateKeyboard(bar, [ctrlKey, arrowLeft]); await elementUpdated(splitter); - expect(eventSpy).calledOnceWith('igcExpansionChanged', { - detail: { pane: 'start', expanded: false }, + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: expectedStartPercent, + endSize: expectedEndPercent, + startCollapsed: true, + endCollapsed: false, + }, }); eventSpy.resetHistory(); simulateKeyboard(bar, [ctrlKey, arrowRight]); await elementUpdated(splitter); - expect(eventSpy).calledOnceWith('igcExpansionChanged', { - detail: { pane: 'start', expanded: true }, + expect(eventSpy).calledWith('igcLayoutChanged', { + detail: { + startSize: splitter.startSize, + endSize: splitter.endSize, + startCollapsed: false, + endCollapsed: false, + }, }); }); diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index a18f312fd..c71dba95e 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -35,7 +35,7 @@ import { styles } from './themes/splitter.base.css.js'; import { all } from './themes/themes.js'; import type { IgcSplitterComponentEventMap, - IgcSplitterExpansionChangedEventArgs, + IgcSplitterLayoutChangedEventArgs, IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, PanePosition, @@ -118,8 +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 igcExpansionChanged - Emitted when a pane's collapsed state changes due to user interaction - * (collapse/expand button click or Ctrl+Arrow keyboard shortcut). + * @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. @@ -194,7 +194,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /** * The orientation of the splitter, which determines the direction of resizing and collapsing. - * * @attr orientation * @default 'horizontal' */ @@ -204,7 +203,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /** * When true, prevents the user from collapsing either pane. * This also hides the expand/collapse buttons on the splitter bar. - * * @attr disable-collapse * @default false */ @@ -214,7 +212,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /** * 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. - * * @attr disable-resize * @default false */ @@ -226,7 +223,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * * 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 */ @@ -241,7 +237,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * When true, hides the drag handle on the splitter bar. * * Note that the drag handle will also be hidden if `disable-resize` is true. - * * @attr hide-drag-handle * @default false */ @@ -257,7 +252,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' }) @@ -274,7 +268,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' }) @@ -291,7 +284,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' }) @@ -308,7 +300,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' }) @@ -325,7 +316,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' }) @@ -342,7 +332,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' }) @@ -356,7 +345,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /** * Gets/sets the collapsed state of the start pane. - * * @attr start-collapsed * @default false */ @@ -371,7 +359,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /** * Gets/sets the collapsed state of the end pane. - * * @attr end-collapsed * @default false */ @@ -540,9 +527,11 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } private _savePaneSizes(): void { - // Guard against saving degenerate sizes when the splitter hasn't been laid out yet - // (e.g. a collapsed state is set programmatically before the first render/layout). + // 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; } this._startPaneState.savedSize = `${this._paneRectAsPercent(0)}%`; @@ -718,12 +707,26 @@ export default class IgcSplitterComponent extends EventEmitterMixin< 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' + ); + } - const detail: IgcSplitterExpansionChangedEventArgs = { - pane: position, - expanded: !this._isCollapsed(position), + private _emitLayoutChanged(): void { + const detail: IgcSplitterLayoutChangedEventArgs = { + startSize: this._reportedSize('start'), + endSize: this._reportedSize('end'), + startCollapsed: this.startCollapsed, + endCollapsed: this.endCollapsed, }; - this.emitEvent('igcExpansionChanged', { detail }); + this.emitEvent('igcLayoutChanged', { detail }); } private _handleArrowsExpandCollapse( @@ -844,6 +847,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< delta, }, }); + this._emitLayoutChanged(); } private _rectSize(): [number, number] { @@ -1072,7 +1076,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< export type { IgcSplitterComponentEventMap, - IgcSplitterExpansionChangedEventArgs, + IgcSplitterLayoutChangedEventArgs, IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, }; diff --git a/src/components/splitter/types.ts b/src/components/splitter/types.ts index 317a905bd..bbec817f7 100644 --- a/src/components/splitter/types.ts +++ b/src/components/splitter/types.ts @@ -37,28 +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 IgcSplitterExpansionChangedEventArgs { - /** The pane whose collapsed state changed */ - pane: PanePosition; - /** Whether the pane is now expanded (true) or collapsed (false) */ - expanded: boolean; +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<IgcSplitterResizeEventArgs>; igcResizing: CustomEvent<IgcSplitterResizeEventArgs>; igcResizeEnd: CustomEvent<IgcSplitterResizeEventArgs>; - igcExpansionChanged: CustomEvent<IgcSplitterExpansionChangedEventArgs>; + igcLayoutChanged: CustomEvent<IgcSplitterLayoutChangedEventArgs>; } export type { IgcSplitterComponentEventMap, - IgcSplitterExpansionChangedEventArgs, + IgcSplitterLayoutChangedEventArgs, IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, PanePosition, diff --git a/src/index.ts b/src/index.ts index c22952feb..15e7d21b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -192,7 +192,7 @@ export type { IgcRangeSliderValueEventArgs } from './components/slider/range-sli export type { IgcSplitterResizeEventArgs, IgcSplitterResizeEventDetail, - IgcSplitterExpansionChangedEventArgs, + IgcSplitterLayoutChangedEventArgs, } from './components/splitter/splitter.js'; export type { IgcActiveStepChangingEventArgs, diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 970ea1a0a..96a3f7deb 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'; @@ -26,7 +27,7 @@ const metadata: Meta<IgcSplitterComponent> = { 'igcResizeStart', 'igcResizing', 'igcResizeEnd', - 'igcExpansionChanged', + 'igcLayoutChanged', ], }, }, @@ -266,6 +267,9 @@ export const Vertical: Story = { 'A splitter with `orientation="vertical"` stacks the start panel on top and the end panel below.', }, }, + actions: { + handles: [], + }, }, render: () => html` <style> @@ -298,6 +302,9 @@ export const WithConstraints: Story = { 'Use the buttons below to apply pixel‑ or percentage‑based constraints at runtime.', }, }, + actions: { + handles: [], + }, }, render: () => { function applyPxConstraints() { @@ -385,6 +392,9 @@ export const ProgrammaticCollapse: Story = { 'Demonstrates the `toggle(position)` API for programmatically collapsing and expanding panels.', }, }, + actions: { + handles: [], + }, }, render: () => { function toggle(position: 'start' | 'end') { @@ -438,10 +448,13 @@ export const PersistedLayout: Story = { description: { story: 'Demonstrates reading/writing the `startSize` and `startCollapsed`/`endCollapsed` properties and listening for ' + - '`igcResizeEnd`/`igcExpansionChanged` to persist the pane size and collapsed state (e.g. in `localStorage`) ' + + 'the single `igcLayoutChanged` event to persist the pane size and collapsed state (e.g. in `localStorage`) ' + 'and restore them on load.', }, }, + actions: { + handles: [], + }, }, render: () => { const saved = localStorage.getItem(PERSISTED_LAYOUT_KEY); @@ -451,15 +464,8 @@ export const PersistedLayout: Story = { const startCollapsed = layout?.startCollapsed ?? false; const endCollapsed = layout?.endCollapsed ?? false; - function persist(splitter: IgcSplitterComponent) { - localStorage.setItem( - PERSISTED_LAYOUT_KEY, - JSON.stringify({ - startSize: splitter.startSize, - startCollapsed: splitter.startCollapsed, - endCollapsed: splitter.endCollapsed, - }) - ); + function persist(event: CustomEvent<IgcSplitterLayoutChangedEventArgs>) { + localStorage.setItem(PERSISTED_LAYOUT_KEY, JSON.stringify(event.detail)); } return html` @@ -475,10 +481,7 @@ export const PersistedLayout: Story = { .startSize=${startSize} .startCollapsed=${startCollapsed} .endCollapsed=${endCollapsed} - @igcResizeEnd=${(e: CustomEvent) => - persist(e.target as IgcSplitterComponent)} - @igcExpansionChanged=${(e: CustomEvent) => - persist(e.target as IgcSplitterComponent)} + @igcLayoutChanged=${persist} > <div slot="start" class="demo-pane"> <strong>Start panel</strong> @@ -503,6 +506,9 @@ export const NestedSplitters: Story = { 'Each inner splitter fills its parent panel and can have its own orientation.', }, }, + actions: { + handles: [], + }, }, render: () => html` <style> From 0787174e0d269ebc9e0439ef0ec646a388ec8a2f Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov <rkaraivanov@infragistics.com> Date: Wed, 5 Aug 2026 13:54:44 +0300 Subject: [PATCH 5/5] refactor: Cover some edge cases and update API documentation --- src/components/splitter/splitter.spec.ts | 93 ++++++++++++++++++++++-- src/components/splitter/splitter.ts | 71 +++++++++++++----- stories/splitter.stories.ts | 37 ++++++---- 3 files changed, 159 insertions(+), 42 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index bdb3dfd86..91f5019b9 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -727,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); @@ -741,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); @@ -842,8 +842,8 @@ describe('Splitter', () => { let { startSize: preCollapseStart, endSize: preCollapseEnd } = getPanesSizes(splitter, 'width'); - let expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 0)}%`; - let expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 0)}%`; + let expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 2)}%`; + let expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 2)}%`; simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); await elementUpdated(splitter); @@ -883,8 +883,8 @@ describe('Splitter', () => { splitter, 'width' )); - expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 0)}%`; - expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 0)}%`; + expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 2)}%`; + expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 2)}%`; simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); await elementUpdated(splitter); @@ -1034,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'; @@ -1280,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); @@ -1394,8 +1471,8 @@ describe('Splitter', () => { const totalSize = getTotalSize(splitter, 'width'); const { startSize: preCollapseStart, endSize: preCollapseEnd } = getPanesSizes(splitter, 'width'); - const expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 0)}%`; - const expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 0)}%`; + const expectedStartPercent = `${roundPrecise((preCollapseStart / totalSize) * 100, 2)}%`; + const expectedEndPercent = `${roundPrecise((preCollapseEnd / totalSize) * 100, 2)}%`; simulateKeyboard(bar, [ctrlKey, arrowLeft]); await elementUpdated(splitter); diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index c71dba95e..47a96e176 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -151,8 +151,14 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private readonly _separatorRef = createRef<HTMLElement>(); - 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; @@ -201,8 +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 */ @@ -210,8 +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,7 +226,7 @@ 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. @@ -234,7 +241,7 @@ 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 @@ -344,7 +351,8 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } /** - * Gets/sets the collapsed state of the start pane. + * Whether the start pane is currently collapsed. Set this property to + * collapse or expand the pane programmatically. * @attr start-collapsed * @default false */ @@ -358,7 +366,8 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } /** - * Gets/sets the collapsed state of the end pane. + * Whether the end pane is currently collapsed. Set this property to + * collapse or expand the pane programmatically. * @attr end-collapsed * @default false */ @@ -475,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(); } @@ -534,8 +538,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< this._endPaneState.savedSize = this._endPaneState.size; return; } - this._startPaneState.savedSize = `${this._paneRectAsPercent(0)}%`; - this._endPaneState.savedSize = `${this._paneRectAsPercent(1)}%`; + // 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 */ @@ -551,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. */ @@ -690,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)}%`; @@ -697,6 +719,15 @@ 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 { diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 96a3f7deb..b3cdd9d73 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -43,28 +43,28 @@ const metadata: Meta<IgcSplitterComponent> = { 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' } }, }, @@ -106,13 +106,15 @@ const metadata: Meta<IgcSplitterComponent> = { }, startCollapsed: { type: 'boolean', - description: 'Gets/sets the collapsed state of the start pane.', + 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: 'Gets/sets the collapsed state of the end pane.', + description: + 'Whether the end pane is currently collapsed. Set this property to\ncollapse or expand the pane programmatically.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -134,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. */ @@ -198,9 +201,15 @@ interface IgcSplitterArgs { * value, or a percentage above 100 falls back to automatic sizing. */ endSize: string; - /** Gets/sets the collapsed state of the start pane. */ + /** + * Whether the start pane is currently collapsed. Set this property to + * collapse or expand the pane programmatically. + */ startCollapsed: boolean; - /** Gets/sets the collapsed state of the end pane. */ + /** + * Whether the end pane is currently collapsed. Set this property to + * collapse or expand the pane programmatically. + */ endCollapsed: boolean; } type Story = StoryObj<IgcSplitterArgs>;