Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
30b6a1c
feat(splitter): initial structure implementation
MonikaKirkova Nov 4, 2025
857ca99
feat(splitter): add initial poc styles
ddaribo Nov 4, 2025
f62528e
chore: fix initial styles, add spec file
ddaribo Nov 4, 2025
fea9e07
feat(splitter): add splitter-pane props
MonikaKirkova Nov 4, 2025
b1f82e4
chore: minor changes; add nested story; test nested
ddaribo Nov 5, 2025
5b3d2fe
feat(splitter): add args for each pane to default story
ddaribo Nov 5, 2025
5d7907d
feat(splitter): add nonCollapsible prop
MonikaKirkova Nov 5, 2025
0abf7b8
feat(splitter): add initial resize logic
MonikaKirkova Nov 6, 2025
96a7364
refactor(splitter): alternative approach to render bars and handle pr…
ddaribo Nov 7, 2025
33d0b78
fix(splitter): make resize work after changes; add updateTarget optio…
ddaribo Nov 10, 2025
edee68f
chore: style splitter bar through horizontal/vertical part
ddaribo Nov 10, 2025
b7b4e8d
fix(splitter): modify flex prop to allow different sizes
MonikaKirkova Nov 12, 2025
a05d121
fix(splitter): revert changes from previous commit
MonikaKirkova Nov 12, 2025
59b3285
chore: style tweaks; more tests; resize fix
ddaribo Nov 12, 2025
da506d4
fix: handle shrink differently to reflect proper percentage sizes?
ddaribo Nov 12, 2025
9184867
feat(splitter): implement keyboard navigation
MonikaKirkova Nov 12, 2025
65685bd
feat(splitter): implement splitter events
MonikaKirkova Nov 13, 2025
2e6130d
refactor(splitter): rename to nonResizable; port skip fn for key bind…
ddaribo Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/components/common/context.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,9 +25,14 @@ const chatUserInputContext = createContext<ChatState>(
Symbol('chat-user-input-context')
);

const splitterContext = createContext<IgcSplitterComponent>(
Symbol('splitter-context')
);

export {
carouselContext,
tileManagerContext,
chatContext,
chatUserInputContext,
splitterContext,
};
6 changes: 6 additions & 0 deletions src/components/common/definitions/defineAllComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -134,6 +137,9 @@ const allComponents: IgniteComponent[] = [
IgcCircularGradientComponent,
IgcSnackbarComponent,
IgcDateTimeInputComponent,
IgcSplitterBarComponent,
IgcSplitterComponent,
IgcSplitterPaneComponent,
IgcStepperComponent,
IgcStepComponent,
IgcTextareaComponent,
Expand Down
9 changes: 7 additions & 2 deletions src/components/resize-container/resize-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ResizeController implements ReactiveController {

private readonly _options: ResizeControllerConfiguration = {
enabled: true,
updateTarget: true,
layer: getDefaultLayer,
};

Expand Down Expand Up @@ -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 {
Expand All @@ -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();
}

Expand Down
1 change: 1 addition & 0 deletions src/components/resize-container/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type ResizeControllerConfiguration = {
enabled?: boolean;
ref?: Ref<HTMLElement>[];
mode?: ResizeMode;
updateTarget?: boolean;
deferredFactory?: ResizeGhostFactory;
layer?: () => HTMLElement;
/** Callback invoked at the start of a resize operation. */
Expand Down
300 changes: 300 additions & 0 deletions src/components/splitter/splitter-bar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
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 {
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';
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';
import type IgcSplitterPaneComponent from './splitter-pane.js';
import { styles } from './themes/splitter-bar.base.css.js';

export interface IgcSplitterBarComponentEventMap {
igcMovingStart: CustomEvent<IgcSplitterPaneComponent>;
igcMoving: CustomEvent<number>;
igcMovingEnd: CustomEvent<number>;
}
export default class IgcSplitterBarComponent extends EventEmitterMixin<
IgcSplitterBarComponentEventMap,
Constructor<LitElement>
>(LitElement) {
public static readonly tagName = 'igc-splitter-bar';
public static styles = [styles];

/* blazorSuppress */
public static register() {
registerComponent(IgcSplitterBarComponent);
}

private readonly _internals = addInternalsController(this, {
initialARIA: {
ariaOrientation: 'horizontal',
},
});

protected _contextConsumer = new ContextConsumer(this, {
context: splitterContext,
subscribe: true,
callback: (value) => {
this._handleContextChange(value);
},
});

protected _resolvePartNames() {
return {
base: true,
[this._orientation.toString()]: true,
};
}

private _internalStyles: StyleInfo = {};

private _orientation: SplitterOrientation = 'horizontal';
private _splitter?: IgcSplitterComponent;

private get _siblingPanes(): Array<IgcSplitterPaneComponent | null> {
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 [];
}

const currentPane = panes[ownerPaneIndex];
const nextPane = panes[ownerPaneIndex + 1] || null;
return [currentPane, nextPane];
}

private get _resizeDisallowed() {
return !!this._siblingPanes.find(
(x) => x && (x.nonResizable || x.collapsed)
);
}

/**
* 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();
this._internalStyles = { '--cursor': this._cursor };

addResizeController(this, {
mode: 'immediate',
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._resizeDisallowed) {
return false;
}
this.emitEvent('igcMovingStart', { detail: this._siblingPanes[0]! });
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: () => {},
});

addKeybindings(this, { skip: this._shouldSkipResize })
.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);
}

public override connectedCallback(): void {
super.connectedCallback();
this._siblingPanes?.forEach((pane) => {
this._createSiblingPaneMutationController(pane!);
});
}

private _shouldSkipResize(_node: Element, event: KeyboardEvent): boolean {
if (this._resizeDisallowed && !event.ctrlKey) {
return true;
}
if (
(event.key === arrowUp || event.key === arrowDown) &&
this._orientation === 'horizontal' &&
!event.ctrlKey
) {
return true;
}
if (
(event.key === arrowLeft || event.key === arrowRight) &&
this._orientation === 'vertical' &&
!event.ctrlKey
) {
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 });
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: () => {
Object.assign(this._internalStyles, { '--cursor': this._cursor });
this.requestUpdate();
},
config: {
attributeFilter: ['collapsed', 'non-resizable'],
},
});
}

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, { '--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 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();
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 { prevButtonHidden, nextButtonHidden } =
this._getExpanderHiddenState();
return html`
<div
part="expander-start"
?hidden=${prevButtonHidden}
@pointerdown=${(e: PointerEvent) => this._handleExpanderClick(true, e)}
></div>
<div part="handle"></div>
<div
part="expander-end"
?hidden=${nextButtonHidden}
@pointerdown=${(e: PointerEvent) => this._handleExpanderClick(false, e)}
></div>
`;
}

protected override render() {
return html`
<div
part=${partMap(this._resolvePartNames())}
style=${styleMap(this._internalStyles)}
tabindex="0"
>
${this._renderBarControls()}
</div>
`;
}
}

declare global {
interface HTMLElementTagNameMap {
'igc-splitter-bar': IgcSplitterBarComponent;
}
}
Loading
Loading