From 30b6a1cd0f58d1bf467f1cd587e9bd9dcafecb5d Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Tue, 4 Nov 2025 10:53:49 +0200 Subject: [PATCH 01/47] feat(splitter): initial structure implementation --- .../common/definitions/defineAllComponents.ts | 6 +++ src/components/splitter/splitter-bar.ts | 32 ++++++++++++ src/components/splitter/splitter-pane.ts | 30 +++++++++++ src/components/splitter/splitter.ts | 52 +++++++++++++++++++ src/components/types.ts | 1 + src/index.ts | 3 ++ stories/splitter.stories.ts | 47 +++++++++++++++++ 7 files changed, 171 insertions(+) create mode 100644 src/components/splitter/splitter-bar.ts create mode 100644 src/components/splitter/splitter-pane.ts create mode 100644 src/components/splitter/splitter.ts create mode 100644 stories/splitter.stories.ts diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index 4d3e03119..cbf01883c 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -56,6 +56,9 @@ import IgcRangeSliderComponent from '../../slider/range-slider.js'; import IgcSliderComponent from '../../slider/slider.js'; import IgcSliderLabelComponent from '../../slider/slider-label.js'; import IgcSnackbarComponent from '../../snackbar/snackbar.js'; +import IgcSplitterComponent from '../../splitter/splitter.js'; +import IgcSplitterBarComponent from '../../splitter/splitter-bar.js'; +import IgcSplitterPaneComponent from '../../splitter/splitter-pane.js'; import IgcStepComponent from '../../stepper/step.js'; import IgcStepperComponent from '../../stepper/stepper.js'; import IgcTabComponent from '../../tabs/tab.js'; @@ -134,6 +137,9 @@ const allComponents: IgniteComponent[] = [ IgcCircularGradientComponent, IgcSnackbarComponent, IgcDateTimeInputComponent, + IgcSplitterBarComponent, + IgcSplitterComponent, + IgcSplitterPaneComponent, IgcStepperComponent, IgcStepComponent, IgcTextareaComponent, diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts new file mode 100644 index 000000000..79c63908d --- /dev/null +++ b/src/components/splitter/splitter-bar.ts @@ -0,0 +1,32 @@ +import { html, LitElement } from 'lit'; +import { registerComponent } from '../common/definitions/register.js'; + +export default class IgcSplitterBarComponent extends LitElement { + public static readonly tagName = 'igc-splitter-bar'; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcSplitterBarComponent); + } + + // constructor() { + // super(); + // //addThemingController(this, all); + // } + + protected override render() { + return html` +
+
+
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-splitter-bar': IgcSplitterBarComponent; + } +} diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts new file mode 100644 index 000000000..c36b0e9fe --- /dev/null +++ b/src/components/splitter/splitter-pane.ts @@ -0,0 +1,30 @@ +import { html, LitElement } from 'lit'; +import { registerComponent } from '../common/definitions/register.js'; + +export default class IgcSplitterPaneComponent extends LitElement { + public static readonly tagName = 'igc-splitter-pane'; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcSplitterPaneComponent); + } + + // constructor() { + // super(); + // //addThemingController(this, all); + // } + + protected override render() { + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-splitter-pane': IgcSplitterPaneComponent; + } +} diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts new file mode 100644 index 000000000..44a44bd10 --- /dev/null +++ b/src/components/splitter/splitter.ts @@ -0,0 +1,52 @@ +import { html, LitElement } from 'lit'; +import { property, queryAssignedElements } from 'lit/decorators.js'; +import { registerComponent } from '../common/definitions/register.js'; +import type { SplitterOrientation } from '../types.js'; +import IgcSplitterPaneComponent from './splitter-pane.js'; + +export default class IgcSplitterComponent extends LitElement { + public static readonly tagName = 'igc-splitter'; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcSplitterComponent, IgcSplitterPaneComponent); + } + + /** Returns all of the splitter's panes. */ + @queryAssignedElements({ selector: 'igc-splitter-pane' }) + public panes!: Array; + + /** Gets/Sets the orientation of the stepper. + * + * @remarks + * Default value is `horizontal`. + */ + @property({ reflect: true }) + public orientation: SplitterOrientation = 'horizontal'; + + // constructor() { + // super(); + // } + + private _onSlotChange = () => { + // panes updates after slot distribution; trigger re-render + this.requestUpdate(); + }; + + private _renderBar() { + return html` `; + } + + protected override render() { + return html` + + ${this.panes.slice(0, -1).map(() => html` ${this._renderBar()} `)} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-splitter': IgcSplitterComponent; + } +} diff --git a/src/components/types.ts b/src/components/types.ts index ab829fcdd..8f213462c 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -47,6 +47,7 @@ export type MaskInputValueMode = 'raw' | 'withFormatting'; export type NavDrawerPosition = 'start' | 'end' | 'top' | 'bottom' | 'relative'; export type SliderTickLabelRotation = 0 | 90 | -90; export type SliderTickOrientation = 'end' | 'mirror' | 'start'; +export type SplitterOrientation = 'horizontal' | 'vertical'; export type StepperOrientation = 'horizontal' | 'vertical'; export type StepperStepType = 'full' | 'indicator' | 'title'; export type StepperTitlePosition = 'auto' | 'bottom' | 'top' | 'end' | 'start'; diff --git a/src/index.ts b/src/index.ts index 324c4660f..9a191cccf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,9 @@ export { default as IgcSwitchComponent } from './components/checkbox/switch.js'; export { default as IgcTextareaComponent } from './components/textarea/textarea.js'; export { default as IgcTreeComponent } from './components/tree/tree.js'; export { default as IgcTreeItemComponent } from './components/tree/tree-item.js'; +export { default as IgcSplitterBarComponent } from './components/splitter/splitter-bar.js'; +export { default as IgcSpltterComponent } from './components/splitter/splitter.js'; +export { default as IgcSplitterPaneComponent } from './components/splitter/splitter-pane.js'; export { default as IgcStepperComponent } from './components/stepper/stepper.js'; export { default as IgcStepComponent } from './components/stepper/step.js'; export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts new file mode 100644 index 000000000..f018e37ed --- /dev/null +++ b/stories/splitter.stories.ts @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/web-components-vite'; +import { html } from 'lit'; + +import { + IgcSplitterPaneComponent, + defineComponents, +} from 'igniteui-webcomponents'; +import IgcSplitterComponent from '../src/components/splitter/splitter.js'; + +defineComponents(IgcSplitterComponent, IgcSplitterPaneComponent); + +const metadata: Meta = { + title: 'Splitter', + component: 'igc-splitter', + parameters: { + docs: { + description: { + component: + 'The Splitter lays out panes with draggable bars rendered between each pair of panes.', + }, + }, + }, + argTypes: { + orientation: { + options: ['horizontal', 'vertical'], + control: { type: 'inline-radio' }, + description: 'Orientation of the splitter.', + table: { defaultValue: { summary: 'horizontal' } }, + }, + }, + args: { + orientation: 'horizontal', + }, +}; + +export default metadata; +type Story = StoryObj; + +export const Default: Story = { + render: () => html` + + Pane 1 + Pane 2 + Pane 3 + + `, +}; From 857ca99c3b4ae2825c5b654f6b8ae07c188d71f5 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Tue, 4 Nov 2025 12:49:27 +0200 Subject: [PATCH 02/47] feat(splitter): add initial poc styles --- src/components/splitter/splitter-bar.ts | 22 ++++++++- src/components/splitter/splitter-pane.ts | 26 ++++++++-- src/components/splitter/splitter.ts | 44 ++++++++++++----- .../splitter/themes/splitter.base.scss | 47 +++++++++++++++++++ stories/splitter.stories.ts | 21 +++++++-- 5 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 src/components/splitter/themes/splitter.base.scss diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index 79c63908d..f1df2544a 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -1,14 +1,34 @@ import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; +import { asNumber } from '../common/util.js'; +import { styles } from './themes/splitter.base.css.js'; export default class IgcSplitterBarComponent extends LitElement { public static readonly tagName = 'igc-splitter-bar'; + public static override styles = [styles]; /* blazorSuppress */ public static register() { registerComponent(IgcSplitterBarComponent); } + private _order = -1; + + /** + * Gets/sets the bar's visual position in the layout. + * @hidden @internal + */ + @property({ type: Number }) + public set order(value: number) { + this._order = asNumber(value); + this.style.order = this._order.toString(); + } + + public get order(): number { + return this._order; + } + // constructor() { // super(); // //addThemingController(this, all); @@ -16,7 +36,7 @@ export default class IgcSplitterBarComponent extends LitElement { protected override render() { return html` -
+
diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index c36b0e9fe..a804e16d3 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -1,25 +1,41 @@ import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; +import { asNumber } from '../common/util.js'; +import { styles } from './themes/splitter.base.css.js'; export default class IgcSplitterPaneComponent extends LitElement { public static readonly tagName = 'igc-splitter-pane'; + public static override styles = [styles]; /* blazorSuppress */ public static register() { registerComponent(IgcSplitterPaneComponent); } + private _order = -1; + + /** + * Gets/sets the pane's visual position in the layout. + * @hidden @internal + */ + @property({ type: Number }) + public set order(value: number) { + this._order = asNumber(value); + this.style.order = this._order.toString(); + } + + public get order(): number { + return this._order; + } + // constructor() { // super(); // //addThemingController(this, all); // } protected override render() { - return html` -
- -
- `; + return html` `; } } diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 44a44bd10..8e777317a 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -1,22 +1,34 @@ import { html, LitElement } from 'lit'; import { property, queryAssignedElements } from 'lit/decorators.js'; +import { addSlotController } from '../common/controllers/slot.js'; import { registerComponent } from '../common/definitions/register.js'; import type { SplitterOrientation } from '../types.js'; +import IgcSplitterBarComponent from './splitter-bar.js'; import IgcSplitterPaneComponent from './splitter-pane.js'; +import { styles } from './themes/splitter.base.css.js'; export default class IgcSplitterComponent extends LitElement { public static readonly tagName = 'igc-splitter'; + public static override styles = [styles]; /* blazorSuppress */ public static register() { - registerComponent(IgcSplitterComponent, IgcSplitterPaneComponent); + registerComponent( + IgcSplitterComponent, + IgcSplitterPaneComponent, + IgcSplitterBarComponent + ); } + private readonly _slots = addSlotController(this, { + onChange: this._handleSlotChange, + }); + /** Returns all of the splitter's panes. */ @queryAssignedElements({ selector: 'igc-splitter-pane' }) public panes!: Array; - /** Gets/Sets the orientation of the stepper. + /** Gets/Sets the orientation of the splitter. * * @remarks * Default value is `horizontal`. @@ -24,23 +36,29 @@ export default class IgcSplitterComponent extends LitElement { @property({ reflect: true }) public orientation: SplitterOrientation = 'horizontal'; - // constructor() { - // super(); - // } + protected _handleSlotChange(): void { + this._assignFlexOrder(); + } - private _onSlotChange = () => { - // panes updates after slot distribution; trigger re-render - this.requestUpdate(); - }; + private _assignFlexOrder() { + let k = 0; + this.panes.forEach((pane) => { + pane.order = k; + k += 2; + }); + } - private _renderBar() { - return html` `; + private _renderBar(order: number) { + return html` `; } protected override render() { return html` - - ${this.panes.slice(0, -1).map(() => html` ${this._renderBar()} `)} + + ${this.panes.map((pane, i) => { + const isLast = i === this.panes.length - 1; + return html`${!isLast ? this._renderBar(pane.order + 1) : ''}`; + })} `; } } diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss new file mode 100644 index 000000000..b037457e7 --- /dev/null +++ b/src/components/splitter/themes/splitter.base.scss @@ -0,0 +1,47 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: flex; + width: 100%; + height: 100%; + color: var(--ig-gray-900); + background: var(--ig-gray-100); + border: 1px solid var(--ig-gray-200); +} + +:host([orientation='horizontal']) { + flex-direction: row; + + igc-splitter-bar { + width: 5px; + height: auto; + } + + igc-splitter-pane { + height: 100%; + } +} + +:host([orientation='vertical']) { + flex-direction: column; + + igc-splitter-bar { + height: 5px; + width: auto; + } + + igc-splitter-pane { + width: 100%; + } +} + +igc-splitter-bar { + background-color: var(--ig-gray-200); +} + +igc-splitter-pane { + background: var(--ig-gray-900-contrast); + border: 1px solid var(--ig-gray-300); + flex: 1 1 0; +} diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index f018e37ed..53e437c2c 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -37,11 +37,22 @@ export default metadata; type Story = StoryObj; export const Default: Story = { - render: () => html` - - Pane 1 - Pane 2 - Pane 3 + render: ({ orientation }) => html` + + + +
Pane 1
+
+ +
Pane 2
+
+ +
Pane 3
+
`, }; From f62528e374ff497c5cab1dbfeefa8c56f2ab9f74 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Tue, 4 Nov 2025 17:15:23 +0200 Subject: [PATCH 03/47] chore: fix initial styles, add spec file --- src/components/splitter/splitter-bar.ts | 2 - src/components/splitter/splitter-pane.ts | 2 - src/components/splitter/splitter.spec.ts | 80 +++++++++++++++++++ .../splitter/themes/splitter.base.scss | 22 +++-- 4 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 src/components/splitter/splitter.spec.ts diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index f1df2544a..9aa22d1d1 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -2,11 +2,9 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; import { asNumber } from '../common/util.js'; -import { styles } from './themes/splitter.base.css.js'; export default class IgcSplitterBarComponent extends LitElement { public static readonly tagName = 'igc-splitter-bar'; - public static override styles = [styles]; /* blazorSuppress */ public static register() { diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index a804e16d3..9450496f6 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -2,11 +2,9 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; import { asNumber } from '../common/util.js'; -import { styles } from './themes/splitter.base.css.js'; export default class IgcSplitterPaneComponent extends LitElement { public static readonly tagName = 'igc-splitter-pane'; - public static override styles = [styles]; /* blazorSuppress */ public static register() { diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts new file mode 100644 index 000000000..076a47c95 --- /dev/null +++ b/src/components/splitter/splitter.spec.ts @@ -0,0 +1,80 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; + +import { defineComponents } from '../common/definitions/defineComponents.js'; +import IgcSplitterComponent from './splitter.js'; +import IgcSplitterBarComponent from './splitter-bar.js'; +import IgcSplitterPaneComponent from './splitter-pane.js'; + +describe('Splitter', () => { + before(() => { + defineComponents( + IgcSplitterComponent, + IgcSplitterPaneComponent, + IgcSplitterBarComponent + ); + }); + + let splitter: IgcSplitterComponent; + + describe('Rendering', () => { + beforeEach(async () => { + splitter = await fixture(createSplitter()); + }); + + it('should render', () => { + expect(splitter).to.exist; + expect(splitter).to.be.instanceOf(IgcSplitterComponent); + }); + + it('is accessible', async () => { + await expect(splitter).to.be.accessible(); + await expect(splitter).shadowDom.to.be.accessible(); + }); + + it('should render panes and assign correct flex order', async () => { + await elementUpdated(splitter); + + expect(splitter.panes).to.have.lengthOf(3); + + expect(splitter.panes[0].order).to.equal(0); + expect(splitter.panes[1].order).to.equal(2); + expect(splitter.panes[2].order).to.equal(4); + }); + + it('should render splitter bars and assign correct flex order', async () => { + await elementUpdated(splitter); + const bars = Array.from( + splitter.renderRoot.querySelectorAll(IgcSplitterBarComponent.tagName) + ); + + expect(bars).to.have.lengthOf(2); + + expect(bars[0].order).to.equal(1); + expect(bars[1].order).to.equal(3); + }); + + it('should have default horizontal orientation', () => { + expect(splitter.orientation).to.equal('horizontal'); + expect(splitter.hasAttribute('orientation')).to.be.true; + expect(splitter.getAttribute('orientation')).to.equal('horizontal'); + }); + + it('should change orientation to vertical', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + expect(splitter.orientation).to.equal('vertical'); + expect(splitter.getAttribute('orientation')).to.equal('vertical'); + }); + }); +}); + +function createSplitter() { + return html` + + Pane 1 + Pane 2 + Pane 3 + + `; +} diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index b037457e7..8b47aa515 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -8,6 +8,10 @@ color: var(--ig-gray-900); background: var(--ig-gray-100); border: 1px solid var(--ig-gray-200); + + ::slotted(igc-splitter-pane) { + flex: 1 1 0; + } } :host([orientation='horizontal']) { @@ -16,10 +20,7 @@ igc-splitter-bar { width: 5px; height: auto; - } - - igc-splitter-pane { - height: 100%; + cursor: col-resize; } } @@ -29,19 +30,14 @@ igc-splitter-bar { height: 5px; width: auto; - } - - igc-splitter-pane { - width: 100%; + cursor: row-resize; } } igc-splitter-bar { background-color: var(--ig-gray-200); -} -igc-splitter-pane { - background: var(--ig-gray-900-contrast); - border: 1px solid var(--ig-gray-300); - flex: 1 1 0; + &:hover { + background-color: var(--ig-gray-400); + } } From fea9e07544c0cd13f443db195e7b5ecb99e52d7e Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Tue, 4 Nov 2025 18:06:54 +0200 Subject: [PATCH 04/47] feat(splitter): add splitter-pane props --- src/components/splitter/splitter-pane.ts | 154 ++++++++++++++++++ src/components/splitter/splitter.ts | 59 ++++++- .../splitter/themes/splitter.base.scss | 8 + stories/splitter.stories.ts | 74 +++++++-- 4 files changed, 279 insertions(+), 16 deletions(-) diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index 9450496f6..c5440fc8a 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -2,6 +2,7 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; import { asNumber } from '../common/util.js'; +import type IgcSplitterComponent from './splitter.js'; export default class IgcSplitterPaneComponent extends LitElement { public static readonly tagName = 'igc-splitter-pane'; @@ -12,6 +13,17 @@ export default class IgcSplitterPaneComponent extends LitElement { } private _order = -1; + private _minSize?: string; + private _maxSize?: string; + private _size = 'auto'; + private _collapsed = false; + private _minWidth?: string; + private _minHeight?: string; + private _maxWidth?: string; + private _maxHeight?: string; + + /** @hidden @internal */ + public owner: IgcSplitterComponent | undefined; /** * Gets/sets the pane's visual position in the layout. @@ -27,11 +39,153 @@ export default class IgcSplitterPaneComponent extends LitElement { return this._order; } + /** + * The minimum size of the pane. + * @attr + */ + @property({ type: String, reflect: true }) + public set minSize(value: string) { + this._minSize = value; + this.dispatchEvent(new CustomEvent('sizeChanged', { bubbles: true })); + } + + public get minSize(): string | undefined { + return this._minSize; + } + + /** + * The maximum size of the pane. + * @attr + */ + @property({ type: String, reflect: true }) + public set maxSize(value: string) { + this._maxSize = value; + this.dispatchEvent(new CustomEvent('sizeChanged', { bubbles: true })); + } + + public get maxSize(): string | undefined { + return this._maxSize; + } + + /** + * Gets/sets the pane's minWidth. + * @hidden @internal + */ + @property({ type: String }) + public set minWidth(value: string) { + this._minWidth = value; + this.style.minWidth = this._minWidth; + } + + public get minWidth(): string | undefined { + return this._minWidth; + } + + /** + * Gets/sets the pane's maxWidth. + * @hidden @internal + */ + @property({ type: String }) + public set maxWidth(value: string) { + this._maxWidth = value; + this.style.maxWidth = this._maxWidth; + } + + public get maxWidth(): string | undefined { + return this._maxWidth; + } + + /** + * Gets/sets the pane's minHeight. + * @hidden @internal + */ + @property({ type: String }) + public set minHeight(value: string) { + this._minHeight = value; + this.style.minHeight = this._minHeight; + } + + public get minHeight(): string | undefined { + return this._minHeight; + } + + /** + * Gets/sets the pane's maxHeight. + * @hidden @internal + */ + @property({ type: String }) + public set maxHeight(value: string) { + this._maxHeight = value; + this.style.maxHeight = this._maxHeight; + } + + public get maxHeight(): string | undefined { + return this._maxHeight; + } + + /** + * Defines if the pane is resizable or not. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public resizable = true; + + /** + * Gets/sets the pane's maxHeight. + * @hidden @internal + */ + @property({ type: String }) + public get flex() { + //const size = this.dragSize || this.size; + //const grow = this.isPercentageSize && !this.dragSize ? 1 : 0; + const grow = this.isPercentageSize ? 1 : 0; + return `${grow} ${grow} ${this.size}`; + //return `${0} ${0} ${this.size}`; + } + + /** + * The size of the pane. + * @attr + */ + @property({ type: String, reflect: true }) + public set size(value: string) { + this._size = value; + this.style.flex = this.flex; + } + + public get size(): string { + return this._size; + } + + /** @hidden @internal */ + public get isPercentageSize() { + return this.size === 'auto' || this.size.indexOf('%') !== -1; + } + + /** + * Collapsed state of the pane. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public set collapsed(value: boolean) { + this._collapsed = value; + //this.requestUpdate(); + } + + public get collapsed(): boolean { + return this._collapsed; + } + // constructor() { // super(); // //addThemingController(this, all); // } + /** Toggles the collapsed state of the pane. */ + public toggle() { + this.collapsed = !this.collapsed; + } + protected override render() { return html` `; } diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 8e777317a..9d6c0177e 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -1,6 +1,7 @@ import { html, LitElement } from 'lit'; import { property, queryAssignedElements } from 'lit/decorators.js'; import { addSlotController } from '../common/controllers/slot.js'; +import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { SplitterOrientation } from '../types.js'; import IgcSplitterBarComponent from './splitter-bar.js'; @@ -37,7 +38,7 @@ export default class IgcSplitterComponent extends LitElement { public orientation: SplitterOrientation = 'horizontal'; protected _handleSlotChange(): void { - this._assignFlexOrder(); + this.initPanes(); } private _assignFlexOrder() { @@ -48,6 +49,62 @@ export default class IgcSplitterComponent extends LitElement { }); } + /** + * @hidden @internal + * This method inits panes with properties. + */ + private initPanes() { + this.panes.forEach((pane) => { + pane.owner = this; + if (this.orientation === 'horizontal') { + pane.minWidth = pane.minSize ?? '0'; + pane.maxWidth = pane.maxSize ?? '100%'; + } else { + pane.minHeight = pane.minSize ?? '0'; + pane.maxHeight = pane.maxSize ?? '100%'; + } + }); + this._assignFlexOrder(); + //in igniteui-angular this is added as feature but i haven't checked why + // if (this.panes.filter(x => x.collapsed).length > 0) { + // // if any panes are collapsed, reset sizes. + // this.resetPaneSizes(); + // } + } + + /** + * @hidden @internal + * This method reset pane sizes. + */ + private resetPaneSizes() { + if (this.panes) { + // if type is changed runtime, should reset sizes. + this.panes.forEach((pane) => { + pane.size = 'auto'; + pane.minWidth = '0'; + pane.maxWidth = '100%'; + pane.minHeight = '0'; + pane.maxHeight = '100%'; + }); + } + } + + @watch('orientation', { waitUntilFirstUpdate: true }) + protected orientationChange(): void { + this.setAttribute('aria-orientation', this.orientation); + this.resetPaneSizes(); + this.initPanes(); + } + + constructor() { + super(); + + this.addEventListener('sizeChanged', (event: any) => { + event.stopPropagation(); + this.initPanes(); + }); + } + private _renderBar(order: number) { return html` `; } diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index 8b47aa515..583fc3fc8 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -22,6 +22,10 @@ height: auto; cursor: col-resize; } + + ::slotted(igc-splitter-pane[collapsed]) { + display: none; + } } :host([orientation='vertical']) { @@ -32,6 +36,10 @@ width: auto; cursor: row-resize; } + + ::slotted(igc-splitter-pane[collapsed]) { + display: none; + } } igc-splitter-bar { diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 53e437c2c..b99a8e6dc 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -1,11 +1,9 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { html } from 'lit'; -import { - IgcSplitterPaneComponent, - defineComponents, -} from 'igniteui-webcomponents'; +import { defineComponents } from 'igniteui-webcomponents'; import IgcSplitterComponent from '../src/components/splitter/splitter.js'; +import IgcSplitterPaneComponent from '../src/components/splitter/splitter-pane.js'; defineComponents(IgcSplitterComponent, IgcSplitterPaneComponent); @@ -36,23 +34,69 @@ const metadata: Meta = { export default metadata; type Story = StoryObj; +function changePaneMinMaxSizes() { + const panes = document.querySelectorAll('igc-splitter-pane'); + panes[0].minSize = '100px'; + panes[0].maxSize = '300px'; + panes[1].minSize = '50px'; + panes[1].maxSize = '200px'; + panes[2].minSize = '150px'; + panes[2].maxSize = '400px'; +} + +function changePaneSize() { + const panes = document.querySelectorAll('igc-splitter-pane'); + panes[1].size = '100px'; +} + export const Default: Story = { render: ({ orientation }) => html` - - -
Pane 1
-
- -
Pane 2
-
- -
Pane 3
-
-
+ + + + +
+ + +
Pane 1
+
+ +
Pane 2
+
+ +
Pane 3
+
+
+ + + +
Pane 1
+
+ +
Pane 2
+
+ +
Pane 3
+
+
+
`, }; From b1f82e4b96c8bab05821cb112a38280594becf1e Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Wed, 5 Nov 2025 12:44:00 +0200 Subject: [PATCH 05/47] chore: minor changes; add nested story; test nested --- src/components/splitter/splitter-pane.ts | 16 +++--- src/components/splitter/splitter.spec.ts | 70 ++++++++++++++++++++++++ src/components/splitter/splitter.ts | 53 ++++++++++++------ stories/splitter.stories.ts | 50 +++++++++++++++-- 4 files changed, 160 insertions(+), 29 deletions(-) diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index c5440fc8a..b651826e9 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -43,7 +43,7 @@ export default class IgcSplitterPaneComponent extends LitElement { * The minimum size of the pane. * @attr */ - @property({ type: String, reflect: true }) + @property({ reflect: true }) public set minSize(value: string) { this._minSize = value; this.dispatchEvent(new CustomEvent('sizeChanged', { bubbles: true })); @@ -57,7 +57,7 @@ export default class IgcSplitterPaneComponent extends LitElement { * The maximum size of the pane. * @attr */ - @property({ type: String, reflect: true }) + @property({ reflect: true }) public set maxSize(value: string) { this._maxSize = value; this.dispatchEvent(new CustomEvent('sizeChanged', { bubbles: true })); @@ -71,7 +71,7 @@ export default class IgcSplitterPaneComponent extends LitElement { * Gets/sets the pane's minWidth. * @hidden @internal */ - @property({ type: String }) + @property() public set minWidth(value: string) { this._minWidth = value; this.style.minWidth = this._minWidth; @@ -85,7 +85,7 @@ export default class IgcSplitterPaneComponent extends LitElement { * Gets/sets the pane's maxWidth. * @hidden @internal */ - @property({ type: String }) + @property() public set maxWidth(value: string) { this._maxWidth = value; this.style.maxWidth = this._maxWidth; @@ -99,7 +99,7 @@ export default class IgcSplitterPaneComponent extends LitElement { * Gets/sets the pane's minHeight. * @hidden @internal */ - @property({ type: String }) + @property() public set minHeight(value: string) { this._minHeight = value; this.style.minHeight = this._minHeight; @@ -113,7 +113,7 @@ export default class IgcSplitterPaneComponent extends LitElement { * Gets/sets the pane's maxHeight. * @hidden @internal */ - @property({ type: String }) + @property() public set maxHeight(value: string) { this._maxHeight = value; this.style.maxHeight = this._maxHeight; @@ -134,7 +134,7 @@ export default class IgcSplitterPaneComponent extends LitElement { * Gets/sets the pane's maxHeight. * @hidden @internal */ - @property({ type: String }) + @property() public get flex() { //const size = this.dragSize || this.size; //const grow = this.isPercentageSize && !this.dragSize ? 1 : 0; @@ -147,7 +147,7 @@ export default class IgcSplitterPaneComponent extends LitElement { * The size of the pane. * @attr */ - @property({ type: String, reflect: true }) + @property({ reflect: true }) public set size(value: string) { this._size = value; this.style.flex = this.flex; diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 076a47c95..0a2037708 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -66,6 +66,57 @@ describe('Splitter', () => { expect(splitter.orientation).to.equal('vertical'); expect(splitter.getAttribute('orientation')).to.equal('vertical'); }); + + it('should render nested splitters correctly', async () => { + const nestedSplitter = await fixture( + createNestedSplitter() + ); + await elementUpdated(nestedSplitter); + + expect(nestedSplitter.panes).to.have.lengthOf(2); + expect(nestedSplitter.orientation).to.equal('horizontal'); + + const outerBars = Array.from( + nestedSplitter.renderRoot.querySelectorAll( + IgcSplitterBarComponent.tagName + ) + ); + expect(outerBars).to.have.lengthOf(1); + + const firstPane = nestedSplitter.panes[0]; + const leftSplitter = firstPane.querySelector( + IgcSplitterComponent.tagName + ) as IgcSplitterComponent; + + expect(leftSplitter).to.exist; + expect(leftSplitter.orientation).to.equal('vertical'); + + expect(leftSplitter.panes).to.have.lengthOf(2); + + const leftBars = Array.from( + leftSplitter.renderRoot.querySelectorAll( + IgcSplitterBarComponent.tagName + ) + ); + expect(leftBars).to.have.lengthOf(1); + + const secondPane = nestedSplitter.panes[1]; + const rightSplitter = secondPane.querySelector( + IgcSplitterComponent.tagName + ) as IgcSplitterComponent; + + expect(rightSplitter).to.exist; + expect(rightSplitter.orientation).to.equal('vertical'); + + expect(rightSplitter.panes).to.have.lengthOf(2); + + const rightBars = Array.from( + rightSplitter.renderRoot.querySelectorAll( + IgcSplitterBarComponent.tagName + ) + ); + expect(rightBars).to.have.lengthOf(1); + }); }); }); @@ -78,3 +129,22 @@ function createSplitter() {
`; } + +function createNestedSplitter() { + return html` + + + + Top Left Pane + Bottom Left Pane + + + + + Top Right Pane + Bottom Right Pane + + + + `; +} diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 9d6c0177e..4e614d69b 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -1,5 +1,6 @@ import { html, LitElement } from 'lit'; import { property, queryAssignedElements } from 'lit/decorators.js'; +import { addInternalsController } from '../common/controllers/internals.js'; import { addSlotController } from '../common/controllers/slot.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; @@ -21,10 +22,18 @@ export default class IgcSplitterComponent extends LitElement { ); } + //#region Properties + private readonly _slots = addSlotController(this, { onChange: this._handleSlotChange, }); + private readonly _internals = addInternalsController(this, { + initialARIA: { + ariaOrientation: 'horizontal', + }, + }); + /** Returns all of the splitter's panes. */ @queryAssignedElements({ selector: 'igc-splitter-pane' }) public panes!: Array; @@ -37,8 +46,28 @@ export default class IgcSplitterComponent extends LitElement { @property({ reflect: true }) public orientation: SplitterOrientation = 'horizontal'; + //#endregion + + //#region Internal API + + @watch('orientation', { waitUntilFirstUpdate: true }) + protected _orientationChange(): void { + this._internals.setARIA({ ariaOrientation: this.orientation }); + this._resetPaneSizes(); + this._initPanes(); + } + + constructor() { + super(); + + this.addEventListener('sizeChanged', (event: any) => { + event.stopPropagation(); + this._initPanes(); + }); + } + protected _handleSlotChange(): void { - this.initPanes(); + this._initPanes(); } private _assignFlexOrder() { @@ -53,7 +82,7 @@ export default class IgcSplitterComponent extends LitElement { * @hidden @internal * This method inits panes with properties. */ - private initPanes() { + private _initPanes() { this.panes.forEach((pane) => { pane.owner = this; if (this.orientation === 'horizontal') { @@ -76,7 +105,7 @@ export default class IgcSplitterComponent extends LitElement { * @hidden @internal * This method reset pane sizes. */ - private resetPaneSizes() { + private _resetPaneSizes() { if (this.panes) { // if type is changed runtime, should reset sizes. this.panes.forEach((pane) => { @@ -89,21 +118,9 @@ export default class IgcSplitterComponent extends LitElement { } } - @watch('orientation', { waitUntilFirstUpdate: true }) - protected orientationChange(): void { - this.setAttribute('aria-orientation', this.orientation); - this.resetPaneSizes(); - this.initPanes(); - } + //#endregion - constructor() { - super(); - - this.addEventListener('sizeChanged', (event: any) => { - event.stopPropagation(); - this.initPanes(); - }); - } + //#region Rendering private _renderBar(order: number) { return html` `; @@ -118,6 +135,8 @@ export default class IgcSplitterComponent extends LitElement { })} `; } + + //#endregion } declare global { diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index b99a8e6dc..323a32f0d 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -4,6 +4,7 @@ import { html } from 'lit'; import { defineComponents } from 'igniteui-webcomponents'; import IgcSplitterComponent from '../src/components/splitter/splitter.js'; import IgcSplitterPaneComponent from '../src/components/splitter/splitter-pane.js'; +import { disableStoryControls } from './story.js'; defineComponents(IgcSplitterComponent, IgcSplitterPaneComponent); @@ -37,11 +38,11 @@ type Story = StoryObj; function changePaneMinMaxSizes() { const panes = document.querySelectorAll('igc-splitter-pane'); panes[0].minSize = '100px'; - panes[0].maxSize = '300px'; + panes[0].maxSize = '200px'; panes[1].minSize = '50px'; - panes[1].maxSize = '200px'; + panes[1].maxSize = '100px'; panes[2].minSize = '150px'; - panes[2].maxSize = '400px'; + panes[2].maxSize = '100px'; } function changePaneSize() { @@ -74,7 +75,7 @@ export const Default: Story = {
- +
Pane 1
@@ -100,3 +101,44 @@ export const Default: Story = {
`, }; + +export const NestedSplitters: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` + + + + + + +
Top Left Pane
+
+ + +
Bottom Left Pane
+
+
+
+ + + + +
Top Right Pane
+
+ + +
Bottom Right Pane
+
+
+
+
+ `, +}; From 5b3d2fe727b4bd8e2f4f65981cad90a7527243a7 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Wed, 5 Nov 2025 16:30:25 +0200 Subject: [PATCH 06/47] feat(splitter): add args for each pane to default story --- src/components/splitter/splitter.ts | 10 + .../splitter/themes/splitter.base.scss | 1 + stories/splitter.stories.ts | 187 ++++++++++++++---- 3 files changed, 165 insertions(+), 33 deletions(-) diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 4e614d69b..fbfc38b4d 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -9,6 +9,16 @@ import IgcSplitterBarComponent from './splitter-bar.js'; import IgcSplitterPaneComponent from './splitter-pane.js'; import { styles } from './themes/splitter.base.css.js'; +/** + * The Splitter component provides a framework for a simple layout, splitting the view horizontally or vertically + * into multiple smaller resizable and collapsible areas. + * + * @element igc-splitter + * * + * @fires igc... - Emitted when ... . + * + * @csspart ... - ... . + */ export default class IgcSplitterComponent extends LitElement { public static readonly tagName = 'igc-splitter'; public static override styles = [styles]; diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index 583fc3fc8..eabf2ed9b 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -11,6 +11,7 @@ ::slotted(igc-splitter-pane) { flex: 1 1 0; + overflow: auto; } } diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 323a32f0d..895c3c8f0 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -8,7 +8,30 @@ import { disableStoryControls } from './story.js'; defineComponents(IgcSplitterComponent, IgcSplitterPaneComponent); -const metadata: Meta = { +type SplitterStoryArgs = IgcSplitterComponent & { + /* Pane 1 properties */ + pane1Size?: string; + pane1MinSize?: string; + pane1MaxSize?: string; + pane1Collapsed?: boolean; + pane1Resizable?: boolean; + + /* Pane 2 properties */ + pane2Size?: string; + pane2MinSize?: string; + pane2MaxSize?: string; + pane2Collapsed?: boolean; + pane2Resizable?: boolean; + + /* Pane 3 properties */ + pane3Size?: string; + pane3MinSize?: string; + pane3MaxSize?: string; + pane3Collapsed?: boolean; + pane3Resizable?: boolean; +}; + +const metadata: Meta = { title: 'Splitter', component: 'igc-splitter', parameters: { @@ -26,14 +49,98 @@ const metadata: Meta = { description: 'Orientation of the splitter.', table: { defaultValue: { summary: 'horizontal' } }, }, + pane1Size: { + control: 'text', + description: 'Size of the first pane (e.g., "auto", "100px", "30%")', + table: { category: 'Pane 1' }, + }, + pane1MinSize: { + control: 'text', + description: 'Minimum size of the first pane', + table: { category: 'Pane 1' }, + }, + pane1MaxSize: { + control: 'text', + description: 'Maximum size of the first pane', + table: { category: 'Pane 1' }, + }, + pane1Collapsed: { + control: 'boolean', + description: 'Collapsed state of the first pane', + table: { category: 'Pane 1' }, + }, + pane1Resizable: { + control: 'boolean', + description: 'Whether the first pane is resizable', + table: { category: 'Pane 1' }, + }, + pane2Size: { + control: 'text', + description: 'Size of the second pane (e.g., "auto", "100px", "30%")', + table: { category: 'Pane 2' }, + }, + pane2MinSize: { + control: 'text', + description: 'Minimum size of the second pane', + table: { category: 'Pane 2' }, + }, + pane2MaxSize: { + control: 'text', + description: 'Maximum size of the second pane', + table: { category: 'Pane 2' }, + }, + pane2Collapsed: { + control: 'boolean', + description: 'Collapsed state of the second pane', + table: { category: 'Pane 2' }, + }, + pane2Resizable: { + control: 'boolean', + description: 'Whether the second pane is resizable', + table: { category: 'Pane 2' }, + }, + pane3Size: { + control: 'text', + description: 'Size of the third pane (e.g., "auto", "100px", "30%")', + table: { category: 'Pane 3' }, + }, + pane3MinSize: { + control: 'text', + description: 'Minimum size of the third pane', + table: { category: 'Pane 3' }, + }, + pane3MaxSize: { + control: 'text', + description: 'Maximum size of the third pane', + table: { category: 'Pane 3' }, + }, + pane3Collapsed: { + control: 'boolean', + description: 'Collapsed state of the third pane', + table: { category: 'Pane 3' }, + }, + pane3Resizable: { + control: 'boolean', + description: 'Whether the third pane is resizable', + table: { category: 'Pane 3' }, + }, }, args: { orientation: 'horizontal', + pane1Size: 'auto', + pane1Resizable: true, + pane1Collapsed: false, + pane2Size: 'auto', + pane2Resizable: true, + pane2Collapsed: false, + pane3Size: 'auto', + pane3Resizable: true, + pane3Collapsed: false, }, }; export default metadata; -type Story = StoryObj; +type Story = StoryObj; function changePaneMinMaxSizes() { const panes = document.querySelectorAll('igc-splitter-pane'); @@ -45,13 +152,25 @@ function changePaneMinMaxSizes() { panes[2].maxSize = '100px'; } -function changePaneSize() { - const panes = document.querySelectorAll('igc-splitter-pane'); - panes[1].size = '100px'; -} - export const Default: Story = { - render: ({ orientation }) => html` + render: ({ + orientation, + pane1Size, + pane1MinSize, + pane1MaxSize, + pane1Collapsed, + pane1Resizable, + pane2Size, + pane2MinSize, + pane2MaxSize, + pane2Collapsed, + pane2Resizable, + pane3Size, + pane3MinSize, + pane3MaxSize, + pane3Collapsed, + pane3Resizable, + }) => html` - - -
- -
Pane 1
-
- -
Pane 2
-
- -
Pane 3
-
-
- - - +
Pane 1
- +
Pane 2
- +
Pane 3
+ Change All Panes Min/Max Sizes `, }; From 5d7907d7799ace4ea92dd0a51d1b19f2ccc4dddd Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Wed, 5 Nov 2025 16:48:02 +0200 Subject: [PATCH 07/47] feat(splitter): add nonCollapsible prop --- src/components/splitter/splitter.ts | 9 +++++++++ stories/splitter.stories.ts | 8 +++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index fbfc38b4d..6fca7d5a8 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -56,6 +56,15 @@ export default class IgcSplitterComponent extends LitElement { @property({ reflect: true }) public orientation: SplitterOrientation = 'horizontal'; + /** + * Sets the visibility of the handle and expanders in the splitter bar. + * @remarks + * Default value is `false`. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public nonCollapsible = false; + //#endregion //#region Internal API diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 895c3c8f0..02d2cf9fd 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -127,6 +127,7 @@ const metadata: Meta = { }, args: { orientation: 'horizontal', + nonCollapsible: false, pane1Size: 'auto', pane1Resizable: true, pane1Collapsed: false, @@ -155,6 +156,7 @@ function changePaneMinMaxSizes() { export const Default: Story = { render: ({ orientation, + nonCollapsible, pane1Size, pane1MinSize, pane1MaxSize, @@ -184,7 +186,11 @@ export const Default: Story = {
- + Date: Thu, 6 Nov 2025 19:38:09 +0200 Subject: [PATCH 08/47] feat(splitter): add initial resize logic --- src/components/splitter/splitter-bar.ts | 68 ++++++++++++- src/components/splitter/splitter.ts | 124 +++++++++++++++++++++++- 2 files changed, 184 insertions(+), 8 deletions(-) diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index 9aa22d1d1..1a6817a26 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -1,9 +1,22 @@ import { html, LitElement } from 'lit'; import { property } from 'lit/decorators.js'; import { registerComponent } from '../common/definitions/register.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { asNumber } from '../common/util.js'; +import { addResizeController } from '../resize-container/resize-controller.js'; +import type { SplitterOrientation } from '../types.js'; +import type IgcSplitterPaneComponent from './splitter-pane.js'; -export default class IgcSplitterBarComponent extends LitElement { +export interface IgcSplitterBarComponentEventMap { + igcMovingStart: CustomEvent; + igcMoving: CustomEvent; + igcMovingEnd: CustomEvent; +} +export default class IgcSplitterBarComponent extends EventEmitterMixin< + IgcSplitterBarComponentEventMap, + Constructor +>(LitElement) { public static readonly tagName = 'igc-splitter-bar'; /* blazorSuppress */ @@ -27,10 +40,55 @@ export default class IgcSplitterBarComponent extends LitElement { return this._order; } - // constructor() { - // super(); - // //addThemingController(this, all); - // } + /** Gets/Sets the orientation of the splitter. + * + * @remarks + * Default value is `horizontal`. + */ + @property({ reflect: true }) + public orientation: SplitterOrientation = 'horizontal'; + + @property({ attribute: false }) + public paneBefore?: IgcSplitterPaneComponent; + + @property({ attribute: false }) + public paneAfter?: IgcSplitterPaneComponent; + + constructor() { + super(); + addResizeController(this, { + mode: 'immediate', + resizeTarget: (): HTMLElement => this.paneBefore ?? this, // we don’t resize the bar, we just use the delta + start: () => { + if ( + !this.paneBefore?.resizable || + !this.paneAfter?.resizable || + this.paneBefore.collapsed + ) { + return false; + } + this.emitEvent('igcMovingStart', { detail: this.paneBefore }); + return true; + }, + resize: ({ state }) => { + const isHorizontal = this.orientation === 'horizontal'; + const delta = isHorizontal ? state.deltaX : state.deltaY; + + if (delta !== 0) { + this.emitEvent('igcMoving', { detail: delta }); + } + }, + end: ({ state }) => { + const isHorizontal = this.orientation === 'horizontal'; + const delta = isHorizontal ? state.deltaX : state.deltaY; + if (delta !== 0) { + this.emitEvent('igcMovingEnd', { detail: delta }); + } + }, + cancel: () => {}, + }); + //addThemingController(this, all); + } protected override render() { return html` diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 6fca7d5a8..e0ab42a5f 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -137,12 +137,130 @@ export default class IgcSplitterComponent extends LitElement { } } + private paneBefore!: IgcSplitterPaneComponent; + private paneAfter!: IgcSplitterPaneComponent; + private initialPaneBeforeSize!: number; + private initialPaneAfterSize!: number; + + private _handleMovingStart(event: CustomEvent) { + // Handle the moving start event + const panes = this.panes; + this.paneBefore = event.detail; + this.paneAfter = panes[panes.indexOf(this.paneBefore) + 1]; + + const paneRect = this.paneBefore.getBoundingClientRect(); + this.initialPaneBeforeSize = + this.orientation === 'horizontal' ? paneRect.width : paneRect.height; + + const siblingRect = this.paneAfter.getBoundingClientRect(); + this.initialPaneAfterSize = + this.orientation === 'horizontal' + ? siblingRect.width + : siblingRect.height; + } + private _handleMoving(event: CustomEvent) { + const [paneSize, siblingSize] = this.calcNewSizes(event.detail); + + this.paneBefore.size = paneSize + 'px'; + this.paneAfter.size = siblingSize + 'px'; + } + + //I am not sure if this code changes anything, it looks like it works without it as well, + // however I found a bug, which I am not sure how to reproduce it and still haven't encaountered it with this code + private _handleMovingEnd(event: CustomEvent) { + const [paneSize, siblingSize] = this.calcNewSizes(event.detail); + + if (this.paneBefore.isPercentageSize) { + // handle % resizes + const totalSize = this.getTotalSize(); + const percentPaneSize = (paneSize / totalSize) * 100; + this.paneBefore.size = percentPaneSize + '%'; + } else { + // px resize + this.paneBefore.size = paneSize + 'px'; + } + + if (this.paneAfter.isPercentageSize) { + // handle % resizes + const totalSize = this.getTotalSize(); + const percentSiblingPaneSize = (siblingSize / totalSize) * 100; + this.paneAfter.size = percentSiblingPaneSize + '%'; + } else { + // px resize + this.paneAfter.size = siblingSize + 'px'; + } + } + + /** + * @hidden @internal + * Calculates new sizes for the panes based on move delta and initial sizes + */ + private calcNewSizes(delta: number): [number, number] { + let finalDelta: number; + const min = + Number.parseInt( + this.paneBefore.minSize ? this.paneBefore.minSize : '0', + 10 + ) || 0; + const minSibling = + Number.parseInt( + this.paneAfter.minSize ? this.paneAfter.minSize : '0', + 10 + ) || 0; + const max = + Number.parseInt( + this.paneBefore.maxSize ? this.paneBefore.maxSize : '0', + 10 + ) || this.initialPaneBeforeSize + this.initialPaneAfterSize - minSibling; + const maxSibling = + Number.parseInt( + this.paneAfter.maxSize ? this.paneAfter.maxSize : '0', + 10 + ) || this.initialPaneBeforeSize + this.initialPaneAfterSize - min; + + if (delta < 0) { + const maxPossibleDelta = Math.min( + this.initialPaneBeforeSize - min, + maxSibling - this.initialPaneAfterSize + ); + finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)) * -1; + } else { + const maxPossibleDelta = Math.min( + max - this.initialPaneBeforeSize, + this.initialPaneAfterSize - minSibling + ); + finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)); + } + return [ + this.initialPaneBeforeSize + finalDelta, + this.initialPaneAfterSize - finalDelta, + ]; + } + + private getTotalSize() { + const computed = document.defaultView?.getComputedStyle(this); + const totalSize = + this.orientation === 'horizontal' + ? computed?.getPropertyValue('width') + : computed?.getPropertyValue('height'); + return Number.parseFloat(totalSize ? totalSize : '0'); + } //#endregion //#region Rendering - private _renderBar(order: number) { - return html` `; + private _renderBar(order: number, i: number) { + return html` + + `; } protected override render() { @@ -150,7 +268,7 @@ export default class IgcSplitterComponent extends LitElement { ${this.panes.map((pane, i) => { const isLast = i === this.panes.length - 1; - return html`${!isLast ? this._renderBar(pane.order + 1) : ''}`; + return html`${!isLast ? this._renderBar(pane.order + 1, i) : ''}`; })} `; } From 96a7364215605f93fd5bd175b4654afe70c48b39 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 7 Nov 2025 15:41:58 +0200 Subject: [PATCH 09/47] refactor(splitter): alternative approach to render bars and handle properties&styles --- src/components/common/context.ts | 6 + src/components/splitter/splitter-bar.ts | 162 ++++++-- src/components/splitter/splitter-pane.ts | 351 ++++++++++++------ src/components/splitter/splitter.spec.ts | 272 ++++++++++++-- src/components/splitter/splitter.ts | 225 +---------- .../splitter/themes/splitter-bar.base.scss | 19 + .../splitter/themes/splitter-pane.scss | 17 + .../splitter/themes/splitter.base.scss | 52 +-- src/index.ts | 2 +- stories/splitter.stories.ts | 15 +- 10 files changed, 691 insertions(+), 430 deletions(-) create mode 100644 src/components/splitter/themes/splitter-bar.base.scss create mode 100644 src/components/splitter/themes/splitter-pane.scss diff --git a/src/components/common/context.ts b/src/components/common/context.ts index b31c3bafb..f41f8ebe7 100644 --- a/src/components/common/context.ts +++ b/src/components/common/context.ts @@ -1,5 +1,6 @@ import { createContext } from '@lit/context'; import type { Ref } from 'lit/directives/ref.js'; +import type { IgcSplitterComponent } from '../../index.js'; import type IgcCarouselComponent from '../carousel/carousel.js'; import type { ChatState } from '../chat/chat-state.js'; import type IgcTileManagerComponent from '../tile-manager/tile-manager.js'; @@ -24,9 +25,14 @@ const chatUserInputContext = createContext( Symbol('chat-user-input-context') ); +const splitterContext = createContext( + Symbol('splitter-context') +); + export { carouselContext, tileManagerContext, chatContext, chatUserInputContext, + splitterContext, }; diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index 1a6817a26..be8efda0c 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -1,12 +1,17 @@ -import { html, LitElement } from 'lit'; -import { property } from 'lit/decorators.js'; +import { ContextConsumer } from '@lit/context'; +import { html, LitElement, nothing } from 'lit'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import { splitterContext } from '../common/context.js'; +import { addInternalsController } from '../common/controllers/internals.js'; +import { createMutationController } from '../common/controllers/mutation-observer.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { asNumber } from '../common/util.js'; import { addResizeController } from '../resize-container/resize-controller.js'; import type { SplitterOrientation } from '../types.js'; -import type IgcSplitterPaneComponent from './splitter-pane.js'; +import type IgcSplitterComponent from './splitter.js'; +import IgcSplitterPaneComponent from './splitter-pane.js'; +import { styles } from './themes/splitter-bar.base.css.js'; export interface IgcSplitterBarComponentEventMap { igcMovingStart: CustomEvent; @@ -18,60 +23,92 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< Constructor >(LitElement) { public static readonly tagName = 'igc-splitter-bar'; + public static styles = [styles]; /* blazorSuppress */ public static register() { registerComponent(IgcSplitterBarComponent); } - private _order = -1; + private readonly _internals = addInternalsController(this, { + initialARIA: { + ariaOrientation: 'horizontal', + }, + }); - /** - * Gets/sets the bar's visual position in the layout. - * @hidden @internal - */ - @property({ type: Number }) - public set order(value: number) { - this._order = asNumber(value); - this.style.order = this._order.toString(); - } + protected _contextConsumer = new ContextConsumer(this, { + context: splitterContext, + subscribe: true, + callback: (value) => { + this._handleContextChange(value); + }, + }); + + private _internalStyles: StyleInfo = {}; + private _orientation?: SplitterOrientation; + private _splitter?: IgcSplitterComponent; + + private get _siblingPanes(): Array { + if (!this._splitter || !this._splitter.panes) { + return []; + } + + const panes = this._splitter.panes; + const ownerPaneIndex = panes.findIndex((p) => p.shadowRoot?.contains(this)); + + if (ownerPaneIndex === -1) { + return []; + } - public get order(): number { - return this._order; + const currentPane = panes[ownerPaneIndex]; + const nextPane = panes[ownerPaneIndex + 1] || null; + return [currentPane, nextPane]; } - /** Gets/Sets the orientation of the splitter. - * - * @remarks - * Default value is `horizontal`. - */ - @property({ reflect: true }) - public orientation: SplitterOrientation = 'horizontal'; + private get _styles(): StyleInfo { + return { + display: 'flex', + flexDirection: this._orientation === 'horizontal' ? 'column' : 'row', + width: this._orientation === 'horizontal' ? '5px' : '100%', + height: this._orientation === 'horizontal' ? '100%' : '5px', + '--cursor': this._cursor, + }; + } - @property({ attribute: false }) - public paneBefore?: IgcSplitterPaneComponent; + private get _resizeDisallowed() { + return !!this._siblingPanes.find( + (x) => x && (x.resizable === false || x.collapsed === true) + ); + } - @property({ attribute: false }) - public paneAfter?: IgcSplitterPaneComponent; + /** + * Returns the appropriate cursor style based on orientation and resize state. + */ + private get _cursor(): string { + if (this._resizeDisallowed) { + return 'default'; + } + return this._orientation === 'horizontal' ? 'col-resize' : 'row-resize'; + } constructor() { super(); addResizeController(this, { mode: 'immediate', - resizeTarget: (): HTMLElement => this.paneBefore ?? this, // we don’t resize the bar, we just use the delta + resizeTarget: (): HTMLElement => this._siblingPanes[0] ?? this, // we don’t resize the bar, we just use the delta start: () => { if ( - !this.paneBefore?.resizable || - !this.paneAfter?.resizable || - this.paneBefore.collapsed + !this._siblingPanes[0]?.resizable || + !this._siblingPanes[1]?.resizable || + this._siblingPanes[0].collapsed ) { return false; } - this.emitEvent('igcMovingStart', { detail: this.paneBefore }); + this.emitEvent('igcMovingStart', { detail: this._siblingPanes[0] }); return true; }, resize: ({ state }) => { - const isHorizontal = this.orientation === 'horizontal'; + const isHorizontal = this._orientation === 'horizontal'; const delta = isHorizontal ? state.deltaX : state.deltaY; if (delta !== 0) { @@ -79,7 +116,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< } }, end: ({ state }) => { - const isHorizontal = this.orientation === 'horizontal'; + const isHorizontal = this._orientation === 'horizontal'; const delta = isHorizontal ? state.deltaX : state.deltaY; if (delta !== 0) { this.emitEvent('igcMovingEnd', { detail: delta }); @@ -90,12 +127,61 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< //addThemingController(this, all); } + public override connectedCallback(): void { + super.connectedCallback(); + this._siblingPanes?.forEach((pane) => { + this._createSiblingPaneMutationController(pane!); + }); + } + + private _createSiblingPaneMutationController(pane: IgcSplitterPaneComponent) { + createMutationController(pane, { + callback: () => { + this.requestUpdate(); + }, + filter: [IgcSplitterPaneComponent.tagName], + config: { + attributeFilter: ['collapsed', 'resizable'], + subtree: true, + }, + }); + } + + private _handleContextChange(splitter: IgcSplitterComponent) { + this._splitter = splitter; + if (this._orientation !== splitter.orientation) { + this._orientation = splitter.orientation; + this._internals.setARIA({ ariaOrientation: this._orientation }); + Object.assign(this._internalStyles, this._styles); + } + } + + private _renderBarControls() { + if (this._splitter?.nonCollapsible) { + return nothing; + } + const siblings = this._siblingPanes; + const prevButtonHidden = siblings[0]?.collapsed && !siblings[1]?.collapsed; + const nextButtonHidden = siblings[1]?.collapsed && !siblings[0]?.collapsed; + return html` +
+
+
+ `; + } + protected override render() { return html` -
-
-
-
+
+ ${this._renderBarControls()}
`; } diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index b651826e9..649f91816 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -1,52 +1,62 @@ -import { html, LitElement } from 'lit'; +import { ContextConsumer } from '@lit/context'; +import { html, LitElement, nothing } from 'lit'; import { property } from 'lit/decorators.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import { splitterContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; -import { asNumber } from '../common/util.js'; +import type { SplitterOrientation } from '../types.js'; import type IgcSplitterComponent from './splitter.js'; +import IgcSplitterBarComponent from './splitter-bar.js'; +import { styles } from './themes/splitter-pane.css.js'; export default class IgcSplitterPaneComponent extends LitElement { public static readonly tagName = 'igc-splitter-pane'; + public static override styles = [styles]; /* blazorSuppress */ public static register() { - registerComponent(IgcSplitterPaneComponent); + registerComponent(IgcSplitterPaneComponent, IgcSplitterBarComponent); } - private _order = -1; + private _splitterContext = new ContextConsumer(this, { + context: splitterContext, + subscribe: true, + callback: (value) => { + this._handleContextChange(value); + }, + }); + + private _internalStyles: StyleInfo = {}; private _minSize?: string; private _maxSize?: string; private _size = 'auto'; private _collapsed = false; - private _minWidth?: string; - private _minHeight?: string; - private _maxWidth?: string; - private _maxHeight?: string; + private _orientation?: SplitterOrientation; - /** @hidden @internal */ - public owner: IgcSplitterComponent | undefined; + private get _isPercentageSize() { + return this._size === 'auto' || this._size.indexOf('%') !== -1; + } - /** - * Gets/sets the pane's visual position in the layout. - * @hidden @internal - */ - @property({ type: Number }) - public set order(value: number) { - this._order = asNumber(value); - this.style.order = this._order.toString(); + private get _splitter(): IgcSplitterComponent | undefined { + return this._splitterContext.value; } - public get order(): number { - return this._order; + private get _flex() { + //const size = this.dragSize || this.size; + //const grow = this.isPercentageSize && !this.dragSize ? 1 : 0; + const grow = this._isPercentageSize ? 1 : 0; + return `${grow} ${grow} ${this._size}`; + //return `${0} ${0} ${this.size}`; } /** * The minimum size of the pane. * @attr */ - @property({ reflect: true }) + @property({ attribute: 'min-size', reflect: true }) public set minSize(value: string) { this._minSize = value; - this.dispatchEvent(new CustomEvent('sizeChanged', { bubbles: true })); + this._initPane(); } public get minSize(): string | undefined { @@ -57,92 +67,16 @@ export default class IgcSplitterPaneComponent extends LitElement { * The maximum size of the pane. * @attr */ - @property({ reflect: true }) + @property({ attribute: 'max-size', reflect: true }) public set maxSize(value: string) { this._maxSize = value; - this.dispatchEvent(new CustomEvent('sizeChanged', { bubbles: true })); + this._initPane(); } public get maxSize(): string | undefined { return this._maxSize; } - /** - * Gets/sets the pane's minWidth. - * @hidden @internal - */ - @property() - public set minWidth(value: string) { - this._minWidth = value; - this.style.minWidth = this._minWidth; - } - - public get minWidth(): string | undefined { - return this._minWidth; - } - - /** - * Gets/sets the pane's maxWidth. - * @hidden @internal - */ - @property() - public set maxWidth(value: string) { - this._maxWidth = value; - this.style.maxWidth = this._maxWidth; - } - - public get maxWidth(): string | undefined { - return this._maxWidth; - } - - /** - * Gets/sets the pane's minHeight. - * @hidden @internal - */ - @property() - public set minHeight(value: string) { - this._minHeight = value; - this.style.minHeight = this._minHeight; - } - - public get minHeight(): string | undefined { - return this._minHeight; - } - - /** - * Gets/sets the pane's maxHeight. - * @hidden @internal - */ - @property() - public set maxHeight(value: string) { - this._maxHeight = value; - this.style.maxHeight = this._maxHeight; - } - - public get maxHeight(): string | undefined { - return this._maxHeight; - } - - /** - * Defines if the pane is resizable or not. - * @attr - */ - @property({ type: Boolean, reflect: true }) - public resizable = true; - - /** - * Gets/sets the pane's maxHeight. - * @hidden @internal - */ - @property() - public get flex() { - //const size = this.dragSize || this.size; - //const grow = this.isPercentageSize && !this.dragSize ? 1 : 0; - const grow = this.isPercentageSize ? 1 : 0; - return `${grow} ${grow} ${this.size}`; - //return `${0} ${0} ${this.size}`; - } - /** * The size of the pane. * @attr @@ -150,17 +84,21 @@ export default class IgcSplitterPaneComponent extends LitElement { @property({ reflect: true }) public set size(value: string) { this._size = value; - this.style.flex = this.flex; + Object.assign(this._internalStyles, { + flex: this._flex, + }); } public get size(): string { return this._size; } - /** @hidden @internal */ - public get isPercentageSize() { - return this.size === 'auto' || this.size.indexOf('%') !== -1; - } + /** + * Defines if the pane is resizable or not. + * @attr + */ + @property({ type: Boolean, reflect: true }) + public resizable = true; /** * Collapsed state of the pane. @@ -169,7 +107,6 @@ export default class IgcSplitterPaneComponent extends LitElement { @property({ type: Boolean, reflect: true }) public set collapsed(value: boolean) { this._collapsed = value; - //this.requestUpdate(); } public get collapsed(): boolean { @@ -181,13 +118,213 @@ export default class IgcSplitterPaneComponent extends LitElement { // //addThemingController(this, all); // } + protected override firstUpdated() { + this._initPane(); + } + + private _handleContextChange(splitter: IgcSplitterComponent) { + if (this._orientation && this._orientation !== splitter.orientation) { + this._resetPane(); + } + this._orientation = splitter.orientation; + this.requestUpdate(); + } + + private _resetPane() { + this.size = 'auto'; + Object.assign(this._internalStyles, { + minWidth: 0, + maxWidth: '100%', + minHeight: 0, + maxHeight: '100%', + flex: this._flex, + }); + } + + private _initPane() { + let sizes = {}; + if (this._orientation === 'horizontal') { + sizes = { + minWidth: this.minSize ?? 0, + maxWidth: this.maxSize ?? '100%', + }; + } else { + sizes = { + minHeight: this.minSize ?? 0, + maxHeight: this.maxSize ?? '100%', + }; + } + Object.assign(this._internalStyles, { ...sizes, flex: this._flex }); + this.requestUpdate(); + } + + private paneBefore!: IgcSplitterPaneComponent; + private paneAfter!: IgcSplitterPaneComponent; + private initialPaneBeforeSize!: number; + private initialPaneAfterSize!: number; + private isPaneBeforePercentage = false; + private isPaneAfterPercentage = false; + + private _handleMovingStart(event: CustomEvent) { + // Only handle if this is the pane that owns the bar + if (event.detail !== this) { + return; + } + + // Handle the moving start event + const panes = this._splitter!.panes; + this.paneBefore = this; + this.paneAfter = panes[panes.indexOf(this) + 1]; + + // Store original size types before we start changing them + this.isPaneBeforePercentage = this.paneBefore._isPercentageSize; + this.isPaneAfterPercentage = this.paneAfter._isPercentageSize; + + const paneBeforeBase = + this.paneBefore.shadowRoot?.querySelector('[part="base"]'); + const paneAfterBase = + this.paneAfter.shadowRoot?.querySelector('[part="base"]'); + + const paneRect = paneBeforeBase!.getBoundingClientRect(); + this.initialPaneBeforeSize = + this._orientation === 'horizontal' ? paneRect.width : paneRect.height; + + const siblingRect = paneAfterBase!.getBoundingClientRect(); + this.initialPaneAfterSize = + this._orientation === 'horizontal' + ? siblingRect.width + : siblingRect.height; + } + + private _handleMoving(event: CustomEvent) { + // Only handle if this pane owns the bar (is the one before the bar) + if (!this.paneBefore || this.paneBefore !== this) { + return; + } + + const [paneSize, siblingSize] = this._calcNewSizes(event.detail); + + this.paneBefore.size = `${paneSize}px`; + this.paneAfter.size = `${siblingSize}px`; + } + + //I am not sure if this code changes anything, it looks like it works without it as well, + // however I found a bug, which I am not sure how to reproduce it and still haven't encaountered it with this code + private _handleMovingEnd(event: CustomEvent) { + // Only handle if this pane owns the bar (is the one before the bar) + if (!this.paneBefore || this.paneBefore !== this) { + return; + } + + const [paneSize, siblingSize] = this._calcNewSizes(event.detail); + + if (this.isPaneBeforePercentage) { + // handle % resizes + const totalSize = this.getTotalSize(); + const percentPaneSize = (paneSize / totalSize) * 100; + this.paneBefore.size = `${percentPaneSize}%`; + } else { + // px resize + this.paneBefore.size = `${paneSize}px`; + } + + if (this.isPaneAfterPercentage) { + // handle % resizes + const totalSize = this.getTotalSize(); + const percentSiblingPaneSize = (siblingSize / totalSize) * 100; + this.paneAfter.size = `${percentSiblingPaneSize}%`; + } else { + // px resize + this.paneAfter.size = `${siblingSize}px`; + } + } + + private _calcNewSizes(delta: number): [number, number] { + let finalDelta: number; + const min = + Number.parseInt( + this.paneBefore.minSize ? this.paneBefore.minSize : '0', + 10 + ) || 0; + const minSibling = + Number.parseInt( + this.paneAfter.minSize ? this.paneAfter.minSize : '0', + 10 + ) || 0; + const max = + Number.parseInt( + this.paneBefore.maxSize ? this.paneBefore.maxSize : '0', + 10 + ) || this.initialPaneBeforeSize + this.initialPaneAfterSize - minSibling; + const maxSibling = + Number.parseInt( + this.paneAfter.maxSize ? this.paneAfter.maxSize : '0', + 10 + ) || this.initialPaneBeforeSize + this.initialPaneAfterSize - min; + + if (delta < 0) { + const maxPossibleDelta = Math.min( + this.initialPaneBeforeSize - min, + maxSibling - this.initialPaneAfterSize + ); + finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)) * -1; + } else { + const maxPossibleDelta = Math.min( + max - this.initialPaneBeforeSize, + this.initialPaneAfterSize - minSibling + ); + finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)); + } + return [ + this.initialPaneBeforeSize + finalDelta, + this.initialPaneAfterSize - finalDelta, + ]; + } + + private getTotalSize() { + if (!this._splitter) { + return 0; + } + // get the size of part base + const splitterBase = + this._splitter.shadowRoot?.querySelector('[part="base"]'); + if (!splitterBase) { + return 0; + } + const rect = splitterBase.getBoundingClientRect(); + return this._orientation === 'horizontal' ? rect.width : rect.height; + } + /** Toggles the collapsed state of the pane. */ public toggle() { this.collapsed = !this.collapsed; } + private get _isLastPane(): boolean { + if (!this._splitter || !this._splitter.panes) { + return false; + } + const panes = this._splitter.panes; + return panes.indexOf(this) === panes.length - 1; + } + + private _renderBar() { + return html` + + `; + } + protected override render() { - return html` `; + return html` +
+ +
+ ${this._isLastPane ? nothing : this._renderBar()} + `; } } diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 0a2037708..8385decf8 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -1,6 +1,6 @@ import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; - import { defineComponents } from '../common/definitions/defineComponents.js'; +import type { SplitterOrientation } from '../types.js'; import IgcSplitterComponent from './splitter.js'; import IgcSplitterBarComponent from './splitter-bar.js'; import IgcSplitterPaneComponent from './splitter-pane.js'; @@ -16,11 +16,11 @@ describe('Splitter', () => { let splitter: IgcSplitterComponent; - describe('Rendering', () => { - beforeEach(async () => { - splitter = await fixture(createSplitter()); - }); + beforeEach(async () => { + splitter = await fixture(createSplitter()); + }); + describe('Rendering', () => { it('should render', () => { expect(splitter).to.exist; expect(splitter).to.be.instanceOf(IgcSplitterComponent); @@ -31,26 +31,19 @@ describe('Splitter', () => { await expect(splitter).shadowDom.to.be.accessible(); }); - it('should render panes and assign correct flex order', async () => { + it('should render a split bar for each splitter pane except the last one', async () => { await elementUpdated(splitter); expect(splitter.panes).to.have.lengthOf(3); - expect(splitter.panes[0].order).to.equal(0); - expect(splitter.panes[1].order).to.equal(2); - expect(splitter.panes[2].order).to.equal(4); - }); - - it('should render splitter bars and assign correct flex order', async () => { - await elementUpdated(splitter); - const bars = Array.from( - splitter.renderRoot.querySelectorAll(IgcSplitterBarComponent.tagName) - ); - + const bars = getSplitterBars(splitter); expect(bars).to.have.lengthOf(2); - expect(bars[0].order).to.equal(1); - expect(bars[1].order).to.equal(3); + bars.forEach((bar, index) => { + const pane = splitter.panes[index]; + const paneBase = getSplitterPaneBase(pane) as HTMLElement; + expect(bar.previousElementSibling).to.equal(paneBase); + }); }); it('should have default horizontal orientation', () => { @@ -76,11 +69,7 @@ describe('Splitter', () => { expect(nestedSplitter.panes).to.have.lengthOf(2); expect(nestedSplitter.orientation).to.equal('horizontal'); - const outerBars = Array.from( - nestedSplitter.renderRoot.querySelectorAll( - IgcSplitterBarComponent.tagName - ) - ); + const outerBars = getSplitterBars(nestedSplitter); expect(outerBars).to.have.lengthOf(1); const firstPane = nestedSplitter.panes[0]; @@ -93,11 +82,7 @@ describe('Splitter', () => { expect(leftSplitter.panes).to.have.lengthOf(2); - const leftBars = Array.from( - leftSplitter.renderRoot.querySelectorAll( - IgcSplitterBarComponent.tagName - ) - ); + const leftBars = getSplitterBars(leftSplitter); expect(leftBars).to.have.lengthOf(1); const secondPane = nestedSplitter.panes[1]; @@ -110,14 +95,184 @@ describe('Splitter', () => { expect(rightSplitter.panes).to.have.lengthOf(2); - const rightBars = Array.from( - rightSplitter.renderRoot.querySelectorAll( - IgcSplitterBarComponent.tagName - ) - ); + const rightBars = getSplitterBars(rightSplitter); expect(rightBars).to.have.lengthOf(1); }); }); + + describe('Properties', () => { + it('should set nonCollapsible property', async () => { + splitter.nonCollapsible = true; + await elementUpdated(splitter); + + expect(splitter.nonCollapsible).to.be.true; + expect(splitter.hasAttribute('non-collapsible')).to.be.true; + }); + + it('should reset pane sizes when orientation changes', async () => { + const pane = splitter.panes[0]; + pane.size = '200px'; + await elementUpdated(splitter); + + const base = getSplitterPaneBase(pane) as HTMLElement; + const style = getComputedStyle(base); + expect(style.flex).to.equal('0 0 200px'); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + expect(pane.size).to.equal('auto'); + }); + + it('should use default min/max values when not specified', async () => { + await elementUpdated(splitter); + + const pane = splitter.panes[0]; + const base = getSplitterPaneBase(pane) as HTMLElement; + const style = getComputedStyle(base); + expect(style.flex).to.equal('1 1 auto'); + + expect(pane.size).to.equal('auto'); + + expect(style.minWidth).to.equal('0px'); + expect(style.maxWidth).to.equal('100%'); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + expect(style.minHeight).to.equal('0px'); + expect(style.maxHeight).to.equal('100%'); + }); + + it('should apply minSize and maxSize to panes for horizontal orientation', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + minSize1: '100px', + maxSize1: '500px', + }) + ); + + await elementUpdated(splitter); + + const pane = splitter.panes[0]; + const base = getSplitterPaneBase(pane) as HTMLElement; + const style = getComputedStyle(base); + expect(style.minWidth).to.equal('100px'); + expect(style.maxWidth).to.equal('500px'); + }); + + it('should apply minSize and maxSize to panes for vertical orientation', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + minSize1: '100px', + maxSize1: '500px', + orientation: 'vertical', + }) + ); + await elementUpdated(splitter); + + const pane = splitter.panes[0]; + const base = getSplitterPaneBase(pane) as HTMLElement; + const style = getComputedStyle(base); + expect(style.minHeight).to.equal('100px'); + expect(style.maxHeight).to.equal('500px'); + }); + + it('should handle percentage sizes', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + size1: '30%', + size2: '70%', + minSize1: '20%', + maxSize1: '80%', + }) + ); + await elementUpdated(splitter); + + const pane1 = splitter.panes[0]; + const base1 = getSplitterPaneBase(pane1) as HTMLElement; + const style1 = getComputedStyle(base1); + + const pane2 = splitter.panes[1]; + const base2 = getSplitterPaneBase(pane2) as HTMLElement; + const style2 = getComputedStyle(base2); + + expect(splitter.panes[0].size).to.equal('30%'); + expect(splitter.panes[1].size).to.equal('70%'); + expect(style1.flex).to.equal('1 1 30%'); + expect(style2.flex).to.equal('1 1 70%'); + + expect(pane1.minSize).to.equal('20%'); + expect(pane1.maxSize).to.equal('80%'); + expect(style1.minWidth).to.equal('20%'); + expect(style1.maxWidth).to.equal('80%'); + + // TODO: test with drag; add constraints to second pane + }); + + it('should handle mixed px and % constraints', async () => { + const mixedConstraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + minSize1: '100px', + maxSize1: '50%', + }) + ); + await elementUpdated(mixedConstraintSplitter); + + const pane = mixedConstraintSplitter.panes[0]; + const base1 = getSplitterPaneBase(pane) as HTMLElement; + const style = getComputedStyle(base1); + + expect(pane.minSize).to.equal('100px'); + expect(pane.maxSize).to.equal('50%'); + expect(style.minWidth).to.equal('100px'); + expect(style.maxWidth).to.equal('50%'); + + // TODO: test with drag + }); + + it('should dynamically update when panes are added', async () => { + expect(splitter.panes).to.have.lengthOf(3); + + const newPane = document.createElement( + 'igc-splitter-pane' + ) as IgcSplitterPaneComponent; + newPane.textContent = 'New Pane'; + splitter.appendChild(newPane); + + await elementUpdated(splitter); + + expect(splitter.panes).to.have.lengthOf(4); + }); + + it('should dynamically update when panes are removed', async () => { + expect(splitter.panes).to.have.lengthOf(3); + + const paneToRemove = splitter.panes[1]; + paneToRemove.remove(); + + await elementUpdated(splitter); + + expect(splitter.panes).to.have.lengthOf(2); + }); + }); + + describe('Methods & Events', () => { + it('should expand/collapse panes when toggle is invoked', async () => { + const pane = splitter.panes[0]; + expect(pane.collapsed).to.be.false; + + pane.toggle(); + await elementUpdated(splitter); + + expect(pane.collapsed).to.be.true; + + pane.toggle(); + await elementUpdated(splitter); + + expect(pane.collapsed).to.be.false; + }); + }); }); function createSplitter() { @@ -148,3 +303,52 @@ function createNestedSplitter() { `; } + +type SplitterTestSizesAndConstraints = { + size1?: string; + size2?: string; + minSize1?: string; + maxSize1?: string; + minSize2?: string; + maxSize2?: string; + orientation?: SplitterOrientation; +}; + +function createTwoPanesWithSizesAndConstraints( + config: SplitterTestSizesAndConstraints +) { + return html` + + + Pane 1 + + + Pane 2 + + + `; +} + +function getSplitterPaneBase(pane: IgcSplitterPaneComponent) { + return pane.shadowRoot!.querySelector('div[part~="base"]'); +} + +function getSplitterBars(splitter: IgcSplitterComponent) { + const bars: IgcSplitterBarComponent[] = []; + + splitter.panes.forEach((pane) => { + const bar = pane.shadowRoot!.querySelector(IgcSplitterBarComponent.tagName); + if (bar) { + bars.push(bar); + } + }); + return bars; +} diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index e0ab42a5f..8c9433e88 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -1,11 +1,11 @@ +import { ContextProvider } from '@lit/context'; import { html, LitElement } from 'lit'; import { property, queryAssignedElements } from 'lit/decorators.js'; +import { splitterContext } from '../common/context.js'; import { addInternalsController } from '../common/controllers/internals.js'; -import { addSlotController } from '../common/controllers/slot.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { SplitterOrientation } from '../types.js'; -import IgcSplitterBarComponent from './splitter-bar.js'; import IgcSplitterPaneComponent from './splitter-pane.js'; import { styles } from './themes/splitter.base.css.js'; @@ -25,29 +25,25 @@ export default class IgcSplitterComponent extends LitElement { /* blazorSuppress */ public static register() { - registerComponent( - IgcSplitterComponent, - IgcSplitterPaneComponent, - IgcSplitterBarComponent - ); + registerComponent(IgcSplitterComponent, IgcSplitterPaneComponent); } //#region Properties - private readonly _slots = addSlotController(this, { - onChange: this._handleSlotChange, - }); - private readonly _internals = addInternalsController(this, { initialARIA: { ariaOrientation: 'horizontal', }, }); - /** Returns all of the splitter's panes. */ @queryAssignedElements({ selector: 'igc-splitter-pane' }) public panes!: Array; + private readonly _context = new ContextProvider(this, { + context: splitterContext, + initialValue: this, + }); + /** Gets/Sets the orientation of the splitter. * * @remarks @@ -62,218 +58,35 @@ export default class IgcSplitterComponent extends LitElement { * Default value is `false`. * @attr */ - @property({ type: Boolean, reflect: true }) + @property({ type: Boolean, attribute: 'non-collapsible', reflect: true }) public nonCollapsible = false; //#endregion //#region Internal API - @watch('orientation', { waitUntilFirstUpdate: true }) + @watch('orientation') protected _orientationChange(): void { this._internals.setARIA({ ariaOrientation: this.orientation }); - this._resetPaneSizes(); - this._initPanes(); + this._updateContext(); } - constructor() { - super(); - - this.addEventListener('sizeChanged', (event: any) => { - event.stopPropagation(); - this._initPanes(); - }); + @watch('panes') + @watch('nonCollapsible') + private _updateContext(): void { + this._context.setValue(this, true); + this.requestUpdate(); } - protected _handleSlotChange(): void { - this._initPanes(); - } - - private _assignFlexOrder() { - let k = 0; - this.panes.forEach((pane) => { - pane.order = k; - k += 2; - }); - } - - /** - * @hidden @internal - * This method inits panes with properties. - */ - private _initPanes() { - this.panes.forEach((pane) => { - pane.owner = this; - if (this.orientation === 'horizontal') { - pane.minWidth = pane.minSize ?? '0'; - pane.maxWidth = pane.maxSize ?? '100%'; - } else { - pane.minHeight = pane.minSize ?? '0'; - pane.maxHeight = pane.maxSize ?? '100%'; - } - }); - this._assignFlexOrder(); - //in igniteui-angular this is added as feature but i haven't checked why - // if (this.panes.filter(x => x.collapsed).length > 0) { - // // if any panes are collapsed, reset sizes. - // this.resetPaneSizes(); - // } - } - - /** - * @hidden @internal - * This method reset pane sizes. - */ - private _resetPaneSizes() { - if (this.panes) { - // if type is changed runtime, should reset sizes. - this.panes.forEach((pane) => { - pane.size = 'auto'; - pane.minWidth = '0'; - pane.maxWidth = '100%'; - pane.minHeight = '0'; - pane.maxHeight = '100%'; - }); - } - } - - private paneBefore!: IgcSplitterPaneComponent; - private paneAfter!: IgcSplitterPaneComponent; - private initialPaneBeforeSize!: number; - private initialPaneAfterSize!: number; - - private _handleMovingStart(event: CustomEvent) { - // Handle the moving start event - const panes = this.panes; - this.paneBefore = event.detail; - this.paneAfter = panes[panes.indexOf(this.paneBefore) + 1]; - - const paneRect = this.paneBefore.getBoundingClientRect(); - this.initialPaneBeforeSize = - this.orientation === 'horizontal' ? paneRect.width : paneRect.height; - - const siblingRect = this.paneAfter.getBoundingClientRect(); - this.initialPaneAfterSize = - this.orientation === 'horizontal' - ? siblingRect.width - : siblingRect.height; - } - private _handleMoving(event: CustomEvent) { - const [paneSize, siblingSize] = this.calcNewSizes(event.detail); - - this.paneBefore.size = paneSize + 'px'; - this.paneAfter.size = siblingSize + 'px'; - } - - //I am not sure if this code changes anything, it looks like it works without it as well, - // however I found a bug, which I am not sure how to reproduce it and still haven't encaountered it with this code - private _handleMovingEnd(event: CustomEvent) { - const [paneSize, siblingSize] = this.calcNewSizes(event.detail); - - if (this.paneBefore.isPercentageSize) { - // handle % resizes - const totalSize = this.getTotalSize(); - const percentPaneSize = (paneSize / totalSize) * 100; - this.paneBefore.size = percentPaneSize + '%'; - } else { - // px resize - this.paneBefore.size = paneSize + 'px'; - } - - if (this.paneAfter.isPercentageSize) { - // handle % resizes - const totalSize = this.getTotalSize(); - const percentSiblingPaneSize = (siblingSize / totalSize) * 100; - this.paneAfter.size = percentSiblingPaneSize + '%'; - } else { - // px resize - this.paneAfter.size = siblingSize + 'px'; - } - } - - /** - * @hidden @internal - * Calculates new sizes for the panes based on move delta and initial sizes - */ - private calcNewSizes(delta: number): [number, number] { - let finalDelta: number; - const min = - Number.parseInt( - this.paneBefore.minSize ? this.paneBefore.minSize : '0', - 10 - ) || 0; - const minSibling = - Number.parseInt( - this.paneAfter.minSize ? this.paneAfter.minSize : '0', - 10 - ) || 0; - const max = - Number.parseInt( - this.paneBefore.maxSize ? this.paneBefore.maxSize : '0', - 10 - ) || this.initialPaneBeforeSize + this.initialPaneAfterSize - minSibling; - const maxSibling = - Number.parseInt( - this.paneAfter.maxSize ? this.paneAfter.maxSize : '0', - 10 - ) || this.initialPaneBeforeSize + this.initialPaneAfterSize - min; - - if (delta < 0) { - const maxPossibleDelta = Math.min( - this.initialPaneBeforeSize - min, - maxSibling - this.initialPaneAfterSize - ); - finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)) * -1; - } else { - const maxPossibleDelta = Math.min( - max - this.initialPaneBeforeSize, - this.initialPaneAfterSize - minSibling - ); - finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)); - } - return [ - this.initialPaneBeforeSize + finalDelta, - this.initialPaneAfterSize - finalDelta, - ]; - } - - private getTotalSize() { - const computed = document.defaultView?.getComputedStyle(this); - const totalSize = - this.orientation === 'horizontal' - ? computed?.getPropertyValue('width') - : computed?.getPropertyValue('height'); - return Number.parseFloat(totalSize ? totalSize : '0'); - } //#endregion - //#region Rendering - - private _renderBar(order: number, i: number) { - return html` - - `; - } - protected override render() { return html` - - ${this.panes.map((pane, i) => { - const isLast = i === this.panes.length - 1; - return html`${!isLast ? this._renderBar(pane.order + 1, i) : ''}`; - })} +
+ +
`; } - - //#endregion } declare global { diff --git a/src/components/splitter/themes/splitter-bar.base.scss b/src/components/splitter/themes/splitter-bar.base.scss new file mode 100644 index 000000000..a3a5b1ba6 --- /dev/null +++ b/src/components/splitter/themes/splitter-bar.base.scss @@ -0,0 +1,19 @@ +@use 'styles/common/component'; + +:host { + display: flex; + background-color: var(--ig-gray-200); + + &:hover { + background-color: var(--ig-gray-400); + } + + [part='base'] { + cursor: var(--cursor, default); + } +} + +[part='expander-start'], +[part='expander-end'] { + cursor: pointer; +} diff --git a/src/components/splitter/themes/splitter-pane.scss b/src/components/splitter/themes/splitter-pane.scss new file mode 100644 index 000000000..24abc1e48 --- /dev/null +++ b/src/components/splitter/themes/splitter-pane.scss @@ -0,0 +1,17 @@ +@use 'styles/common/component'; + +:host { + [part='base'] { + overflow: auto; + } + + display: flex; + flex: 1 1 auto; + width: 100%; + height: 100%; +} + + +:host([collapsed]) [part='base'] { + display: none; +} diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index eabf2ed9b..49a1397e0 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -2,51 +2,33 @@ @use 'styles/utilities' as *; :host { - display: flex; - width: 100%; - height: 100%; - color: var(--ig-gray-900); - background: var(--ig-gray-100); - border: 1px solid var(--ig-gray-200); - - ::slotted(igc-splitter-pane) { - flex: 1 1 0; - overflow: auto; + [part='base'] { + width: 100%; + height: 100%; + display: flex; + color: var(--ig-gray-900); + background: var(--ig-gray-100); + border: 1px solid var(--ig-gray-200); } -} -:host([orientation='horizontal']) { - flex-direction: row; - - igc-splitter-bar { - width: 5px; - height: auto; - cursor: col-resize; + ::slotted(igc-splitter-pane) { + width: 100%; + height: 100%; + display: contents; } ::slotted(igc-splitter-pane[collapsed]) { - display: none; + height: 0; + width: 0; } } :host([orientation='vertical']) { - flex-direction: column; - - igc-splitter-bar { - height: 5px; - width: auto; - cursor: row-resize; + [part='base'] { + flex-direction: column; } - ::slotted(igc-splitter-pane[collapsed]) { - display: none; - } -} - -igc-splitter-bar { - background-color: var(--ig-gray-200); - - &:hover { - background-color: var(--ig-gray-400); + ::slotted(igc-splitter-pane) { + flex-direction: column; } } diff --git a/src/index.ts b/src/index.ts index 9a191cccf..69494757e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,7 +67,7 @@ export { default as IgcTextareaComponent } from './components/textarea/textarea. export { default as IgcTreeComponent } from './components/tree/tree.js'; export { default as IgcTreeItemComponent } from './components/tree/tree-item.js'; export { default as IgcSplitterBarComponent } from './components/splitter/splitter-bar.js'; -export { default as IgcSpltterComponent } from './components/splitter/splitter.js'; +export { default as IgcSplitterComponent } from './components/splitter/splitter.js'; export { default as IgcSplitterPaneComponent } from './components/splitter/splitter-pane.js'; export { default as IgcStepperComponent } from './components/stepper/stepper.js'; export { default as IgcStepComponent } from './components/stepper/step.js'; diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 02d2cf9fd..c1dc91b8e 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -144,7 +144,11 @@ export default metadata; type Story = StoryObj; function changePaneMinMaxSizes() { - const panes = document.querySelectorAll('igc-splitter-pane'); + const splitter = document.querySelector('igc-splitter'); + const panes = splitter?.panes; + if (!panes) { + return; + } panes[0].minSize = '100px'; panes[0].maxSize = '200px'; panes[1].minSize = '50px'; @@ -179,9 +183,7 @@ export const Default: Story = { } .splitters { - display: flex; - flex-direction: column; - gap: 40px; + height: 400px; } @@ -189,7 +191,6 @@ export const Default: Story = { From 33d0b788d0676cbec6b61329272dcb462d44c760 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Mon, 10 Nov 2025 11:09:57 +0200 Subject: [PATCH 10/47] fix(splitter): make resize work after changes; add updateTarget option for resize controller --- .../resize-container/resize-controller.ts | 9 +++++++-- src/components/resize-container/types.ts | 1 + src/components/splitter/splitter-bar.ts | 10 +++++++++- src/components/splitter/themes/splitter.base.scss | 4 ++++ stories/splitter.stories.ts | 13 ++++++++----- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/components/resize-container/resize-controller.ts b/src/components/resize-container/resize-controller.ts index dd5636347..9f030189d 100644 --- a/src/components/resize-container/resize-controller.ts +++ b/src/components/resize-container/resize-controller.ts @@ -21,6 +21,7 @@ class ResizeController implements ReactiveController { private readonly _options: ResizeControllerConfiguration = { enabled: true, + updateTarget: true, layer: getDefaultLayer, }; @@ -166,7 +167,9 @@ class ResizeController implements ReactiveController { const parameters = { event, state: this._stateParameters }; this._options.resize?.call(this._host, parameters); this._state.current = parameters.state.current; - this._updatePosition(this._isDeferred ? this._ghost : this._resizeTarget); + if (this._options.updateTarget) { + this._updatePosition(this._isDeferred ? this._ghost : this._resizeTarget); + } } private _handlePointerEnd(event: PointerEvent): void { @@ -175,7 +178,9 @@ class ResizeController implements ReactiveController { this._options.end?.call(this._host, parameters); this._state.current = parameters.state.current; - parameters.state.commit?.() ?? this._updatePosition(this._resizeTarget); + if (this._options.updateTarget) { + parameters.state.commit?.() ?? this._updatePosition(this._resizeTarget); + } this.dispose(); } diff --git a/src/components/resize-container/types.ts b/src/components/resize-container/types.ts index 29dc520d8..fefae198f 100644 --- a/src/components/resize-container/types.ts +++ b/src/components/resize-container/types.ts @@ -24,6 +24,7 @@ export type ResizeControllerConfiguration = { enabled?: boolean; ref?: Ref[]; mode?: ResizeMode; + updateTarget?: boolean; deferredFactory?: ResizeGhostFactory; layer?: () => HTMLElement; /** Callback invoked at the start of a resize operation. */ diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index be8efda0c..a5f1ca537 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -95,7 +95,15 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< super(); addResizeController(this, { mode: 'immediate', - resizeTarget: (): HTMLElement => this._siblingPanes[0] ?? this, // we don’t resize the bar, we just use the delta + updateTarget: false, + resizeTarget: () => { + // we don’t resize the bar, we just use the delta + const pane = this._siblingPanes[0]; + return ( + (pane?.shadowRoot?.querySelector('[part="base"]') as HTMLElement) ?? + this + ); + }, start: () => { if ( !this._siblingPanes[0]?.resizable || diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index 49a1397e0..15ae54ab2 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -2,6 +2,10 @@ @use 'styles/utilities' as *; :host { + display: flex; + width: 100%; + height: 100%; + [part='base'] { width: 100%; height: 100%; diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index c1dc91b8e..cd6774506 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -149,12 +149,12 @@ function changePaneMinMaxSizes() { if (!panes) { return; } - panes[0].minSize = '100px'; + panes[0].minSize = '50px'; panes[0].maxSize = '200px'; - panes[1].minSize = '50px'; - panes[1].maxSize = '100px'; + panes[1].minSize = '100px'; + panes[1].maxSize = '300px'; panes[2].minSize = '150px'; - panes[2].maxSize = '100px'; + panes[2].maxSize = '450px'; } export const Default: Story = { @@ -234,12 +234,15 @@ export const NestedSplitters: Story = { argTypes: disableStoryControls(metadata), render: () => html` - + From edee68fdac25b689e9f08fc3c10e7a623bf992dc Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Mon, 10 Nov 2025 12:28:07 +0200 Subject: [PATCH 11/47] chore: style splitter bar through horizontal/vertical part --- src/components/splitter/splitter-bar.ts | 44 +++++++++---------- .../splitter/themes/splitter-bar.base.scss | 37 +++++++++++++++- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index a5f1ca537..93f9f58cd 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -7,6 +7,7 @@ import { createMutationController } from '../common/controllers/mutation-observe import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { partMap } from '../common/part-map.js'; import { addResizeController } from '../resize-container/resize-controller.js'; import type { SplitterOrientation } from '../types.js'; import type IgcSplitterComponent from './splitter.js'; @@ -44,8 +45,18 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< }, }); - private _internalStyles: StyleInfo = {}; - private _orientation?: SplitterOrientation; + protected _resolvePartNames() { + return { + base: true, + [this._orientation.toString()]: true, + }; + } + + private _internalStyles: StyleInfo = { + '--cursor': this._cursor, + }; + + private _orientation: SplitterOrientation = 'horizontal'; private _splitter?: IgcSplitterComponent; private get _siblingPanes(): Array { @@ -65,16 +76,6 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< return [currentPane, nextPane]; } - private get _styles(): StyleInfo { - return { - display: 'flex', - flexDirection: this._orientation === 'horizontal' ? 'column' : 'row', - width: this._orientation === 'horizontal' ? '5px' : '100%', - height: this._orientation === 'horizontal' ? '100%' : '5px', - '--cursor': this._cursor, - }; - } - private get _resizeDisallowed() { return !!this._siblingPanes.find( (x) => x && (x.resizable === false || x.collapsed === true) @@ -160,7 +161,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< if (this._orientation !== splitter.orientation) { this._orientation = splitter.orientation; this._internals.setARIA({ ariaOrientation: this._orientation }); - Object.assign(this._internalStyles, this._styles); + Object.assign(this._internalStyles, { cursor: this._cursor }); } } @@ -172,23 +173,18 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< const prevButtonHidden = siblings[0]?.collapsed && !siblings[1]?.collapsed; const nextButtonHidden = siblings[1]?.collapsed && !siblings[0]?.collapsed; return html` -
+
-
+
`; } protected override render() { return html` -
+
${this._renderBarControls()}
`; diff --git a/src/components/splitter/themes/splitter-bar.base.scss b/src/components/splitter/themes/splitter-bar.base.scss index a3a5b1ba6..655d30817 100644 --- a/src/components/splitter/themes/splitter-bar.base.scss +++ b/src/components/splitter/themes/splitter-bar.base.scss @@ -8,12 +8,47 @@ background-color: var(--ig-gray-400); } - [part='base'] { + [part~='base'] { + display: flex; cursor: var(--cursor, default); } + + [part='base horizontal'] { + [part='handle'] { + height: 50px; + } + + flex-direction: column; + width: 5px; + height: 100%; + } + + [part='base vertical'] { + [part='handle'] { + width: 50px; + } + + flex-direction: row; + width: 100%; + height: 5px; + } } [part='expander-start'], [part='expander-end'] { cursor: pointer; + width: 5px; + height: 5px; +} + +[part='expander-start'] { + background-color: red; +} + +[part='expander-end'] { + background-color: green; +} + +[part='handle'] { + background-color: yellow; } From b7b4e8d4cc562cd32c24d412d7badba4159167c2 Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Wed, 12 Nov 2025 11:02:04 +0200 Subject: [PATCH 12/47] fix(splitter): modify flex prop to allow different sizes --- src/components/splitter/splitter-pane.ts | 112 +++++++++++++----- .../splitter/themes/splitter.base.scss | 1 + 2 files changed, 84 insertions(+), 29 deletions(-) diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index 649f91816..e08018aa5 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -44,9 +44,24 @@ export default class IgcSplitterPaneComponent extends LitElement { private get _flex() { //const size = this.dragSize || this.size; //const grow = this.isPercentageSize && !this.dragSize ? 1 : 0; - const grow = this._isPercentageSize ? 1 : 0; - return `${grow} ${grow} ${this._size}`; - //return `${0} ${0} ${this.size}`; + + //tova ne raboti ako setnem procent na nqkoi pane a ako e dolnoto se mesti malko po-malko nadqsno vupreki che go handelvam + // const grow = this._isPercentageSize ? 1 : 0; + // return `${grow} ${grow} ${this._size}`; + + // Flex rules: + // - Explicit percentage (e.g., 30%): fixed at that size => flex: 0 0 + // - Explicit px (e.g., 200px): fixed => flex: 0 0 + // - Auto: participates in remaining space => flex: 1 1 0px + if (this._size === 'auto') { + return '1 1 0px'; + // } if (this._isPercentageSize) { + // const basis = this._isLastPane + // ? this._size // last pane has no internal bar after it + // : `calc(${this._size} - 5px)`; + // return `0 0 ${`calc(${this._size} - 3px)`}`; + } + return `0 0 ${this._size}`; } /** @@ -106,6 +121,12 @@ export default class IgcSplitterPaneComponent extends LitElement { */ @property({ type: Boolean, reflect: true }) public set collapsed(value: boolean) { + if (this._splitter) { + // reset sibling sizes when pane collapse state changes. + this._splitter.panes.forEach((pane) => { + pane.size = 'auto'; + }); + } this._collapsed = value; } @@ -176,6 +197,10 @@ export default class IgcSplitterPaneComponent extends LitElement { this.paneBefore = this; this.paneAfter = panes[panes.indexOf(this) + 1]; + // Normalize any 'auto' pane sizes to explicit percentages so flex redistribution + // does not modify unaffected panes when only a subset gets pixel / percent updates. + //this._normalizeAutoPaneSizes(); + // Store original size types before we start changing them this.isPaneBeforePercentage = this.paneBefore._isPercentageSize; this.isPaneAfterPercentage = this.paneAfter._isPercentageSize; @@ -208,9 +233,8 @@ export default class IgcSplitterPaneComponent extends LitElement { this.paneAfter.size = `${siblingSize}px`; } - //I am not sure if this code changes anything, it looks like it works without it as well, - // however I found a bug, which I am not sure how to reproduce it and still haven't encaountered it with this code private _handleMovingEnd(event: CustomEvent) { + let last = false; // Only handle if this pane owns the bar (is the one before the bar) if (!this.paneBefore || this.paneBefore !== this) { return; @@ -220,9 +244,11 @@ export default class IgcSplitterPaneComponent extends LitElement { if (this.isPaneBeforePercentage) { // handle % resizes - const totalSize = this.getTotalSize(); - const percentPaneSize = (paneSize / totalSize) * 100; - this.paneBefore.size = `${percentPaneSize}%`; + last = this.paneBefore._isLastPane; + + this._convertSizeToPercentage(this.paneBefore, last); + + //this._convertSizeToPercentage(this.paneBefore, false); } else { // px resize this.paneBefore.size = `${paneSize}px`; @@ -230,9 +256,10 @@ export default class IgcSplitterPaneComponent extends LitElement { if (this.isPaneAfterPercentage) { // handle % resizes - const totalSize = this.getTotalSize(); - const percentSiblingPaneSize = (siblingSize / totalSize) * 100; - this.paneAfter.size = `${percentSiblingPaneSize}%`; + last = this.paneAfter._isLastPane; + this._convertSizeToPercentage(this.paneAfter, last); + + //this._convertSizeToPercentage(this.paneAfter, false); } else { // px resize this.paneAfter.size = `${siblingSize}px`; @@ -241,26 +268,14 @@ export default class IgcSplitterPaneComponent extends LitElement { private _calcNewSizes(delta: number): [number, number] { let finalDelta: number; - const min = - Number.parseInt( - this.paneBefore.minSize ? this.paneBefore.minSize : '0', - 10 - ) || 0; - const minSibling = - Number.parseInt( - this.paneAfter.minSize ? this.paneAfter.minSize : '0', - 10 - ) || 0; + const min = Number.parseInt(this.paneBefore.minSize ?? '0', 10) || 0; + const minSibling = Number.parseInt(this.paneAfter.minSize ?? '0', 10) || 0; const max = - Number.parseInt( - this.paneBefore.maxSize ? this.paneBefore.maxSize : '0', - 10 - ) || this.initialPaneBeforeSize + this.initialPaneAfterSize - minSibling; + Number.parseInt(this.paneBefore.maxSize ?? '0', 10) || + this.initialPaneBeforeSize + this.initialPaneAfterSize - minSibling; const maxSibling = - Number.parseInt( - this.paneAfter.maxSize ? this.paneAfter.maxSize : '0', - 10 - ) || this.initialPaneBeforeSize + this.initialPaneAfterSize - min; + Number.parseInt(this.paneAfter.maxSize ?? '0', 10) || + this.initialPaneBeforeSize + this.initialPaneAfterSize - min; if (delta < 0) { const maxPossibleDelta = Math.min( @@ -295,6 +310,45 @@ export default class IgcSplitterPaneComponent extends LitElement { return this._orientation === 'horizontal' ? rect.width : rect.height; } + /** Converts all panes with size 'auto' to explicit percentage sizes based on their current rendered size. */ + private _normalizeAutoPaneSizes() { + if (!this._splitter || !this._splitter.panes.length) { + return; + } + for (const pane of this._splitter.panes) { + if (pane.size === 'auto') { + if (this._isLastPane) { + this._convertSizeToPercentage(pane, true); + } else { + this._convertSizeToPercentage(pane, false); + } + } + } + } + + private _convertSizeToPercentage( + pane: IgcSplitterPaneComponent, + last: boolean + ) { + const base = pane.shadowRoot?.querySelector('[part="base"]'); + if (!base) { + return; + } + const rect = base.getBoundingClientRect(); + let currentSize = + this._orientation === 'horizontal' ? rect.width : rect.height; + if (!last) { + currentSize += 5; + } + const visual = last ? currentSize : currentSize; + const totalSize = this.getTotalSize(); + const percentSize = (visual / totalSize) * 100; + // if (!last) { + // percentSize += (5 / totalSize) * 100; + // } + pane.size = `${percentSize.toFixed(3)}%`; + } + /** Toggles the collapsed state of the pane. */ public toggle() { this.collapsed = !this.collapsed; diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index 15ae54ab2..6df7c0b01 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -13,6 +13,7 @@ color: var(--ig-gray-900); background: var(--ig-gray-100); border: 1px solid var(--ig-gray-200); + user-select: none; } ::slotted(igc-splitter-pane) { From a05d121028e4802dcdc0d81ed84a4122be343116 Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Wed, 12 Nov 2025 11:23:36 +0200 Subject: [PATCH 13/47] fix(splitter): revert changes from previous commit --- src/components/splitter/splitter-pane.ts | 78 +++--------------------- 1 file changed, 8 insertions(+), 70 deletions(-) diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index e08018aa5..4015c71ea 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -45,23 +45,8 @@ export default class IgcSplitterPaneComponent extends LitElement { //const size = this.dragSize || this.size; //const grow = this.isPercentageSize && !this.dragSize ? 1 : 0; - //tova ne raboti ako setnem procent na nqkoi pane a ako e dolnoto se mesti malko po-malko nadqsno vupreki che go handelvam - // const grow = this._isPercentageSize ? 1 : 0; - // return `${grow} ${grow} ${this._size}`; - - // Flex rules: - // - Explicit percentage (e.g., 30%): fixed at that size => flex: 0 0 - // - Explicit px (e.g., 200px): fixed => flex: 0 0 - // - Auto: participates in remaining space => flex: 1 1 0px - if (this._size === 'auto') { - return '1 1 0px'; - // } if (this._isPercentageSize) { - // const basis = this._isLastPane - // ? this._size // last pane has no internal bar after it - // : `calc(${this._size} - 5px)`; - // return `0 0 ${`calc(${this._size} - 3px)`}`; - } - return `0 0 ${this._size}`; + const grow = this._isPercentageSize ? 1 : 0; + return `${grow} ${grow} ${this._size}`; } /** @@ -197,10 +182,6 @@ export default class IgcSplitterPaneComponent extends LitElement { this.paneBefore = this; this.paneAfter = panes[panes.indexOf(this) + 1]; - // Normalize any 'auto' pane sizes to explicit percentages so flex redistribution - // does not modify unaffected panes when only a subset gets pixel / percent updates. - //this._normalizeAutoPaneSizes(); - // Store original size types before we start changing them this.isPaneBeforePercentage = this.paneBefore._isPercentageSize; this.isPaneAfterPercentage = this.paneAfter._isPercentageSize; @@ -234,7 +215,6 @@ export default class IgcSplitterPaneComponent extends LitElement { } private _handleMovingEnd(event: CustomEvent) { - let last = false; // Only handle if this pane owns the bar (is the one before the bar) if (!this.paneBefore || this.paneBefore !== this) { return; @@ -244,11 +224,9 @@ export default class IgcSplitterPaneComponent extends LitElement { if (this.isPaneBeforePercentage) { // handle % resizes - last = this.paneBefore._isLastPane; - - this._convertSizeToPercentage(this.paneBefore, last); - - //this._convertSizeToPercentage(this.paneBefore, false); + const totalSize = this.getTotalSize(); + const percentPaneSize = (paneSize / totalSize) * 100; + this.paneBefore.size = `${percentPaneSize}%`; } else { // px resize this.paneBefore.size = `${paneSize}px`; @@ -256,10 +234,9 @@ export default class IgcSplitterPaneComponent extends LitElement { if (this.isPaneAfterPercentage) { // handle % resizes - last = this.paneAfter._isLastPane; - this._convertSizeToPercentage(this.paneAfter, last); - - //this._convertSizeToPercentage(this.paneAfter, false); + const totalSize = this.getTotalSize(); + const percentPaneSize = (paneSize / totalSize) * 100; + this.paneBefore.size = `${percentPaneSize}%`; } else { // px resize this.paneAfter.size = `${siblingSize}px`; @@ -310,45 +287,6 @@ export default class IgcSplitterPaneComponent extends LitElement { return this._orientation === 'horizontal' ? rect.width : rect.height; } - /** Converts all panes with size 'auto' to explicit percentage sizes based on their current rendered size. */ - private _normalizeAutoPaneSizes() { - if (!this._splitter || !this._splitter.panes.length) { - return; - } - for (const pane of this._splitter.panes) { - if (pane.size === 'auto') { - if (this._isLastPane) { - this._convertSizeToPercentage(pane, true); - } else { - this._convertSizeToPercentage(pane, false); - } - } - } - } - - private _convertSizeToPercentage( - pane: IgcSplitterPaneComponent, - last: boolean - ) { - const base = pane.shadowRoot?.querySelector('[part="base"]'); - if (!base) { - return; - } - const rect = base.getBoundingClientRect(); - let currentSize = - this._orientation === 'horizontal' ? rect.width : rect.height; - if (!last) { - currentSize += 5; - } - const visual = last ? currentSize : currentSize; - const totalSize = this.getTotalSize(); - const percentSize = (visual / totalSize) * 100; - // if (!last) { - // percentSize += (5 / totalSize) * 100; - // } - pane.size = `${percentSize.toFixed(3)}%`; - } - /** Toggles the collapsed state of the pane. */ public toggle() { this.collapsed = !this.collapsed; From 59b3285c45beca8edf64cfcb012965bb054a1f3f Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Wed, 12 Nov 2025 12:37:43 +0200 Subject: [PATCH 14/47] chore: style tweaks; more tests; resize fix --- src/components/splitter/splitter-bar.ts | 39 ++++- src/components/splitter/splitter-pane.ts | 158 ++++++++++-------- src/components/splitter/splitter.spec.ts | 128 +++++++++++++- .../splitter/themes/splitter-bar.base.scss | 6 +- .../splitter/themes/splitter-pane.scss | 1 + .../splitter/themes/splitter.base.scss | 3 - stories/splitter.stories.ts | 5 +- 7 files changed, 249 insertions(+), 91 deletions(-) diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index 93f9f58cd..43e32c61c 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -52,9 +52,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< }; } - private _internalStyles: StyleInfo = { - '--cursor': this._cursor, - }; + private _internalStyles: StyleInfo = {}; private _orientation: SplitterOrientation = 'horizontal'; private _splitter?: IgcSplitterComponent; @@ -94,6 +92,8 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< constructor() { super(); + this._internalStyles = { '--cursor': this._cursor }; + addResizeController(this, { mode: 'immediate', updateTarget: false, @@ -146,6 +146,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< private _createSiblingPaneMutationController(pane: IgcSplitterPaneComponent) { createMutationController(pane, { callback: () => { + Object.assign(this._internalStyles, { '--cursor': this._cursor }); this.requestUpdate(); }, filter: [IgcSplitterPaneComponent.tagName], @@ -161,8 +162,26 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< if (this._orientation !== splitter.orientation) { this._orientation = splitter.orientation; this._internals.setARIA({ ariaOrientation: this._orientation }); - Object.assign(this._internalStyles, { cursor: this._cursor }); + Object.assign(this._internalStyles, { '--cursor': this._cursor }); + } + this.requestUpdate(); + } + + private _handleExpanderClick(start: boolean, event: PointerEvent) { + // Prevent resize controller from starting + event.stopPropagation(); + + const prevSibling = this._siblingPanes[0]!; + const nextSibling = this._siblingPanes[1]!; + let target: IgcSplitterPaneComponent; + if (start) { + // if next is clicked when prev pane is hidden, show prev pane, else hide next pane. + target = prevSibling.collapsed ? prevSibling : nextSibling; + } else { + // if prev is clicked when next pane is hidden, show next pane, else hide prev pane. + target = nextSibling.collapsed ? nextSibling : prevSibling; } + target.toggle(); } private _renderBarControls() { @@ -173,9 +192,17 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< const prevButtonHidden = siblings[0]?.collapsed && !siblings[1]?.collapsed; const nextButtonHidden = siblings[1]?.collapsed && !siblings[0]?.collapsed; return html` -
+
this._handleExpanderClick(true, e)} + >
-
+
this._handleExpanderClick(false, e)} + >
`; } diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index 4015c71ea..f4e2f0623 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -1,6 +1,6 @@ import { ContextConsumer } from '@lit/context'; import { html, LitElement, nothing } from 'lit'; -import { property } from 'lit/decorators.js'; +import { property, query } from 'lit/decorators.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { splitterContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; @@ -33,6 +33,16 @@ export default class IgcSplitterPaneComponent extends LitElement { private _collapsed = false; private _orientation?: SplitterOrientation; + private _prevPane!: IgcSplitterPaneComponent; + private _nextPane!: IgcSplitterPaneComponent; + private _prevPaneInitialSize!: number; + private _nextPaneInitialSize!: number; + private _isPrevPanePercentage = false; + private _isNextPanePercentage = false; + + @query('[part~="base"]', true) + private readonly _base!: HTMLElement; + private get _isPercentageSize() { return this._size === 'auto' || this._size.indexOf('%') !== -1; } @@ -42,13 +52,17 @@ export default class IgcSplitterPaneComponent extends LitElement { } private get _flex() { - //const size = this.dragSize || this.size; - //const grow = this.isPercentageSize && !this.dragSize ? 1 : 0; - const grow = this._isPercentageSize ? 1 : 0; return `${grow} ${grow} ${this._size}`; } + private get _rectSize() { + const relevantDimension = + this._orientation === 'horizontal' ? 'width' : 'height'; + const paneRect = this._base.getBoundingClientRect(); + return paneRect[relevantDimension]; + } + /** * The minimum size of the pane. * @attr @@ -164,112 +178,97 @@ export default class IgcSplitterPaneComponent extends LitElement { this.requestUpdate(); } - private paneBefore!: IgcSplitterPaneComponent; - private paneAfter!: IgcSplitterPaneComponent; - private initialPaneBeforeSize!: number; - private initialPaneAfterSize!: number; - private isPaneBeforePercentage = false; - private isPaneAfterPercentage = false; - private _handleMovingStart(event: CustomEvent) { - // Only handle if this is the pane that owns the bar if (event.detail !== this) { return; } - // Handle the moving start event const panes = this._splitter!.panes; - this.paneBefore = this; - this.paneAfter = panes[panes.indexOf(this) + 1]; + this._prevPane = this; + this._nextPane = panes[panes.indexOf(this) + 1]; // Store original size types before we start changing them - this.isPaneBeforePercentage = this.paneBefore._isPercentageSize; - this.isPaneAfterPercentage = this.paneAfter._isPercentageSize; - - const paneBeforeBase = - this.paneBefore.shadowRoot?.querySelector('[part="base"]'); - const paneAfterBase = - this.paneAfter.shadowRoot?.querySelector('[part="base"]'); - - const paneRect = paneBeforeBase!.getBoundingClientRect(); - this.initialPaneBeforeSize = - this._orientation === 'horizontal' ? paneRect.width : paneRect.height; - - const siblingRect = paneAfterBase!.getBoundingClientRect(); - this.initialPaneAfterSize = - this._orientation === 'horizontal' - ? siblingRect.width - : siblingRect.height; + this._isPrevPanePercentage = this._prevPane._isPercentageSize; + this._isNextPanePercentage = this._nextPane._isPercentageSize; + + this._prevPaneInitialSize = this._rectSize; + this._nextPaneInitialSize = this._nextPane._rectSize; } private _handleMoving(event: CustomEvent) { - // Only handle if this pane owns the bar (is the one before the bar) - if (!this.paneBefore || this.paneBefore !== this) { - return; - } - const [paneSize, siblingSize] = this._calcNewSizes(event.detail); - this.paneBefore.size = `${paneSize}px`; - this.paneAfter.size = `${siblingSize}px`; + this._prevPane.size = `${paneSize}px`; + this._nextPane.size = `${siblingSize}px`; } private _handleMovingEnd(event: CustomEvent) { - // Only handle if this pane owns the bar (is the one before the bar) - if (!this.paneBefore || this.paneBefore !== this) { - return; - } - const [paneSize, siblingSize] = this._calcNewSizes(event.detail); - if (this.isPaneBeforePercentage) { - // handle % resizes - const totalSize = this.getTotalSize(); - const percentPaneSize = (paneSize / totalSize) * 100; - this.paneBefore.size = `${percentPaneSize}%`; - } else { - // px resize - this.paneBefore.size = `${paneSize}px`; - } + const totalSize = this.getTotalSize(); + + this._adjustPaneSize( + this._prevPane, + this._isPrevPanePercentage, + paneSize, + totalSize + ); + this._adjustPaneSize( + this._nextPane, + this._isNextPanePercentage, + siblingSize, + totalSize + ); + + this._splitter!.panes.filter( + (pane) => pane !== this && pane !== this._nextPane + ).forEach((pane) => { + const size = pane._rectSize; + this._adjustPaneSize(pane, pane._isPercentageSize, size, totalSize); + }); + } - if (this.isPaneAfterPercentage) { - // handle % resizes - const totalSize = this.getTotalSize(); - const percentPaneSize = (paneSize / totalSize) * 100; - this.paneBefore.size = `${percentPaneSize}%`; + private _adjustPaneSize( + pane: IgcSplitterPaneComponent, + isPercent: boolean, + size: number, + totalSize: number + ) { + if (isPercent) { + const percentPaneSize = (size / totalSize) * 100; + pane.size = `${percentPaneSize}%`; } else { - // px resize - this.paneAfter.size = `${siblingSize}px`; + pane.size = `${size}px`; } } private _calcNewSizes(delta: number): [number, number] { let finalDelta: number; - const min = Number.parseInt(this.paneBefore.minSize ?? '0', 10) || 0; - const minSibling = Number.parseInt(this.paneAfter.minSize ?? '0', 10) || 0; + const min = Number.parseInt(this._prevPane.minSize ?? '0', 10) || 0; + const minSibling = Number.parseInt(this._nextPane.minSize ?? '0', 10) || 0; const max = - Number.parseInt(this.paneBefore.maxSize ?? '0', 10) || - this.initialPaneBeforeSize + this.initialPaneAfterSize - minSibling; + Number.parseInt(this._prevPane.maxSize ?? '0', 10) || + this._prevPaneInitialSize + this._nextPaneInitialSize - minSibling; const maxSibling = - Number.parseInt(this.paneAfter.maxSize ?? '0', 10) || - this.initialPaneBeforeSize + this.initialPaneAfterSize - min; + Number.parseInt(this._nextPane.maxSize ?? '0', 10) || + this._prevPaneInitialSize + this._nextPaneInitialSize - min; if (delta < 0) { const maxPossibleDelta = Math.min( - this.initialPaneBeforeSize - min, - maxSibling - this.initialPaneAfterSize + this._prevPaneInitialSize - min, + maxSibling - this._nextPaneInitialSize ); finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)) * -1; } else { const maxPossibleDelta = Math.min( - max - this.initialPaneBeforeSize, - this.initialPaneAfterSize - minSibling + max - this._prevPaneInitialSize, + this._nextPaneInitialSize - minSibling ); finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)); } return [ - this.initialPaneBeforeSize + finalDelta, - this.initialPaneAfterSize - finalDelta, + this._prevPaneInitialSize + finalDelta, + this._nextPaneInitialSize - finalDelta, ]; } @@ -277,14 +276,25 @@ export default class IgcSplitterPaneComponent extends LitElement { if (!this._splitter) { return 0; } - // get the size of part base const splitterBase = this._splitter.shadowRoot?.querySelector('[part="base"]'); if (!splitterBase) { return 0; } + + const bar = this.shadowRoot?.querySelector('igc-splitter-bar'); + const barSize = bar + ? Number.parseInt( + getComputedStyle(bar).getPropertyValue('--bar-size').trim(), + 10 + ) || 0 + : 0; + const rect = splitterBase.getBoundingClientRect(); - return this._orientation === 'horizontal' ? rect.width : rect.height; + const barsLength = this._splitter.panes.length - 1; + const barsSize = barsLength * barSize; + const size = this._orientation === 'horizontal' ? rect.width : rect.height; + return size - barsSize; } /** Toggles the collapsed state of the pane. */ diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 8385decf8..b107bb8e8 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -1,4 +1,10 @@ -import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; import { defineComponents } from '../common/definitions/defineComponents.js'; import type { SplitterOrientation } from '../types.js'; import IgcSplitterComponent from './splitter.js'; @@ -98,6 +104,50 @@ describe('Splitter', () => { const rightBars = getSplitterBars(rightSplitter); expect(rightBars).to.have.lengthOf(1); }); + + it('should not display the bar elements if the splitter is nonCollapsible', async () => { + splitter.nonCollapsible = true; + await elementUpdated(splitter); + + const bars = getSplitterBars(splitter); + bars.forEach((bar) => { + const base = bar.shadowRoot!.querySelector( + '[part~="base"]' + ) as HTMLElement; + expect(base.children).to.have.lengthOf(0); + }); + }); + + it('should set a default cursor on the bar in case any of its siblings is not resizable or collapsed', async () => { + const firstPane = splitter.panes[0]; + const secondPane = splitter.panes[1]; + const bars = getSplitterBars(splitter); + const firstBar = bars[0].shadowRoot!.querySelector( + '[part~="base"]' + ) as HTMLElement; + + const style = getComputedStyle(firstBar); + expect(style.cursor).to.equal('col-resize'); + + firstPane.resizable = false; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('default'); + + firstPane.resizable = true; + secondPane.collapsed = true; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('default'); + + secondPane.collapsed = false; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('col-resize'); + }); }); describe('Properties', () => { @@ -257,7 +307,7 @@ describe('Splitter', () => { }); }); - describe('Methods & Events', () => { + describe('Methods, Events & Interactions', () => { it('should expand/collapse panes when toggle is invoked', async () => { const pane = splitter.panes[0]; expect(pane.collapsed).to.be.false; @@ -272,6 +322,74 @@ describe('Splitter', () => { expect(pane.collapsed).to.be.false; }); + + it('should toggle the previous pane when the bar expander-end is clicked', async () => { + const bars = getSplitterBars(splitter); + const firstBar = bars[0]; + const firstPane = splitter.panes[0]; + const secondPane = splitter.panes[1]; + + expect(firstPane.collapsed).to.be.false; + + const expanderStart = getExpander(firstBar, 'start'); + const expanderEnd = getExpander(firstBar, 'end'); + + expanderEnd.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true }) + ); + await elementUpdated(splitter); + await nextFrame(); + + expect(firstPane.collapsed).to.be.true; + expect(secondPane.collapsed).to.be.false; + expect(expanderStart.hidden).to.be.true; + expect(expanderEnd.hidden).to.be.false; + + expanderEnd.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true }) + ); + await elementUpdated(splitter); + await nextFrame(); + + expect(firstPane.collapsed).to.be.false; + expect(secondPane.collapsed).to.be.false; + expect(expanderStart.hidden).to.be.false; + expect(expanderEnd.hidden).to.be.false; + }); + + it('should toggle the next pane when the bar expander-start is clicked', async () => { + const bars = getSplitterBars(splitter); + const firstBar = bars[0]; + const firstPane = splitter.panes[0]; + const secondPane = splitter.panes[1]; + + expect(secondPane.collapsed).to.be.false; + + const expanderStart = getExpander(firstBar, 'start'); + const expanderEnd = getExpander(firstBar, 'end'); + + expanderStart.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true }) + ); + await elementUpdated(splitter); + await nextFrame(); + + expect(secondPane.collapsed).to.be.true; + expect(firstPane.collapsed).to.be.false; + expect(expanderStart.hidden).to.be.false; + expect(expanderEnd.hidden).to.be.true; + + expanderStart.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true }) + ); + await elementUpdated(splitter); + await nextFrame(); + + expect(firstPane.collapsed).to.be.false; + expect(secondPane.collapsed).to.be.false; + expect(expanderStart.hidden).to.be.false; + expect(expanderEnd.hidden).to.be.false; + }); }); }); @@ -352,3 +470,9 @@ function getSplitterBars(splitter: IgcSplitterComponent) { }); return bars; } + +function getExpander(bar: IgcSplitterBarComponent, which: 'start' | 'end') { + return bar.shadowRoot!.querySelector( + `[part="expander-${which}"]` + ) as HTMLElement; +} diff --git a/src/components/splitter/themes/splitter-bar.base.scss b/src/components/splitter/themes/splitter-bar.base.scss index 655d30817..21575d725 100644 --- a/src/components/splitter/themes/splitter-bar.base.scss +++ b/src/components/splitter/themes/splitter-bar.base.scss @@ -1,6 +1,7 @@ @use 'styles/common/component'; :host { + --bar-size: 5px; display: flex; background-color: var(--ig-gray-200); @@ -11,6 +12,7 @@ [part~='base'] { display: flex; cursor: var(--cursor, default); + justify-content: center; } [part='base horizontal'] { @@ -19,7 +21,7 @@ } flex-direction: column; - width: 5px; + width: var(--bar-size); height: 100%; } @@ -30,7 +32,7 @@ flex-direction: row; width: 100%; - height: 5px; + height: var(--bar-size); } } diff --git a/src/components/splitter/themes/splitter-pane.scss b/src/components/splitter/themes/splitter-pane.scss index 24abc1e48..9aa3b017a 100644 --- a/src/components/splitter/themes/splitter-pane.scss +++ b/src/components/splitter/themes/splitter-pane.scss @@ -3,6 +3,7 @@ :host { [part='base'] { overflow: auto; + box-sizing: border-box; } display: flex; diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index 6df7c0b01..6d219793a 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -33,7 +33,4 @@ flex-direction: column; } - ::slotted(igc-splitter-pane) { - flex-direction: column; - } } diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index cd6774506..71a5a38b7 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -234,15 +234,12 @@ export const NestedSplitters: Story = { argTypes: disableStoryControls(metadata), render: () => html` - + From da506d4c824d2361ef805978dcb475f3854b03e1 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Wed, 12 Nov 2025 14:06:02 +0200 Subject: [PATCH 15/47] fix: handle shrink differently to reflect proper percentage sizes? --- src/components/splitter/splitter-pane.ts | 32 ++++++++++++++++-------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index f4e2f0623..4aad9acc3 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -43,17 +43,22 @@ export default class IgcSplitterPaneComponent extends LitElement { @query('[part~="base"]', true) private readonly _base!: HTMLElement; + private get _splitter(): IgcSplitterComponent | undefined { + return this._splitterContext.value; + } + private get _isPercentageSize() { - return this._size === 'auto' || this._size.indexOf('%') !== -1; + return this._size.indexOf('%') !== -1; } - private get _splitter(): IgcSplitterComponent | undefined { - return this._splitterContext.value; + private get _isAutoSize() { + return this._size === 'auto'; } private get _flex() { - const grow = this._isPercentageSize ? 1 : 0; - return `${grow} ${grow} ${this._size}`; + const grow = this._isAutoSize ? 1 : 0; + const shrink = this._isAutoSize || this._isPercentageSize ? 1 : 0; + return `${grow} ${shrink} ${this._size}`; } private get _rectSize() { @@ -188,8 +193,10 @@ export default class IgcSplitterPaneComponent extends LitElement { this._nextPane = panes[panes.indexOf(this) + 1]; // Store original size types before we start changing them - this._isPrevPanePercentage = this._prevPane._isPercentageSize; - this._isNextPanePercentage = this._nextPane._isPercentageSize; + this._isPrevPanePercentage = + this._prevPane._isPercentageSize || this._prevPane._isAutoSize; + this._isNextPanePercentage = + this._nextPane._isPercentageSize || this._nextPane._isAutoSize; this._prevPaneInitialSize = this._rectSize; this._nextPaneInitialSize = this._nextPane._rectSize; @@ -224,17 +231,22 @@ export default class IgcSplitterPaneComponent extends LitElement { (pane) => pane !== this && pane !== this._nextPane ).forEach((pane) => { const size = pane._rectSize; - this._adjustPaneSize(pane, pane._isPercentageSize, size, totalSize); + this._adjustPaneSize( + pane, + pane._isPercentageSize || pane._isAutoSize, + size, + totalSize + ); }); } private _adjustPaneSize( pane: IgcSplitterPaneComponent, - isPercent: boolean, + isPercentOrAuto: boolean, size: number, totalSize: number ) { - if (isPercent) { + if (isPercentOrAuto) { const percentPaneSize = (size / totalSize) * 100; pane.size = `${percentPaneSize}%`; } else { From 9184867c8f2889138b83e5ce2ec44e88075f795b Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Wed, 12 Nov 2025 17:42:08 +0200 Subject: [PATCH 16/47] feat(splitter): implement keyboard navigation --- src/components/splitter/splitter-bar.ts | 65 ++++++++++++++++++++----- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index 43e32c61c..02e655706 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -3,6 +3,14 @@ import { html, LitElement, nothing } from 'lit'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { splitterContext } from '../common/context.js'; import { addInternalsController } from '../common/controllers/internals.js'; +import { + addKeybindings, + arrowDown, + arrowLeft, + arrowRight, + arrowUp, + ctrlKey, +} from '../common/controllers/key-bindings.js'; import { createMutationController } from '../common/controllers/mutation-observer.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; @@ -106,14 +114,10 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< ); }, start: () => { - if ( - !this._siblingPanes[0]?.resizable || - !this._siblingPanes[1]?.resizable || - this._siblingPanes[0].collapsed - ) { + if (this._resizeDisallowed) { return false; } - this.emitEvent('igcMovingStart', { detail: this._siblingPanes[0] }); + this.emitEvent('igcMovingStart', { detail: this._siblingPanes[0]! }); return true; }, resize: ({ state }) => { @@ -133,6 +137,16 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< }, cancel: () => {}, }); + + addKeybindings(this) + .set(arrowUp, this.resizePanes) + .set(arrowDown, this.resizePanes) + .set(arrowLeft, this.resizePanes) + .set(arrowRight, this.resizePanes) + .set([ctrlKey, arrowUp], () => this._handleExpanderClick(true)) + .set([ctrlKey, arrowDown], () => this._handleExpanderClick(false)) + .set([ctrlKey, arrowLeft], () => this._handleExpanderClick(true)) + .set([ctrlKey, arrowRight], () => this._handleExpanderClick(false)); //addThemingController(this, all); } @@ -143,6 +157,34 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< }); } + private resizePanes(event: KeyboardEvent) { + if (this._resizeDisallowed) { + return false; + } + if ( + (event.key === arrowUp || event.key === arrowDown) && + this._orientation === 'horizontal' + ) { + return false; + } + if ( + (event.key === arrowLeft || event.key === arrowRight) && + this._orientation === 'vertical' + ) { + return false; + } + let delta = 0; + if (event.key === arrowUp || event.key === arrowLeft) { + delta = -10; + } else { + delta = 10; + } + this.emitEvent('igcMovingStart', { detail: this._siblingPanes[0]! }); + this.emitEvent('igcMoving', { detail: delta }); + this.emitEvent('igcMovingEnd', { detail: delta }); + return true; + } + private _createSiblingPaneMutationController(pane: IgcSplitterPaneComponent) { createMutationController(pane, { callback: () => { @@ -167,19 +209,19 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< this.requestUpdate(); } - private _handleExpanderClick(start: boolean, event: PointerEvent) { + private _handleExpanderClick(start: boolean, event?: PointerEvent) { // Prevent resize controller from starting - event.stopPropagation(); + event?.stopPropagation(); const prevSibling = this._siblingPanes[0]!; const nextSibling = this._siblingPanes[1]!; let target: IgcSplitterPaneComponent; if (start) { - // if next is clicked when prev pane is hidden, show prev pane, else hide next pane. - target = prevSibling.collapsed ? prevSibling : nextSibling; - } else { // if prev is clicked when next pane is hidden, show next pane, else hide prev pane. target = nextSibling.collapsed ? nextSibling : prevSibling; + } else { + // if next is clicked when prev pane is hidden, show prev pane, else hide next pane. + target = prevSibling.collapsed ? prevSibling : nextSibling; } target.toggle(); } @@ -211,6 +253,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin<
${this._renderBarControls()}
From 65685bddbe955bcfdbe05cb77ddbd16b9bac5a6e Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Thu, 13 Nov 2025 13:15:47 +0200 Subject: [PATCH 17/47] feat(splitter): implement splitter events --- src/components/splitter/splitter-bar.ts | 56 +++++++++++++++++++----- src/components/splitter/splitter-pane.ts | 24 +++++++++- src/components/splitter/splitter.ts | 20 ++++++++- 3 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index 02e655706..3a7f43411 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -139,14 +139,14 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< }); addKeybindings(this) - .set(arrowUp, this.resizePanes) - .set(arrowDown, this.resizePanes) - .set(arrowLeft, this.resizePanes) - .set(arrowRight, this.resizePanes) - .set([ctrlKey, arrowUp], () => this._handleExpanderClick(true)) - .set([ctrlKey, arrowDown], () => this._handleExpanderClick(false)) - .set([ctrlKey, arrowLeft], () => this._handleExpanderClick(true)) - .set([ctrlKey, arrowRight], () => this._handleExpanderClick(false)); + .set(arrowUp, this._handleResizePanes) + .set(arrowDown, this._handleResizePanes) + .set(arrowLeft, this._handleResizePanes) + .set(arrowRight, this._handleResizePanes) + .set([ctrlKey, arrowUp], this._handleExpandPanes) + .set([ctrlKey, arrowDown], this._handleExpandPanes) + .set([ctrlKey, arrowLeft], this._handleExpandPanes) + .set([ctrlKey, arrowRight], this._handleExpandPanes); //addThemingController(this, all); } @@ -157,7 +157,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< }); } - private resizePanes(event: KeyboardEvent) { + private _handleResizePanes(event: KeyboardEvent) { if (this._resizeDisallowed) { return false; } @@ -185,6 +185,29 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< return true; } + private _handleExpandPanes(event: KeyboardEvent) { + if (this._splitter?.nonCollapsible) { + return; + } + const { prevButtonHidden, nextButtonHidden } = + this._getExpanderHiddenState(); + + if ( + ((event.key === arrowUp && this._orientation === 'vertical') || + (event.key === arrowLeft && this._orientation === 'horizontal')) && + !prevButtonHidden + ) { + this._handleExpanderClick(true); + } + if ( + ((event.key === arrowDown && this._orientation === 'vertical') || + (event.key === arrowRight && this._orientation === 'horizontal')) && + !nextButtonHidden + ) { + this._handleExpanderClick(false); + } + } + private _createSiblingPaneMutationController(pane: IgcSplitterPaneComponent) { createMutationController(pane, { callback: () => { @@ -215,6 +238,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< const prevSibling = this._siblingPanes[0]!; const nextSibling = this._siblingPanes[1]!; + let target: IgcSplitterPaneComponent; if (start) { // if prev is clicked when next pane is hidden, show next pane, else hide prev pane. @@ -224,15 +248,23 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< target = prevSibling.collapsed ? prevSibling : nextSibling; } target.toggle(); + target.emitEvent('igcToggle', { detail: target }); + } + + private _getExpanderHiddenState() { + const [prev, next] = this._siblingPanes; + return { + prevButtonHidden: !!(prev?.collapsed && !next?.collapsed), + nextButtonHidden: !!(next?.collapsed && !prev?.collapsed), + }; } private _renderBarControls() { if (this._splitter?.nonCollapsible) { return nothing; } - const siblings = this._siblingPanes; - const prevButtonHidden = siblings[0]?.collapsed && !siblings[1]?.collapsed; - const nextButtonHidden = siblings[1]?.collapsed && !siblings[0]?.collapsed; + const { prevButtonHidden, nextButtonHidden } = + this._getExpanderHiddenState(); return html`
; +} +export default class IgcSplitterPaneComponent extends EventEmitterMixin< + IgcSplitterPaneComponentEventMap, + Constructor +>(LitElement) { public static readonly tagName = 'igc-splitter-pane'; - public static override styles = [styles]; + public static styles = [styles]; /* blazorSuppress */ public static register() { @@ -200,6 +208,10 @@ export default class IgcSplitterPaneComponent extends LitElement { this._prevPaneInitialSize = this._rectSize; this._nextPaneInitialSize = this._nextPane._rectSize; + + this._splitter!.emitEvent('igcResizeStart', { + detail: { pane: this, sibling: this._nextPane }, + }); } private _handleMoving(event: CustomEvent) { @@ -207,6 +219,10 @@ export default class IgcSplitterPaneComponent extends LitElement { this._prevPane.size = `${paneSize}px`; this._nextPane.size = `${siblingSize}px`; + + this._splitter!.emitEvent('igcResizing', { + detail: { pane: this, sibling: this._nextPane }, + }); } private _handleMovingEnd(event: CustomEvent) { @@ -238,6 +254,10 @@ export default class IgcSplitterPaneComponent extends LitElement { totalSize ); }); + + this._splitter!.emitEvent('igcResizeEnd', { + detail: { pane: this, sibling: this._nextPane }, + }); } private _adjustPaneSize( diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 8c9433e88..a05704707 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -5,10 +5,23 @@ import { splitterContext } from '../common/context.js'; import { addInternalsController } from '../common/controllers/internals.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import type { SplitterOrientation } from '../types.js'; import IgcSplitterPaneComponent from './splitter-pane.js'; import { styles } from './themes/splitter.base.css.js'; +export interface IgcSplitterBarResizeEventArgs { + pane: IgcSplitterPaneComponent; + sibling: IgcSplitterPaneComponent; +} + +export interface IgcSplitterComponentEventMap { + igcResizeStart: CustomEvent; + igcResizing: CustomEvent; + igcResizeEnd: CustomEvent; +} + /** * The Splitter component provides a framework for a simple layout, splitting the view horizontally or vertically * into multiple smaller resizable and collapsible areas. @@ -19,9 +32,12 @@ import { styles } from './themes/splitter.base.css.js'; * * @csspart ... - ... . */ -export default class IgcSplitterComponent extends LitElement { +export default class IgcSplitterComponent extends EventEmitterMixin< + IgcSplitterComponentEventMap, + Constructor +>(LitElement) { public static readonly tagName = 'igc-splitter'; - public static override styles = [styles]; + public static styles = [styles]; /* blazorSuppress */ public static register() { From 2e6130d3c35355c9fdd28d0e6d33f256524e6837 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Thu, 13 Nov 2025 14:20:21 +0200 Subject: [PATCH 18/47] refactor(splitter): rename to nonResizable; port skip fn for key bindings (wip) --- src/components/splitter/splitter-bar.ts | 38 +++++++++---------- src/components/splitter/splitter-pane.ts | 4 +- src/components/splitter/splitter.spec.ts | 28 +++++++------- .../splitter/themes/splitter-bar.base.scss | 1 + .../splitter/themes/splitter-pane.scss | 1 - .../splitter/themes/splitter.base.scss | 1 - stories/splitter.stories.ts | 30 +++++++-------- 7 files changed, 51 insertions(+), 52 deletions(-) diff --git a/src/components/splitter/splitter-bar.ts b/src/components/splitter/splitter-bar.ts index 3a7f43411..0411753fa 100644 --- a/src/components/splitter/splitter-bar.ts +++ b/src/components/splitter/splitter-bar.ts @@ -19,7 +19,7 @@ import { partMap } from '../common/part-map.js'; import { addResizeController } from '../resize-container/resize-controller.js'; import type { SplitterOrientation } from '../types.js'; import type IgcSplitterComponent from './splitter.js'; -import IgcSplitterPaneComponent from './splitter-pane.js'; +import type IgcSplitterPaneComponent from './splitter-pane.js'; import { styles } from './themes/splitter-bar.base.css.js'; export interface IgcSplitterBarComponentEventMap { @@ -84,7 +84,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< private get _resizeDisallowed() { return !!this._siblingPanes.find( - (x) => x && (x.resizable === false || x.collapsed === true) + (x) => x && (x.nonResizable || x.collapsed) ); } @@ -138,7 +138,7 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< cancel: () => {}, }); - addKeybindings(this) + addKeybindings(this, { skip: this._shouldSkipResize }) .set(arrowUp, this._handleResizePanes) .set(arrowDown, this._handleResizePanes) .set(arrowLeft, this._handleResizePanes) @@ -157,28 +157,30 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< }); } - private _handleResizePanes(event: KeyboardEvent) { - if (this._resizeDisallowed) { - return false; + private _shouldSkipResize(_node: Element, event: KeyboardEvent): boolean { + if (this._resizeDisallowed && !event.ctrlKey) { + return true; } if ( (event.key === arrowUp || event.key === arrowDown) && - this._orientation === 'horizontal' + this._orientation === 'horizontal' && + !event.ctrlKey ) { - return false; + return true; } if ( (event.key === arrowLeft || event.key === arrowRight) && - this._orientation === 'vertical' + this._orientation === 'vertical' && + !event.ctrlKey ) { - return false; - } - let delta = 0; - if (event.key === arrowUp || event.key === arrowLeft) { - delta = -10; - } else { - delta = 10; + return true; } + return false; + } + + private _handleResizePanes(event: KeyboardEvent) { + const delta = event.key === arrowUp || event.key === arrowLeft ? -10 : 10; + this.emitEvent('igcMovingStart', { detail: this._siblingPanes[0]! }); this.emitEvent('igcMoving', { detail: delta }); this.emitEvent('igcMovingEnd', { detail: delta }); @@ -214,10 +216,8 @@ export default class IgcSplitterBarComponent extends EventEmitterMixin< Object.assign(this._internalStyles, { '--cursor': this._cursor }); this.requestUpdate(); }, - filter: [IgcSplitterPaneComponent.tagName], config: { - attributeFilter: ['collapsed', 'resizable'], - subtree: true, + attributeFilter: ['collapsed', 'non-resizable'], }, }); } diff --git a/src/components/splitter/splitter-pane.ts b/src/components/splitter/splitter-pane.ts index 382143cf9..ff107f33c 100644 --- a/src/components/splitter/splitter-pane.ts +++ b/src/components/splitter/splitter-pane.ts @@ -124,8 +124,8 @@ export default class IgcSplitterPaneComponent extends EventEmitterMixin< * Defines if the pane is resizable or not. * @attr */ - @property({ type: Boolean, reflect: true }) - public resizable = true; + @property({ type: Boolean, reflect: true, attribute: 'non-resizable' }) + public nonResizable = false; /** * Collapsed state of the pane. diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index b107bb8e8..066cf4f24 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -129,13 +129,13 @@ describe('Splitter', () => { const style = getComputedStyle(firstBar); expect(style.cursor).to.equal('col-resize'); - firstPane.resizable = false; + firstPane.nonResizable = true; await elementUpdated(splitter); await nextFrame(); expect(style.cursor).to.equal('default'); - firstPane.resizable = true; + firstPane.nonResizable = false; secondPane.collapsed = true; await elementUpdated(splitter); await nextFrame(); @@ -249,8 +249,8 @@ describe('Splitter', () => { expect(splitter.panes[0].size).to.equal('30%'); expect(splitter.panes[1].size).to.equal('70%'); - expect(style1.flex).to.equal('1 1 30%'); - expect(style2.flex).to.equal('1 1 70%'); + expect(style1.flex).to.equal('0 1 30%'); + expect(style2.flex).to.equal('0 1 70%'); expect(pane1.minSize).to.equal('20%'); expect(pane1.maxSize).to.equal('80%'); @@ -323,7 +323,7 @@ describe('Splitter', () => { expect(pane.collapsed).to.be.false; }); - it('should toggle the previous pane when the bar expander-end is clicked', async () => { + it('should toggle the next pane when the bar expander-end is clicked', async () => { const bars = getSplitterBars(splitter); const firstBar = bars[0]; const firstPane = splitter.panes[0]; @@ -340,10 +340,10 @@ describe('Splitter', () => { await elementUpdated(splitter); await nextFrame(); - expect(firstPane.collapsed).to.be.true; - expect(secondPane.collapsed).to.be.false; - expect(expanderStart.hidden).to.be.true; - expect(expanderEnd.hidden).to.be.false; + expect(firstPane.collapsed).to.be.false; + expect(secondPane.collapsed).to.be.true; + expect(expanderStart.hidden).to.be.false; + expect(expanderEnd.hidden).to.be.true; expanderEnd.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }) @@ -357,7 +357,7 @@ describe('Splitter', () => { expect(expanderEnd.hidden).to.be.false; }); - it('should toggle the next pane when the bar expander-start is clicked', async () => { + it('should toggle the previous pane when the bar expander-start is clicked', async () => { const bars = getSplitterBars(splitter); const firstBar = bars[0]; const firstPane = splitter.panes[0]; @@ -374,10 +374,10 @@ describe('Splitter', () => { await elementUpdated(splitter); await nextFrame(); - expect(secondPane.collapsed).to.be.true; - expect(firstPane.collapsed).to.be.false; - expect(expanderStart.hidden).to.be.false; - expect(expanderEnd.hidden).to.be.true; + expect(firstPane.collapsed).to.be.true; + expect(secondPane.collapsed).to.be.false; + expect(expanderStart.hidden).to.be.true; + expect(expanderEnd.hidden).to.be.false; expanderStart.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }) diff --git a/src/components/splitter/themes/splitter-bar.base.scss b/src/components/splitter/themes/splitter-bar.base.scss index 21575d725..8191a5f07 100644 --- a/src/components/splitter/themes/splitter-bar.base.scss +++ b/src/components/splitter/themes/splitter-bar.base.scss @@ -2,6 +2,7 @@ :host { --bar-size: 5px; + display: flex; background-color: var(--ig-gray-200); diff --git a/src/components/splitter/themes/splitter-pane.scss b/src/components/splitter/themes/splitter-pane.scss index 9aa3b017a..74085da18 100644 --- a/src/components/splitter/themes/splitter-pane.scss +++ b/src/components/splitter/themes/splitter-pane.scss @@ -12,7 +12,6 @@ height: 100%; } - :host([collapsed]) [part='base'] { display: none; } diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index 6d219793a..97c29509b 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -32,5 +32,4 @@ [part='base'] { flex-direction: column; } - } diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 71a5a38b7..2bd38d63a 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -14,21 +14,21 @@ type SplitterStoryArgs = IgcSplitterComponent & { pane1MinSize?: string; pane1MaxSize?: string; pane1Collapsed?: boolean; - pane1Resizable?: boolean; + pane1NonResizable?: boolean; /* Pane 2 properties */ pane2Size?: string; pane2MinSize?: string; pane2MaxSize?: string; pane2Collapsed?: boolean; - pane2Resizable?: boolean; + pane2NonResizable?: boolean; /* Pane 3 properties */ pane3Size?: string; pane3MinSize?: string; pane3MaxSize?: string; pane3Collapsed?: boolean; - pane3Resizable?: boolean; + pane3NonResizable?: boolean; }; const metadata: Meta = { @@ -69,7 +69,7 @@ const metadata: Meta = { description: 'Collapsed state of the first pane', table: { category: 'Pane 1' }, }, - pane1Resizable: { + pane1NonResizable: { control: 'boolean', description: 'Whether the first pane is resizable', table: { category: 'Pane 1' }, @@ -94,7 +94,7 @@ const metadata: Meta = { description: 'Collapsed state of the second pane', table: { category: 'Pane 2' }, }, - pane2Resizable: { + pane2NonResizable: { control: 'boolean', description: 'Whether the second pane is resizable', table: { category: 'Pane 2' }, @@ -119,7 +119,7 @@ const metadata: Meta = { description: 'Collapsed state of the third pane', table: { category: 'Pane 3' }, }, - pane3Resizable: { + pane3NonResizable: { control: 'boolean', description: 'Whether the third pane is resizable', table: { category: 'Pane 3' }, @@ -129,13 +129,13 @@ const metadata: Meta = { orientation: 'horizontal', nonCollapsible: false, pane1Size: 'auto', - pane1Resizable: true, + pane1NonResizable: false, pane1Collapsed: false, pane2Size: 'auto', - pane2Resizable: true, + pane2NonResizable: false, pane2Collapsed: false, pane3Size: 'auto', - pane3Resizable: true, + pane3NonResizable: false, pane3Collapsed: false, }, }; @@ -165,17 +165,17 @@ export const Default: Story = { pane1MinSize, pane1MaxSize, pane1Collapsed, - pane1Resizable, + pane1NonResizable, pane2Size, pane2MinSize, pane2MaxSize, pane2Collapsed, - pane2Resizable, + pane2NonResizable, pane3Size, pane3MinSize, pane3MaxSize, pane3Collapsed, - pane3Resizable, + pane3NonResizable, }) => html` + + //
+ // + // + //
Pane 1
+ //
+ // + //
Pane 2
+ //
+ // + //
Pane 3
+ //
+ //
+ //
+ // Change All Panes Min/Max Sizes + // `, render: ({ orientation, nonCollapsible, - pane1Size, - pane1MinSize, - pane1MaxSize, - pane1Collapsed, - pane1NonResizable, - pane2Size, - pane2MinSize, - pane2MaxSize, - pane2Collapsed, - pane2NonResizable, - pane3Size, - pane3MinSize, - pane3MaxSize, - pane3Collapsed, - pane3NonResizable, + nonResizable, + startCollapsed, + endCollapsed, }) => html` - +
- -
Top Left Pane
-
+
Top Left Pane
- -
Bottom Left Pane
-
+
Bottom Left Pane
- +
- +
- -
Top Right Pane
-
- - -
Bottom Right Pane
-
+
Top Right Pane
+
Bottom Right Pane
- +
`, }; From c3ffbde9053ce0e8a2add9b088f065c8bd9b48d9 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Tue, 18 Nov 2025 11:04:39 +0200 Subject: [PATCH 20/47] refactor existing tests; add initial resize tests; minor refactor --- src/components/splitter/splitter.spec.ts | 556 +++++++++++------- src/components/splitter/splitter.ts | 77 ++- .../splitter/themes/splitter.base.scss | 4 +- stories/splitter.stories.ts | 264 +++------ 4 files changed, 465 insertions(+), 436 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 066cf4f24..5d89bae1b 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -6,24 +6,25 @@ import { nextFrame, } from '@open-wc/testing'; import { defineComponents } from '../common/definitions/defineComponents.js'; +import { roundPrecise } from '../common/util.js'; +import { + simulateLostPointerCapture, + simulatePointerDown, + simulatePointerMove, +} from '../common/utils.spec.js'; import type { SplitterOrientation } from '../types.js'; import IgcSplitterComponent from './splitter.js'; -import IgcSplitterBarComponent from './splitter-bar.js'; -import IgcSplitterPaneComponent from './splitter-pane.js'; describe('Splitter', () => { before(() => { - defineComponents( - IgcSplitterComponent, - IgcSplitterPaneComponent, - IgcSplitterBarComponent - ); + defineComponents(IgcSplitterComponent); }); let splitter: IgcSplitterComponent; beforeEach(async () => { splitter = await fixture(createSplitter()); + await elementUpdated(splitter); }); describe('Rendering', () => { @@ -37,19 +38,61 @@ describe('Splitter', () => { await expect(splitter).shadowDom.to.be.accessible(); }); - it('should render a split bar for each splitter pane except the last one', async () => { - await elementUpdated(splitter); + it('should render start and end slots', async () => { + let slot = getSplitterSlot(splitter, 'start'); + let elements = slot.assignedElements(); + expect(elements).to.have.lengthOf(1); + expect(elements[0].textContent).to.equal('Pane 1'); + + slot = getSplitterSlot(splitter, 'end'); + elements = slot.assignedElements(); + expect(elements).to.have.lengthOf(1); + expect(elements[0].textContent).to.equal('Pane 2'); + }); - expect(splitter.panes).to.have.lengthOf(3); + it('should render splitter bar between start and end parts', async () => { + const base = getSplitterPart(splitter, 'base'); + const startPart = getSplitterPart(splitter, 'startPane'); + const endPart = getSplitterPart(splitter, 'endPane'); + const bar = getSplitterPart(splitter, 'bar'); - const bars = getSplitterBars(splitter); - expect(bars).to.have.lengthOf(2); + expect(base).to.exist; + expect(startPart).to.exist; + expect(endPart).to.exist; + expect(bar).to.exist; - bars.forEach((bar, index) => { - const pane = splitter.panes[index]; - const paneBase = getSplitterPaneBase(pane) as HTMLElement; - expect(bar.previousElementSibling).to.equal(paneBase); - }); + expect(base.contains(startPart)).to.be.true; + expect(base.contains(endPart)).to.be.true; + expect(base.contains(bar)).to.be.true; + + expect(startPart.nextElementSibling).to.equal(bar); + expect(bar.nextElementSibling).to.equal(endPart); + }); + + it('should render splitter bar parts', async () => { + const bar = getSplitterPart(splitter, 'bar'); + const expanderStart = getSplitterPart(splitter, 'expander-start'); + const barHandle = getSplitterPart(splitter, 'handle'); + const expanderEnd = getSplitterPart(splitter, 'expander-end'); + + expect(expanderStart).to.exist; + expect(barHandle).to.exist; + expect(expanderEnd).to.exist; + + expect(bar.contains(expanderStart)).to.be.true; + expect(bar.contains(expanderEnd)).to.be.true; + expect(bar.contains(barHandle)).to.be.true; + + expect(expanderStart.nextElementSibling).to.equal(barHandle); + expect(barHandle.nextElementSibling).to.equal(expanderEnd); + }); + + it('should not display the bar elements if the splitter is nonCollapsible', async () => { + splitter.nonCollapsible = true; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, 'bar'); + expect(bar.children).to.have.lengthOf(0); }); it('should have default horizontal orientation', () => { @@ -72,82 +115,91 @@ describe('Splitter', () => { ); await elementUpdated(nestedSplitter); - expect(nestedSplitter.panes).to.have.lengthOf(2); - expect(nestedSplitter.orientation).to.equal('horizontal'); - - const outerBars = getSplitterBars(nestedSplitter); - expect(outerBars).to.have.lengthOf(1); - - const firstPane = nestedSplitter.panes[0]; - const leftSplitter = firstPane.querySelector( - IgcSplitterComponent.tagName - ) as IgcSplitterComponent; - - expect(leftSplitter).to.exist; - expect(leftSplitter.orientation).to.equal('vertical'); - - expect(leftSplitter.panes).to.have.lengthOf(2); - - const leftBars = getSplitterBars(leftSplitter); - expect(leftBars).to.have.lengthOf(1); - - const secondPane = nestedSplitter.panes[1]; - const rightSplitter = secondPane.querySelector( - IgcSplitterComponent.tagName - ) as IgcSplitterComponent; + const outerStartSlot = getSplitterSlot(nestedSplitter, 'start'); + const startElements = outerStartSlot.assignedElements(); + expect(startElements).to.have.lengthOf(1); + expect(startElements[0].tagName.toLowerCase()).to.equal( + IgcSplitterComponent.tagName.toLowerCase() + ); - expect(rightSplitter).to.exist; - expect(rightSplitter.orientation).to.equal('vertical'); + const outerEndSlot = getSplitterSlot(nestedSplitter, 'end'); + const endElements = outerEndSlot.assignedElements(); + expect(endElements).to.have.lengthOf(1); + expect(endElements[0].tagName.toLowerCase()).to.equal( + IgcSplitterComponent.tagName.toLowerCase() + ); - expect(rightSplitter.panes).to.have.lengthOf(2); + const innerStartSlot1 = getSplitterSlot( + startElements[0] as IgcSplitterComponent, + 'start' + ); + expect(innerStartSlot1.assignedElements()[0].textContent).to.equal( + 'Top Left Pane' + ); - const rightBars = getSplitterBars(rightSplitter); - expect(rightBars).to.have.lengthOf(1); - }); + const innerEndSlot1 = getSplitterSlot( + startElements[0] as IgcSplitterComponent, + 'end' + ); + expect(innerEndSlot1.assignedElements()[0].textContent).to.equal( + 'Bottom Left Pane' + ); - it('should not display the bar elements if the splitter is nonCollapsible', async () => { - splitter.nonCollapsible = true; - await elementUpdated(splitter); + const innerStartSlot2 = getSplitterSlot( + endElements[0] as IgcSplitterComponent, + 'start' + ); + expect(innerStartSlot2.assignedElements()[0].textContent).to.equal( + 'Top Right Pane' + ); - const bars = getSplitterBars(splitter); - bars.forEach((bar) => { - const base = bar.shadowRoot!.querySelector( - '[part~="base"]' - ) as HTMLElement; - expect(base.children).to.have.lengthOf(0); - }); + const innerEndSlot2 = getSplitterSlot( + endElements[0] as IgcSplitterComponent, + 'end' + ); + expect(innerEndSlot2.assignedElements()[0].textContent).to.equal( + 'Bottom Right Pane' + ); }); - it('should set a default cursor on the bar in case any of its siblings is not resizable or collapsed', async () => { - const firstPane = splitter.panes[0]; - const secondPane = splitter.panes[1]; - const bars = getSplitterBars(splitter); - const firstBar = bars[0].shadowRoot!.querySelector( - '[part~="base"]' - ) as HTMLElement; + it('should set a default cursor on the bar in case splitter is not resizable or any pane is collapsed', async () => { + const bar = getSplitterPart(splitter, 'bar'); - const style = getComputedStyle(firstBar); + const style = getComputedStyle(bar); expect(style.cursor).to.equal('col-resize'); - firstPane.nonResizable = true; + splitter.nonResizable = true; await elementUpdated(splitter); await nextFrame(); expect(style.cursor).to.equal('default'); - firstPane.nonResizable = false; - secondPane.collapsed = true; + splitter.nonResizable = false; + splitter.endCollapsed = true; await elementUpdated(splitter); await nextFrame(); expect(style.cursor).to.equal('default'); - secondPane.collapsed = false; + splitter.endCollapsed = false; await elementUpdated(splitter); await nextFrame(); expect(style.cursor).to.equal('col-resize'); }); + + it('should change the bar cursor based on the orientation', async () => { + const bar = getSplitterPart(splitter, 'bar'); + + const style = getComputedStyle(bar); + expect(style.cursor).to.equal('col-resize'); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + await nextFrame(); + + expect(style.cursor).to.equal('row-resize'); + }); }); describe('Properties', () => { @@ -160,30 +212,29 @@ describe('Splitter', () => { }); it('should reset pane sizes when orientation changes', async () => { - const pane = splitter.panes[0]; - pane.size = '200px'; + splitter.startSize = '200px'; await elementUpdated(splitter); - const base = getSplitterPaneBase(pane) as HTMLElement; - const style = getComputedStyle(base); + const startPart = getSplitterPart(splitter, 'startPane'); + const style = getComputedStyle(startPart); expect(style.flex).to.equal('0 0 200px'); splitter.orientation = 'vertical'; await elementUpdated(splitter); - expect(pane.size).to.equal('auto'); + expect(splitter.startSize).to.equal('auto'); + expect(style.flex).to.equal('1 1 auto'); }); - it('should use default min/max values when not specified', async () => { + // TODO: verify the attribute type, default value, reflection + it('should properly set default min/max values when not specified', async () => { await elementUpdated(splitter); - const pane = splitter.panes[0]; - const base = getSplitterPaneBase(pane) as HTMLElement; - const style = getComputedStyle(base); + const startPart = getSplitterPart(splitter, 'startPane'); + const style = getComputedStyle(startPart); expect(style.flex).to.equal('1 1 auto'); - expect(pane.size).to.equal('auto'); - + expect(splitter.startSize).to.equal('auto'); expect(style.minWidth).to.equal('0px'); expect(style.maxWidth).to.equal('100%'); @@ -197,16 +248,15 @@ describe('Splitter', () => { it('should apply minSize and maxSize to panes for horizontal orientation', async () => { splitter = await fixture( createTwoPanesWithSizesAndConstraints({ - minSize1: '100px', - maxSize1: '500px', + startMinSize: '100px', + startMaxSize: '500px', }) ); await elementUpdated(splitter); - const pane = splitter.panes[0]; - const base = getSplitterPaneBase(pane) as HTMLElement; - const style = getComputedStyle(base); + const startPane = getSplitterPart(splitter, 'startPane'); + const style = getComputedStyle(startPane); expect(style.minWidth).to.equal('100px'); expect(style.maxWidth).to.equal('500px'); }); @@ -214,16 +264,15 @@ describe('Splitter', () => { it('should apply minSize and maxSize to panes for vertical orientation', async () => { splitter = await fixture( createTwoPanesWithSizesAndConstraints({ - minSize1: '100px', - maxSize1: '500px', + startMinSize: '100px', + startMaxSize: '500px', orientation: 'vertical', }) ); await elementUpdated(splitter); - const pane = splitter.panes[0]; - const base = getSplitterPaneBase(pane) as HTMLElement; - const style = getComputedStyle(base); + const startPane = getSplitterPart(splitter, 'startPane'); + const style = getComputedStyle(startPane); expect(style.minHeight).to.equal('100px'); expect(style.maxHeight).to.equal('500px'); }); @@ -231,29 +280,27 @@ describe('Splitter', () => { it('should handle percentage sizes', async () => { const splitter = await fixture( createTwoPanesWithSizesAndConstraints({ - size1: '30%', - size2: '70%', - minSize1: '20%', - maxSize1: '80%', + startSize: '30%', + endSize: '70%', + startMinSize: '20%', + startMaxSize: '80%', }) ); await elementUpdated(splitter); - const pane1 = splitter.panes[0]; - const base1 = getSplitterPaneBase(pane1) as HTMLElement; - const style1 = getComputedStyle(base1); + const startPane = getSplitterPart(splitter, 'startPane'); + const style1 = getComputedStyle(startPane); - const pane2 = splitter.panes[1]; - const base2 = getSplitterPaneBase(pane2) as HTMLElement; - const style2 = getComputedStyle(base2); + const endPane = getSplitterPart(splitter, 'endPane'); + const style2 = getComputedStyle(endPane); - expect(splitter.panes[0].size).to.equal('30%'); - expect(splitter.panes[1].size).to.equal('70%'); + expect(splitter.startSize).to.equal('30%'); + expect(splitter.endSize).to.equal('70%'); expect(style1.flex).to.equal('0 1 30%'); expect(style2.flex).to.equal('0 1 70%'); - expect(pane1.minSize).to.equal('20%'); - expect(pane1.maxSize).to.equal('80%'); + expect(splitter.startMinSize).to.equal('20%'); + expect(splitter.startMaxSize).to.equal('80%'); expect(style1.minWidth).to.equal('20%'); expect(style1.maxWidth).to.equal('80%'); @@ -263,76 +310,48 @@ describe('Splitter', () => { it('should handle mixed px and % constraints', async () => { const mixedConstraintSplitter = await fixture( createTwoPanesWithSizesAndConstraints({ - minSize1: '100px', - maxSize1: '50%', + startMinSize: '100px', + startMaxSize: '50%', }) ); await elementUpdated(mixedConstraintSplitter); - const pane = mixedConstraintSplitter.panes[0]; - const base1 = getSplitterPaneBase(pane) as HTMLElement; - const style = getComputedStyle(base1); + const startPane = getSplitterPart(mixedConstraintSplitter, 'startPane'); + const style = getComputedStyle(startPane); - expect(pane.minSize).to.equal('100px'); - expect(pane.maxSize).to.equal('50%'); + expect(mixedConstraintSplitter.startMinSize).to.equal('100px'); + expect(mixedConstraintSplitter.startMaxSize).to.equal('50%'); expect(style.minWidth).to.equal('100px'); expect(style.maxWidth).to.equal('50%'); // TODO: test with drag }); - - it('should dynamically update when panes are added', async () => { - expect(splitter.panes).to.have.lengthOf(3); - - const newPane = document.createElement( - 'igc-splitter-pane' - ) as IgcSplitterPaneComponent; - newPane.textContent = 'New Pane'; - splitter.appendChild(newPane); - - await elementUpdated(splitter); - - expect(splitter.panes).to.have.lengthOf(4); - }); - - it('should dynamically update when panes are removed', async () => { - expect(splitter.panes).to.have.lengthOf(3); - - const paneToRemove = splitter.panes[1]; - paneToRemove.remove(); - - await elementUpdated(splitter); - - expect(splitter.panes).to.have.lengthOf(2); - }); }); describe('Methods, Events & Interactions', () => { it('should expand/collapse panes when toggle is invoked', async () => { - const pane = splitter.panes[0]; - expect(pane.collapsed).to.be.false; - - pane.toggle(); + splitter.toggle('start'); await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.true; - expect(pane.collapsed).to.be.true; + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.false; - pane.toggle(); + splitter.toggle('end'); await elementUpdated(splitter); + expect(splitter.endCollapsed).to.be.true; - expect(pane.collapsed).to.be.false; + // edge case: supports collapsing both at a time? + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.true; }); it('should toggle the next pane when the bar expander-end is clicked', async () => { - const bars = getSplitterBars(splitter); - const firstBar = bars[0]; - const firstPane = splitter.panes[0]; - const secondPane = splitter.panes[1]; - - expect(firstPane.collapsed).to.be.false; - - const expanderStart = getExpander(firstBar, 'start'); - const expanderEnd = getExpander(firstBar, 'end'); + const expanderStart = getSplitterPart(splitter, 'expander-start'); + const expanderEnd = getSplitterPart(splitter, 'expander-end'); expanderEnd.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }) @@ -340,8 +359,8 @@ describe('Splitter', () => { await elementUpdated(splitter); await nextFrame(); - expect(firstPane.collapsed).to.be.false; - expect(secondPane.collapsed).to.be.true; + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; expect(expanderStart.hidden).to.be.false; expect(expanderEnd.hidden).to.be.true; @@ -351,22 +370,15 @@ describe('Splitter', () => { await elementUpdated(splitter); await nextFrame(); - expect(firstPane.collapsed).to.be.false; - expect(secondPane.collapsed).to.be.false; + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; expect(expanderStart.hidden).to.be.false; expect(expanderEnd.hidden).to.be.false; }); it('should toggle the previous pane when the bar expander-start is clicked', async () => { - const bars = getSplitterBars(splitter); - const firstBar = bars[0]; - const firstPane = splitter.panes[0]; - const secondPane = splitter.panes[1]; - - expect(secondPane.collapsed).to.be.false; - - const expanderStart = getExpander(firstBar, 'start'); - const expanderEnd = getExpander(firstBar, 'end'); + const expanderStart = getSplitterPart(splitter, 'expander-start'); + const expanderEnd = getSplitterPart(splitter, 'expander-end'); expanderStart.dispatchEvent( new PointerEvent('pointerdown', { bubbles: true }) @@ -374,8 +386,8 @@ describe('Splitter', () => { await elementUpdated(splitter); await nextFrame(); - expect(firstPane.collapsed).to.be.true; - expect(secondPane.collapsed).to.be.false; + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; expect(expanderStart.hidden).to.be.true; expect(expanderEnd.hidden).to.be.false; @@ -385,20 +397,89 @@ describe('Splitter', () => { await elementUpdated(splitter); await nextFrame(); - expect(firstPane.collapsed).to.be.false; - expect(secondPane.collapsed).to.be.false; + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; expect(expanderStart.hidden).to.be.false; expect(expanderEnd.hidden).to.be.false; }); + + it('should resize horizontally in both directions', async () => { + const startPane = getSplitterPart(splitter, 'startPane'); + const endPane = getSplitterPart(splitter, 'endPane'); + const startSizeBefore = roundPrecise( + startPane.getBoundingClientRect().width + ); + const endSizeBefore = roundPrecise(endPane.getBoundingClientRect().width); + let deltaX = 100; + + await resize(splitter, deltaX, 0); + await elementUpdated(splitter); + + const startSizeAfter = roundPrecise( + startPane.getBoundingClientRect().width + ); + const endSizeAfter = roundPrecise(endPane.getBoundingClientRect().width); + + expect(startSizeAfter).to.equal(startSizeBefore + deltaX); + expect(endSizeAfter).to.equal(endSizeBefore - deltaX); + + deltaX *= -1; + await resize(splitter, deltaX, 0); + + const startSizeFinal = roundPrecise( + startPane.getBoundingClientRect().width + ); + const endSizeFinal = roundPrecise(endPane.getBoundingClientRect().width); + + expect(startSizeFinal).to.equal(startSizeBefore); + expect(endSizeFinal).to.equal(endSizeBefore); + }); + + it('should resize vertically in both directions', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + const startPane = getSplitterPart(splitter, 'startPane'); + const endPane = getSplitterPart(splitter, 'endPane'); + const startSizeBefore = roundPrecise( + startPane.getBoundingClientRect().height + ); + const endSizeBefore = roundPrecise( + endPane.getBoundingClientRect().height + ); + let deltaY = 100; + + await resize(splitter, 0, deltaY); + + const startSizeAfter = roundPrecise( + startPane.getBoundingClientRect().height + ); + const endSizeAfter = roundPrecise(endPane.getBoundingClientRect().height); + + expect(startSizeAfter).to.equal(startSizeBefore + deltaY); + expect(endSizeAfter).to.equal(endSizeBefore - deltaY); + + deltaY *= -1; + await resize(splitter, 0, deltaY); + + const startSizeFinal = roundPrecise( + startPane.getBoundingClientRect().height + ); + const endSizeFinal = roundPrecise(endPane.getBoundingClientRect().height); + + expect(startSizeFinal).to.equal(startSizeBefore); + expect(endSizeFinal).to.equal(endSizeBefore); + }); + // TODO: test when the slots have assigned sizes/min sizes + edge cases + // currently observing issue when resizing to the end and panes have fixed px sizes }); }); function createSplitter() { return html` - - Pane 1 - Pane 2 - Pane 3 + +
Pane 1
+
Pane 2
`; } @@ -406,29 +487,25 @@ function createSplitter() { function createNestedSplitter() { return html` - - - Top Left Pane - Bottom Left Pane - - - - - Top Right Pane - Bottom Right Pane - - + +
Top Left Pane
+
Bottom Left Pane
+
+ +
Top Right Pane
+
Bottom Right Pane
+
`; } type SplitterTestSizesAndConstraints = { - size1?: string; - size2?: string; - minSize1?: string; - maxSize1?: string; - minSize2?: string; - maxSize2?: string; + startSize?: string; + endSize?: string; + startMinSize?: string; + startMaxSize?: string; + endMinSize?: string; + endMaxSize?: string; orientation?: SplitterOrientation; }; @@ -436,43 +513,82 @@ function createTwoPanesWithSizesAndConstraints( config: SplitterTestSizesAndConstraints ) { return html` - - - Pane 1 - - - Pane 2 - + +
Pane 1
+
Pane 2
`; } -function getSplitterPaneBase(pane: IgcSplitterPaneComponent) { - return pane.shadowRoot!.querySelector('div[part~="base"]'); +function getSplitterSlot( + splitter: IgcSplitterComponent, + which: 'start' | 'end' +) { + return splitter.renderRoot.querySelector( + `slot[name="${which}"]` + ) as HTMLSlotElement; +} + +// TODO: more parts and names? +type SplitterParts = + | 'startPane' + | 'endPane' + | 'bar' + | 'base' + | 'expander-start' + | 'expander-end' + | 'handle'; + +function getSplitterPart(splitter: IgcSplitterComponent, which: SplitterParts) { + return splitter.shadowRoot!.querySelector( + `[part~="${which}"]` + ) as HTMLElement; } -function getSplitterBars(splitter: IgcSplitterComponent) { - const bars: IgcSplitterBarComponent[] = []; +async function resize( + splitter: IgcSplitterComponent, + deltaX: number, + deltaY: number +) { + const bar = getSplitterPart(splitter, 'bar'); + const barRect = bar.getBoundingClientRect(); - splitter.panes.forEach((pane) => { - const bar = pane.shadowRoot!.querySelector(IgcSplitterBarComponent.tagName); - if (bar) { - bars.push(bar); - } + simulatePointerDown(bar, { + clientX: barRect.left, + clientY: barRect.top, }); - return bars; + await elementUpdated(splitter); + + simulatePointerMove( + bar, + { + clientX: barRect.left, + clientY: barRect.top, + }, + { x: deltaX, y: deltaY } + ); + await elementUpdated(splitter); + + simulateLostPointerCapture(bar); + await elementUpdated(splitter); + await nextFrame(); } -function getExpander(bar: IgcSplitterBarComponent, which: 'start' | 'end') { - return bar.shadowRoot!.querySelector( - `[part="expander-${which}"]` - ) as HTMLElement; -} +// function checkPanesAreWithingBounds( +// splitter: IgcSplitterComponent, +// startSize: number, +// endSize: number, +// dimension: 'x' | 'y' +// ) { +// const splitterSize = +// splitter.getBoundingClientRect()[dimension === 'x' ? 'width' : 'height']; +// expect(startSize + endSize).to.be.at.most(splitterSize); +// } diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 007b10e21..eee3d88ce 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -56,6 +56,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private readonly _barRef = createRef(); private _startPaneInternalStyles: StyleInfo = {}; private _endPaneInternalStyles: StyleInfo = {}; + private _barInternalStyles: StyleInfo = {}; private _startSize = 'auto'; private _endSize = 'auto'; private _startPaneInitialSize!: number; @@ -135,6 +136,13 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return `${grow} ${shrink} ${this._endSize}`; } + private get _barCursor(): string { + if (this._resizeDisallowed) { + return 'default'; + } + return this.orientation === 'horizontal' ? 'col-resize' : 'row-resize'; + } + /** * The minimum size of the start pane. * @attr @@ -216,7 +224,13 @@ export default class IgcSplitterComponent extends EventEmitterMixin< @watch('orientation', { waitUntilFirstUpdate: true }) protected _orientationChange(): void { this._internals.setARIA({ ariaOrientation: this.orientation }); - this._resetPane(); + Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); + this._resetPanes(); + } + + @watch('nonResizable') + protected _changeCursor(): void { + Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); } @watch('startCollapsed', { waitUntilFirstUpdate: true }) @@ -224,6 +238,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< protected _collapsedChange(): void { this.startSize = 'auto'; this.endSize = 'auto'; + this._changeCursor(); } protected override willUpdate(changed: PropertyValues) { @@ -235,18 +250,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< changed.has('endMinSize') || changed.has('endMaxSize') ) { - this._initPane( - this.startMinSize!, - this.startMaxSize!, - this._startFlex, - this._startPaneInternalStyles - ); - this._initPane( - this.endMinSize!, - this.endMaxSize!, - this._endFlex, - this._endPaneInternalStyles - ); + this._initPanes(); } } @@ -301,6 +305,10 @@ export default class IgcSplitterComponent extends EventEmitterMixin< }); } + protected override firstUpdated() { + this._initPanes(); + } + //#endregion /** Toggles the collapsed state of the pane. */ @@ -473,44 +481,46 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return size - barSize; } - private _resetPane() { + private _resetPanes() { this.startSize = 'auto'; this.endSize = 'auto'; - Object.assign(this._startPaneInternalStyles, { + const commonStyles = { minWidth: 0, maxWidth: '100%', minHeight: 0, maxHeight: '100%', + }; + Object.assign(this._startPaneInternalStyles, { + ...commonStyles, flex: this._startFlex, }); Object.assign(this._endPaneInternalStyles, { - minWidth: 0, - maxWidth: '100%', - minHeight: 0, - maxHeight: '100%', + ...commonStyles, flex: this._endFlex, }); } - private _initPane( - minSize: string, - maxSize: string, - flex: string, - internalStyles: StyleInfo - ) { + private _initPanes() { let sizes = {}; if (this.orientation === 'horizontal') { sizes = { - minWidth: minSize ?? 0, - maxWidth: maxSize ?? '100%', + minWidth: this.startMinSize ?? 0, + maxWidth: this.startMaxSize ?? '100%', }; } else { sizes = { - minHeight: minSize ?? 0, - maxHeight: maxSize ?? '100%', + minHeight: this.startMinSize ?? 0, + maxHeight: this.startMaxSize ?? '100%', }; } - Object.assign(internalStyles, { ...sizes, flex: flex }); + Object.assign(this._startPaneInternalStyles, { + ...sizes, + flex: this._startFlex, + }); + Object.assign(this._endPaneInternalStyles, { + ...sizes, + flex: this._endFlex, + }); this.requestUpdate(); } @@ -563,7 +573,12 @@ export default class IgcSplitterComponent extends EventEmitterMixin<
-
+
${this._renderBarControls()}
diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index e89185972..5f6c3b12d 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -33,6 +33,7 @@ height: 100%; min-width: 0; min-height: 0; + box-sizing: border-box; } // Bar styles (moved from splitter-bar.base.scss) @@ -79,7 +80,6 @@ flex-direction: column; width: var(--bar-size); height: 100%; - --cursor: col-resize; [part='handle'] { height: 50px; @@ -96,8 +96,8 @@ [part='bar'] { flex-direction: row; width: 100%; + height: var(--bar-size); - --cursor: row-resize; [part='handle'] { width: 50px; diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index c23bbff33..82c9bad8a 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -7,30 +7,7 @@ import { disableStoryControls } from './story.js'; defineComponents(IgcSplitterComponent); -type SplitterStoryArgs = IgcSplitterComponent & { - /* Pane 1 properties */ - pane1Size?: string; - pane1MinSize?: string; - pane1MaxSize?: string; - pane1Collapsed?: boolean; - pane1NonResizable?: boolean; - - /* Pane 2 properties */ - pane2Size?: string; - pane2MinSize?: string; - pane2MaxSize?: string; - pane2Collapsed?: boolean; - pane2NonResizable?: boolean; - - /* Pane 3 properties */ - pane3Size?: string; - pane3MinSize?: string; - pane3MaxSize?: string; - pane3Collapsed?: boolean; - pane3NonResizable?: boolean; -}; - -const metadata: Meta = { +const metadata: Meta = { title: 'Splitter', component: 'igc-splitter', parameters: { @@ -48,80 +25,52 @@ const metadata: Meta = { description: 'Orientation of the splitter.', table: { defaultValue: { summary: 'horizontal' } }, }, - pane1Size: { - control: 'text', - description: 'Size of the first pane (e.g., "auto", "100px", "30%")', - table: { category: 'Pane 1' }, - }, - pane1MinSize: { - control: 'text', - description: 'Minimum size of the first pane', - table: { category: 'Pane 1' }, - }, - pane1MaxSize: { - control: 'text', - description: 'Maximum size of the first pane', - table: { category: 'Pane 1' }, - }, - pane1Collapsed: { + nonCollapsible: { + type: 'boolean', + description: 'Disables pane collapsing.', control: 'boolean', - description: 'Collapsed state of the first pane', - table: { category: 'Pane 1' }, + table: { defaultValue: { summary: 'false' } }, }, - pane1NonResizable: { + nonResizable: { + type: 'boolean', + description: 'Disables pane resizing.', control: 'boolean', - description: 'Whether the first pane is resizable', - table: { category: 'Pane 1' }, + table: { defaultValue: { summary: 'false' } }, }, - pane2Size: { - control: 'text', - description: 'Size of the second pane (e.g., "auto", "100px", "30%")', - table: { category: 'Pane 2' }, + startCollapsed: { + type: 'boolean', + description: 'Collapses the start pane.', + table: { defaultValue: { summary: 'false' } }, }, - pane2MinSize: { - control: 'text', - description: 'Minimum size of the second pane', - table: { category: 'Pane 2' }, - }, - pane2MaxSize: { - control: 'text', - description: 'Maximum size of the second pane', - table: { category: 'Pane 2' }, - }, - pane2Collapsed: { + endCollapsed: { + type: 'boolean', + description: 'Collapses the end pane.', control: 'boolean', - description: 'Collapsed state of the second pane', - table: { category: 'Pane 2' }, + table: { defaultValue: { summary: 'false' } }, }, - pane2NonResizable: { - control: 'boolean', - description: 'Whether the second pane is resizable', - table: { category: 'Pane 2' }, + startSize: { + control: { type: 'text' }, + description: 'Size of the start pane (e.g., "200px", "50%", "auto").', }, - pane3Size: { - control: 'text', - description: 'Size of the third pane (e.g., "auto", "100px", "30%")', - table: { category: 'Pane 3' }, + endSize: { + control: { type: 'text' }, + description: 'Size of the end pane (e.g., "200px", "50%", "auto").', }, - pane3MinSize: { - control: 'text', - description: 'Minimum size of the third pane', - table: { category: 'Pane 3' }, + startMinSize: { + control: { type: 'text' }, + description: 'Minimum size of the start pane.', }, - pane3MaxSize: { - control: 'text', - description: 'Maximum size of the third pane', - table: { category: 'Pane 3' }, + startMaxSize: { + control: { type: 'text' }, + description: 'Maximum size of the start pane.', }, - pane3Collapsed: { - control: 'boolean', - description: 'Collapsed state of the third pane', - table: { category: 'Pane 3' }, + endMinSize: { + control: { type: 'text' }, + description: 'Minimum size of the end pane.', }, - pane3NonResizable: { - control: 'boolean', - description: 'Whether the third pane is resizable', - table: { category: 'Pane 3' }, + endMaxSize: { + control: { type: 'text' }, + description: 'Maximum size of the end pane.', }, }, args: { @@ -130,112 +79,51 @@ const metadata: Meta = { nonResizable: false, startCollapsed: false, endCollapsed: false, - pane1Size: 'auto', - pane1NonResizable: false, - pane1Collapsed: false, - pane2Size: 'auto', - pane2NonResizable: false, - pane2Collapsed: false, - pane3Size: 'auto', - pane3NonResizable: false, - pane3Collapsed: false, }, }; export default metadata; -type Story = StoryObj; -// function changePaneMinMaxSizes() { -// const splitter = document.querySelector('igc-splitter'); -// const panes = splitter?.panes; -// if (!panes) { -// return; -// } -// panes[0].minSize = '50px'; -// panes[0].maxSize = '200px'; -// panes[1].minSize = '100px'; -// panes[1].maxSize = '300px'; -// panes[2].minSize = '150px'; -// panes[2].maxSize = '450px'; -// } +interface IgcSplitterArgs { + orientation: 'horizontal' | 'vertical'; + nonCollapsible: boolean; + nonResizable: boolean; + startCollapsed: boolean; + endCollapsed: boolean; + startSize?: string; + endSize?: string; + startMinSize?: string; + startMaxSize?: string; + endMinSize?: string; + endMaxSize?: string; +} + +type Story = StoryObj; + +function changePaneMinMaxSizes() { + const splitter = document.querySelector('igc-splitter'); + if (!splitter) { + return; + } + splitter.startMinSize = '50px'; + splitter.startMaxSize = '200px'; + splitter.endMinSize = '100px'; + splitter.endMaxSize = '300px'; +} export const Default: Story = { - // render: ({ - // orientation, - // nonCollapsible, - // pane1Size, - // pane1MinSize, - // pane1MaxSize, - // pane1Collapsed, - // pane1NonResizable, - // pane2Size, - // pane2MinSize, - // pane2MaxSize, - // pane2Collapsed, - // pane2NonResizable, - // pane3Size, - // pane3MinSize, - // pane3MaxSize, - // pane3Collapsed, - // pane3NonResizable, - // }) => html` - // - - //
- // - // - //
Pane 1
- //
- // - //
Pane 2
- //
- // - //
Pane 3
- //
- //
- //
- // Change All Panes Min/Max Sizes - // `, render: ({ orientation, nonCollapsible, nonResizable, startCollapsed, endCollapsed, + startSize, + endSize, + startMinSize, + startMaxSize, + endMinSize, + endMaxSize, }) => html` @@ -152,15 +166,44 @@ export const Default: Story = { .endMinSize=${endMinSize} .endMaxSize=${endMaxSize} > -
Pane 1
-
Pane 2
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque + scelerisque elementum ante, et tincidunt eros ultrices sit amet. + Mauris non consectetur nunc. In hac habitasse platea dictumst. + Pellentesque ornare et tellus sit amet varius. Nulla in augue rhoncus, + finibus mauris semper, tincidunt sem. Cras vitae semper neque, eget + tempus massa. Maecenas gravida turpis quis interdum bibendum. Nam quis + ultricies est. Fusce ante erat, iaculis quis iaculis ut, iaculis sed + nunc. Cras iaculis condimentum lacus nec tempus. Nam ex massa, mattis + vitae iaculis in, suscipit ut nibh. +
+
+ Maecenas sit amet ipsum non ipsum scelerisque varius. Maecenas + scelerisque nisl scelerisque nulla ultricies eleifend. Aliquam sit + amet velit mauris. Duis at nulla vitae risus condimentum semper. Nam + ornare arcu vitae euismod pharetra. Morbi facilisis tincidunt lorem at + consequat. Aliquam varius quam non eros suscipit, ac tincidunt sapien + porttitor. Sed sed lorem quam. Praesent blandit aliquam arcu a + vestibulum. Mauris porta faucibus ex in vehicula. Pellentesque ut + risus quis felis molestie facilisis eget et est. Proin interdum urna + vitae porttitor suscipit. Curabitur lobortis aliquet dolor sit amet + varius. Proin a semper velit, non molestie libero. Suspendisse + potenti. Aliquam vestibulum dui id lacus suscipit, eget posuere justo + venenatis. Vestibulum id velit ac dui posuere pretium. +
Change All Panes Min/Max SizesChange All Panes Min/Max Sizes (px) + Change All Panes Min/Max Sizes (%) `, }; From 15a03e77b1406b38be51bbd8d2fbe237942884ea Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 5 Dec 2025 11:13:07 +0200 Subject: [PATCH 28/47] chore(splitter): apply code suggestions; reuse similar tests in functions --- src/components/splitter/splitter.spec.ts | 337 ++++++++++------------- src/components/splitter/splitter.ts | 4 +- 2 files changed, 148 insertions(+), 193 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 3d851b7ac..8c40b00a5 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -801,218 +801,173 @@ describe('Splitter', () => { }); }); - // TODO: test when the slots have assigned sizes/min sizes + edge cases describe('Resizing with constraints and edge cases', () => { - describe('Horizontal orientation', () => { - it('should honor minSize and maxSize constraints when resizing, constraints in px', async () => { - const mixedConstraintSplitter = await fixture( - createTwoPanesWithSizesAndConstraints({ - startSize: '200px', - startMinSize: '100px', - startMaxSize: '300px', - endSize: '200px', - endMinSize: '100px', - endMaxSize: '300px', - }) - ); - await elementUpdated(mixedConstraintSplitter); + const testMinMaxConstraintsPx = async ( + orientation: SplitterOrientation + ) => { + const mixedConstraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '200px', + startMinSize: '100px', + startMaxSize: '300px', + endSize: '200px', + endMinSize: '100px', + endMaxSize: '300px', + }) + ); + await elementUpdated(mixedConstraintSplitter); - let deltaX = 1000; - await resize(mixedConstraintSplitter, deltaX, 0); + const isX = orientation === 'horizontal'; + let delta = 1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); - let sizes = getPanesSizes(mixedConstraintSplitter, 'width'); - expect(sizes.startSize).to.equal(300); + let sizes = getPanesSizes( + mixedConstraintSplitter, + isX ? 'width' : 'height' + ); + expect(sizes.startSize).to.equal(300); - deltaX = -1000; - await resize(mixedConstraintSplitter, deltaX, 0); + delta = -1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); - sizes = getPanesSizes(mixedConstraintSplitter, 'width'); - expect(sizes.startSize).to.equal(100); + sizes = getPanesSizes(mixedConstraintSplitter, isX ? 'width' : 'height'); + expect(sizes.startSize).to.equal(100); - deltaX = 1000; - await resize(mixedConstraintSplitter, deltaX, 0); + delta = 1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + sizes = getPanesSizes(mixedConstraintSplitter, isX ? 'width' : 'height'); + expect(sizes.endSize).to.equal(100); - sizes = getPanesSizes(mixedConstraintSplitter, 'width'); - expect(sizes.endSize).to.equal(100); + delta = -1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); - deltaX = -1000; - await resize(mixedConstraintSplitter, deltaX, 0); + sizes = getPanesSizes(mixedConstraintSplitter, isX ? 'width' : 'height'); + expect(sizes.endSize).to.equal(300); + }; - sizes = getPanesSizes(mixedConstraintSplitter, 'width'); - expect(sizes.endSize).to.equal(300); - }); + const testMinMaxConstraintsInPercentage = async ( + orientation: SplitterOrientation + ) => { + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '30%', + startMinSize: '10%', + startMaxSize: '80%', + endSize: '70%', + endMinSize: '20%', + endMaxSize: '90%', + }) + ); + await elementUpdated(constraintSplitter); - it('should respect both panes constraints when they conflict during resize in px', async () => { - const constraintSplitter = await fixture( - createTwoPanesWithSizesAndConstraints({ - startSize: '200px', - startMinSize: '100px', - startMaxSize: '400px', - endSize: '200px', - endMinSize: '150px', - endMaxSize: '350px', - }) - ); - await elementUpdated(constraintSplitter); - - const bar = getSplitterPart(constraintSplitter, 'bar'); - const barSize = bar.getBoundingClientRect().width; - const totalAvailable = 500 - barSize; - expect(totalAvailable).to.equal(495); - - const initialSizes = getPanesSizes(constraintSplitter, 'width'); - const initialCombinedSize = - initialSizes.startSize + initialSizes.endSize; - - // Try to grow start pane to max, but end pane has min (150px) - // Result: Start pane can only grow as much as end pane allows - const deltaX = 1000; - await resize(constraintSplitter, deltaX, 0); - - const sizes = getPanesSizes(constraintSplitter, 'width'); - - // Start pane can only grow until end pane hits its minSize - // Within the initial combined size of 400px - because of flex basis - expect(sizes.startSize).to.equal(250); - expect(sizes.endSize).to.equal(150); - - expect(sizes.startSize + sizes.endSize).to.equal(initialCombinedSize); - expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + const isX = orientation === 'horizontal'; + + const bar = getSplitterPart(constraintSplitter, 'bar'); + const barSize = bar.getBoundingClientRect()[isX ? 'width' : 'height']; + const totalAvailable = 500 - barSize; + expect(totalAvailable).to.equal(495); + + let delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + let sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + const expectedStartMax = Math.round((totalAvailable * 80) / 100); + expect(sizes.startSize).to.be.closeTo(expectedStartMax, 2); + + delta = -1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + const expectedStartMin = Math.round((totalAvailable * 10) / 100); + expect(sizes.startSize).to.be.closeTo(expectedStartMin, 2); + + delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + const expectedEndMin = Math.round((totalAvailable * 20) / 100); + expect(sizes.endSize).to.be.closeTo(expectedEndMin, 2); + + delta = -1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + const expectedEndMax = Math.round((totalAvailable * 90) / 100); + expect(sizes.endSize).to.be.closeTo(expectedEndMax, 2); + }; + + const testConflictingConstraints = async ( + orientation: SplitterOrientation + ) => { + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '200px', + startMinSize: '100px', + startMaxSize: '400px', + endSize: '200px', + endMinSize: '150px', + endMaxSize: '350px', + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + + const bar = getSplitterPart(constraintSplitter, 'bar'); + const barSize = bar.getBoundingClientRect()[isX ? 'width' : 'height']; + const totalAvailable = 500 - barSize; + expect(totalAvailable).to.equal(495); + + const initialSizes = getPanesSizes( + constraintSplitter, + isX ? 'width' : 'height' + ); + const initialCombinedSize = initialSizes.startSize + initialSizes.endSize; + + // Try to grow start pane to max, but end pane has min (150px) + // Result: Start pane can only grow as much as end pane allows + const delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + + // Start pane can only grow until end pane hits its minSize + // Within the initial combined size of 400px - because of flex basis + expect(sizes.startSize).to.equal(250); + expect(sizes.endSize).to.equal(150); + + expect(sizes.startSize + sizes.endSize).to.equal(initialCombinedSize); + expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + }; + + describe('Horizontal orientation', () => { + it('should honor minSize and maxSize constraints when resizing, constraints in px', async () => { + await testMinMaxConstraintsPx('horizontal'); }); it('should honor minSize and maxSize constraints when resizing, constraints in %', async () => { - const constraintSplitter = await fixture( - createTwoPanesWithSizesAndConstraints({ - startSize: '30%', - startMinSize: '10%', - startMaxSize: '80%', - endSize: '70%', - endMinSize: '20%', - endMaxSize: '90%', - }) - ); - await elementUpdated(constraintSplitter); - - const bar = getSplitterPart(constraintSplitter, 'bar'); - const barSize = bar.getBoundingClientRect().width; - const totalAvailable = 500 - barSize; - expect(totalAvailable).to.equal(495); - - let deltaX = 1000; - await resize(constraintSplitter, deltaX, 0); - - let sizes = getPanesSizes(constraintSplitter, 'width'); - const expectedStartMax = Math.round((totalAvailable * 80) / 100); - expect(sizes.startSize).to.be.closeTo(expectedStartMax, 2); - - deltaX = -1000; - await resize(constraintSplitter, deltaX, 0); - - sizes = getPanesSizes(constraintSplitter, 'width'); - const expectedStartMin = Math.round((totalAvailable * 10) / 100); - expect(sizes.startSize).to.be.closeTo(expectedStartMin, 2); - - deltaX = 1000; - await resize(constraintSplitter, deltaX, 0); - - sizes = getPanesSizes(constraintSplitter, 'width'); - const expectedEndMin = Math.round((totalAvailable * 20) / 100); - expect(sizes.endSize).to.be.closeTo(expectedEndMin, 2); - - deltaX = -1000; - await resize(constraintSplitter, deltaX, 0); - - sizes = getPanesSizes(constraintSplitter, 'width'); - const expectedEndMax = Math.round((totalAvailable * 90) / 100); - expect(sizes.endSize).to.be.closeTo(expectedEndMax, 2); + await testMinMaxConstraintsInPercentage('horizontal'); + }); + + it('should respect both panes constraints when they conflict during resize in px', async () => { + await testConflictingConstraints('horizontal'); }); }); describe('Vertical orientation', () => { it('should honor minSize and maxSize constraints when resizing - constraints in px - vertical', async () => { - const mixedConstraintSplitter = await fixture( - createTwoPanesWithSizesAndConstraints({ - orientation: 'vertical', - startSize: '200px', - startMinSize: '100px', - startMaxSize: '300px', - endSize: '200px', - endMinSize: '100px', - endMaxSize: '300px', - }) - ); - await elementUpdated(mixedConstraintSplitter); - - let deltaY = 1000; - await resize(mixedConstraintSplitter, 0, deltaY); - - let sizes = getPanesSizes(mixedConstraintSplitter, 'height'); - expect(sizes.startSize).to.equal(300); - - deltaY = -1000; - await resize(mixedConstraintSplitter, 0, deltaY); - - sizes = getPanesSizes(mixedConstraintSplitter, 'height'); - expect(sizes.startSize).to.equal(100); - - deltaY = 1000; - await resize(mixedConstraintSplitter, 0, deltaY); - sizes = getPanesSizes(mixedConstraintSplitter, 'height'); - expect(sizes.endSize).to.equal(100); - - deltaY = -1000; - await resize(mixedConstraintSplitter, 0, deltaY); - - sizes = getPanesSizes(mixedConstraintSplitter, 'height'); - expect(sizes.endSize).to.equal(300); + await testMinMaxConstraintsPx('vertical'); }); it('should honor minSize and maxSize constraints when resizing, constraints in % - vertical', async () => { - const constraintSplitter = await fixture( - createTwoPanesWithSizesAndConstraints({ - orientation: 'vertical', - startSize: '30%', - startMinSize: '10%', - startMaxSize: '80%', - endSize: '70%', - endMinSize: '20%', - endMaxSize: '90%', - }) - ); - await elementUpdated(constraintSplitter); - - const bar = getSplitterPart(constraintSplitter, 'bar'); - const barSize = bar.getBoundingClientRect().height; - const totalAvailable = 500 - barSize; - expect(totalAvailable).to.equal(495); - - let deltaY = 1000; - await resize(constraintSplitter, 0, deltaY); - - let sizes = getPanesSizes(constraintSplitter, 'height'); - const expectedStartMax = Math.round((totalAvailable * 80) / 100); - expect(sizes.startSize).to.be.closeTo(expectedStartMax, 2); - - deltaY = -1000; - await resize(constraintSplitter, 0, deltaY); - - sizes = getPanesSizes(constraintSplitter, 'height'); - const expectedStartMin = Math.round((totalAvailable * 10) / 100); - expect(sizes.startSize).to.be.closeTo(expectedStartMin, 2); - - deltaY = 1000; - await resize(constraintSplitter, 0, deltaY); - - sizes = getPanesSizes(constraintSplitter, 'height'); - const expectedEndMin = Math.round((totalAvailable * 20) / 100); - expect(sizes.endSize).to.be.closeTo(expectedEndMin, 2); - - deltaY = -1000; - await resize(constraintSplitter, 0, deltaY); - - sizes = getPanesSizes(constraintSplitter, 'height'); - const expectedEndMax = Math.round((totalAvailable * 90) / 100); - expect(sizes.endSize).to.be.closeTo(expectedEndMax, 2); + await testMinMaxConstraintsInPercentage('vertical'); + }); + + it('should respect both panes constraints when they conflict during resize in px - vertical', async () => { + await testConflictingConstraints('vertical'); }); }); diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 5d479548e..5a74affcf 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -461,10 +461,10 @@ export default class IgcSplitterComponent extends EventEmitterMixin< const totalSize = this.getTotalSize(); let result: number; if (value.indexOf('%') !== -1) { - const percentageValue = Number.parseInt(value ?? '0', 10) || 0; + const percentageValue = Number.parseInt(value, 10) || 0; result = (percentageValue / 100) * totalSize; } else { - result = Number.parseInt(value ?? '0', 10) || 0; + result = Number.parseInt(value, 10) || 0; } return result; } From b621214495c5742f566ff97537089ae3124f2a72 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 5 Dec 2025 15:54:10 +0200 Subject: [PATCH 29/47] chore(splitter): resize tests checkpoint --- src/components/splitter/splitter.spec.ts | 311 ++++++++++++++++++----- 1 file changed, 247 insertions(+), 64 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 8c40b00a5..0ecc44478 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -301,34 +301,88 @@ describe('Splitter', () => { expect(style.maxHeight).to.equal('500px'); }); - it('should handle percentage sizes', async () => { - const splitter = await fixture( - createTwoPanesWithSizesAndConstraints({ - startSize: '30%', - endSize: '70%', - startMinSize: '20%', - startMaxSize: '80%', - }) - ); - await elementUpdated(splitter); - - const startPane = getSplitterPart(splitter, 'start-pane'); - const style1 = getComputedStyle(startPane); - - const endPane = getSplitterPart(splitter, 'end-pane'); - const style2 = getComputedStyle(endPane); - - expect(splitter.startSize).to.equal('30%'); - expect(splitter.endSize).to.equal('70%'); - expect(style1.flex).to.equal('0 1 30%'); - expect(style2.flex).to.equal('0 1 70%'); - - expect(splitter.startMinSize).to.equal('20%'); - expect(splitter.startMaxSize).to.equal('80%'); - expect(style1.minWidth).to.equal('20%'); - expect(style1.maxWidth).to.equal('80%'); + it('should handle percentage sizes - horizontal and vertical', async () => { + const testPercentageSizes = async (orientation: SplitterOrientation) => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '30%', + endSize: '70%', + startMinSize: '20%', + startMaxSize: '80%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + const totalAvailable = getTotalSize( + splitter, + orientation === 'horizontal' ? 'width' : 'height' + ); + const startPane = getSplitterPart(splitter, 'start-pane'); + const style1 = getComputedStyle(startPane); + + const endPane = getSplitterPart(splitter, 'end-pane'); + const style2 = getComputedStyle(endPane); + const sizes = getPanesSizes( + splitter, + orientation === 'horizontal' ? 'width' : 'height' + ); + + expect(sizes.startSize).to.be.closeTo(totalAvailable * 0.3, 2); + expect(sizes.endSize).to.be.closeTo(totalAvailable * 0.7, 2); + + expect(splitter.startSize).to.equal('30%'); + expect(splitter.endSize).to.equal('70%'); + expect(style1.flex).to.equal('0 1 30%'); + expect(style2.flex).to.equal('0 1 70%'); + + expect(splitter.startMinSize).to.equal('20%'); + expect(splitter.startMaxSize).to.equal('80%'); + + const minProp = orientation === 'horizontal' ? 'minWidth' : 'minHeight'; + expect(style1[minProp]).to.equal('20%'); + const maxProp = orientation === 'horizontal' ? 'maxWidth' : 'maxHeight'; + expect(style1[maxProp]).to.equal('80%'); + }; + + await testPercentageSizes('horizontal'); + await testPercentageSizes('vertical'); + }); - // TODO: test with drag; add constraints to second pane + it('should handle mixed % and auto size - horizontal and vertical', async () => { + const testMixedSizes = async (orientation: SplitterOrientation) => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + endSize: '30%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + const totalAvailable = getTotalSize( + splitter, + orientation === 'horizontal' ? 'width' : 'height' + ); + + const startPart = getSplitterPart(splitter, 'start-pane'); + const style = getComputedStyle(startPart); + expect(style.flex).to.equal('1 1 auto'); + + const sizes = getPanesSizes( + splitter, + orientation === 'horizontal' ? 'width' : 'height' + ); + const expectedEndSize = roundPrecise((30 / 100) * totalAvailable, 2); + expect(sizes.endSize).to.be.closeTo(expectedEndSize, 2); + + const endPart = getSplitterPart(splitter, 'end-pane'); + const styleEnd = getComputedStyle(endPart); + expect(styleEnd.flex).to.equal('0 1 30%'); + }; + await testMixedSizes('horizontal'); + await testMixedSizes('vertical'); }); }); @@ -864,10 +918,10 @@ describe('Splitter', () => { const isX = orientation === 'horizontal'; - const bar = getSplitterPart(constraintSplitter, 'bar'); - const barSize = bar.getBoundingClientRect()[isX ? 'width' : 'height']; - const totalAvailable = 500 - barSize; - expect(totalAvailable).to.equal(495); + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); let delta = 1000; await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); @@ -898,7 +952,7 @@ describe('Splitter', () => { expect(sizes.endSize).to.be.closeTo(expectedEndMax, 2); }; - const testConflictingConstraints = async ( + const testConflictingConstraintsInPx = async ( orientation: SplitterOrientation ) => { const constraintSplitter = await fixture( @@ -916,10 +970,10 @@ describe('Splitter', () => { const isX = orientation === 'horizontal'; - const bar = getSplitterPart(constraintSplitter, 'bar'); - const barSize = bar.getBoundingClientRect()[isX ? 'width' : 'height']; - const totalAvailable = 500 - barSize; - expect(totalAvailable).to.equal(495); + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); const initialSizes = getPanesSizes( constraintSplitter, @@ -943,6 +997,108 @@ describe('Splitter', () => { expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); }; + const testConflictingConstraintsInPercentage = async ( + orientation: SplitterOrientation + ) => { + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startSize: '40%', + startMinSize: '20%', + startMaxSize: '80%', + endSize: '60%', + endMinSize: '30%', + endMaxSize: '70%', + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); + + const initialSizes = getPanesSizes( + constraintSplitter, + isX ? 'width' : 'height' + ); + const initialCombinedSize = initialSizes.startSize + initialSizes.endSize; + + // Try to grow start pane to max (80%), but end pane has min (30%) + // Result: Start pane can only grow as much as end pane allows + const delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + + // Start pane can only grow until end pane hits its minSize (30% of 495px) + // Within the initial combined size - because of flex basis + const expectedEndMin = Math.round((totalAvailable * 30) / 100); + const expectedStartAfterResize = initialCombinedSize - expectedEndMin; + + expect(sizes.startSize).to.be.closeTo(expectedStartAfterResize, 2); + expect(sizes.endSize).to.be.closeTo(expectedEndMin, 2); + + expect(sizes.startSize + sizes.endSize).to.be.closeTo( + initialCombinedSize, + 2 + ); + expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + }; + + const testMixedConstraintsPxAndPercentage = async ( + orientation: SplitterOrientation + ) => { + const mixedConstraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + startMinSize: '100px', + startMaxSize: '50%', + }) + ); + await elementUpdated(mixedConstraintSplitter); + + const startPane = getSplitterPart(mixedConstraintSplitter, 'start-pane'); + const style = getComputedStyle(startPane); + + expect(mixedConstraintSplitter.startMinSize).to.equal('100px'); + expect(mixedConstraintSplitter.startMaxSize).to.equal('50%'); + const targetMinProp = + orientation === 'horizontal' ? 'minWidth' : 'minHeight'; + const targetMaxProp = + orientation === 'horizontal' ? 'maxWidth' : 'maxHeight'; + expect(style[targetMinProp]).to.equal('100px'); + expect(style[targetMaxProp]).to.equal('50%'); + + const isX = orientation === 'horizontal'; + const totalAvailable = getTotalSize( + mixedConstraintSplitter, + isX ? 'width' : 'height' + ); + const expectedEndMax = Math.round((totalAvailable * 50) / 100); + + let delta = 1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes( + mixedConstraintSplitter, + isX ? 'width' : 'height' + ); + expect(sizes.startSize).to.be.closeTo(totalAvailable - expectedEndMax, 2); + expect(sizes.endSize).to.be.closeTo(expectedEndMax, 2); + + delta = -1000; + await resize(mixedConstraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizesAfterSecondResize = getPanesSizes( + mixedConstraintSplitter, + isX ? 'width' : 'height' + ); + expect(sizesAfterSecondResize.startSize).to.equal(100); + expect(sizesAfterSecondResize.endSize).to.equal(totalAvailable - 100); + }; + describe('Horizontal orientation', () => { it('should honor minSize and maxSize constraints when resizing, constraints in px', async () => { await testMinMaxConstraintsPx('horizontal'); @@ -953,7 +1109,23 @@ describe('Splitter', () => { }); it('should respect both panes constraints when they conflict during resize in px', async () => { - await testConflictingConstraints('horizontal'); + await testConflictingConstraintsInPx('horizontal'); + }); + + it('should respect both panes constraints when they conflict during resize in %', async () => { + await testConflictingConstraintsInPercentage('horizontal'); + }); + + it('should handle mixed px and % constraints - start in px; end in %', async () => { + await testMixedConstraintsPxAndPercentage('horizontal'); + }); + + it('should handle resize with mixed % and auto size', async () => { + // TODO + }); + + it('should handle mixed px and auto size', async () => { + // TODO }); }); @@ -967,40 +1139,38 @@ describe('Splitter', () => { }); it('should respect both panes constraints when they conflict during resize in px - vertical', async () => { - await testConflictingConstraints('vertical'); + await testConflictingConstraintsInPx('vertical'); }); - }); - it('should result in % sizes after resize when the panes size is auto', () => { - //TODO - }); - - it('should handle mixed px and % constraints - start in px; end in %', async () => { - const mixedConstraintSplitter = await fixture( - createTwoPanesWithSizesAndConstraints({ - startMinSize: '100px', - startMaxSize: '50%', - }) - ); - await elementUpdated(mixedConstraintSplitter); + it('should respect both panes constraints when they conflict during resize in % - vertical', async () => { + await testConflictingConstraintsInPercentage('vertical'); + }); - const startPane = getSplitterPart(mixedConstraintSplitter, 'start-pane'); - const style = getComputedStyle(startPane); + it('should handle mixed px and % constraints - start in px; end in %', async () => { + await testMixedConstraintsPxAndPercentage('vertical'); + }); - expect(mixedConstraintSplitter.startMinSize).to.equal('100px'); - expect(mixedConstraintSplitter.startMaxSize).to.equal('50%'); - expect(style.minWidth).to.equal('100px'); - expect(style.maxWidth).to.equal('50%'); + it('should handle resize with mixed % and auto size - vertical', async () => { + // TODO + }); - // TODO: test with drag + it('should handle resize with mixed px and auto size - vertical', async () => { + // TODO + }); }); - it('should handle mixed % and auto size', async () => { - // TODO - }); + it('should result in % sizes after resize when the panes size is auto', async () => { + const previousSizes = getPanesSizes(splitter, 'width'); + const deltaX = 100; - it('should handle mixed px and auto size', async () => { - // TODO + await resize(splitter, deltaX, 0); + await elementUpdated(splitter); + + const currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaX); + expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaX); + expect(splitter.startSize).to.contain('%'); + expect(splitter.endSize).to.contain('%'); }); it('panes should not exceed splitter size when set in px and horizontally resizing to end', async () => { @@ -1114,6 +1284,8 @@ type SplitterTestSizesAndConstraints = { endMinSize?: string; endMaxSize?: string; orientation?: SplitterOrientation; + splitterWidth?: string; + splitterHeight?: string; }; function createTwoPanesWithSizesAndConstraints( @@ -1121,7 +1293,7 @@ function createTwoPanesWithSizesAndConstraints( ) { return html` Date: Mon, 8 Dec 2025 14:05:48 +0200 Subject: [PATCH 30/47] fix(splitter): change flex to resolve multiple edge cases --- src/components/splitter/splitter.spec.ts | 8 +- src/components/splitter/splitter.ts | 175 +++++------------- .../splitter/themes/splitter.base.scss | 1 + 3 files changed, 53 insertions(+), 131 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 0ecc44478..a8cabe671 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -241,13 +241,13 @@ describe('Splitter', () => { const startPart = getSplitterPart(splitter, 'start-pane'); const style = getComputedStyle(startPart); - expect(style.flex).to.equal('0 0 200px'); + expect(style.flex).to.equal('0 1 200px'); splitter.orientation = 'vertical'; await elementUpdated(splitter); expect(splitter.startSize).to.equal('auto'); - expect(style.flex).to.equal('1 1 auto'); + expect(style.flex).to.equal('1 1 0px'); }); // TODO: verify the attribute type, default value, reflection @@ -256,7 +256,7 @@ describe('Splitter', () => { const startPart = getSplitterPart(splitter, 'start-pane'); const style = getComputedStyle(startPart); - expect(style.flex).to.equal('1 1 auto'); + expect(style.flex).to.equal('1 1 0px'); expect(splitter.startSize).to.equal('auto'); expect(style.minWidth).to.equal('0px'); @@ -368,7 +368,7 @@ describe('Splitter', () => { const startPart = getSplitterPart(splitter, 'start-pane'); const style = getComputedStyle(startPart); - expect(style.flex).to.equal('1 1 auto'); + expect(style.flex).to.equal('1 1 0px'); const sizes = getPanesSizes( splitter, diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 5a74affcf..d7cf351e9 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -77,7 +77,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private _startSize = 'auto'; private _endSize = 'auto'; private _resizeState: SplitterResizeState | null = null; - private _baseResizeObserver?: ResizeObserver; private readonly _internals = addInternalsController(this, { initialARIA: { @@ -97,20 +96,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< @query('[part~="bar"]', true) private readonly _bar!: HTMLElement; - private get _startFlex() { - const grow = this._isAutoSize('start') ? 1 : 0; - const shrink = - this._isAutoSize('start') || this._isPercentageSize('start') ? 1 : 0; - return `${grow} ${shrink} ${this._startSize}`; - } - - private get _endFlex() { - const grow = this._isAutoSize('end') ? 1 : 0; - const shrink = - this._isAutoSize('end') || this._isPercentageSize('end') ? 1 : 0; - return `${grow} ${shrink} ${this._endSize}`; - } - private get _resizeDisallowed() { return this.nonResizable || this.startCollapsed || this.endCollapsed; } @@ -183,9 +168,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * @attr */ @property({ attribute: 'start-size', reflect: true }) - public set startSize(value: string) { - this._startSize = value; - this._setPaneFlex(this._startPaneInternalStyles, this._startFlex); + public set startSize(value: string | undefined) { + this._startSize = value ? value : 'auto'; + this._setPaneFlex(this._startPaneInternalStyles, this._getFlex('start')); } public get startSize(): string | undefined { @@ -197,9 +182,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * @attr */ @property({ attribute: 'end-size', reflect: true }) - public set endSize(value: string) { - this._endSize = value; - this._setPaneFlex(this._endPaneInternalStyles, this._endFlex); + public set endSize(value: string | undefined) { + this._endSize = value ? value : 'auto'; + this._setPaneFlex(this._endPaneInternalStyles, this._getFlex('end')); } public get endSize(): string | undefined { @@ -261,14 +246,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } } - public override disconnectedCallback(): void { - super.disconnectedCallback(); - if (this._baseResizeObserver) { - this._baseResizeObserver.disconnect(); - this._baseResizeObserver = undefined; - } - } - constructor() { super(); @@ -329,10 +306,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< protected override firstUpdated() { this._initPanes(); - this._baseResizeObserver = new ResizeObserver(() => - this._onContainerResized() - ); - this._baseResizeObserver.observe(this._base); } //#endregion @@ -352,27 +325,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< //#region Internal API - private _onContainerResized = () => { - window.setTimeout(() => { - const [startSize, endSize] = this._rectSize(); - const total = this.getTotalSize(); - if ( - !this._isPercentageSize('end') && - !this._isAutoSize('end') && - startSize + endSize > total - ) { - this.endSize = `${total - startSize}px`; - } - if ( - !this._isPercentageSize('start') && - !this._isAutoSize('start') && - startSize + endSize > total - ) { - this.startSize = `${total - endSize}px`; - } - }, 100); - }; - private _isPercentageSize(which: 'start' | 'end') { const targetSize = which === 'start' ? this._startSize : this._endSize; return !!targetSize && targetSize.indexOf('%') !== -1; @@ -383,6 +335,17 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return !!targetSize && targetSize === 'auto'; } + private _getFlex(which: 'start' | 'end'): string { + const grow = this._isAutoSize(which) ? 1 : 0; + const shrink = 1; + const size = this._isAutoSize(which) + ? '0px' + : which === 'start' + ? this._startSize + : this._endSize; + return `${grow} ${shrink} ${size}`; + } + private _handleResizePanes( direction: -1 | 1, validOrientation: 'horizontal' | 'vertical' @@ -470,14 +433,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } private _resizing(delta: number) { - let [paneSize, siblingSize] = this._calcNewSizes(delta); - const totalSize = this.getTotalSize(); - [paneSize, siblingSize] = this._fitInSplitter( - totalSize, - paneSize, - siblingSize, - delta - ); + const [paneSize, siblingSize] = this._calcNewSizes(delta); this.startSize = `${paneSize}px`; this.endSize = `${siblingSize}px`; @@ -487,35 +443,22 @@ export default class IgcSplitterComponent extends EventEmitterMixin< }); } - private _resizeEnd(delta: number) { - if (!this._resizeState) return; - let [paneSize, siblingSize] = this._calcNewSizes(delta); + private _computeSize(pane: PaneResizeState, paneSize: number): string { const totalSize = this.getTotalSize(); - - [paneSize, siblingSize] = this._fitInSplitter( - totalSize, - paneSize, - siblingSize, - delta - ); - - if (this._resizeState.startPane.isPercentageBased) { - // handle % resizes + if (pane.isPercentageBased) { const percentPaneSize = (paneSize / totalSize) * 100; - this.startSize = `${percentPaneSize}%`; - } else { - // px resize - this.startSize = `${paneSize}px`; + return `${percentPaneSize}%`; } + return `${paneSize}px`; + } + + private _resizeEnd(delta: number) { + if (!this._resizeState) return; + const [paneSize, siblingSize] = this._calcNewSizes(delta); + + this.startSize = this._computeSize(this._resizeState.startPane, paneSize); + this.endSize = this._computeSize(this._resizeState.endPane, siblingSize); - if (this._resizeState.endPane.isPercentageBased) { - // handle % resizes - const percentSiblingSize = (siblingSize / totalSize) * 100; - this.endSize = `${percentSiblingSize}%`; - } else { - // px resize - this.endSize = `${siblingSize}px`; - } this.emitEvent('igcResizeEnd', { detail: { pane: this._startPane, sibling: this._endPane }, }); @@ -530,57 +473,35 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return [startPaneRect[relevantDimension], endPaneRect[relevantDimension]]; } - private _fitInSplitter( - total: number, - startSize: number, - endSize: number, - delta: number - ): [number, number] { - let newStartSize = startSize; - let newEndSize = endSize; - if (startSize + endSize > total && delta > 0) { - newEndSize = total - newStartSize; - } else if (newStartSize + newEndSize > total && delta < 0) { - newStartSize = total - newEndSize; - } - return [newStartSize, newEndSize]; - } - // TODO: handle RTL private _calcNewSizes(delta: number): [number, number] { if (!this._resizeState) return [0, 0]; + const start = this._resizeState.startPane; + const end = this._resizeState.endPane; + const minStart = start.minSizePx || 0; + const minEnd = end.minSizePx || 0; + const maxStart = + start.maxSizePx || start.initialSize + end.initialSize - minEnd; + const maxEnd = + end.maxSizePx || start.initialSize + end.initialSize - minStart; + let finalDelta: number; - const min = this._resizeState.startPane.minSizePx || 0; - const minSibling = this._resizeState.endPane.minSizePx || 0; - const max = - this._resizeState.startPane.maxSizePx || - this._resizeState.startPane.initialSize + - this._resizeState.endPane.initialSize - - minSibling; - const maxSibling = - this._resizeState.endPane.maxSizePx || - this._resizeState.startPane.initialSize + - this._resizeState.endPane.initialSize - - min; if (delta < 0) { const maxPossibleDelta = Math.min( - this._resizeState.startPane.initialSize - min, - maxSibling - this._resizeState.endPane.initialSize + start.initialSize - minStart, + maxEnd - end.initialSize ); finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)) * -1; } else { const maxPossibleDelta = Math.min( - max - this._resizeState.startPane.initialSize, - this._resizeState.endPane.initialSize - minSibling + maxStart - start.initialSize, + end.initialSize - minEnd ); finalDelta = Math.min(maxPossibleDelta, Math.abs(delta)); } - return [ - this._resizeState.startPane.initialSize + finalDelta, - this._resizeState.endPane.initialSize - finalDelta, - ]; + return [start.initialSize + finalDelta, end.initialSize - finalDelta]; } private getTotalSize() { @@ -604,9 +525,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< this.startSize = 'auto'; this.endSize = 'auto'; - this._setPaneFlex(this._startPaneInternalStyles, this._startFlex); + this._setPaneFlex(this._startPaneInternalStyles, this._getFlex('start')); this._setPaneMinMaxSizes(this._startPaneInternalStyles, '0', '100%'); - this._setPaneFlex(this._endPaneInternalStyles, this._endFlex); + this._setPaneFlex(this._endPaneInternalStyles, this._getFlex('end')); this._setPaneMinMaxSizes(this._endPaneInternalStyles, '0', '100%'); } @@ -627,8 +548,8 @@ export default class IgcSplitterComponent extends EventEmitterMixin< ); } - this._setPaneFlex(this._startPaneInternalStyles, this._startFlex); - this._setPaneFlex(this._endPaneInternalStyles, this._endFlex); + this._setPaneFlex(this._startPaneInternalStyles, this._getFlex('start')); + this._setPaneFlex(this._endPaneInternalStyles, this._getFlex('end')); this.requestUpdate(); } diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index ae4e46eb0..c018bcf5a 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -41,6 +41,7 @@ --bar-size: 5px; display: flex; + flex-shrink: 0; background-color: var(--ig-gray-200); justify-content: center; cursor: var(--cursor, default); From dc307edc9f64513f19c8b6c74100d84da504a714 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Mon, 8 Dec 2025 16:06:19 +0200 Subject: [PATCH 31/47] feat(splitter): handle RTL --- src/components/splitter/splitter.spec.ts | 210 ++++++++++++++++++++++- src/components/splitter/splitter.ts | 31 +++- 2 files changed, 232 insertions(+), 9 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index a8cabe671..9732c1808 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -855,7 +855,7 @@ describe('Splitter', () => { }); }); - describe('Resizing with constraints and edge cases', () => { + describe('Resizing with constraints', () => { const testMinMaxConstraintsPx = async ( orientation: SplitterOrientation ) => { @@ -1250,6 +1250,214 @@ describe('Splitter', () => { //TODO: others }); + + describe('RTL', () => { + beforeEach(async () => { + splitter.dir = 'rtl'; + await elementUpdated(splitter); + }); + + it('should resize correctly with pointer in RTL', async () => { + const previousSizes = getPanesSizes(splitter, 'width'); + let deltaX = 100; + + await resize(splitter, deltaX, 0); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + // In RTL, moving pointer to the right (positive mouse delta) decreases start pane size + expect(currentSizes.startSize).to.equal(previousSizes.startSize - deltaX); + expect(currentSizes.endSize).to.equal(previousSizes.endSize + deltaX); + + deltaX *= -1; + await resize(splitter, deltaX, 0); + + currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize); + expect(currentSizes.endSize).to.equal(previousSizes.endSize); + }); + + it('should resize correctly with keyboard in RTL', async () => { + const bar = getSplitterPart(splitter, 'bar'); + let previousSizes = getPanesSizes(splitter, 'width'); + const resizeDelta = 10; + + bar.focus(); + await elementUpdated(splitter); + + // arrowLeft should increase start pane size in RTL, as opposed to LTR, where arrowLeft decreases it + simulateKeyboard(bar, arrowLeft); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta + ); + + simulateKeyboard(bar, arrowLeft); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta * 2 + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta * 2 + ); + + previousSizes = getPanesSizes(splitter, 'width'); + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal( + previousSizes.startSize - resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize + resizeDelta + ); + }); + + it('should expand/collapse correctly with keyboard in RTL', async () => { + const bar = getSplitterPart(splitter, 'bar'); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + const splitterSize = splitter.getBoundingClientRect().width; + const barSize = bar.getBoundingClientRect().width; + + expect(currentSizes.startSize).to.equal(0); + expect(currentSizes.endSize).to.equal(splitterSize - barSize); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.be.greaterThan(0); + expect(currentSizes.endSize).to.equal( + splitterSize - barSize - currentSizes.startSize + ); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal(splitterSize - barSize); + expect(currentSizes.endSize).to.equal(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal( + splitterSize - barSize - currentSizes.endSize + ); + expect(currentSizes.endSize).to.be.greaterThan(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + }); + + it('direction should not affect interactions in vertical orientation', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + // 1. Resize with keyboard + const bar = getSplitterPart(splitter, 'bar'); + let previousSizes = getPanesSizes(splitter, 'height'); + const resizeDelta = 10; + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, arrowUp); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.equal( + previousSizes.startSize - resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize + resizeDelta + ); + + previousSizes = getPanesSizes(splitter, 'height'); + simulateKeyboard(bar, arrowDown); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal( + previousSizes.startSize + resizeDelta + ); + expect(currentSizes.endSize).to.equal( + previousSizes.endSize - resizeDelta + ); + + // 2. Resize with pointer + previousSizes = getPanesSizes(splitter, 'height'); + let deltaY = 100; + + await resize(splitter, 0, deltaY); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaY); + expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaY); + + deltaY *= -1; + await resize(splitter, 0, deltaY); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.equal(previousSizes.startSize); + expect(currentSizes.endSize).to.equal(previousSizes.endSize); + + // 3. Expand/collapse with keyboard + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowUp]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + const splitterSize = splitter.getBoundingClientRect().height; + const barSize = bar.getBoundingClientRect().height; + + expect(currentSizes.startSize).to.equal(0); + expect(currentSizes.endSize).to.equal(splitterSize - barSize); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowDown]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'height'); + + expect(currentSizes.startSize).to.be.greaterThan(0); + expect(currentSizes.endSize).to.equal( + splitterSize - barSize - currentSizes.startSize + ); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + }); + }); }); function createSplitter() { diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index d7cf351e9..a6eee92d2 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -277,7 +277,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< mode: 'immediate', updateTarget: false, resizeTarget: () => { - return this._startPane; + return this.orientation === 'horizontal' && !isLTR(this) + ? this._endPane + : this._startPane; }, start: () => { if (this._resizeDisallowed) { @@ -287,15 +289,13 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return true; }, resize: ({ state }) => { - const isHorizontal = this.orientation === 'horizontal'; - const delta = isHorizontal ? state.deltaX : state.deltaY; + const delta = this._resolveDelta(state.deltaX, state.deltaY); if (delta !== 0) { this._resizing(delta); } }, end: ({ state }) => { - const isHorizontal = this.orientation === 'horizontal'; - const delta = isHorizontal ? state.deltaX : state.deltaY; + const delta = this._resolveDelta(state.deltaX, state.deltaY); if (delta !== 0) { this._resizeEnd(delta); } @@ -353,7 +353,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< if (this._resizeDisallowed || this.orientation !== validOrientation) { return; } - const delta = 10 * direction * (isLTR(this) ? 1 : -1); + const delta = this._resolveDelta(10, 10, direction); this._resizeStart(); this._resizing(delta); @@ -361,6 +361,17 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return true; } + private _resolveDelta( + deltaX: number, + deltaY: number, + direction?: -1 | 1 + ): number { + const isHorizontal = this.orientation === 'horizontal'; + const rtlMultiplier = isHorizontal && !isLTR(this) ? -1 : 1; + const delta = isHorizontal ? deltaX : deltaY; + return delta * rtlMultiplier * (direction ?? 1); + } + private _handleExpanderStartAction() { const target = this.endCollapsed ? 'end' : 'start'; this.toggle(target); @@ -378,7 +389,12 @@ export default class IgcSplitterComponent extends EventEmitterMixin< if (this.nonCollapsible || this.orientation !== validOrientation) { return; } - target === 'start' + let effectiveTarget = target; + if (validOrientation === 'horizontal' && !isLTR(this)) { + effectiveTarget = target === 'start' ? 'end' : 'start'; + } + + effectiveTarget === 'start' ? this._handleExpanderStartAction() : this._handleExpanderEndAction(); } @@ -473,7 +489,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return [startPaneRect[relevantDimension], endPaneRect[relevantDimension]]; } - // TODO: handle RTL private _calcNewSizes(delta: number): [number, number] { if (!this._resizeState) return [0, 0]; From 2425e6d54dd4af6bc7fce88105d87505bef4a32c Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Mon, 8 Dec 2025 17:31:03 +0200 Subject: [PATCH 32/47] fix(splitter): reset min/max size properties --- src/components/splitter/splitter.spec.ts | 50 +++++++++++++++++++++++- src/components/splitter/splitter.ts | 27 +++++++++---- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 9732c1808..72a2ffa01 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -210,7 +210,6 @@ describe('Splitter', () => { expect(style.cursor).to.equal('row-resize'); }); - //TODO: this is the behavior in Angular - to discuss it('should reset sizes when pane is initially collapsed.', async () => { splitter = await fixture( createSplitterWithCollapsedPane() @@ -236,7 +235,17 @@ describe('Splitter', () => { describe('Properties', () => { it('should reset pane sizes when orientation changes', async () => { - splitter.startSize = '200px'; + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation: 'horizontal', + startSize: '200px', + startMinSize: '100px', + startMaxSize: '300px', + endSize: '100px', + endMinSize: '100px', + endMaxSize: '300px', + }) + ); await elementUpdated(splitter); const startPart = getSplitterPart(splitter, 'start-pane'); @@ -248,6 +257,14 @@ describe('Splitter', () => { expect(splitter.startSize).to.equal('auto'); expect(style.flex).to.equal('1 1 0px'); + + expect(splitter.startMinSize).to.be.undefined; + expect(splitter.startMaxSize).to.be.undefined; + + expect(style.minHeight).to.equal('0px'); + expect(style.maxHeight).to.equal('100%'); + expect(style.minWidth).to.equal('0px'); + expect(style.maxWidth).to.equal('100%'); }); // TODO: verify the attribute type, default value, reflection @@ -1237,6 +1254,35 @@ describe('Splitter', () => { 'y' ); }); + + it('should properly resize after switching orientation (horizontal -> vertical -> horizontal) w/ constraints', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation: 'horizontal', + startSize: '100px', + startMinSize: '100px', + startMaxSize: '300px', + endSize: '100px', + endMinSize: '100px', + endMaxSize: '300px', + }) + ); + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + splitter.orientation = 'horizontal'; + await elementUpdated(splitter); + + const previousSizes = getPanesSizes(splitter, 'width'); + const deltaX = 100; + + await resize(splitter, deltaX, 0); + await elementUpdated(splitter); + + const currentSizes = getPanesSizes(splitter, 'width'); + expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaX); + expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaX); + }); }); describe('Behavior on window resize', () => { diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index a6eee92d2..5b1507978 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -539,6 +539,10 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private _resetPanes() { this.startSize = 'auto'; this.endSize = 'auto'; + this.startMinSize = undefined; + this.startMaxSize = undefined; + this.endMinSize = undefined; + this.endMaxSize = undefined; this._setPaneFlex(this._startPaneInternalStyles, this._getFlex('start')); this._setPaneMinMaxSizes(this._startPaneInternalStyles, '0', '100%'); @@ -547,7 +551,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } private _initPanes() { - // TODO: discuss if panes should be reset if one is collapsed (as in Angular currently) if (this.startCollapsed || this.endCollapsed) { this._resetPanes(); } else { @@ -574,13 +577,23 @@ export default class IgcSplitterComponent extends EventEmitterMixin< maxSize?: string ) { const isHorizontal = this.orientation === 'horizontal'; - const minProp = isHorizontal ? 'minWidth' : 'minHeight'; - const maxProp = isHorizontal ? 'maxWidth' : 'maxHeight'; - const sizes = { - [minProp]: minSize ?? 0, - [maxProp]: maxSize ?? '100%', - }; + const min = minSize ?? 0; + const max = maxSize ?? '100%'; + + const sizes = isHorizontal + ? { + minWidth: min, + maxWidth: max, + minHeight: 0, + maxHeight: '100%', + } + : { + minWidth: 0, + maxWidth: '100%', + minHeight: min, + maxHeight: max, + }; Object.assign(styles, { ...sizes, From 0bf0cc15d88208a30b3df5bf334f28345a8a7f3f Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Tue, 9 Dec 2025 18:47:36 +0200 Subject: [PATCH 33/47] chore(*): rename pane and siblingPane to startPane and endPane --- src/components/splitter/splitter.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 5b1507978..1cf383e4f 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -22,8 +22,8 @@ import type { SplitterOrientation } from '../types.js'; import { styles } from './themes/splitter.base.css.js'; export interface IgcSplitterBarResizeEventArgs { - pane: HTMLElement; - sibling: HTMLElement; + startPane: HTMLElement; + endPane: HTMLElement; } export interface IgcSplitterComponentEventMap { @@ -408,7 +408,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< }; // TODO: are these event args needed? this.emitEvent('igcResizeStart', { - detail: { pane: this._startPane, sibling: this._endPane }, + detail: { startPane: this._startPane, endPane: this._endPane }, }); } @@ -449,13 +449,13 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } private _resizing(delta: number) { - const [paneSize, siblingSize] = this._calcNewSizes(delta); + const [startPaneSize, endPaneSize] = this._calcNewSizes(delta); - this.startSize = `${paneSize}px`; - this.endSize = `${siblingSize}px`; + this.startSize = `${startPaneSize}px`; + this.endSize = `${endPaneSize}px`; this.emitEvent('igcResizing', { - detail: { pane: this._startPane, sibling: this._endPane }, + detail: { startPane: this._startPane, endPane: this._endPane }, }); } @@ -470,13 +470,16 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private _resizeEnd(delta: number) { if (!this._resizeState) return; - const [paneSize, siblingSize] = this._calcNewSizes(delta); + const [startPaneSize, endPaneSize] = this._calcNewSizes(delta); - this.startSize = this._computeSize(this._resizeState.startPane, paneSize); - this.endSize = this._computeSize(this._resizeState.endPane, siblingSize); + this.startSize = this._computeSize( + this._resizeState.startPane, + startPaneSize + ); + this.endSize = this._computeSize(this._resizeState.endPane, endPaneSize); this.emitEvent('igcResizeEnd', { - detail: { pane: this._startPane, sibling: this._endPane }, + detail: { startPane: this._startPane, endPane: this._endPane }, }); this._resizeState = null; } From 560e2b61d9afb73733b638b144291d41aafdda26 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Wed, 10 Dec 2025 23:27:02 +0200 Subject: [PATCH 34/47] chore: add todo tests --- src/components/splitter/splitter.spec.ts | 237 +++++++++++++++++++++-- src/components/splitter/splitter.ts | 2 + 2 files changed, 228 insertions(+), 11 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 72a2ffa01..11057168a 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -267,7 +267,6 @@ describe('Splitter', () => { expect(style.maxWidth).to.equal('100%'); }); - // TODO: verify the attribute type, default value, reflection it('should properly set default min/max values when not specified', async () => { await elementUpdated(splitter); @@ -1116,6 +1115,101 @@ describe('Splitter', () => { expect(sizesAfterSecondResize.endSize).to.equal(totalAvailable - 100); }; + const testConstraintsPxAndAutoSizes = async ( + orientation: SplitterOrientation + ) => { + const startMaxSize = 400; + const startMinSize = 100; + const endMaxSize = 350; + const endMinSize = 150; + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + endSize: '200px', + startMinSize: `${startMinSize}px`, + startMaxSize: `${startMaxSize}px`, + endMinSize: `${endMinSize}px`, + endMaxSize: `${endMaxSize}px`, + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); + + const initialSizes = getPanesSizes( + constraintSplitter, + isX ? 'width' : 'height' + ); + const initialCombinedSize = initialSizes.startSize + initialSizes.endSize; + + // Try to grow start pane to max, but end pane has min (150px) + // Result: Start pane can only grow as much as end pane allows + const delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + + // Start pane can only grow until end pane hits its minSize + expect(sizes.startSize).to.equal(totalAvailable - endMinSize); + expect(sizes.endSize).to.equal(endMinSize); + + expect(sizes.startSize + sizes.endSize).to.equal(initialCombinedSize); + expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + }; + + const testConstraintsPercentAndAutoSizes = async ( + orientation: SplitterOrientation + ) => { + const constraintSplitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + orientation, + endSize: '40%', + startMinSize: '20%', + startMaxSize: '80%', + endMinSize: '30%', + endMaxSize: '70%', + }) + ); + await elementUpdated(constraintSplitter); + + const isX = orientation === 'horizontal'; + const totalAvailable = getTotalSize( + constraintSplitter, + isX ? 'width' : 'height' + ); + + const initialSizes = getPanesSizes( + constraintSplitter, + isX ? 'width' : 'height' + ); + const initialCombinedSize = initialSizes.startSize + initialSizes.endSize; + + // Try to grow start pane to max (80%), but end pane has min (30%) + // Result: Start pane can only grow as much as end pane allows + const delta = 1000; + await resize(constraintSplitter, isX ? delta : 0, isX ? 0 : delta); + + const sizes = getPanesSizes(constraintSplitter, isX ? 'width' : 'height'); + + // Start pane can only grow until end pane hits its minSize (30% of total) + const expectedEndMin = Math.round((totalAvailable * 30) / 100); + const expectedStartAfterResize = totalAvailable - expectedEndMin; + + expect(sizes.startSize).to.be.closeTo(expectedStartAfterResize, 2); + expect(sizes.endSize).to.be.closeTo(expectedEndMin, 2); + + expect(sizes.startSize + sizes.endSize).to.be.closeTo( + initialCombinedSize, + 2 + ); + expect(sizes.startSize + sizes.endSize).to.be.at.most(totalAvailable); + }; + describe('Horizontal orientation', () => { it('should honor minSize and maxSize constraints when resizing, constraints in px', async () => { await testMinMaxConstraintsPx('horizontal'); @@ -1138,11 +1232,11 @@ describe('Splitter', () => { }); it('should handle resize with mixed % and auto size', async () => { - // TODO + await testConstraintsPercentAndAutoSizes('horizontal'); }); it('should handle mixed px and auto size', async () => { - // TODO + await testConstraintsPxAndAutoSizes('horizontal'); }); }); @@ -1168,11 +1262,11 @@ describe('Splitter', () => { }); it('should handle resize with mixed % and auto size - vertical', async () => { - // TODO + await testConstraintsPercentAndAutoSizes('vertical'); }); it('should handle resize with mixed px and auto size - vertical', async () => { - // TODO + await testConstraintsPxAndAutoSizes('vertical'); }); }); @@ -1285,16 +1379,83 @@ describe('Splitter', () => { }); }); - describe('Behavior on window resize', () => { - it('should maintain panes sizes in px on window resize', async () => { - //TODO + describe('Behavior on splitter resize', () => { + it('should maintain panes sizes in px on splitter resize', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '200px', + endSize: '200px', + splitterWidth: '600px', + }) + ); + await elementUpdated(splitter); + + splitter.style.width = '800px'; + await elementUpdated(splitter); + + const newSizes = getPanesSizes(splitter, 'width'); + + expect(newSizes.startSize).to.equal(200); + expect(newSizes.endSize).to.equal(200); + }); + + it('should handle panes sizes in % on window resize', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '20%', + endSize: '20%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + splitter.style.width = '800px'; + await elementUpdated(splitter); + + const newSizes = getPanesSizes(splitter, 'width'); + + expect(newSizes.startSize).to.equal(0.2 * 800); + expect(newSizes.endSize).to.equal(0.2 * 800); }); - it('should maintain panes sizes in % on window resize', async () => { - //TODO + it('should handle panes sizes with mixed px and % on window resize', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '200px', + endSize: '20%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + splitter.style.width = '800px'; + await elementUpdated(splitter); + + const newSizes = getPanesSizes(splitter, 'width'); + const totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.equal(200); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.2, 2); }); - //TODO: others + it('should handle sizes on window resize with auto and % sizes', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + endSize: '30%', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + splitter.style.width = '800px'; + await elementUpdated(splitter); + + const newSizes = getPanesSizes(splitter, 'width'); + const totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.3, 2); + expect(newSizes.startSize).to.equal(totalAvailable - newSizes.endSize); + }); }); describe('RTL', () => { @@ -1420,6 +1581,60 @@ describe('Splitter', () => { expect(splitter.endCollapsed).to.be.false; }); + it('should expand/collapse the correct pane through the expander buttons in RTL', async () => { + const startExpander = getSplitterPart(splitter, 'start-expander'); + const endExpander = getSplitterPart(splitter, 'end-expander'); + + simulatePointerDown(startExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + const totalAvailable = getTotalSize(splitter, 'width'); + let currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal(0); + expect(currentSizes.endSize).to.equal(totalAvailable); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + simulatePointerDown(startExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.be.greaterThan(0); + expect(currentSizes.endSize).to.equal( + totalAvailable - currentSizes.startSize + ); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + simulatePointerDown(endExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal(totalAvailable); + expect(currentSizes.endSize).to.equal(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + simulatePointerDown(endExpander, { bubbles: true }); + await elementUpdated(splitter); + await nextFrame(); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal( + totalAvailable - currentSizes.endSize + ); + expect(currentSizes.endSize).to.be.greaterThan(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + }); + it('direction should not affect interactions 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 1cf383e4f..9adf3b248 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -372,6 +372,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return delta * rtlMultiplier * (direction ?? 1); } + // TODO: should there be events on expand/collapse? private _handleExpanderStartAction() { const target = this.endCollapsed ? 'end' : 'start'; this.toggle(target); @@ -635,6 +636,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } const { prevButtonHidden, nextButtonHidden } = this._getExpanderHiddenState(); + // TODO: expander button icons direction should be reversed in RTL return html`
Date: Thu, 22 Jan 2026 15:56:10 +0200 Subject: [PATCH 35/47] refactor(splitter): poc implementation without ResizeController --- src/components/splitter/splitter.spec.ts | 10 ++- src/components/splitter/splitter.ts | 108 ++++++++++++++++------- 2 files changed, 84 insertions(+), 34 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 11057168a..afc24ed7c 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -17,9 +17,9 @@ import { defineComponents } from '../common/definitions/defineComponents.js'; import { roundPrecise } from '../common/util.js'; import { simulateKeyboard, - simulateLostPointerCapture, simulatePointerDown, simulatePointerMove, + simulatePointerUp, } from '../common/utils.spec.js'; import type { SplitterOrientation } from '../types.js'; import IgcSplitterComponent from './splitter.js'; @@ -1827,6 +1827,7 @@ async function resize( simulatePointerDown(bar, { clientX: barRect.left, clientY: barRect.top, + pointerId: 1, }); await elementUpdated(splitter); @@ -1835,12 +1836,17 @@ async function resize( { clientX: barRect.left, clientY: barRect.top, + pointerId: 1, }, { x: deltaX, y: deltaY } ); await elementUpdated(splitter); - simulateLostPointerCapture(bar); + simulatePointerUp(bar, { + clientX: barRect.left + deltaX, + clientY: barRect.top + deltaY, + pointerId: 1, + }); await elementUpdated(splitter); await nextFrame(); } diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 9adf3b248..f84b7daaf 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -17,7 +17,6 @@ import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { isLTR } from '../common/util.js'; -import { addResizeController } from '../resize-container/resize-controller.js'; import type { SplitterOrientation } from '../types.js'; import { styles } from './themes/splitter.base.css.js'; @@ -77,6 +76,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private _startSize = 'auto'; private _endSize = 'auto'; private _resizeState: SplitterResizeState | null = null; + private _isDragging = false; + private _dragPointerId = -1; + private _dragStartPosition = { x: 0, y: 0 }; private readonly _internals = addInternalsController(this, { initialARIA: { @@ -271,37 +273,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< .set([ctrlKey, arrowRight], () => this._handleArrowsExpandCollapse('end', 'horizontal') ); - - addResizeController(this, { - ref: [this._barRef], - mode: 'immediate', - updateTarget: false, - resizeTarget: () => { - return this.orientation === 'horizontal' && !isLTR(this) - ? this._endPane - : this._startPane; - }, - start: () => { - if (this._resizeDisallowed) { - return false; - } - this._resizeStart(); - return true; - }, - resize: ({ state }) => { - const delta = this._resolveDelta(state.deltaX, state.deltaY); - if (delta !== 0) { - this._resizing(delta); - } - }, - end: ({ state }) => { - const delta = this._resolveDelta(state.deltaX, state.deltaY); - if (delta !== 0) { - this._resizeEnd(delta); - } - }, - cancel: () => {}, - }); } protected override firstUpdated() { @@ -310,6 +281,72 @@ export default class IgcSplitterComponent extends EventEmitterMixin< //#endregion + //#region Resize Event Handlers + + private _handleBarPointerDown(e: PointerEvent) { + if (e.button !== 0 || this._resizeDisallowed) { + return; + } + + e.preventDefault(); + + this._isDragging = true; + this._dragPointerId = e.pointerId; + this._dragStartPosition = { x: e.clientX, y: e.clientY }; + + this._resizeStart(); + this._bar.setPointerCapture(this._dragPointerId); + } + + private _handleBarPointerMove(e: PointerEvent) { + if (!this._isDragging || e.pointerId !== this._dragPointerId) { + return; + } + + const deltaX = e.clientX - this._dragStartPosition.x; + const deltaY = e.clientY - this._dragStartPosition.y; + const delta = this._resolveDelta(deltaX, deltaY); + + if (delta !== 0) { + this._resizing(delta); + } + } + + private _handleEndDrag(e: PointerEvent) { + if (!this._isDragging || e.pointerId !== this._dragPointerId) { + return; + } + + const deltaX = e.clientX - this._dragStartPosition.x; + const deltaY = e.clientY - this._dragStartPosition.y; + const delta = this._resolveDelta(deltaX, deltaY); + + if (delta !== 0) { + this._resizeEnd(delta); + } + + this._endDrag(); + } + + private _handleBarPointerCancel() { + if (!this._isDragging) { + return; + } + + this._resizeState = null; + this._endDrag(); + } + + private _endDrag() { + if (this._isDragging && this._dragPointerId !== -1) { + this._bar.releasePointerCapture(this._dragPointerId); + } + this._isDragging = false; + this._dragPointerId = -1; + } + + //#endregion + //#region Public Methods /** Toggles the collapsed state of the pane. */ @@ -664,6 +701,13 @@ export default class IgcSplitterComponent extends EventEmitterMixin< part="bar" tabindex="0" style=${styleMap(this._barInternalStyles)} + @touchstart=${(e: TouchEvent) => e.preventDefault()} + @contextmenu=${(e: PointerEvent) => e.preventDefault()} + @pointerdown=${this._handleBarPointerDown} + @pointermove=${this._handleBarPointerMove} + @pointerup=${this._handleEndDrag} + @lostpointercapture=${this._handleEndDrag} + @pointercancel=${this._handleBarPointerCancel} > ${this._renderBarControls()}
From 7d5c5536fb520511093be55e6afc93248065e69f Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Mon, 9 Feb 2026 11:13:10 +0200 Subject: [PATCH 36/47] feat(splitter): event args according to current spec --- src/components/splitter/splitter.spec.ts | 145 +++++++++++++++++++---- src/components/splitter/splitter.ts | 31 +++-- stories/splitter.stories.ts | 144 ++++++++++++---------- 3 files changed, 221 insertions(+), 99 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index afc24ed7c..5f97bec9a 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -22,7 +22,9 @@ import { simulatePointerUp, } from '../common/utils.spec.js'; import type { SplitterOrientation } from '../types.js'; -import IgcSplitterComponent from './splitter.js'; +import IgcSplitterComponent, { + type IgcSplitterResizeEventDetail, +} from './splitter.js'; describe('Splitter', () => { before(() => { @@ -480,7 +482,22 @@ describe('Splitter', () => { let currentSizes = getPanesSizes(splitter, 'width'); expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaX); expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaX); - checkResizeEvents(eventSpy); + + const newStart = previousSizes.startSize + deltaX; + const newEnd = previousSizes.endSize - deltaX; + + let startArgs = { + startPanelSize: previousSizes.startSize, + endPanelSize: previousSizes.endSize, + }; + let resizingArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + delta: deltaX, + }; + let endArgs = resizingArgs; + + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); deltaX *= -1; await resize(splitter, deltaX, 0); @@ -488,7 +505,19 @@ describe('Splitter', () => { currentSizes = getPanesSizes(splitter, 'width'); expect(currentSizes.startSize).to.equal(previousSizes.startSize); expect(currentSizes.endSize).to.equal(previousSizes.endSize); - checkResizeEvents(eventSpy); + + startArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + }; + resizingArgs = { + startPanelSize: currentSizes.startSize, + endPanelSize: currentSizes.endSize, + delta: deltaX, + }; + endArgs = resizingArgs; + + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); }); it('should resize vertically in both directions', async () => { @@ -504,7 +533,22 @@ describe('Splitter', () => { expect(currentSizes.startSize).to.equal(previousSizes.startSize + deltaY); expect(currentSizes.endSize).to.equal(previousSizes.endSize - deltaY); - checkResizeEvents(eventSpy); + + const newStart = previousSizes.startSize + deltaY; + const newEnd = previousSizes.endSize - deltaY; + + let startArgs = { + startPanelSize: previousSizes.startSize, + endPanelSize: previousSizes.endSize, + }; + let resizingArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + delta: deltaY, + }; + let endArgs = resizingArgs; + + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); deltaY *= -1; await resize(splitter, 0, deltaY); @@ -513,7 +557,17 @@ describe('Splitter', () => { expect(currentSizes.startSize).to.equal(previousSizes.startSize); expect(currentSizes.endSize).to.equal(previousSizes.endSize); - checkResizeEvents(eventSpy); + startArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + }; + resizingArgs = { + startPanelSize: currentSizes.startSize, + endPanelSize: previousSizes.endSize, + delta: deltaY, + }; + endArgs = resizingArgs; + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); }); it('should resize horizontally by 10px delta with left/right arrow keys', async () => { @@ -529,25 +583,44 @@ describe('Splitter', () => { await elementUpdated(splitter); let currentSizes = getPanesSizes(splitter, 'width'); - expect(currentSizes.startSize).to.equal( - previousSizes.startSize + resizeDelta - ); - expect(currentSizes.endSize).to.equal( - previousSizes.endSize - resizeDelta - ); - checkResizeEvents(eventSpy); + let newStart = previousSizes.startSize + resizeDelta; + let newEnd = previousSizes.endSize - resizeDelta; + expect(currentSizes.startSize).to.equal(newStart); + expect(currentSizes.endSize).to.equal(newEnd); + + let startArgs = { + startPanelSize: previousSizes.startSize, + endPanelSize: previousSizes.endSize, + }; + let resizingArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + delta: resizeDelta, + }; + let endArgs = resizingArgs; + + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); simulateKeyboard(bar, arrowRight); await elementUpdated(splitter); currentSizes = getPanesSizes(splitter, 'width'); - expect(currentSizes.startSize).to.equal( - previousSizes.startSize + resizeDelta * 2 - ); - expect(currentSizes.endSize).to.equal( - previousSizes.endSize - resizeDelta * 2 - ); - checkResizeEvents(eventSpy); + newStart = previousSizes.startSize + resizeDelta * 2; + newEnd = previousSizes.endSize - resizeDelta * 2; + expect(currentSizes.startSize).to.equal(newStart); + expect(currentSizes.endSize).to.equal(newEnd); + + startArgs = { + startPanelSize: previousSizes.startSize + resizeDelta, + endPanelSize: previousSizes.endSize - resizeDelta, + }; + resizingArgs = { + startPanelSize: newStart, + endPanelSize: newEnd, + delta: resizeDelta, + }; + endArgs = resizingArgs; + checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); previousSizes = getPanesSizes(splitter, 'width'); simulateKeyboard(bar, arrowLeft); @@ -555,12 +628,11 @@ describe('Splitter', () => { currentSizes = getPanesSizes(splitter, 'width'); - expect(currentSizes.startSize).to.equal( - previousSizes.startSize - resizeDelta - ); - expect(currentSizes.endSize).to.equal( - previousSizes.endSize + resizeDelta - ); + newStart = previousSizes.startSize - resizeDelta; + newEnd = previousSizes.endSize + resizeDelta; + expect(currentSizes.startSize).to.equal(newStart); + expect(currentSizes.endSize).to.equal(newEnd); + checkResizeEvents(eventSpy); }); @@ -1864,10 +1936,31 @@ function getPanesSizes( }; } -function checkResizeEvents(eventSpy: sinon.SinonSpy) { +function checkResizeEvents( + eventSpy: sinon.SinonSpy, + startArgs?: IgcSplitterResizeEventDetail, + resizingArgs?: IgcSplitterResizeEventDetail, + endArgs?: IgcSplitterResizeEventDetail +) { expect(eventSpy.calledWith('igcResizeStart')).to.be.true; expect(eventSpy.calledWith('igcResizing')).to.be.true; expect(eventSpy.calledWith('igcResizeEnd')).to.be.true; + + if (startArgs) { + expect( + eventSpy.withArgs('igcResizeStart').lastCall.lastArg.detail + ).to.deep.equal(startArgs); + } + if (resizingArgs) { + expect( + eventSpy.withArgs('igcResizing').lastCall.lastArg.detail + ).to.deep.equal(resizingArgs); + } + if (endArgs) { + expect( + eventSpy.withArgs('igcResizeEnd').lastCall.lastArg.detail + ).to.deep.equal(endArgs); + } eventSpy.resetHistory(); } diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index f84b7daaf..41880e046 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -20,15 +20,19 @@ import { isLTR } from '../common/util.js'; import type { SplitterOrientation } from '../types.js'; import { styles } from './themes/splitter.base.css.js'; -export interface IgcSplitterBarResizeEventArgs { - startPane: HTMLElement; - endPane: HTMLElement; +export interface IgcSplitterResizeEventDetail { + /** The current size of the start panel in pixels */ + startPanelSize: number; + /** The current size of the end panel in pixels */ + endPanelSize: number; + /** The change in size since the resize operation started (only for igcResizing and igcResizeEnd) */ + delta?: number; } export interface IgcSplitterComponentEventMap { - igcResizeStart: CustomEvent; - igcResizing: CustomEvent; - igcResizeEnd: CustomEvent; + igcResizeStart: CustomEvent; + igcResizing: CustomEvent; + igcResizeEnd: CustomEvent; } interface PaneResizeState { @@ -444,9 +448,8 @@ export default class IgcSplitterComponent extends EventEmitterMixin< startPane: this._createPaneState('start', startSize), endPane: this._createPaneState('end', endSize), }; - // TODO: are these event args needed? this.emitEvent('igcResizeStart', { - detail: { startPane: this._startPane, endPane: this._endPane }, + detail: { startPanelSize: startSize, endPanelSize: endSize }, }); } @@ -493,7 +496,11 @@ export default class IgcSplitterComponent extends EventEmitterMixin< this.endSize = `${endPaneSize}px`; this.emitEvent('igcResizing', { - detail: { startPane: this._startPane, endPane: this._endPane }, + detail: { + startPanelSize: startPaneSize, + endPanelSize: endPaneSize, + delta, + }, }); } @@ -517,7 +524,11 @@ export default class IgcSplitterComponent extends EventEmitterMixin< this.endSize = this._computeSize(this._resizeState.endPane, endPaneSize); this.emitEvent('igcResizeEnd', { - detail: { startPane: this._startPane, endPane: this._endPane }, + detail: { + startPanelSize: startPaneSize, + endPanelSize: endPaneSize, + delta, + }, }); this._resizeState = null; } diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index b68f3c594..3889ba8c4 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -140,72 +140,90 @@ export const Default: Story = { startMaxSize, endMinSize, endMaxSize, - }) => html` - + return html` + + +
+ +
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. + Pellentesque scelerisque elementum ante, et tincidunt eros ultrices + sit amet. Mauris non consectetur nunc. In hac habitasse platea + dictumst. Pellentesque ornare et tellus sit amet varius. Nulla in + augue rhoncus, finibus mauris semper, tincidunt sem. Cras vitae + semper neque, eget tempus massa. Maecenas gravida turpis quis + interdum bibendum. Nam quis ultricies est. Fusce ante erat, iaculis + quis iaculis ut, iaculis sed nunc. Cras iaculis condimentum lacus + nec tempus. Nam ex massa, mattis vitae iaculis in, suscipit ut nibh. +
+
+ Maecenas sit amet ipsum non ipsum scelerisque varius. Maecenas + scelerisque nisl scelerisque nulla ultricies eleifend. Aliquam sit + amet velit mauris. Duis at nulla vitae risus condimentum semper. Nam + ornare arcu vitae euismod pharetra. Morbi facilisis tincidunt lorem + at consequat. Aliquam varius quam non eros suscipit, ac tincidunt + sapien porttitor. Sed sed lorem quam. Praesent blandit aliquam arcu + a vestibulum. Mauris porta faucibus ex in vehicula. Pellentesque ut + risus quis felis molestie facilisis eget et est. Proin interdum urna + vitae porttitor suscipit. Curabitur lobortis aliquet dolor sit amet + varius. Proin a semper velit, non molestie libero. Suspendisse + potenti. Aliquam vestibulum dui id lacus suscipit, eget posuere + justo venenatis. Vestibulum id velit ac dui posuere pretium. +
+
+
+ Change All Panes Min/Max Sizes (px) -
- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque - scelerisque elementum ante, et tincidunt eros ultrices sit amet. - Mauris non consectetur nunc. In hac habitasse platea dictumst. - Pellentesque ornare et tellus sit amet varius. Nulla in augue rhoncus, - finibus mauris semper, tincidunt sem. Cras vitae semper neque, eget - tempus massa. Maecenas gravida turpis quis interdum bibendum. Nam quis - ultricies est. Fusce ante erat, iaculis quis iaculis ut, iaculis sed - nunc. Cras iaculis condimentum lacus nec tempus. Nam ex massa, mattis - vitae iaculis in, suscipit ut nibh. -
-
- Maecenas sit amet ipsum non ipsum scelerisque varius. Maecenas - scelerisque nisl scelerisque nulla ultricies eleifend. Aliquam sit - amet velit mauris. Duis at nulla vitae risus condimentum semper. Nam - ornare arcu vitae euismod pharetra. Morbi facilisis tincidunt lorem at - consequat. Aliquam varius quam non eros suscipit, ac tincidunt sapien - porttitor. Sed sed lorem quam. Praesent blandit aliquam arcu a - vestibulum. Mauris porta faucibus ex in vehicula. Pellentesque ut - risus quis felis molestie facilisis eget et est. Proin interdum urna - vitae porttitor suscipit. Curabitur lobortis aliquet dolor sit amet - varius. Proin a semper velit, non molestie libero. Suspendisse - potenti. Aliquam vestibulum dui id lacus suscipit, eget posuere justo - venenatis. Vestibulum id velit ac dui posuere pretium. -
-
-
- Change All Panes Min/Max Sizes (px) - Change All Panes Min/Max Sizes (%) - `, + Change All Panes Min/Max Sizes (%) + `; + }, }; export const NestedSplitters: Story = { From cad9de372059d2b27a747842cddab96f6d0297a0 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Mon, 9 Feb 2026 12:51:32 +0200 Subject: [PATCH 37/47] feat(splitter): implement drag and expander icons slots --- src/components/splitter/splitter.ts | 42 +++++++++++++++++--- stories/splitter.stories.ts | 59 ++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 41880e046..c2bb5fa54 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -17,6 +17,7 @@ import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { isLTR } from '../common/util.js'; +import IgcIconComponent from '../icon/icon.js'; import type { SplitterOrientation } from '../types.js'; import { styles } from './themes/splitter.base.css.js'; @@ -57,6 +58,14 @@ interface SplitterResizeState { * @fires igcResizing - Emitted while resizing. * @fires igcResizeEnd - Emitted when resizing ends. * + * @slot start - Content for the start pane. + * @slot end - Content for the end pane. + * @slot drag-handle - Optional slot for custom cursor content (not visually rendered, can be used for cursor customization). + * @slot start-expand - Optional slot to customize the icon for expanding the start panel. + * @slot start-collapse - Optional slot to customize the icon for collapsing the start panel. + * @slot end-expand - Optional slot to customize the icon for expanding the end panel. + * @slot end-collapse - Optional slot to customize the icon for collapsing the end panel. + * * @csspart ... - ... . */ export default class IgcSplitterComponent extends EventEmitterMixin< @@ -68,7 +77,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /* blazorSuppress */ public static register() { - registerComponent(IgcSplitterComponent); + registerComponent(IgcSplitterComponent, IgcIconComponent); } //#region Private Properties @@ -256,7 +265,15 @@ export default class IgcSplitterComponent extends EventEmitterMixin< super(); addSlotController(this, { - slots: setSlots('start', 'end'), + slots: setSlots( + 'start', + 'end', + 'drag-handle', + 'start-expand', + 'start-collapse', + 'end-expand', + 'end-collapse' + ), }); addKeybindings(this, { ref: this._barRef, @@ -684,20 +701,33 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } const { prevButtonHidden, nextButtonHidden } = this._getExpanderHiddenState(); - // TODO: expander button icons direction should be reversed in RTL + + const startExpanderSlot = this.endCollapsed + ? 'end-expand' + : 'start-collapse'; + const endExpanderSlot = this.startCollapsed + ? 'start-expand' + : 'end-collapse'; + return html`
this._handleExpanderClick('start', e)} - >
-
+ > + +
+
+ +
this._handleExpanderClick('end', e)} - >
+ > + +
`; } diff --git a/stories/splitter.stories.ts b/stories/splitter.stories.ts index 3889ba8c4..303b8f54b 100644 --- a/stories/splitter.stories.ts +++ b/stories/splitter.stories.ts @@ -1,11 +1,11 @@ import type { Meta, StoryObj } from '@storybook/web-components-vite'; import { html } from 'lit'; -import { defineComponents } from 'igniteui-webcomponents'; +import { defineComponents, IgcIconComponent } from 'igniteui-webcomponents'; import IgcSplitterComponent from '../src/components/splitter/splitter.js'; import { disableStoryControls } from './story.js'; -defineComponents(IgcSplitterComponent); +defineComponents(IgcSplitterComponent, IgcIconComponent); const metadata: Meta = { title: 'Splitter', @@ -253,3 +253,58 @@ export const NestedSplitters: Story = {
`, }; + +export const Slots: Story = { + render: ({ + orientation, + nonCollapsible, + nonResizable, + startCollapsed, + endCollapsed, + }) => html` + + + +
Start panel with custom icons
+
End panel with custom icons
+ + + + ${orientation === 'horizontal' ? '⋮' : '⋯'} + + + + + ${orientation === 'horizontal' ? '➡️' : '⬇️'} + + + ${orientation === 'horizontal' ? '⬅️' : '⬆️'} + + + + + ${orientation === 'horizontal' ? '🔙' : '🔼'} + + + ${orientation === 'horizontal' ? '🔜' : '🔽'} + +
+ `, +}; From 398ff7fd0d3bb283253f7e219fd8b82d585a6302 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Mon, 9 Feb 2026 13:38:54 +0200 Subject: [PATCH 38/47] refactor(splitter): change css parts according to updated spec --- src/components/splitter/splitter.spec.ts | 213 ++++++++++++------ src/components/splitter/splitter.ts | 41 +++- .../splitter/themes/splitter.base.scss | 30 +-- 3 files changed, 185 insertions(+), 99 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 5f97bec9a..4cd475fe0 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -26,6 +26,15 @@ import IgcSplitterComponent, { type IgcSplitterResizeEventDetail, } from './splitter.js'; +const BAR_PART = 'splitter-bar'; +const START_PART = 'start-panel'; +const END_PART = 'end-panel'; +const START_EXPANDER_PART = 'start-expand-btn'; +const END_EXPANDER_PART = 'end-expand-btn'; +const START_COLLAPSE_PART = 'start-collapse-btn'; +const END_COLLAPSE_PART = 'end-collapse-btn'; +const DRAG_HANDLE_PART = 'drag-handle'; + describe('Splitter', () => { before(() => { defineComponents(IgcSplitterComponent); @@ -63,9 +72,9 @@ describe('Splitter', () => { it('should render splitter bar between start and end parts', async () => { const base = getSplitterPart(splitter, 'base'); - const startPart = getSplitterPart(splitter, 'start-pane'); - const endPart = getSplitterPart(splitter, 'end-pane'); - const bar = getSplitterPart(splitter, 'bar'); + const startPart = getSplitterPart(splitter, START_PART); + const endPart = getSplitterPart(splitter, END_PART); + const bar = getSplitterPart(splitter, BAR_PART); expect(base).to.exist; expect(startPart).to.exist; @@ -81,28 +90,34 @@ describe('Splitter', () => { }); it('should render splitter bar parts', async () => { - const bar = getSplitterPart(splitter, 'bar'); - const expanderStart = getSplitterPart(splitter, 'start-expander'); - const barHandle = getSplitterPart(splitter, 'handle'); - const expanderEnd = getSplitterPart(splitter, 'end-expander'); + const bar = getSplitterPart(splitter, BAR_PART); + const expanderStartCollapseBtn = getSplitterPart( + splitter, + START_COLLAPSE_PART + ); + const barHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + const expanderEndCollapseBtn = getSplitterPart( + splitter, + END_COLLAPSE_PART + ); - expect(expanderStart).to.exist; + expect(expanderStartCollapseBtn).to.exist; expect(barHandle).to.exist; - expect(expanderEnd).to.exist; + expect(expanderEndCollapseBtn).to.exist; - expect(bar.contains(expanderStart)).to.be.true; - expect(bar.contains(expanderEnd)).to.be.true; + expect(bar.contains(expanderStartCollapseBtn)).to.be.true; + expect(bar.contains(expanderEndCollapseBtn)).to.be.true; expect(bar.contains(barHandle)).to.be.true; - expect(expanderStart.nextElementSibling).to.equal(barHandle); - expect(barHandle.nextElementSibling).to.equal(expanderEnd); + expect(expanderStartCollapseBtn.nextElementSibling).to.equal(barHandle); + expect(barHandle.nextElementSibling).to.equal(expanderEndCollapseBtn); }); it('should not display the bar elements if the splitter is nonCollapsible', async () => { splitter.nonCollapsible = true; await elementUpdated(splitter); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); expect(bar.children).to.have.lengthOf(0); }); @@ -174,7 +189,7 @@ describe('Splitter', () => { }); it('should set a default cursor on the bar in case splitter is not resizable or any pane is collapsed', async () => { - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); const style = getComputedStyle(bar); expect(style.cursor).to.equal('col-resize'); @@ -200,7 +215,7 @@ describe('Splitter', () => { }); it('should change the bar cursor based on the orientation', async () => { - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); const style = getComputedStyle(bar); expect(style.cursor).to.equal('col-resize'); @@ -250,7 +265,7 @@ describe('Splitter', () => { ); await elementUpdated(splitter); - const startPart = getSplitterPart(splitter, 'start-pane'); + const startPart = getSplitterPart(splitter, START_PART); const style = getComputedStyle(startPart); expect(style.flex).to.equal('0 1 200px'); @@ -272,7 +287,7 @@ describe('Splitter', () => { it('should properly set default min/max values when not specified', async () => { await elementUpdated(splitter); - const startPart = getSplitterPart(splitter, 'start-pane'); + const startPart = getSplitterPart(splitter, START_PART); const style = getComputedStyle(startPart); expect(style.flex).to.equal('1 1 0px'); @@ -297,7 +312,7 @@ describe('Splitter', () => { await elementUpdated(splitter); - const startPane = getSplitterPart(splitter, 'start-pane'); + const startPane = getSplitterPart(splitter, START_PART); const style = getComputedStyle(startPane); expect(style.minWidth).to.equal('100px'); expect(style.maxWidth).to.equal('500px'); @@ -313,7 +328,7 @@ describe('Splitter', () => { ); await elementUpdated(splitter); - const startPane = getSplitterPart(splitter, 'start-pane'); + const startPane = getSplitterPart(splitter, START_PART); const style = getComputedStyle(startPane); expect(style.minHeight).to.equal('100px'); expect(style.maxHeight).to.equal('500px'); @@ -337,10 +352,10 @@ describe('Splitter', () => { splitter, orientation === 'horizontal' ? 'width' : 'height' ); - const startPane = getSplitterPart(splitter, 'start-pane'); + const startPane = getSplitterPart(splitter, START_PART); const style1 = getComputedStyle(startPane); - const endPane = getSplitterPart(splitter, 'end-pane'); + const endPane = getSplitterPart(splitter, END_PART); const style2 = getComputedStyle(endPane); const sizes = getPanesSizes( splitter, @@ -384,7 +399,7 @@ describe('Splitter', () => { orientation === 'horizontal' ? 'width' : 'height' ); - const startPart = getSplitterPart(splitter, 'start-pane'); + const startPart = getSplitterPart(splitter, START_PART); const style = getComputedStyle(startPart); expect(style.flex).to.equal('1 1 0px'); @@ -395,7 +410,7 @@ describe('Splitter', () => { const expectedEndSize = roundPrecise((30 / 100) * totalAvailable, 2); expect(sizes.endSize).to.be.closeTo(expectedEndSize, 2); - const endPart = getSplitterPart(splitter, 'end-pane'); + const endPart = getSplitterPart(splitter, END_PART); const styleEnd = getComputedStyle(endPart); expect(styleEnd.flex).to.equal('0 1 30%'); }; @@ -425,50 +440,64 @@ describe('Splitter', () => { expect(splitter.endCollapsed).to.be.true; }); - it('should toggle the next pane when the bar expander-end is clicked', async () => { - const expanderStart = getSplitterPart(splitter, 'start-expander'); - const expanderEnd = getSplitterPart(splitter, 'end-expander'); + it('should toggle the next pane when the bar expander-end parts are clicked', async () => { + let parts = getButtonParts(splitter); - simulatePointerDown(expanderEnd, { bubbles: true }); + simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); + parts = getButtonParts(splitter); + expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.true; - expect(expanderStart.hidden).to.be.false; - expect(expanderEnd.hidden).to.be.true; - simulatePointerDown(expanderStart, { bubbles: true }); + expect(parts.startCollapseBtn).to.be.null; + expect(parts.endCollapseBtn.hidden).to.be.true; + expect(parts.startExpander).to.be.null; + expect(parts.endExpander.hidden).to.be.false; + + simulatePointerDown(parts.endExpander, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); - expect(splitter.startCollapsed).to.be.false; - expect(splitter.endCollapsed).to.be.false; - expect(expanderStart.hidden).to.be.false; - expect(expanderEnd.hidden).to.be.false; + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.hidden).to.be.false; + expect(parts.endCollapseBtn.hidden).to.be.false; + expect(parts.startExpander).to.be.null; + expect(parts.endExpander).to.be.null; }); - it('should toggle the previous pane when the bar expander-start is clicked', async () => { - const expanderStart = getSplitterPart(splitter, 'start-expander'); - const expanderEnd = getSplitterPart(splitter, 'end-expander'); + it('should toggle the previous pane when the bar expander-start parts are clicked', async () => { + let parts = getButtonParts(splitter); - simulatePointerDown(expanderStart, { bubbles: true }); + simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); + parts = getButtonParts(splitter); + expect(splitter.startCollapsed).to.be.true; expect(splitter.endCollapsed).to.be.false; - expect(expanderStart.hidden).to.be.true; - expect(expanderEnd.hidden).to.be.false; - simulatePointerDown(expanderEnd, { bubbles: true }); + expect(parts.startCollapseBtn.hidden).to.be.true; + expect(parts.startExpander.hidden).to.be.false; + expect(parts.endCollapseBtn).to.be.null; + expect(parts.endExpander).to.be.null; + + simulatePointerDown(parts.startExpander, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); + parts = getButtonParts(splitter); + expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.false; - expect(expanderStart.hidden).to.be.false; - expect(expanderEnd.hidden).to.be.false; + + expect(parts.startCollapseBtn.hidden).to.be.false; + expect(parts.endCollapseBtn.hidden).to.be.false; + expect(parts.startExpander).to.be.null; + expect(parts.endExpander).to.be.null; }); it('should resize horizontally in both directions', async () => { @@ -572,7 +601,7 @@ describe('Splitter', () => { it('should resize horizontally by 10px delta with left/right arrow keys', async () => { const eventSpy = spy(splitter, 'emitEvent'); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); let previousSizes = getPanesSizes(splitter, 'width'); const resizeDelta = 10; @@ -641,7 +670,7 @@ describe('Splitter', () => { splitter.orientation = 'vertical'; await elementUpdated(splitter); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); let previousSizes = getPanesSizes(splitter, 'height'); const resizeDelta = 10; @@ -692,7 +721,7 @@ describe('Splitter', () => { await elementUpdated(splitter); const eventSpy = spy(splitter, 'emitEvent'); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); const previousSizes = getPanesSizes(splitter, 'height'); bar.focus(); @@ -714,7 +743,7 @@ describe('Splitter', () => { it('should not resize with up/down keys when in horizontal orientation', async () => { const eventSpy = spy(splitter, 'emitEvent'); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); const previousSizes = getPanesSizes(splitter, 'width'); bar.focus(); @@ -736,7 +765,7 @@ describe('Splitter', () => { // TODO: should there be events on expand/collapse? it('should expand/collapse panes with Ctrl + left/right arrow keys in horizontal orientation', async () => { - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); bar.focus(); await elementUpdated(splitter); @@ -791,7 +820,7 @@ describe('Splitter', () => { splitter.orientation = 'vertical'; await elementUpdated(splitter); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); bar.focus(); await elementUpdated(splitter); @@ -848,7 +877,7 @@ describe('Splitter', () => { const eventSpy = spy(splitter, 'emitEvent'); let previousSizes = getPanesSizes(splitter, 'width'); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); await resize(splitter, 100, 0); @@ -896,7 +925,7 @@ describe('Splitter', () => { expect(splitter.nonCollapsible).to.be.true; expect(splitter.hasAttribute('non-collapsible')).to.be.true; - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); bar.focus(); await elementUpdated(splitter); @@ -1147,7 +1176,7 @@ describe('Splitter', () => { ); await elementUpdated(mixedConstraintSplitter); - const startPane = getSplitterPart(mixedConstraintSplitter, 'start-pane'); + const startPane = getSplitterPart(mixedConstraintSplitter, START_PART); const style = getComputedStyle(startPane); expect(mixedConstraintSplitter.startMinSize).to.equal('100px'); @@ -1367,7 +1396,7 @@ describe('Splitter', () => { splitter.style.width = `${totalSplitterSize}px`; await elementUpdated(splitter); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); const barSize = bar.getBoundingClientRect().width; const previousSizes = getPanesSizes(splitter, 'width'); const deltaX = 100; @@ -1400,7 +1429,7 @@ describe('Splitter', () => { splitter.style.height = `${totalSplitterSize}px`; await elementUpdated(splitter); - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); const barSize = bar.getBoundingClientRect().height; const previousSizes = getPanesSizes(splitter, 'height'); const deltaY = 100; @@ -1557,7 +1586,7 @@ describe('Splitter', () => { }); it('should resize correctly with keyboard in RTL', async () => { - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); let previousSizes = getPanesSizes(splitter, 'width'); const resizeDelta = 10; @@ -1602,7 +1631,7 @@ describe('Splitter', () => { }); it('should expand/collapse correctly with keyboard in RTL', async () => { - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); bar.focus(); await elementUpdated(splitter); @@ -1654,10 +1683,9 @@ describe('Splitter', () => { }); it('should expand/collapse the correct pane through the expander buttons in RTL', async () => { - const startExpander = getSplitterPart(splitter, 'start-expander'); - const endExpander = getSplitterPart(splitter, 'end-expander'); + let parts = getButtonParts(splitter); - simulatePointerDown(startExpander, { bubbles: true }); + simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); @@ -1666,10 +1694,17 @@ describe('Splitter', () => { expect(currentSizes.startSize).to.equal(0); expect(currentSizes.endSize).to.equal(totalAvailable); + expect(splitter.startCollapsed).to.be.true; expect(splitter.endCollapsed).to.be.false; - simulatePointerDown(startExpander, { bubbles: true }); + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.hidden).to.be.true; + expect(parts.startExpander.hidden).to.be.false; + expect(parts.endCollapseBtn).to.be.null; + expect(parts.endExpander).to.be.null; + + simulatePointerDown(parts.startExpander, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); @@ -1682,7 +1717,13 @@ describe('Splitter', () => { expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.false; - simulatePointerDown(endExpander, { bubbles: true }); + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.hidden).to.be.false; + expect(parts.startExpander).to.be.null; + expect(parts.endCollapseBtn.hidden).to.be.false; + expect(parts.endExpander).to.be.null; + + simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); @@ -1693,7 +1734,13 @@ describe('Splitter', () => { expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.true; - simulatePointerDown(endExpander, { bubbles: true }); + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn).to.be.null; + expect(parts.startExpander).to.be.null; + expect(parts.endCollapseBtn.hidden).to.be.true; + expect(parts.endExpander.hidden).to.be.false; + + simulatePointerDown(parts.endExpander, { bubbles: true }); await elementUpdated(splitter); await nextFrame(); @@ -1705,6 +1752,12 @@ describe('Splitter', () => { expect(currentSizes.endSize).to.be.greaterThan(0); expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.false; + + parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.hidden).to.be.false; + expect(parts.startExpander).to.be.null; + expect(parts.endCollapseBtn.hidden).to.be.false; + expect(parts.endExpander).to.be.null; }); it('direction should not affect interactions in vertical orientation', async () => { @@ -1712,7 +1765,7 @@ describe('Splitter', () => { await elementUpdated(splitter); // 1. Resize with keyboard - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); let previousSizes = getPanesSizes(splitter, 'height'); const resizeDelta = 10; @@ -1872,15 +1925,16 @@ function getSplitterSlot( ) as HTMLSlotElement; } -// TODO: more parts and names? type SplitterParts = - | 'start-pane' - | 'end-pane' - | 'bar' | 'base' - | 'start-expander' - | 'end-expander' - | 'handle'; + | typeof START_PART + | typeof END_PART + | typeof BAR_PART + | typeof START_EXPANDER_PART + | typeof END_EXPANDER_PART + | typeof START_COLLAPSE_PART + | typeof END_COLLAPSE_PART + | typeof DRAG_HANDLE_PART; function getSplitterPart(splitter: IgcSplitterComponent, which: SplitterParts) { return splitter.shadowRoot!.querySelector( @@ -1888,12 +1942,21 @@ function getSplitterPart(splitter: IgcSplitterComponent, which: SplitterParts) { ) as HTMLElement; } +function getButtonParts(splitter: IgcSplitterComponent) { + return { + startExpander: getSplitterPart(splitter, START_EXPANDER_PART), + endExpander: getSplitterPart(splitter, END_EXPANDER_PART), + startCollapseBtn: getSplitterPart(splitter, START_COLLAPSE_PART), + endCollapseBtn: getSplitterPart(splitter, END_COLLAPSE_PART), + }; +} + async function resize( splitter: IgcSplitterComponent, deltaX: number, deltaY: number ) { - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); const barRect = bar.getBoundingClientRect(); simulatePointerDown(bar, { @@ -1927,8 +1990,8 @@ function getPanesSizes( splitter: IgcSplitterComponent, dimension: 'width' | 'height' = 'width' ) { - const startPane = getSplitterPart(splitter, 'start-pane'); - const endPane = getSplitterPart(splitter, 'end-pane'); + const startPane = getSplitterPart(splitter, START_PART); + const endPane = getSplitterPart(splitter, END_PART); return { startSize: roundPrecise(startPane.getBoundingClientRect()[dimension]), @@ -1979,7 +2042,7 @@ function getTotalSize( splitter: IgcSplitterComponent, dimension: 'width' | 'height' ) { - const bar = getSplitterPart(splitter, 'bar'); + const bar = getSplitterPart(splitter, BAR_PART); const barSize = bar.getBoundingClientRect()[dimension]; const splitterSize = splitter.getBoundingClientRect()[dimension]; const totalAvailable = splitterSize - barSize; diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index c2bb5fa54..79dfa15a0 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -16,6 +16,7 @@ import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { partMap } from '../common/part-map.js'; import { isLTR } from '../common/util.js'; import IgcIconComponent from '../icon/icon.js'; import type { SplitterOrientation } from '../types.js'; @@ -66,7 +67,14 @@ interface SplitterResizeState { * @slot end-expand - Optional slot to customize the icon for expanding the end panel. * @slot end-collapse - Optional slot to customize the icon for collapsing the end panel. * - * @csspart ... - ... . + * @csspart splitter-bar - The resizable bar element between the two panels. + * @csspart drag-handle - The drag handle icon/element on the splitter bar. + * @csspart start-panel - The container for the start panel content. + * @csspart end-panel - The container for the end panel content. + * @csspart start-collapse-btn - The button to collapse the start panel. + * @csspart end-collapse-btn - The button to collapse the end panel. + * @csspart start-expand-btn - The button to expand the start panel when collapsed. + * @csspart end-expand-btn - The button to expand the end panel when collapsed. */ export default class IgcSplitterComponent extends EventEmitterMixin< IgcSplitterComponentEventMap, @@ -102,13 +110,13 @@ export default class IgcSplitterComponent extends EventEmitterMixin< @query('[part~="base"]', true) private readonly _base!: HTMLElement; - @query('[part~="start-pane"]', true) + @query('[part~="start-panel"]', true) private readonly _startPane!: HTMLElement; - @query('[part~="end-pane"]', true) + @query('[part~="end-panel"]', true) private readonly _endPane!: HTMLElement; - @query('[part~="bar"]', true) + @query('[part~="splitter-bar"]', true) private readonly _bar!: HTMLElement; private get _resizeDisallowed() { @@ -709,20 +717,30 @@ export default class IgcSplitterComponent extends EventEmitterMixin< ? 'start-expand' : 'end-collapse'; + const startExpanderParts = { + 'end-expand-btn': this.endCollapsed, + 'start-collapse-btn': !this.endCollapsed, + }; + + const endExpanderParts = { + 'start-expand-btn': this.startCollapsed, + 'end-collapse-btn': !this.startCollapsed, + }; + return html`
this._handleExpanderClick('start', e)} >
-
+
this._handleExpanderClick('end', e)} > @@ -734,12 +752,15 @@ export default class IgcSplitterComponent extends EventEmitterMixin< protected override render() { return html`
-
+
e.preventDefault()} @@ -752,7 +773,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< > ${this._renderBarControls()}
-
+
diff --git a/src/components/splitter/themes/splitter.base.scss b/src/components/splitter/themes/splitter.base.scss index c018bcf5a..c31914b01 100644 --- a/src/components/splitter/themes/splitter.base.scss +++ b/src/components/splitter/themes/splitter.base.scss @@ -17,8 +17,8 @@ } // Styles for the pane wrapper divs (startPane/endPane) - [part~='start-pane'], - [part~='end-pane'] { + [part~='start-panel'], + [part~='end-panel'] { display: flex; flex: 1 1 auto; width: 100%; @@ -37,7 +37,7 @@ } // Bar styles (moved from splitter-bar.base.scss) - [part='bar'] { + [part='splitter-bar'] { --bar-size: 5px; display: flex; @@ -50,22 +50,24 @@ background-color: var(--ig-gray-400); } - [part='start-expander'], - [part='end-expander'] { + [part*='collapse-btn'], + [part*='expand-btn'] { cursor: pointer; width: 5px; height: 5px; } - [part='start-expander'] { + [part='start-collapse-btn'], + [part='start-expand-btn'] { background-color: red; } - [part='end-expander'] { + [part='end-collapse-btn'], + [part='end-expand-btn'] { background-color: green; } - [part='handle'] { + [part='drag-handle'] { background-color: yellow; } } @@ -77,12 +79,12 @@ flex-direction: row; } - [part='bar'] { + [part='splitter-bar'] { flex-direction: column; width: var(--bar-size); height: 100%; - [part='handle'] { + [part='drag-handle'] { height: 50px; } } @@ -94,12 +96,12 @@ flex-direction: column; } - [part='bar'] { + [part='splitter-bar'] { flex-direction: row; width: 100%; height: var(--bar-size); - [part='handle'] { + [part='drag-handle'] { width: 50px; } } @@ -107,13 +109,13 @@ // Collapsed states :host([start-collapsed]) { - [part='start-pane'] { + [part='start-panel'] { display: none; } } :host([end-collapsed]) { - [part~='end-pane'] { + [part~='end-panel'] { display: none; } } From 3479c4599928fadccd04d0d7956f8b78e8f7c2ed Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Mon, 9 Feb 2026 14:22:03 +0200 Subject: [PATCH 39/47] feat(splitter): props according to current spec --- src/components/splitter/splitter.spec.ts | 167 +++++++++++++++++++++-- src/components/splitter/splitter.ts | 58 +++++--- stories/splitter.stories.ts | 52 +++++-- 3 files changed, 236 insertions(+), 41 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 4cd475fe0..4f445163c 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -113,12 +113,88 @@ describe('Splitter', () => { expect(barHandle.nextElementSibling).to.equal(expanderEndCollapseBtn); }); - it('should not display the bar elements if the splitter is nonCollapsible', async () => { - splitter.nonCollapsible = true; + it('should not display the collapse/expand button parts if disableCollapse is true', async () => { + splitter.disableCollapse = true; await elementUpdated(splitter); const bar = getSplitterPart(splitter, BAR_PART); - expect(bar.children).to.have.lengthOf(0); + expect(bar.children).to.have.lengthOf(3); + + let startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + let endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + let dragHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + + expect(startCollapseBtn.hidden).to.be.true; + expect(endCollapseBtn.hidden).to.be.true; + expect(dragHandle.hidden).to.be.false; + + // verify this with programmatic expand/collapse as well + splitter.toggle('start'); + await elementUpdated(splitter); + + startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + dragHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + + const startExpandButton = getSplitterPart(splitter, START_EXPANDER_PART); + + expect(startExpandButton.hidden).to.be.true; + expect(startCollapseBtn.hidden).to.be.true; + expect(endCollapseBtn).to.be.null; + expect(dragHandle.hidden).to.be.false; + }); + + it('should not display the collapse/expand button parts if hideCollapseButtons is true', async () => { + splitter.hideCollapseButtons = true; + await elementUpdated(splitter); + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.children).to.have.lengthOf(3); + + let startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + let endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + let dragHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + + expect(startCollapseBtn.hidden).to.be.true; + expect(endCollapseBtn.hidden).to.be.true; + expect(dragHandle.hidden).to.be.false; + + splitter.toggle('end'); + await elementUpdated(splitter); + + startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + dragHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + + const startExpandButton = getSplitterPart(splitter, START_EXPANDER_PART); + + expect(startExpandButton).to.be.null; + expect(startCollapseBtn).to.be.null; + expect(endCollapseBtn.hidden).to.be.true; + expect(dragHandle.hidden).to.be.false; + }); + + it('should not display bar handle if disableResize is true', async () => { + splitter.disableResize = true; + await elementUpdated(splitter); + + const barHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + const startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + const endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + expect(barHandle.hidden).to.be.true; + expect(startCollapseBtn.hidden).to.be.false; + expect(endCollapseBtn.hidden).to.be.false; + }); + + it('should not display bar handle if hideDragHandle is true', async () => { + splitter.hideDragHandle = true; + await elementUpdated(splitter); + + const barHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + const startCollapseBtn = getSplitterPart(splitter, START_COLLAPSE_PART); + const endCollapseBtn = getSplitterPart(splitter, END_COLLAPSE_PART); + expect(barHandle.hidden).to.be.true; + expect(startCollapseBtn.hidden).to.be.false; + expect(endCollapseBtn.hidden).to.be.false; }); it('should have default horizontal orientation', () => { @@ -194,13 +270,13 @@ describe('Splitter', () => { const style = getComputedStyle(bar); expect(style.cursor).to.equal('col-resize'); - splitter.nonResizable = true; + splitter.disableResize = true; await elementUpdated(splitter); await nextFrame(); expect(style.cursor).to.equal('default'); - splitter.nonResizable = false; + splitter.disableResize = false; splitter.endCollapsed = true; await elementUpdated(splitter); await nextFrame(); @@ -871,8 +947,8 @@ describe('Splitter', () => { expect(splitter.endCollapsed).to.be.false; }); - it('should not resize when nonResizable is true', async () => { - splitter.nonResizable = true; + it('should not resize when disableResize is true', async () => { + splitter.disableResize = true; await elementUpdated(splitter); const eventSpy = spy(splitter, 'emitEvent'); @@ -918,12 +994,12 @@ describe('Splitter', () => { expect(eventSpy.called).to.be.false; }); - it('should not expand/collapse panes with Ctrl + arrow keys when nonCollapsible is true', async () => { - splitter.nonCollapsible = true; + it('should not expand/collapse panes with Ctrl + arrow keys when disableCollapse is true', async () => { + splitter.disableCollapse = true; await elementUpdated(splitter); - expect(splitter.nonCollapsible).to.be.true; - expect(splitter.hasAttribute('non-collapsible')).to.be.true; + expect(splitter.disableCollapse).to.be.true; + expect(splitter.hasAttribute('disable-collapse')).to.be.true; const bar = getSplitterPart(splitter, BAR_PART); bar.focus(); @@ -961,6 +1037,75 @@ describe('Splitter', () => { expect(splitter.endCollapsed).to.be.false; }); + it('should expand/collapse panes via keyboard and API when hideCollapseButtons is true', async () => { + splitter.hideCollapseButtons = true; + await elementUpdated(splitter); + + expect(splitter.hideCollapseButtons).to.be.true; + expect(splitter.hasAttribute('hide-collapse-buttons')).to.be.true; + + const bar = getSplitterPart(splitter, BAR_PART); + + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + + let currentSizes = getPanesSizes(splitter, 'width'); + const splitterSize = splitter.getBoundingClientRect().width; + const barSize = bar.getBoundingClientRect().width; + + expect(currentSizes.startSize).to.equal(0); + expect(currentSizes.endSize).to.equal(splitterSize - barSize); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.be.greaterThan(0); + expect(currentSizes.endSize).to.equal( + splitterSize - barSize - currentSizes.startSize + ); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + simulateKeyboard(bar, [ctrlKey, arrowRight]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal(splitterSize - barSize); + expect(currentSizes.endSize).to.equal(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + simulateKeyboard(bar, [ctrlKey, arrowLeft]); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal( + splitterSize - barSize - currentSizes.endSize + ); + expect(currentSizes.endSize).to.be.greaterThan(0); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + splitter.toggle('start'); + await elementUpdated(splitter); + + currentSizes = getPanesSizes(splitter, 'width'); + + expect(currentSizes.startSize).to.equal(0); + expect(currentSizes.endSize).to.equal(splitterSize - barSize); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + }); + it('should not be able to resize a pane when it is collapsed', async () => { splitter.toggle('start'); await elementUpdated(splitter); diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 79dfa15a0..ca03b9587 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -1,4 +1,4 @@ -import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { html, LitElement, type PropertyValues } from 'lit'; import { property, query } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; @@ -120,7 +120,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private readonly _bar!: HTMLElement; private get _resizeDisallowed() { - return this.nonResizable || this.startCollapsed || this.endCollapsed; + return this.disableResize || this.startCollapsed || this.endCollapsed; } private get _barCursor(): string { @@ -143,20 +143,46 @@ export default class IgcSplitterComponent extends EventEmitterMixin< public orientation: SplitterOrientation = 'horizontal'; /** - * Sets the visibility of the handle and expanders in the splitter bar. + * Sets whether the user can resize the panels by interacting with the splitter bar. * @remarks * Default value is `false`. * @attr */ - @property({ type: Boolean, attribute: 'non-collapsible', reflect: true }) - public nonCollapsible = false; + @property({ type: Boolean, attribute: 'disable-collapse', reflect: true }) + public disableCollapse = false; /** - * Defines if the splitter is resizable or not. + * Sets whether the user can resize the panels by interacting with the splitter bar. * @attr */ - @property({ type: Boolean, reflect: true, attribute: 'non-resizable' }) - public nonResizable = false; + @property({ type: Boolean, reflect: true, attribute: 'disable-resize' }) + public disableResize = false; + + /** + * Controls the visibility of the expand/collapse buttons on the splitter bar. + * @remarks + * Default value is `false`. + * @attr + */ + @property({ + type: Boolean, + attribute: 'hide-collapse-buttons', + reflect: true, + }) + public hideCollapseButtons = false; + + /** + * Controls the visibility of the drag handle on the splitter bar. + * @remarks + * Default value is `false`. + * @attr + */ + @property({ + type: Boolean, + attribute: 'hide-drag-handle', + reflect: true, + }) + public hideDragHandle = false; /** * The minimum size of the start pane. @@ -239,7 +265,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< this._resetPanes(); } - @watch('nonResizable') + @watch('disableResize') protected _changeCursor(): void { Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); } @@ -380,6 +406,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /** Toggles the collapsed state of the pane. */ public toggle(position: 'start' | 'end') { + // TODO: determine behavior when disableCollapsed is true if (position === 'start') { this.startCollapsed = !this.startCollapsed; } else { @@ -453,7 +480,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< target: 'start' | 'end', validOrientation: 'horizontal' | 'vertical' ) { - if (this.nonCollapsible || this.orientation !== validOrientation) { + if (this.disableCollapse || this.orientation !== validOrientation) { return; } let effectiveTarget = target; @@ -697,18 +724,17 @@ export default class IgcSplitterComponent extends EventEmitterMixin< //#region Rendering private _getExpanderHiddenState() { + const hidden = this.disableCollapse || this.hideCollapseButtons; return { - prevButtonHidden: !!(this.startCollapsed && !this.endCollapsed), - nextButtonHidden: !!(this.endCollapsed && !this.startCollapsed), + prevButtonHidden: hidden || !!(this.startCollapsed && !this.endCollapsed), + nextButtonHidden: hidden || !!(this.endCollapsed && !this.startCollapsed), }; } private _renderBarControls() { - if (this.nonCollapsible) { - return nothing; - } const { prevButtonHidden, nextButtonHidden } = this._getExpanderHiddenState(); + const dragHandleHidden = this.hideDragHandle || this.disableResize; const startExpanderSlot = this.endCollapsed ? 'end-expand' @@ -736,7 +762,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< >
-
+
= { description: 'Orientation of the splitter.', table: { defaultValue: { summary: 'horizontal' } }, }, - nonCollapsible: { + disableCollapse: { type: 'boolean', description: 'Disables pane collapsing.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, - nonResizable: { + hideCollapseButtons: { + type: 'boolean', + description: 'Hides the collapse buttons on the splitter bar.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + hideDragHandle: { + type: 'boolean', + description: 'Hides the drag handle on the splitter bar.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + disableResize: { type: 'boolean', description: 'Disables pane resizing.', control: 'boolean', @@ -78,8 +90,10 @@ const metadata: Meta = { }, args: { orientation: 'horizontal', - nonCollapsible: false, - nonResizable: false, + disableCollapse: false, + hideCollapseButtons: false, + hideDragHandle: false, + disableResize: false, startCollapsed: false, endCollapsed: false, }, @@ -89,8 +103,10 @@ export default metadata; interface IgcSplitterArgs { orientation: 'horizontal' | 'vertical'; - nonCollapsible: boolean; - nonResizable: boolean; + disableCollapse: boolean; + hideCollapseButtons: boolean; + hideDragHandle: boolean; + disableResize: boolean; startCollapsed: boolean; endCollapsed: boolean; startSize?: string; @@ -130,8 +146,10 @@ function changePaneMinMaxSizesPercent() { export const Default: Story = { render: ({ orientation, - nonCollapsible, - nonResizable, + disableCollapse, + hideCollapseButtons, + hideDragHandle, + disableResize, startCollapsed, endCollapsed, startSize, @@ -172,8 +190,10 @@ export const Default: Story = { html` @@ -276,8 +298,10 @@ export const Slots: Story = { Date: Mon, 9 Feb 2026 17:27:37 +0200 Subject: [PATCH 40/47] feat(splitter): single collapsed pane constraint --- src/components/splitter/splitter.spec.ts | 37 ++++++++++++++++++- src/components/splitter/splitter.ts | 47 +++++++++++++++++------- stories/card.stories.ts | 10 +++-- 3 files changed, 76 insertions(+), 18 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 4f445163c..fa9152e30 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -327,6 +327,26 @@ describe('Splitter', () => { }); describe('Properties', () => { + it('should change panels state from the startExpanded and endExpanded properties', async () => { + splitter.startCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + splitter.endCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.true; + + splitter.startCollapsed = true; + await elementUpdated(splitter); + + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + }); + it('should reset pane sizes when orientation changes', async () => { splitter = await fixture( createTwoPanesWithSizesAndConstraints({ @@ -509,11 +529,26 @@ describe('Splitter', () => { await elementUpdated(splitter); expect(splitter.endCollapsed).to.be.true; - // edge case: supports collapsing both at a time? + // Single collapsed pane constraint splitter.toggle('start'); await elementUpdated(splitter); expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; + + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.false; + expect(splitter.endCollapsed).to.be.false; + + splitter.toggle('end'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.true; + + splitter.toggle('start'); + await elementUpdated(splitter); + expect(splitter.startCollapsed).to.be.true; + expect(splitter.endCollapsed).to.be.false; }); it('should toggle the next pane when the bar expander-end parts are clicked', async () => { diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index ca03b9587..f471e9b39 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -18,7 +18,6 @@ import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; import { partMap } from '../common/part-map.js'; import { isLTR } from '../common/util.js'; -import IgcIconComponent from '../icon/icon.js'; import type { SplitterOrientation } from '../types.js'; import { styles } from './themes/splitter.base.css.js'; @@ -85,7 +84,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< /* blazorSuppress */ public static register() { - registerComponent(IgcSplitterComponent, IgcIconComponent); + registerComponent(IgcSplitterComponent); } //#region Private Properties @@ -94,6 +93,8 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private _startPaneInternalStyles: StyleInfo = {}; private _endPaneInternalStyles: StyleInfo = {}; private _barInternalStyles: StyleInfo = {}; + private _startCollapsed = false; + private _endCollapsed = false; private _startSize = 'auto'; private _endSize = 'auto'; private _resizeState: SplitterResizeState | null = null; @@ -245,14 +246,34 @@ export default class IgcSplitterComponent extends EventEmitterMixin< * @attr */ @property({ type: Boolean, attribute: 'start-collapsed', reflect: true }) - public startCollapsed = false; + public get startCollapsed() { + return this._startCollapsed; + } + + public set startCollapsed(value: boolean) { + this._startCollapsed = value; + if (this._startCollapsed && this._endCollapsed) { + this.endCollapsed = false; + } + this._collapsedChange(); + } /** * Collapsed state of the end pane. * @attr */ @property({ type: Boolean, attribute: 'end-collapsed', reflect: true }) - public endCollapsed = false; + public get endCollapsed() { + return this._endCollapsed; + } + + public set endCollapsed(value: boolean) { + this._endCollapsed = value; + if (this._startCollapsed && this._endCollapsed) { + this.startCollapsed = false; + } + this._collapsedChange(); + } //#endregion @@ -270,14 +291,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); } - @watch('startCollapsed', { waitUntilFirstUpdate: true }) - @watch('endCollapsed', { waitUntilFirstUpdate: true }) - protected _collapsedChange(): void { - this.startSize = 'auto'; - this.endSize = 'auto'; - this._changeCursor(); - } - //#endregion //#region Lifecycle @@ -439,6 +452,12 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return `${grow} ${shrink} ${size}`; } + private _collapsedChange(): void { + this.startSize = 'auto'; + this.endSize = 'auto'; + this._changeCursor(); + } + private _handleResizePanes( direction: -1 | 1, validOrientation: 'horizontal' | 'vertical' @@ -465,7 +484,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return delta * rtlMultiplier * (direction ?? 1); } - // TODO: should there be events on expand/collapse? + // TODO: should there be events on expand/collapse - existing resize events or others? private _handleExpanderStartAction() { const target = this.endCollapsed ? 'end' : 'start'; this.toggle(target); @@ -711,7 +730,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } private _handleExpanderClick(pane: 'start' | 'end', event: PointerEvent) { - // Prevent resize controller from starting + // Prevent resize action being initiated event.stopPropagation(); pane === 'start' diff --git a/stories/card.stories.ts b/stories/card.stories.ts index a61d776ed..649bc83b0 100644 --- a/stories/card.stories.ts +++ b/stories/card.stories.ts @@ -36,14 +36,15 @@ const metadata: Meta = { docs: { description: { component: - 'A container which wraps different elements related to a single subject', + 'A container component that wraps different elements related to a single subject.\nThe card component provides a flexible container for organizing content such as headers,\nmedia, text content, and actions.', }, }, }, argTypes: { elevated: { type: 'boolean', - description: 'Sets card elevated style, otherwise card looks outlined.', + description: + 'Sets the card to have an elevated appearance with shadow.\nWhen false, the card uses an outlined style with a border.', control: 'boolean', table: { defaultValue: { summary: 'false' } }, }, @@ -54,7 +55,10 @@ const metadata: Meta = { export default metadata; interface IgcCardArgs { - /** Sets card elevated style, otherwise card looks outlined. */ + /** + * Sets the card to have an elevated appearance with shadow. + * When false, the card uses an outlined style with a border. + */ elevated: boolean; } type Story = StoryObj; From e6a0da9861d09f7d19e7b5ac422030e474780726 Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Mon, 9 Feb 2026 18:42:12 +0200 Subject: [PATCH 41/47] feat(splitter): add Home/End key bindings and aria attributes --- src/components/splitter/splitter.spec.ts | 107 ++++++++++++++++++ src/components/splitter/splitter.ts | 132 +++++++++++++++++++++-- 2 files changed, 233 insertions(+), 6 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index fa9152e30..6b8af0cfe 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -12,6 +12,8 @@ import { arrowRight, arrowUp, ctrlKey, + endKey, + homeKey, } from '../common/controllers/key-bindings.js'; import { defineComponents } from '../common/definitions/defineComponents.js'; import { roundPrecise } from '../common/util.js'; @@ -201,6 +203,9 @@ describe('Splitter', () => { expect(splitter.orientation).to.equal('horizontal'); expect(splitter.hasAttribute('orientation')).to.be.true; expect(splitter.getAttribute('orientation')).to.equal('horizontal'); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.getAttribute('aria-orientation')).to.equal('horizontal'); }); it('should change orientation to vertical', async () => { @@ -209,6 +214,9 @@ describe('Splitter', () => { expect(splitter.orientation).to.equal('vertical'); expect(splitter.getAttribute('orientation')).to.equal('vertical'); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.getAttribute('aria-orientation')).to.equal('vertical'); }); it('should render nested splitters correctly', async () => { @@ -469,6 +477,11 @@ describe('Splitter', () => { expect(splitter.startMinSize).to.equal('20%'); expect(splitter.startMaxSize).to.equal('80%'); + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.getAttribute('aria-valuenow')).to.equal('30'); + expect(bar.getAttribute('aria-valuemin')).to.equal('20'); + expect(bar.getAttribute('aria-valuemax')).to.equal('80'); + const minProp = orientation === 'horizontal' ? 'minWidth' : 'minHeight'; expect(style1[minProp]).to.equal('20%'); const maxProp = orientation === 'horizontal' ? 'maxWidth' : 'maxHeight'; @@ -553,6 +566,9 @@ describe('Splitter', () => { it('should toggle the next pane when the bar expander-end parts are clicked', async () => { let parts = getButtonParts(splitter); + expect(parts.endCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse end pane' + ); simulatePointerDown(parts.endCollapseBtn, { bubbles: true }); await elementUpdated(splitter); @@ -567,6 +583,9 @@ describe('Splitter', () => { expect(parts.endCollapseBtn.hidden).to.be.true; expect(parts.startExpander).to.be.null; expect(parts.endExpander.hidden).to.be.false; + expect(parts.endExpander.getAttribute('aria-label')).to.equal( + 'Expand end pane' + ); simulatePointerDown(parts.endExpander, { bubbles: true }); await elementUpdated(splitter); @@ -577,10 +596,19 @@ describe('Splitter', () => { expect(parts.endCollapseBtn.hidden).to.be.false; expect(parts.startExpander).to.be.null; expect(parts.endExpander).to.be.null; + expect(parts.startCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse start pane' + ); + expect(parts.endCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse end pane' + ); }); it('should toggle the previous pane when the bar expander-start parts are clicked', async () => { let parts = getButtonParts(splitter); + expect(parts.startCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse start pane' + ); simulatePointerDown(parts.startCollapseBtn, { bubbles: true }); await elementUpdated(splitter); @@ -595,6 +623,9 @@ describe('Splitter', () => { expect(parts.startExpander.hidden).to.be.false; expect(parts.endCollapseBtn).to.be.null; expect(parts.endExpander).to.be.null; + expect(parts.startExpander.getAttribute('aria-label')).to.equal( + 'Expand start pane' + ); simulatePointerDown(parts.startExpander, { bubbles: true }); await elementUpdated(splitter); @@ -609,6 +640,31 @@ describe('Splitter', () => { expect(parts.endCollapseBtn.hidden).to.be.false; expect(parts.startExpander).to.be.null; expect(parts.endExpander).to.be.null; + expect(parts.startCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse start pane' + ); + expect(parts.endCollapseBtn.getAttribute('aria-label')).to.equal( + 'Collapse end pane' + ); + }); + + it('should set tabindex correctly on the bar based on interactivity', async () => { + const bar = getSplitterPart(splitter, BAR_PART); + + expect(bar.getAttribute('tabindex')).to.equal('0'); + + splitter.disableResize = true; + await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('0'); + + splitter.disableResize = false; + splitter.disableCollapse = true; + await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('0'); + + splitter.disableResize = true; + await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('-1'); }); it('should resize horizontally in both directions', async () => { @@ -827,6 +883,57 @@ describe('Splitter', () => { checkResizeEvents(eventSpy); }); + it('should set start pane size to minSize/maxSize with Home/End key in horizontal orientation', async () => { + splitter.startMinSize = '100px'; + splitter.startMaxSize = '80%'; + await elementUpdated(splitter); + + const totalAvailable = getTotalSize(splitter, 'width'); + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, homeKey); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('100px'); + expect(splitter.endSize).to.equal(`${totalAvailable - 100}px`); + + const minPercent = Math.round((100 / totalAvailable) * 100); + expect(bar.getAttribute('aria-valuenow')).to.equal(minPercent.toString()); + + simulateKeyboard(bar, endKey); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('80%'); + expect(splitter.endSize).to.equal('20%'); + + expect(bar.getAttribute('aria-valuenow')).to.equal('80'); + expect(bar.getAttribute('aria-valuemax')).to.equal('80'); + }); + + it('should set start pane size to minSize/maxSize with Home/End key in vertical orientation', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + const totalAvailable = getTotalSize(splitter, 'height'); + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + simulateKeyboard(bar, homeKey); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('0px'); + expect(splitter.endSize).to.equal(`${totalAvailable}px`); + + simulateKeyboard(bar, endKey); + await elementUpdated(splitter); + + expect(splitter.startSize).to.equal('100%'); + expect(splitter.endSize).to.equal('0%'); + }); + it('should not resize with left/right keys when in vertical orientation', async () => { splitter.orientation = 'vertical'; await elementUpdated(splitter); diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index f471e9b39..db267916f 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -1,4 +1,4 @@ -import { html, LitElement, type PropertyValues } from 'lit'; +import { html, LitElement, nothing, type PropertyValues } from 'lit'; import { property, query } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; @@ -10,6 +10,8 @@ import { arrowRight, arrowUp, ctrlKey, + endKey, + homeKey, } from '../common/controllers/key-bindings.js'; import { addSlotController, setSlots } from '../common/controllers/slot.js'; import { watch } from '../common/decorators/watch.js'; @@ -131,6 +133,10 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return this.orientation === 'horizontal' ? 'col-resize' : 'row-resize'; } + private get _barTabIndex(): number { + return this.disableCollapse && this.disableResize ? -1 : 0; + } + //#endregion //#region Public Properties @@ -329,6 +335,8 @@ export default class IgcSplitterComponent extends EventEmitterMixin< .set(arrowDown, () => this._handleResizePanes(1, 'vertical')) .set(arrowLeft, () => this._handleResizePanes(-1, 'horizontal')) .set(arrowRight, () => this._handleResizePanes(1, 'horizontal')) + .set(homeKey, () => this._handleMinMaxResize('min')) + .set(endKey, () => this._handleMinMaxResize('max')) .set([ctrlKey, arrowUp], () => this._handleArrowsExpandCollapse('start', 'vertical') ) @@ -345,6 +353,21 @@ export default class IgcSplitterComponent extends EventEmitterMixin< protected override firstUpdated() { this._initPanes(); + this._updateBarARIA(); + } + + protected override updated(changed: PropertyValues): void { + super.updated(changed); + if ( + changed.has('startSize') || + changed.has('endSize') || + changed.has('startMinSize') || + changed.has('startMaxSize') || + changed.has('startCollapsed') || + changed.has('endCollapsed') + ) { + this._updateBarARIA(); + } } //#endregion @@ -431,6 +454,55 @@ export default class IgcSplitterComponent extends EventEmitterMixin< //#region Internal API + private _sizeToPercent(sizeValue: string | undefined): number { + const totalSize = this._getTotalSize(); + if (totalSize === 0) { + return 0; + } + + if (!sizeValue || sizeValue === 'auto') { + const [startSize] = this._rectSize(); + return Math.round((startSize / totalSize) * 100); + } + + if (sizeValue.indexOf('%') !== -1) { + return Number.parseInt(sizeValue, 10) || 0; + } + + const pxValue = Number.parseInt(sizeValue, 10) || 0; + return Math.round((pxValue / totalSize) * 100); + } + + private _getStartPaneSizePercent(): number { + if (!this._startPane || this.startCollapsed) { + return 0; + } + if (this.endCollapsed) { + return 100; + } + return this._sizeToPercent(this.startSize); + } + + private _getMinMaxAsPercent(type: 'min' | 'max'): number { + const value = type === 'min' ? this.startMinSize : this.startMaxSize; + const defaultValue = type === 'min' ? 0 : 100; + + return value ? this._sizeToPercent(value) : defaultValue; + } + + private _updateBarARIA(): void { + if (!this._bar) { + return; + } + const valuenow = this._getStartPaneSizePercent(); + const valuemin = this._getMinMaxAsPercent('min'); + const valuemax = this._getMinMaxAsPercent('max'); + + this._bar.setAttribute('aria-valuenow', valuenow.toString()); + this._bar.setAttribute('aria-valuemin', valuemin.toString()); + this._bar.setAttribute('aria-valuemax', valuemax.toString()); + } + private _isPercentageSize(which: 'start' | 'end') { const targetSize = which === 'start' ? this._startSize : this._endSize; return !!targetSize && targetSize.indexOf('%') !== -1; @@ -484,6 +556,31 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return delta * rtlMultiplier * (direction ?? 1); } + private _handleMinMaxResize(type: 'min' | 'max') { + if (this._resizeDisallowed) { + return; + } + + const totalSize = this._getTotalSize(); + const boundaryValue = + type === 'min' ? this.startMinSize : this.startMaxSize; + const isPercentage = boundaryValue + ? boundaryValue.includes('%') + : type === 'max'; + + const targetStartSizePx = + this._setMinMaxInPx('start', type) ?? (type === 'min' ? 0 : totalSize); + const targetEndSizePx = totalSize - targetStartSizePx; + + if (isPercentage) { + this.startSize = `${(targetStartSizePx / totalSize) * 100}%`; + this.endSize = `${(targetEndSizePx / totalSize) * 100}%`; + } else { + this.startSize = `${targetStartSizePx}px`; + this.endSize = `${targetEndSizePx}px`; + } + } + // TODO: should there be events on expand/collapse - existing resize events or others? private _handleExpanderStartAction() { const target = this.endCollapsed ? 'end' : 'start'; @@ -549,7 +646,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< if (!value) { return undefined; } - const totalSize = this.getTotalSize(); + const totalSize = this._getTotalSize(); let result: number; if (value.indexOf('%') !== -1) { const percentageValue = Number.parseInt(value, 10) || 0; @@ -576,7 +673,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } private _computeSize(pane: PaneResizeState, paneSize: number): string { - const totalSize = this.getTotalSize(); + const totalSize = this._getTotalSize(); if (pane.isPercentageBased) { const percentPaneSize = (paneSize / totalSize) * 100; return `${percentPaneSize}%`; @@ -642,7 +739,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return [start.initialSize + finalDelta, end.initialSize - finalDelta]; } - private getTotalSize() { + private _getTotalSize() { if (!this._base) { return 0; } @@ -772,10 +869,23 @@ export default class IgcSplitterComponent extends EventEmitterMixin< 'end-collapse-btn': !this.startCollapsed, }; + const startLabel = prevButtonHidden + ? nothing + : this.endCollapsed + ? 'Expand end pane' + : 'Collapse start pane'; + const endLabel = nextButtonHidden + ? nothing + : this.startCollapsed + ? 'Expand start pane' + : 'Collapse end pane'; + return html`
this._handleExpanderClick('start', e)} > @@ -787,6 +897,8 @@ export default class IgcSplitterComponent extends EventEmitterMixin<
this._handleExpanderClick('end', e)} > @@ -799,6 +911,7 @@ export default class IgcSplitterComponent extends EventEmitterMixin<
@@ -806,7 +919,10 @@ export default class IgcSplitterComponent extends EventEmitterMixin<
e.preventDefault()} @contextmenu=${(e: PointerEvent) => e.preventDefault()} @@ -818,7 +934,11 @@ export default class IgcSplitterComponent extends EventEmitterMixin< > ${this._renderBarControls()}
-
+
From 6628b48f3d0a8671f66c116c874dad746d6892b8 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Tue, 10 Feb 2026 18:00:28 +0200 Subject: [PATCH 42/47] chore(splitter): tests checkpoint --- src/components/splitter/splitter.spec.ts | 117 +++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 6b8af0cfe..c7799ca5d 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -58,6 +58,9 @@ describe('Splitter', () => { it('is accessible', async () => { await expect(splitter).to.be.accessible(); await expect(splitter).shadowDom.to.be.accessible(); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar.getAttribute('role')).to.equal('separator'); }); it('should render start and end slots', async () => { @@ -72,6 +75,39 @@ describe('Splitter', () => { expect(elements[0].textContent).to.equal('Pane 2'); }); + it('should render both panes with equal sizes if no explicit sizes set', async () => { + const startPart = getSplitterPart(splitter, START_PART); + const endPart = getSplitterPart(splitter, END_PART); + + const startStyle = getComputedStyle(startPart); + const endStyle = getComputedStyle(endPart); + + const totalWidth = getTotalSize(splitter, 'width'); + const startWidth = Number.parseFloat(startStyle.width); + const endWidth = Number.parseFloat(endStyle.width); + + expect(startWidth).to.be.closeTo(totalWidth / 2, 1); + expect(endWidth).to.be.closeTo(totalWidth / 2, 1); + }); + + it('should render both panes with equal sizes if no explicit sizes set - vertical', async () => { + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + const startPart = getSplitterPart(splitter, START_PART); + const endPart = getSplitterPart(splitter, END_PART); + + const startStyle = getComputedStyle(startPart); + const endStyle = getComputedStyle(endPart); + + const totalHeight = getTotalSize(splitter, 'height'); + const startHeight = Number.parseFloat(startStyle.height); + const endHeight = Number.parseFloat(endStyle.height); + + expect(startHeight).to.be.closeTo(totalHeight / 2, 1); + expect(endHeight).to.be.closeTo(totalHeight / 2, 1); + }); + it('should render splitter bar between start and end parts', async () => { const base = getSplitterPart(splitter, 'base'); const startPart = getSplitterPart(splitter, START_PART); @@ -197,6 +233,26 @@ describe('Splitter', () => { expect(barHandle.hidden).to.be.true; expect(startCollapseBtn.hidden).to.be.false; expect(endCollapseBtn.hidden).to.be.false; + + // Splitter bar is still focusable + const bar = getSplitterPart(splitter, BAR_PART); + bar.focus(); + await elementUpdated(splitter); + + expect(splitter.shadowRoot!.activeElement).to.equal(bar); + + const previousSizes = getPanesSizes(splitter, 'width'); + const resizeDelta = 10; + + // Splitter bar is still interactive for resizing + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + + const currentSizes = getPanesSizes(splitter, 'width'); + const newStart = previousSizes.startSize + resizeDelta; + const newEnd = previousSizes.endSize - resizeDelta; + expect(currentSizes.startSize).to.equal(newStart); + expect(currentSizes.endSize).to.equal(newEnd); }); it('should have default horizontal orientation', () => { @@ -388,6 +444,37 @@ describe('Splitter', () => { expect(style.maxWidth).to.equal('100%'); }); + it('should set pane sizes to alternative CSS units such as em, rem, etc.', async () => { + splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '5em', + endSize: '2rem', + splitterWidth: '1000px', + }) + ); + await elementUpdated(splitter); + + document.body.style.fontSize = '16px'; + splitter.style.fontSize = '10px'; + await elementUpdated(splitter); + + const startPart = getSplitterPart(splitter, START_PART); + const style = getComputedStyle(startPart); + + const expectedStartSizeInPixels = 5 * 10; // 5em with font size of 10px + expect(style.flex).to.equal( + `0 1 ${expectedStartSizeInPixels.toString()}px` + ); + + const endPart = getSplitterPart(splitter, END_PART); + const style2 = getComputedStyle(endPart); + + const expectedEndSizeInPixels = 2 * 16; // 2rem with root font size of 16px + expect(style2.flex).to.equal( + `0 1 ${expectedEndSizeInPixels.toString()}px` + ); + }); + it('should properly set default min/max values when not specified', async () => { await elementUpdated(splitter); @@ -519,6 +606,12 @@ describe('Splitter', () => { const expectedEndSize = roundPrecise((30 / 100) * totalAvailable, 2); expect(sizes.endSize).to.be.closeTo(expectedEndSize, 2); + // When only one size is set, the other panel fills remaining space + expect(sizes.startSize).to.be.closeTo( + totalAvailable - expectedEndSize, + 2 + ); + const endPart = getSplitterPart(splitter, END_PART); const styleEnd = getComputedStyle(endPart); expect(styleEnd.flex).to.equal('0 1 30%'); @@ -775,6 +868,8 @@ describe('Splitter', () => { bar.focus(); await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('0'); + simulateKeyboard(bar, arrowRight); await elementUpdated(splitter); @@ -784,6 +879,9 @@ describe('Splitter', () => { expect(currentSizes.startSize).to.equal(newStart); expect(currentSizes.endSize).to.equal(newEnd); + // Focus is not lost during resize + expect(bar.getAttribute('tabindex')).to.equal('0'); + let startArgs = { startPanelSize: previousSizes.startSize, endPanelSize: previousSizes.endSize, @@ -806,6 +904,8 @@ describe('Splitter', () => { expect(currentSizes.startSize).to.equal(newStart); expect(currentSizes.endSize).to.equal(newEnd); + expect(bar.getAttribute('tabindex')).to.equal('0'); + startArgs = { startPanelSize: previousSizes.startSize + resizeDelta, endPanelSize: previousSizes.endSize - resizeDelta, @@ -987,6 +1087,8 @@ describe('Splitter', () => { bar.focus(); await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('0'); + simulateKeyboard(bar, [ctrlKey, arrowLeft]); await elementUpdated(splitter); @@ -999,6 +1101,9 @@ describe('Splitter', () => { expect(splitter.startCollapsed).to.be.true; expect(splitter.endCollapsed).to.be.false; + // Focus is not lost during collapse + expect(bar.getAttribute('tabindex')).to.equal('0'); + simulateKeyboard(bar, [ctrlKey, arrowRight]); await elementUpdated(splitter); @@ -1020,6 +1125,7 @@ describe('Splitter', () => { expect(currentSizes.endSize).to.equal(0); expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.true; + expect(bar.getAttribute('tabindex')).to.equal('0'); simulateKeyboard(bar, [ctrlKey, arrowLeft]); await elementUpdated(splitter); @@ -1032,6 +1138,7 @@ describe('Splitter', () => { expect(currentSizes.endSize).to.be.greaterThan(0); expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.false; + expect(bar.getAttribute('tabindex')).to.equal('0'); }); it('should expand/collapse panes with Ctrl + up/down arrow keys in vertical orientation', async () => { @@ -1041,6 +1148,7 @@ describe('Splitter', () => { const bar = getSplitterPart(splitter, BAR_PART); bar.focus(); await elementUpdated(splitter); + expect(bar.getAttribute('tabindex')).to.equal('0'); simulateKeyboard(bar, [ctrlKey, arrowUp]); await elementUpdated(splitter); @@ -1053,6 +1161,7 @@ describe('Splitter', () => { expect(currentSizes.endSize).to.equal(splitterSize - barSize); expect(splitter.startCollapsed).to.be.true; expect(splitter.endCollapsed).to.be.false; + expect(bar.getAttribute('tabindex')).to.equal('0'); simulateKeyboard(bar, [ctrlKey, arrowDown]); await elementUpdated(splitter); @@ -1075,6 +1184,7 @@ describe('Splitter', () => { expect(currentSizes.endSize).to.equal(0); expect(splitter.startCollapsed).to.be.false; expect(splitter.endCollapsed).to.be.true; + expect(bar.getAttribute('tabindex')).to.equal('0'); simulateKeyboard(bar, [ctrlKey, arrowUp]); await elementUpdated(splitter); @@ -1096,6 +1206,13 @@ describe('Splitter', () => { const eventSpy = spy(splitter, 'emitEvent'); let previousSizes = getPanesSizes(splitter, 'width'); const bar = getSplitterPart(splitter, BAR_PART); + // Splitter bar is still visible but non-interactive + expect(bar).to.exist; + expect(bar.hidden).to.be.false; + // Bar handle is hidden + const barHandle = getSplitterPart(splitter, DRAG_HANDLE_PART); + expect(barHandle).to.exist; + expect(barHandle.hidden).to.be.true; await resize(splitter, 100, 0); From 40041e8c0b0eb6dd0263857ba7c9af2b6696b7f3 Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Wed, 11 Feb 2026 15:16:21 +0200 Subject: [PATCH 43/47] refactor(splitter): update aria attributes and revert changes in resize controller --- src/components/common/context.ts | 6 --- .../resize-container/resize-controller.ts | 9 +--- src/components/resize-container/types.ts | 1 - src/components/splitter/splitter.ts | 43 +++---------------- 4 files changed, 9 insertions(+), 50 deletions(-) diff --git a/src/components/common/context.ts b/src/components/common/context.ts index f41f8ebe7..b31c3bafb 100644 --- a/src/components/common/context.ts +++ b/src/components/common/context.ts @@ -1,6 +1,5 @@ import { createContext } from '@lit/context'; import type { Ref } from 'lit/directives/ref.js'; -import type { IgcSplitterComponent } from '../../index.js'; import type IgcCarouselComponent from '../carousel/carousel.js'; import type { ChatState } from '../chat/chat-state.js'; import type IgcTileManagerComponent from '../tile-manager/tile-manager.js'; @@ -25,14 +24,9 @@ const chatUserInputContext = createContext( Symbol('chat-user-input-context') ); -const splitterContext = createContext( - Symbol('splitter-context') -); - export { carouselContext, tileManagerContext, chatContext, chatUserInputContext, - splitterContext, }; diff --git a/src/components/resize-container/resize-controller.ts b/src/components/resize-container/resize-controller.ts index 9f030189d..dd5636347 100644 --- a/src/components/resize-container/resize-controller.ts +++ b/src/components/resize-container/resize-controller.ts @@ -21,7 +21,6 @@ class ResizeController implements ReactiveController { private readonly _options: ResizeControllerConfiguration = { enabled: true, - updateTarget: true, layer: getDefaultLayer, }; @@ -167,9 +166,7 @@ class ResizeController implements ReactiveController { const parameters = { event, state: this._stateParameters }; this._options.resize?.call(this._host, parameters); this._state.current = parameters.state.current; - if (this._options.updateTarget) { - this._updatePosition(this._isDeferred ? this._ghost : this._resizeTarget); - } + this._updatePosition(this._isDeferred ? this._ghost : this._resizeTarget); } private _handlePointerEnd(event: PointerEvent): void { @@ -178,9 +175,7 @@ class ResizeController implements ReactiveController { this._options.end?.call(this._host, parameters); this._state.current = parameters.state.current; - if (this._options.updateTarget) { - parameters.state.commit?.() ?? this._updatePosition(this._resizeTarget); - } + parameters.state.commit?.() ?? this._updatePosition(this._resizeTarget); this.dispose(); } diff --git a/src/components/resize-container/types.ts b/src/components/resize-container/types.ts index fefae198f..29dc520d8 100644 --- a/src/components/resize-container/types.ts +++ b/src/components/resize-container/types.ts @@ -24,7 +24,6 @@ export type ResizeControllerConfiguration = { enabled?: boolean; ref?: Ref[]; mode?: ResizeMode; - updateTarget?: boolean; deferredFactory?: ResizeGhostFactory; layer?: () => HTMLElement; /** Callback invoked at the start of a resize operation. */ diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index db267916f..7b3f410a3 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -2,7 +2,6 @@ import { html, LitElement, nothing, type PropertyValues } from 'lit'; import { property, query } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; -import { addInternalsController } from '../common/controllers/internals.js'; import { addKeybindings, arrowDown, @@ -104,12 +103,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< private _dragPointerId = -1; private _dragStartPosition = { x: 0, y: 0 }; - private readonly _internals = addInternalsController(this, { - initialARIA: { - ariaOrientation: 'horizontal', - }, - }); - @query('[part~="base"]', true) private readonly _base!: HTMLElement; @@ -287,7 +280,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< @watch('orientation', { waitUntilFirstUpdate: true }) protected _orientationChange(): void { - this._internals.setARIA({ ariaOrientation: this.orientation }); Object.assign(this._barInternalStyles, { '--cursor': this._barCursor }); this._resetPanes(); } @@ -353,21 +345,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< protected override firstUpdated() { this._initPanes(); - this._updateBarARIA(); - } - - protected override updated(changed: PropertyValues): void { - super.updated(changed); - if ( - changed.has('startSize') || - changed.has('endSize') || - changed.has('startMinSize') || - changed.has('startMaxSize') || - changed.has('startCollapsed') || - changed.has('endCollapsed') - ) { - this._updateBarARIA(); - } } //#endregion @@ -448,6 +425,10 @@ export default class IgcSplitterComponent extends EventEmitterMixin< } else { this.endCollapsed = !this.endCollapsed; } + + if (!this.startCollapsed || !this.endCollapsed) { + this.updateComplete.then(() => this.requestUpdate()); + } } //#endregion @@ -490,19 +471,6 @@ export default class IgcSplitterComponent extends EventEmitterMixin< return value ? this._sizeToPercent(value) : defaultValue; } - private _updateBarARIA(): void { - if (!this._bar) { - return; - } - const valuenow = this._getStartPaneSizePercent(); - const valuemin = this._getMinMaxAsPercent('min'); - const valuemax = this._getMinMaxAsPercent('max'); - - this._bar.setAttribute('aria-valuenow', valuenow.toString()); - this._bar.setAttribute('aria-valuemin', valuemin.toString()); - this._bar.setAttribute('aria-valuemax', valuemax.toString()); - } - private _isPercentageSize(which: 'start' | 'end') { const targetSize = which === 'start' ? this._startSize : this._endSize; return !!targetSize && targetSize.indexOf('%') !== -1; @@ -923,6 +891,9 @@ export default class IgcSplitterComponent extends EventEmitterMixin< tabindex=${this._barTabIndex} aria-controls="start-panel end-panel" aria-orientation=${this.orientation} + aria-valuenow=${this._getStartPaneSizePercent()} + aria-valuemin=${this._getMinMaxAsPercent('min')} + aria-valuemax=${this._getMinMaxAsPercent('max')} style=${styleMap(this._barInternalStyles)} @touchstart=${(e: TouchEvent) => e.preventDefault()} @contextmenu=${(e: PointerEvent) => e.preventDefault()} From 591aba09ac17bb7d7205ed4d51471f0491660609 Mon Sep 17 00:00:00 2001 From: MonikaKirkova Date: Thu, 12 Feb 2026 17:09:20 +0200 Subject: [PATCH 44/47] chore(splitter): add tests for nested splitters --- src/components/splitter/splitter.spec.ts | 162 ++++++++++++++++++++++- 1 file changed, 159 insertions(+), 3 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index c7799ca5d..195a21e0d 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -2248,6 +2248,154 @@ describe('Splitter', () => { expect(splitter.endCollapsed).to.be.false; }); }); + + describe('Nested Splitters', () => { + let outerSplitter: IgcSplitterComponent; + let leftInnerSplitter: IgcSplitterComponent; + let rightInnerSplitter: IgcSplitterComponent; + + beforeEach(async () => { + outerSplitter = await fixture( + createNestedSplitter() + ); + await elementUpdated(outerSplitter); + + const outerStartSlot = getSplitterSlot(outerSplitter, 'start'); + const outerEndSlot = getSplitterSlot(outerSplitter, 'end'); + leftInnerSplitter = + outerStartSlot.assignedElements()[0] as IgcSplitterComponent; + rightInnerSplitter = + outerEndSlot.assignedElements()[0] as IgcSplitterComponent; + + await elementUpdated(leftInnerSplitter); + await elementUpdated(rightInnerSplitter); + }); + + it('should maintain independent state in nested splitters', async () => { + outerSplitter.startSize = '60%'; + leftInnerSplitter.startSize = '40%'; + rightInnerSplitter.startSize = '30%'; + + await elementUpdated(outerSplitter); + await elementUpdated(leftInnerSplitter); + await elementUpdated(rightInnerSplitter); + + expect(outerSplitter.startSize).to.equal('60%'); + expect(outerSplitter.orientation).to.equal('horizontal'); + expect(leftInnerSplitter.startSize).to.equal('40%'); + expect(leftInnerSplitter.orientation).to.equal('vertical'); + expect(rightInnerSplitter.startSize).to.equal('30%'); + expect(rightInnerSplitter.orientation).to.equal('vertical'); + + outerSplitter.startCollapsed = true; + await elementUpdated(outerSplitter); + await elementUpdated(leftInnerSplitter); + + expect(outerSplitter.startCollapsed).to.be.true; + expect(leftInnerSplitter.startCollapsed).to.be.false; + expect(rightInnerSplitter.startCollapsed).to.be.false; + + leftInnerSplitter.startCollapsed = true; + await elementUpdated(leftInnerSplitter); + + expect(outerSplitter.startCollapsed).to.be.true; + expect(leftInnerSplitter.startCollapsed).to.be.true; + expect(rightInnerSplitter.startCollapsed).to.be.false; + + outerSplitter.startCollapsed = false; + await elementUpdated(outerSplitter); + await elementUpdated(leftInnerSplitter); + + expect(outerSplitter.startCollapsed).to.be.false; + expect(leftInnerSplitter.startCollapsed).to.be.true; + expect(rightInnerSplitter.startCollapsed).to.be.false; + }); + + it('should not interfere with parent/child resize operations', async () => { + const outerEventSpy = spy(outerSplitter, 'emitEvent'); + const innerEventSpy = spy(leftInnerSplitter, 'emitEvent'); + + await resize(outerSplitter, 50, 0); + + checkResizeEvents(outerEventSpy); + expect(innerEventSpy.called).to.be.false; + + await resize(leftInnerSplitter, 0, 30); + + checkResizeEvents(innerEventSpy); + expect(outerEventSpy.called).to.be.false; + }); + + it('should handle focus management correctly with nested splitters', async () => { + const outerBar = getSplitterPart(outerSplitter, BAR_PART); + const innerBar = getSplitterPart(leftInnerSplitter, BAR_PART); + const resizeDelta = 10; + + outerBar.focus(); + await elementUpdated(outerSplitter); + + expect(outerSplitter.shadowRoot!.activeElement).to.equal(outerBar); + + const outerPreviousSizes = getPanesSizes(outerSplitter, 'width'); + + simulateKeyboard(outerBar, arrowRight); + await elementUpdated(outerSplitter); + + const outerCurrentSizes = getPanesSizes(outerSplitter, 'width'); + expect(outerCurrentSizes.startSize).to.equal( + outerPreviousSizes.startSize + resizeDelta + ); + expect(outerSplitter.shadowRoot!.activeElement).to.equal(outerBar); + + innerBar.focus(); + await elementUpdated(leftInnerSplitter); + + expect(leftInnerSplitter.shadowRoot!.activeElement).to.equal(innerBar); + + const innerPreviousSizes = getPanesSizes(leftInnerSplitter, 'height'); + + simulateKeyboard(innerBar, arrowDown); + await elementUpdated(leftInnerSplitter); + + const innerCurrentSizes = getPanesSizes(leftInnerSplitter, 'height'); + expect(innerCurrentSizes.startSize).to.equal( + innerPreviousSizes.startSize + resizeDelta + ); + expect(leftInnerSplitter.shadowRoot!.activeElement).to.equal(innerBar); + + const outerFinalSizes = getPanesSizes(outerSplitter, 'width'); + expect(outerFinalSizes.startSize).to.equal(outerCurrentSizes.startSize); + }); + + it('should handle tabindex correctly for nested splitters', async () => { + const outerBar = getSplitterPart(outerSplitter, BAR_PART); + const leftInnerBar = getSplitterPart(leftInnerSplitter, BAR_PART); + const rightInnerBar = getSplitterPart(rightInnerSplitter, BAR_PART); + + expect(outerBar.tabIndex).to.equal(0); + expect(leftInnerBar.tabIndex).to.equal(0); + expect(rightInnerBar.tabIndex).to.equal(0); + + outerSplitter.disableResize = true; + outerSplitter.disableCollapse = true; + await elementUpdated(outerSplitter); + + expect(outerBar.tabIndex).to.equal(-1); + expect(leftInnerBar.tabIndex).to.equal(0); + expect(rightInnerBar.tabIndex).to.equal(0); + + outerSplitter.disableResize = false; + outerSplitter.disableCollapse = false; + leftInnerSplitter.disableResize = true; + leftInnerSplitter.disableCollapse = true; + await elementUpdated(outerSplitter); + await elementUpdated(leftInnerSplitter); + + expect(outerBar.tabIndex).to.equal(0); + expect(leftInnerBar.tabIndex).to.equal(-1); + expect(rightInnerBar.tabIndex).to.equal(0); + }); + }); }); function createSplitter() { @@ -2261,12 +2409,20 @@ function createSplitter() { function createNestedSplitter() { return html` - - + +
Top Left Pane
Bottom Left Pane
- +
Top Right Pane
Bottom Right Pane
From 41800a0ea5f0dab62f4a722976e3536d17e5d8bc Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 13 Feb 2026 14:27:29 +0200 Subject: [PATCH 45/47] feat(splitter): more tests --- src/components/splitter/splitter.spec.ts | 318 ++++++++++++++++++----- 1 file changed, 247 insertions(+), 71 deletions(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index 195a21e0d..a4e9312ef 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -23,6 +23,8 @@ import { simulatePointerMove, simulatePointerUp, } from '../common/utils.spec.js'; +import IgcTreeComponent from '../tree/tree.js'; +import IgcTreeItemComponent from '../tree/tree-item.js'; import type { SplitterOrientation } from '../types.js'; import IgcSplitterComponent, { type IgcSplitterResizeEventDetail, @@ -63,18 +65,6 @@ describe('Splitter', () => { expect(bar.getAttribute('role')).to.equal('separator'); }); - it('should render start and end slots', async () => { - let slot = getSplitterSlot(splitter, 'start'); - let elements = slot.assignedElements(); - expect(elements).to.have.lengthOf(1); - expect(elements[0].textContent).to.equal('Pane 1'); - - slot = getSplitterSlot(splitter, 'end'); - elements = slot.assignedElements(); - expect(elements).to.have.lengthOf(1); - expect(elements[0].textContent).to.equal('Pane 2'); - }); - it('should render both panes with equal sizes if no explicit sizes set', async () => { const startPart = getSplitterPart(splitter, START_PART); const endPart = getSplitterPart(splitter, END_PART); @@ -275,59 +265,6 @@ describe('Splitter', () => { expect(bar.getAttribute('aria-orientation')).to.equal('vertical'); }); - it('should render nested splitters correctly', async () => { - const nestedSplitter = await fixture( - createNestedSplitter() - ); - await elementUpdated(nestedSplitter); - - const outerStartSlot = getSplitterSlot(nestedSplitter, 'start'); - const startElements = outerStartSlot.assignedElements(); - expect(startElements).to.have.lengthOf(1); - expect(startElements[0].tagName.toLowerCase()).to.equal( - IgcSplitterComponent.tagName.toLowerCase() - ); - - const outerEndSlot = getSplitterSlot(nestedSplitter, 'end'); - const endElements = outerEndSlot.assignedElements(); - expect(endElements).to.have.lengthOf(1); - expect(endElements[0].tagName.toLowerCase()).to.equal( - IgcSplitterComponent.tagName.toLowerCase() - ); - - const innerStartSlot1 = getSplitterSlot( - startElements[0] as IgcSplitterComponent, - 'start' - ); - expect(innerStartSlot1.assignedElements()[0].textContent).to.equal( - 'Top Left Pane' - ); - - const innerEndSlot1 = getSplitterSlot( - startElements[0] as IgcSplitterComponent, - 'end' - ); - expect(innerEndSlot1.assignedElements()[0].textContent).to.equal( - 'Bottom Left Pane' - ); - - const innerStartSlot2 = getSplitterSlot( - endElements[0] as IgcSplitterComponent, - 'start' - ); - expect(innerStartSlot2.assignedElements()[0].textContent).to.equal( - 'Top Right Pane' - ); - - const innerEndSlot2 = getSplitterSlot( - endElements[0] as IgcSplitterComponent, - 'end' - ); - expect(innerEndSlot2.assignedElements()[0].textContent).to.equal( - 'Bottom Right Pane' - ); - }); - it('should set a default cursor on the bar in case splitter is not resizable or any pane is collapsed', async () => { const bar = getSplitterPart(splitter, BAR_PART); @@ -390,6 +327,146 @@ describe('Splitter', () => { }); }); + describe('Slotted content', () => { + it('should render start and end slots', async () => { + let slot = getSplitterSlot(splitter, 'start'); + let elements = slot.assignedElements(); + expect(elements).to.have.lengthOf(1); + expect(elements[0].textContent).to.equal('Pane 1'); + + slot = getSplitterSlot(splitter, 'end'); + elements = slot.assignedElements(); + expect(elements).to.have.lengthOf(1); + expect(elements[0].textContent).to.equal('Pane 2'); + }); + + it('should update content when slot content changes', async () => { + let slot = getSplitterSlot(splitter, 'start'); + let elements = slot.assignedElements(); + expect(elements[0].textContent).to.equal('Pane 1'); + + elements[0].textContent = 'Updated Pane 1'; + await elementUpdated(splitter); + + slot = getSplitterSlot(splitter, 'start'); + elements = slot.assignedElements(); + expect(elements[0].textContent).to.equal('Updated Pane 1'); + }); + + it('should render complex content (forms, tables, etc.) correctly', async () => { + splitter = await fixture( + createSplitterWithComplexContent() + ); + await elementUpdated(splitter); + + const startSlot = getSplitterSlot(splitter, 'start'); + let elements = startSlot.assignedElements(); + let slottedDiv = elements.find( + (el) => el.tagName.toLowerCase() === 'div' + )!; + let children = slottedDiv.children!; + expect(children).to.have.lengthOf(3); + expect(children[0].tagName.toLowerCase()).to.equal('h1'); + expect(children[1].tagName.toLowerCase()).to.equal('form'); + const formElements = children[1].children; + expect(formElements).to.have.lengthOf(2); + expect(formElements[0].tagName.toLowerCase()).to.equal('input'); + expect(formElements[1].tagName.toLowerCase()).to.equal('button'); + expect(children[2].tagName.toLowerCase()).to.equal('button'); + + const endSlot = getSplitterSlot(splitter, 'end'); + elements = endSlot.assignedElements(); + slottedDiv = elements.find((el) => el.tagName.toLowerCase() === 'div')!; + children = slottedDiv.children!; + expect(children).to.have.lengthOf(2); + expect(children[0].tagName.toLowerCase()).to.equal('h1'); + expect(children[1].tagName.toLowerCase()).to.equal('igc-tree'); + }); + + describe('Custom icons', () => { + beforeEach(async () => { + splitter = await fixture( + createSplitterWithCustomSlots() + ); + }); + + it('should render custom drag handle via drag-handle slot', async () => { + const dragHandlePart = getSplitterPart(splitter, DRAG_HANDLE_PART); + const dragHandleSlot = getSplitterSlot(splitter, 'drag-handle'); + const assignedElements = dragHandleSlot.assignedElements(); + expect(assignedElements).to.have.lengthOf(1); + + const customIcon = assignedElements[0] as HTMLElement; + expect(customIcon.tagName.toLowerCase()).to.equal('span'); + expect(customIcon.textContent?.trim()).to.equal('drag-handle-icon'); + expect(dragHandlePart.contains(dragHandleSlot)).to.be.true; + }); + + it('should render custom expand icons via start-expand and end-expand slots', async () => { + splitter.toggle('start'); + await elementUpdated(splitter); + + const startExpandPart = getSplitterPart(splitter, START_EXPANDER_PART); + const startExpandSlot = getSplitterSlot(splitter, 'start-expand'); + const startAssignedElements = startExpandSlot.assignedElements(); + + expect(startAssignedElements).to.have.lengthOf(1); + const startCustomIcon = startAssignedElements[0] as HTMLElement; + expect(startCustomIcon.tagName.toLowerCase()).to.equal('span'); + expect(startCustomIcon.classList.contains('custom-icon')).to.be.true; + expect(startCustomIcon.textContent?.trim()).to.equal( + 'start-expand-icon' + ); + expect(startExpandPart.contains(startExpandSlot)).to.be.true; + + splitter.toggle('start'); + await elementUpdated(splitter); + splitter.toggle('end'); + await elementUpdated(splitter); + + const endExpandPart = getSplitterPart(splitter, END_EXPANDER_PART); + const endExpandSlot = getSplitterSlot(splitter, 'end-expand'); + const endAssignedElements = endExpandSlot.assignedElements(); + + expect(endAssignedElements).to.have.lengthOf(1); + const endCustomIcon = endAssignedElements[0] as HTMLElement; + expect(endCustomIcon.tagName.toLowerCase()).to.equal('span'); + expect(endCustomIcon.classList.contains('custom-icon')).to.be.true; + expect(endCustomIcon.textContent?.trim()).to.equal('end-expand-icon'); + expect(endExpandPart.contains(endExpandSlot)).to.be.true; + }); + + it('should render custom collapse icons via start-collapse and end-collapse slots', async () => { + const startCollapsePart = getSplitterPart( + splitter, + START_COLLAPSE_PART + ); + const startCollapseSlot = getSplitterSlot(splitter, 'start-collapse'); + const startAssignedElements = startCollapseSlot.assignedElements(); + + expect(startAssignedElements).to.have.lengthOf(1); + const startCustomIcon = startAssignedElements[0] as HTMLElement; + expect(startCustomIcon.tagName.toLowerCase()).to.equal('span'); + expect(startCustomIcon.classList.contains('custom-icon')).to.be.true; + expect(startCustomIcon.textContent?.trim()).to.equal( + 'start-collapse-icon' + ); + expect(startCollapsePart.contains(startCollapseSlot)).to.be.true; + + const endCollapsePart = getSplitterPart(splitter, END_COLLAPSE_PART); + const endCollapseSlot = getSplitterSlot(splitter, 'end-collapse'); + const endAssignedElements = endCollapseSlot.assignedElements(); + + expect(endAssignedElements).to.have.lengthOf(1); + const endCustomIcon = endAssignedElements[0] as HTMLElement; + expect(endCustomIcon.tagName.toLowerCase()).to.equal('span'); + expect(endCustomIcon.classList.contains('custom-icon')).to.be.true; + expect(endCustomIcon.textContent?.trim()).to.equal('end-collapse-icon'); + expect(endCollapsePart.contains(endCollapseSlot)).to.be.true; + }); + }); + }); + describe('Properties', () => { it('should change panels state from the startExpanded and endExpanded properties', async () => { splitter.startCollapsed = true; @@ -2253,6 +2330,8 @@ describe('Splitter', () => { let outerSplitter: IgcSplitterComponent; let leftInnerSplitter: IgcSplitterComponent; let rightInnerSplitter: IgcSplitterComponent; + let outerStartSlot: HTMLSlotElement; + let outerEndSlot: HTMLSlotElement; beforeEach(async () => { outerSplitter = await fixture( @@ -2260,8 +2339,8 @@ describe('Splitter', () => { ); await elementUpdated(outerSplitter); - const outerStartSlot = getSplitterSlot(outerSplitter, 'start'); - const outerEndSlot = getSplitterSlot(outerSplitter, 'end'); + outerStartSlot = getSplitterSlot(outerSplitter, 'start'); + outerEndSlot = getSplitterSlot(outerSplitter, 'end'); leftInnerSplitter = outerStartSlot.assignedElements()[0] as IgcSplitterComponent; rightInnerSplitter = @@ -2271,6 +2350,52 @@ describe('Splitter', () => { await elementUpdated(rightInnerSplitter); }); + it('should render nested splitters correctly', async () => { + const startElements = outerStartSlot.assignedElements(); + expect(startElements).to.have.lengthOf(1); + expect(startElements[0].tagName.toLowerCase()).to.equal( + IgcSplitterComponent.tagName.toLowerCase() + ); + + const endElements = outerEndSlot.assignedElements(); + expect(endElements).to.have.lengthOf(1); + expect(endElements[0].tagName.toLowerCase()).to.equal( + IgcSplitterComponent.tagName.toLowerCase() + ); + + const innerStartSlot1 = getSplitterSlot( + startElements[0] as IgcSplitterComponent, + 'start' + ); + expect(innerStartSlot1.assignedElements()[0].textContent).to.equal( + 'Top Left Pane' + ); + + const innerEndSlot1 = getSplitterSlot( + startElements[0] as IgcSplitterComponent, + 'end' + ); + expect(innerEndSlot1.assignedElements()[0].textContent).to.equal( + 'Bottom Left Pane' + ); + + const innerStartSlot2 = getSplitterSlot( + endElements[0] as IgcSplitterComponent, + 'start' + ); + expect(innerStartSlot2.assignedElements()[0].textContent).to.equal( + 'Top Right Pane' + ); + + const innerEndSlot2 = getSplitterSlot( + endElements[0] as IgcSplitterComponent, + 'end' + ); + expect(innerEndSlot2.assignedElements()[0].textContent).to.equal( + 'Bottom Right Pane' + ); + }); + it('should maintain independent state in nested splitters', async () => { outerSplitter.startSize = '60%'; leftInnerSplitter.startSize = '40%'; @@ -2476,10 +2601,61 @@ function createSplitterWithCollapsedPane() { `; } -function getSplitterSlot( - splitter: IgcSplitterComponent, - which: 'start' | 'end' -) { +function createSplitterWithComplexContent() { + defineComponents(IgcTreeComponent, IgcTreeItemComponent); + return html` + +
+

Pane 1

+
+ + +
+ +
+
+

Pane 2

+ + Item 1 + Subitem 1.1 + Subitem 1.2 + + Item 2 + Subitem 2.1 + + Item 3 + +
+
+ `; +} + +function createSplitterWithCustomSlots() { + return html` +
Pane 1
+
Pane 2
+ drag-handle-icon + + start-expand-icon + start-collapse-icon + + end-expand-icon + end-collapse-icon +
`; +} + +type SplitterSlot = + | 'start' + | 'end' + | 'drag-handle' + | 'start-expand' + | 'start-collapse' + | 'end-expand' + | 'end-collapse'; + +function getSplitterSlot(splitter: IgcSplitterComponent, which: SplitterSlot) { return splitter.renderRoot.querySelector( `slot[name="${which}"]` ) as HTMLSlotElement; From a14b47a36814b134e0a41baaf8c3d9aff56ab97e Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 13 Feb 2026 18:13:47 +0200 Subject: [PATCH 46/47] refactor(splitter): expander config code --- src/components/splitter/splitter.ts | 73 +++++++++++++++-------------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/src/components/splitter/splitter.ts b/src/components/splitter/splitter.ts index 7b3f410a3..86746f5be 100644 --- a/src/components/splitter/splitter.ts +++ b/src/components/splitter/splitter.ts @@ -1,4 +1,4 @@ -import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { html, LitElement, type PropertyValues } from 'lit'; import { property, query } from 'lit/decorators.js'; import { createRef, ref } from 'lit/directives/ref.js'; import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; @@ -17,7 +17,6 @@ import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import type { Constructor } from '../common/mixins/constructor.js'; import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; -import { partMap } from '../common/part-map.js'; import { isLTR } from '../common/util.js'; import type { SplitterOrientation } from '../types.js'; import { styles } from './themes/splitter.base.css.js'; @@ -49,6 +48,13 @@ interface SplitterResizeState { endPane: PaneResizeState; } +interface ExpanderConfig { + slotName: string; + partName: string; + label: string; + hidden: boolean; +} + /** * The Splitter component provides a framework for a simple layout, splitting the view horizontally or vertically * into multiple smaller resizable and collapsible areas. @@ -815,61 +821,56 @@ export default class IgcSplitterComponent extends EventEmitterMixin< }; } - private _renderBarControls() { + private _getExpanderConfig(position: 'start' | 'end'): ExpanderConfig { const { prevButtonHidden, nextButtonHidden } = this._getExpanderHiddenState(); - const dragHandleHidden = this.hideDragHandle || this.disableResize; - - const startExpanderSlot = this.endCollapsed - ? 'end-expand' - : 'start-collapse'; - const endExpanderSlot = this.startCollapsed - ? 'start-expand' - : 'end-collapse'; - const startExpanderParts = { - 'end-expand-btn': this.endCollapsed, - 'start-collapse-btn': !this.endCollapsed, - }; + if (position === 'start') { + const isExpandingEnd = this.endCollapsed; + return { + slotName: isExpandingEnd ? 'end-expand' : 'start-collapse', + partName: isExpandingEnd ? 'end-expand-btn' : 'start-collapse-btn', + label: isExpandingEnd ? 'Expand end pane' : 'Collapse start pane', + hidden: prevButtonHidden, + }; + } - const endExpanderParts = { - 'start-expand-btn': this.startCollapsed, - 'end-collapse-btn': !this.startCollapsed, + const isExpandingStart = this.startCollapsed; + return { + slotName: isExpandingStart ? 'start-expand' : 'end-collapse', + partName: isExpandingStart ? 'start-expand-btn' : 'end-collapse-btn', + label: isExpandingStart ? 'Expand start pane' : 'Collapse end pane', + hidden: nextButtonHidden, }; + } - const startLabel = prevButtonHidden - ? nothing - : this.endCollapsed - ? 'Expand end pane' - : 'Collapse start pane'; - const endLabel = nextButtonHidden - ? nothing - : this.startCollapsed - ? 'Expand start pane' - : 'Collapse end pane'; + private _renderBarControls() { + const dragHandleHidden = this.hideDragHandle || this.disableResize; + const startConfig = this._getExpanderConfig('start'); + const endConfig = this._getExpanderConfig('end'); return html`
this._handleExpanderClick('start', e)} > - +
this._handleExpanderClick('end', e)} > - +
`; } From 09acf64540c20cc77556a22e1ddfe55b054dafb6 Mon Sep 17 00:00:00 2001 From: Bozhidara Pachilova Date: Fri, 13 Feb 2026 18:14:14 +0200 Subject: [PATCH 47/47] test(splitter): add more scenarios from spec --- src/components/splitter/splitter.spec.ts | 295 ++++++++++++++++++++++- 1 file changed, 294 insertions(+), 1 deletion(-) diff --git a/src/components/splitter/splitter.spec.ts b/src/components/splitter/splitter.spec.ts index a4e9312ef..b08a4a591 100644 --- a/src/components/splitter/splitter.spec.ts +++ b/src/components/splitter/splitter.spec.ts @@ -936,6 +936,76 @@ describe('Splitter', () => { checkResizeEvents(eventSpy, startArgs, resizingArgs, endArgs); }); + it('should respect minSize and maxSize constraints when resizing with arrows', async () => { + splitter.style.width = '1000px'; + splitter.startMinSize = '100px'; + splitter.startMaxSize = '400px'; + splitter.endMinSize = '50px'; + splitter.startSize = '250px'; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, BAR_PART); + let barSize = bar.getBoundingClientRect().width; + bar.focus(); + await elementUpdated(splitter); + + // resize below minSize + for (let i = 0; i < 20; i++) { + simulateKeyboard(bar, arrowLeft); + await elementUpdated(splitter); + } + + let currentSizes = getPanesSizes(splitter, 'width'); + // should stop at minSize (100px) + expect(currentSizes.startSize).to.be.closeTo(100, 1); + + splitter.startSize = '250px'; + await elementUpdated(splitter); + + // resize beyond maxSize + for (let i = 0; i < 20; i++) { + simulateKeyboard(bar, arrowRight); + await elementUpdated(splitter); + } + + currentSizes = getPanesSizes(splitter, 'width'); + // should stop at maxSize (400px) + expect(currentSizes.startSize).to.be.closeTo(400 - barSize, 2); + + splitter.orientation = 'vertical'; + await elementUpdated(splitter); + + splitter.startMinSize = '150px'; + splitter.startMaxSize = '350px'; + splitter.endMinSize = '50px'; + splitter.style.height = '1000px'; + splitter.startSize = '250px'; + await elementUpdated(splitter); + + barSize = bar.getBoundingClientRect().height; + bar.focus(); + await elementUpdated(splitter); + + for (let i = 0; i < 15; i++) { + simulateKeyboard(bar, arrowUp); + await elementUpdated(splitter); + } + + currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.be.closeTo(150, 2); + + splitter.startSize = '250px'; + await elementUpdated(splitter); + + for (let i = 0; i < 15; i++) { + simulateKeyboard(bar, arrowDown); + await elementUpdated(splitter); + } + + currentSizes = getPanesSizes(splitter, 'height'); + expect(currentSizes.startSize).to.be.closeTo(350, 2); + }); + it('should resize horizontally by 10px delta with left/right arrow keys', async () => { const eventSpy = spy(splitter, 'emitEvent'); const bar = getSplitterPart(splitter, BAR_PART); @@ -1961,7 +2031,7 @@ describe('Splitter', () => { }); }); - describe('Behavior on splitter resize', () => { + describe('Behavior on splitter/container resize', () => { it('should maintain panes sizes in px on splitter resize', async () => { const splitter = await fixture( createTwoPanesWithSizesAndConstraints({ @@ -2038,6 +2108,115 @@ describe('Splitter', () => { expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.3, 2); expect(newSizes.startSize).to.equal(totalAvailable - newSizes.endSize); }); + + it('component adapts when container size changes', async () => { + const container = await fixture( + createSplitterInContainer({ + startSize: '300px', + endSize: '40%', + containerWidth: '800px', + }) + ); + const splitter = container.querySelector( + 'igc-splitter' + ) as IgcSplitterComponent; + await elementUpdated(splitter); + + container.style.width = '1200px'; + await elementUpdated(splitter); + + let newSizes = getPanesSizes(splitter, 'width'); + let totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.equal(300); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.4, 2); + + container.style.width = '600px'; + await elementUpdated(splitter); + + newSizes = getPanesSizes(splitter, 'width'); + totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.equal(300); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.4, 2); + expect(newSizes.startSize + newSizes.endSize).to.be.at.most( + totalAvailable + ); + }); + + it('relative sizes (percentages) update correctly', async () => { + const container = await fixture( + createSplitterInContainer({ + startSize: '25%', + endSize: '50%', + containerWidth: '1000px', + }) + ); + const splitter = container.querySelector( + 'igc-splitter' + ) as IgcSplitterComponent; + await elementUpdated(splitter); + + const initialSizes = getPanesSizes(splitter, 'width'); + const initialTotal = getTotalSize(splitter, 'width'); + + // increase tolerance to 3px to account for rounding differences in percentage calculations across browsers + expect(initialSizes.startSize).to.be.closeTo(initialTotal * 0.25, 3); + expect(initialSizes.endSize).to.be.closeTo(initialTotal * 0.5, 3); + + container.style.width = '1600px'; + await elementUpdated(splitter); + + let newSizes = getPanesSizes(splitter, 'width'); + let totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.be.closeTo(totalAvailable * 0.25, 3); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.5, 3); + + container.style.width = '500px'; + await elementUpdated(splitter); + + newSizes = getPanesSizes(splitter, 'width'); + totalAvailable = getTotalSize(splitter, 'width'); + + expect(newSizes.startSize).to.be.closeTo(totalAvailable * 0.25, 3); + expect(newSizes.endSize).to.be.closeTo(totalAvailable * 0.5, 3); + }); + + it('absolute sizes are maintained when possible', async () => { + const container = await fixture( + createSplitterInContainer({ + startSize: '250px', + endSize: '350px', + containerWidth: '800px', + }) + ); + const splitter = container.querySelector( + 'igc-splitter' + ) as IgcSplitterComponent; + await elementUpdated(splitter); + + const initialSizes = getPanesSizes(splitter, 'width'); + + expect(initialSizes.startSize).to.equal(250); + expect(initialSizes.endSize).to.equal(350); + + container.style.width = '1200px'; + await elementUpdated(splitter); + + let newSizes = getPanesSizes(splitter, 'width'); + + expect(newSizes.startSize).to.equal(250); + expect(newSizes.endSize).to.equal(350); + + container.style.width = '700px'; + await elementUpdated(splitter); + + newSizes = getPanesSizes(splitter, 'width'); + + expect(newSizes.startSize).to.equal(250); + expect(newSizes.endSize).to.equal(350); + }); }); describe('RTL', () => { @@ -2521,6 +2700,93 @@ describe('Splitter', () => { expect(rightInnerBar.tabIndex).to.equal(0); }); }); + + describe('Edge scenarios', () => { + it('invalid size values should be handled gracefully', async () => { + // TODO: determine expected behavior for invalid size values (negative, non-numeric, etc.) + }); + + it('should handle invalid constraint values gracefully', async () => { + // TODO: determine how + }); + + it('should predictably handle the case where min sizes exceed container', async () => { + // TODO: determine how + }); + + it('should predictably handle the case where max sizes are smaller than container', async () => { + const splitter = await fixture( + createTwoPanesWithSizesAndConstraints({ + startSize: '250px', + endSize: '350px', + startMaxSize: '200px', + endMaxSize: '300px', + splitterWidth: '800px', + }) + ); + await elementUpdated(splitter); + + const sizes = getPanesSizes(splitter, 'width'); + const totalAvailable = getTotalSize(splitter, 'width'); + + expect(sizes.startSize).to.be.at.most(200); + expect(sizes.endSize).to.be.at.most(300); + + expect(sizes.startSize + sizes.endSize).to.be.lessThan(totalAvailable); + }); + + it('invalid orientation values should fall back to default', async () => { + splitter.orientation = 'diagonal' as unknown as SplitterOrientation; + await elementUpdated(splitter); + + const bar = getSplitterPart(splitter, BAR_PART); + expect(bar).to.exist; + + splitter.orientation = 'horizontal'; + await elementUpdated(splitter); + expect(splitter.orientation).to.equal('horizontal'); + expect(splitter.getAttribute('orientation')).to.equal('horizontal'); + }); + + it('missing slot content should not break rendering', async () => { + const emptySplitter = await fixture(html` + + `); + await elementUpdated(emptySplitter); + + expect(emptySplitter).to.exist; + + const startPane = getSplitterPart(emptySplitter, START_PART); + const endPane = getSplitterPart(emptySplitter, END_PART); + const bar = getSplitterPart(emptySplitter, BAR_PART); + + expect(startPane).to.exist; + expect(endPane).to.exist; + expect(bar).to.exist; + + const partialSplitter = await fixture(html` + +
Only Start Pane
+
+ `); + await elementUpdated(partialSplitter); + + expect(partialSplitter).to.exist; + + const partialStartPane = getSplitterPart(partialSplitter, START_PART); + const partialEndPane = getSplitterPart(partialSplitter, END_PART); + const partialBar = getSplitterPart(partialSplitter, BAR_PART); + + expect(partialStartPane).to.exist; + expect(partialEndPane).to.exist; + expect(partialBar).to.exist; + + partialSplitter.startSize = '300px'; + await elementUpdated(partialSplitter); + + expect(partialSplitter.startSize).to.equal('300px'); + }); + }); }); function createSplitter() { @@ -2565,6 +2831,8 @@ type SplitterTestSizesAndConstraints = { orientation?: SplitterOrientation; splitterWidth?: string; splitterHeight?: string; + containerWidth?: string; + containerHeight?: string; }; function createTwoPanesWithSizesAndConstraints( @@ -2646,6 +2914,31 @@ function createSplitterWithCustomSlots() {
`; } +function createSplitterInContainer( + config: SplitterTestSizesAndConstraints = {} +) { + return html` +
+ +
Pane 1
+
Pane 2
+
+
+ `; +} + type SplitterSlot = | 'start' | 'end'