diff --git a/package-lock.json b/package-lock.json index c146dfd31..e972ac370 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "globby": "^14.0.2", "husky": "^9.1.7", "ig-typedoc-theme": "^5.0.4", - "igniteui-theming": "^14.3.0", + "igniteui-theming": "^14.4.0-beta.1", "keep-a-changelog": "^2.5.3", "lint-staged": "^15.2.10", "lit-analyzer": "^2.0.3", @@ -6856,9 +6856,9 @@ } }, "node_modules/igniteui-theming": { - "version": "14.3.0", - "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-14.3.0.tgz", - "integrity": "sha512-NF43En3g7Qrr2lDOd+nKRYk7T54RJ9OrdjtQplNVGnoxkKnQYeQ2iHEhd0lmmTMRUnNiJUUQwYqM4VUVQyo5dw==", + "version": "14.4.0-beta.1", + "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-14.4.0-beta.1.tgz", + "integrity": "sha512-9gS5PqkRIKLJm6u0V/TqQIa7LSVcJR5nq7e3m5IkP7HN42cplCMYSdChAmt+GpcBYf3DvZ8mW0s5+LfrIw8Pkg==", "dev": true, "license": "MIT", "peerDependencies": { diff --git a/package.json b/package.json index c084f6e5f..31f69e1b4 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "globby": "^14.0.2", "husky": "^9.1.7", "ig-typedoc-theme": "^5.0.4", - "igniteui-theming": "^14.3.0", + "igniteui-theming": "^14.4.0-beta.1", "keep-a-changelog": "^2.5.3", "lint-staged": "^15.2.10", "lit-analyzer": "^2.0.3", diff --git a/src/components/carousel/carousel-indicator.ts b/src/components/carousel/carousel-indicator.ts index 8d88c052a..b2b4254b7 100644 --- a/src/components/carousel/carousel-indicator.ts +++ b/src/components/carousel/carousel-indicator.ts @@ -2,10 +2,10 @@ import { consume } from '@lit/context'; import { LitElement, html } from 'lit'; import { property } from 'lit/decorators.js'; import { styleMap } from 'lit/directives/style-map.js'; +import { carouselContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; import { formatString } from '../common/util.js'; import type IgcCarouselComponent from './carousel.js'; -import { carouselContext } from './context.js'; import { styles } from './themes/carousel-indicator.base.css.js'; /** diff --git a/src/components/carousel/carousel-slide.ts b/src/components/carousel/carousel-slide.ts index 61c6d5286..f282a85d7 100644 --- a/src/components/carousel/carousel-slide.ts +++ b/src/components/carousel/carousel-slide.ts @@ -5,11 +5,11 @@ import { property } from 'lit/decorators.js'; import { type Ref, createRef, ref } from 'lit/directives/ref.js'; import { EaseInOut } from '../../animations/easings.js'; import { addAnimationController } from '../../animations/player.js'; +import { carouselContext } from '../common/context.js'; import { registerComponent } from '../common/definitions/register.js'; import { createCounter, formatString, partNameMap } from '../common/util.js'; import { animations } from './animations.js'; import type IgcCarouselComponent from './carousel.js'; -import { carouselContext } from './context.js'; import { styles } from './themes/carousel-slide.base.css.js'; /** diff --git a/src/components/carousel/carousel.ts b/src/components/carousel/carousel.ts index 60f9fc3a3..3c3a9d879 100644 --- a/src/components/carousel/carousel.ts +++ b/src/components/carousel/carousel.ts @@ -11,6 +11,7 @@ import { type Ref, createRef, ref } from 'lit/directives/ref.js'; import { styleMap } from 'lit/directives/style-map.js'; import { themes } from '../../theming/theming-decorator.js'; import IgcButtonComponent from '../button/button.js'; +import { carouselContext } from '../common/context.js'; import { addKeyboardFocusRing } from '../common/controllers/focus-ring.js'; import { type SwipeEvent, @@ -46,7 +47,6 @@ import IgcIconComponent from '../icon/icon.js'; import IgcCarouselIndicatorContainerComponent from './carousel-indicator-container.js'; import IgcCarouselIndicatorComponent from './carousel-indicator.js'; import IgcCarouselSlideComponent from './carousel-slide.js'; -import { carouselContext } from './context.js'; import { styles } from './themes/carousel.base.css.js'; import { all } from './themes/container.js'; import { styles as shared } from './themes/shared/carousel.common.css.js'; diff --git a/src/components/carousel/context.ts b/src/components/carousel/context.ts deleted file mode 100644 index aba82dfdb..000000000 --- a/src/components/carousel/context.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createContext } from '@lit/context'; -import type IgcCarouselComponent from './carousel.js'; - -export const carouselContext = createContext( - Symbol('carousel-context') -); diff --git a/src/components/common/context.ts b/src/components/common/context.ts new file mode 100644 index 000000000..e45b3523c --- /dev/null +++ b/src/components/common/context.ts @@ -0,0 +1,29 @@ +import { createContext } from '@lit/context'; +import type IgcCarouselComponent from '../carousel/carousel.js'; +import type IgcTileManagerComponent from '../tile-manager/tile-manager.js'; +import type IgcTileComponent from '../tile-manager/tile.js'; + +export type TileManagerContext = { + instance: IgcTileManagerComponent; + draggedItem: IgcTileComponent | null; +}; + +export type TileContext = { + instance: IgcTileComponent; + setFullscreenState: ( + fullscreen: boolean, + isUserTriggered?: boolean + ) => unknown; +}; + +const carouselContext = createContext( + Symbol('carousel-context') +); + +const tileContext = createContext(Symbol('tile-context')); + +const tileManagerContext = createContext( + Symbol('tile-manager-context') +); + +export { carouselContext, tileContext, tileManagerContext }; diff --git a/src/components/common/util.ts b/src/components/common/util.ts index e31ff8ea1..723387422 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -288,3 +288,17 @@ export function asArray(value?: T | T[]): T[] { if (!isDefined(value)) return []; return Array.isArray(value) ? value : [value]; } + +export function partition( + array: T[], + isTruthy: (value: T) => boolean +): [truthy: T[], falsy: T[]] { + const truthy: T[] = []; + const falsy: T[] = []; + + for (const item of array) { + (isTruthy(item) ? truthy : falsy).push(item); + } + + return [truthy, falsy]; +} diff --git a/src/components/common/utils.spec.ts b/src/components/common/utils.spec.ts index 4d15fa254..ce20ef0f9 100644 --- a/src/components/common/utils.spec.ts +++ b/src/components/common/utils.spec.ts @@ -337,6 +337,41 @@ export function simulateWheel(node: Element, options?: WheelEventInit) { ); } +export function simulateDragStart(node: Element) { + node.dispatchEvent( + new DragEvent('dragstart', { + bubbles: true, + composed: true, + dataTransfer: new DataTransfer(), + }) + ); +} + +export function simulateDragOver(node: Element) { + node.dispatchEvent( + new DragEvent('dragover', { + bubbles: true, + composed: true, + }) + ); +} + +export function simulateDragEnd(node: Element) { + node.dispatchEvent( + new DragEvent('dragend', { bubbles: true, composed: true }) + ); +} + +export function simulateDrop(node: Element) { + node.dispatchEvent(new DragEvent('drop', { bubbles: true, composed: true })); +} + +export function simulateDoubleClick(node: Element) { + node.dispatchEvent( + new PointerEvent('dblclick', { bubbles: true, composed: true }) + ); +} + /** * Returns an array of all Animation objects affecting this element or which are scheduled to do so in the future. * It can optionally return Animation objects for descendant elements too. diff --git a/src/components/icon/icon-references.ts b/src/components/icon/icon-references.ts index 6eacfb28f..61e58c95b 100644 --- a/src/components/icon/icon-references.ts +++ b/src/components/icon/icon-references.ts @@ -275,3 +275,49 @@ addIcon('error', { collection: 'internal', }, }); +addIcon('fullscreen', { + default: { + name: 'fullscreen', + collection: 'internal', + }, + indigo: { + name: 'indigo_fullscreen', + collection: 'internal', + }, +}); +addIcon('fullscreen_exit', { + default: { + name: 'fullscreen_exit', + collection: 'internal', + }, + indigo: { + name: 'indigo_fullscreen_exit', + collection: 'internal', + }, +}); +addIcon('expand_content', { + default: { + name: 'expand_content', + collection: 'internal', + }, + indigo: { + name: 'indigo_expand_content', + collection: 'internal', + }, +}); +addIcon('collapse_content', { + default: { + name: 'collapse_content', + collection: 'internal', + }, + indigo: { + name: 'indigo_collapse_content', + collection: 'internal', + }, +}); +addIcon('resize', { + default: { + name: 'resize', + collection: 'internal', + }, +}); diff --git a/src/components/icon/internal-icons-lib.ts b/src/components/icon/internal-icons-lib.ts index 64e7cc26a..6e865a071 100644 --- a/src/components/icon/internal-icons-lib.ts +++ b/src/components/icon/internal-icons-lib.ts @@ -62,6 +62,53 @@ export const internalIcons = new Map( calendar_today: { svg: ``, }, + expand_content: { + svg: ``, + }, + indigo_expand_content: { + svg: ` + + + +`, + }, + collapse_content: { + svg: ``, + }, + indigo_collapse_content: { + svg: ` + + + +`, + }, + fullscreen: { + svg: ``, + }, + indigo_fullscreen: { + svg: ` + + + + + +`, + }, + fullscreen_exit: { + svg: ``, + }, + indigo_fullscreen_exit: { + svg: ` + + + + + +`, + }, + resize: { + svg: ``, + }, indigo_clear: { svg: ` diff --git a/src/components/tile-manager/controllers/fullscreen.ts b/src/components/tile-manager/controllers/fullscreen.ts new file mode 100644 index 000000000..e7ae5b268 --- /dev/null +++ b/src/components/tile-manager/controllers/fullscreen.ts @@ -0,0 +1,61 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; + +type FullscreenControllerCallback = (state: boolean) => boolean; + +class FullscreenController implements ReactiveController { + private _host: ReactiveControllerHost & HTMLElement; + private _callback?: FullscreenControllerCallback; + + private _fullscreen = false; + + public get fullscreen(): boolean { + return this._fullscreen; + } + + constructor( + host: ReactiveControllerHost & HTMLElement, + callback?: FullscreenControllerCallback + ) { + this._host = host; + this._callback = callback; + host.addController(this); + } + + public setState(fullscreen: boolean, isUserTriggered = false): void { + if (isUserTriggered && this._callback) { + if (!this._callback.call(this._host, fullscreen)) { + return; + } + } + + this._fullscreen = fullscreen; + + if (this._fullscreen) { + this._host.requestFullscreen(); + } else if (document.fullscreenElement) { + document.exitFullscreen(); + } + } + + public handleEvent() { + const isFullscreen = document.fullscreenElement === this._host; + if (!isFullscreen && this._fullscreen) { + this.setState(false, true); + } + } + + public hostConnected(): void { + this._host.addEventListener('fullscreenchange', this); + } + + public hostDisconnected(): void { + this._host.removeEventListener('fullscreenchange', this); + } +} + +export function addFullscreenController( + host: ReactiveControllerHost & HTMLElement, + callback?: FullscreenControllerCallback +) { + return new FullscreenController(host, callback); +} diff --git a/src/components/tile-manager/controllers/tile-dnd.ts b/src/components/tile-manager/controllers/tile-dnd.ts new file mode 100644 index 000000000..2f3c54ec7 --- /dev/null +++ b/src/components/tile-manager/controllers/tile-dnd.ts @@ -0,0 +1,72 @@ +import type { ReactiveController } from 'lit'; +import type IgcTileComponent from '../tile.js'; + +type TileDragCallback = (event: DragEvent) => unknown; + +type TileDragAndDropConfig = { + dragStart: TileDragCallback; + dragEnd: TileDragCallback; + dragEnter: TileDragCallback; + dragLeave: TileDragCallback; + dragOver: TileDragCallback; + drop: TileDragCallback; +}; + +const DragEvents = [ + 'dragstart', + 'dragend', + 'dragenter', + 'dragleave', + 'dragover', + 'drop', +]; + +export class TileDragAndDropController implements ReactiveController { + public enabled = true; + + private _host: IgcTileComponent; + private _handlers!: Map; + + constructor(host: IgcTileComponent, config: Partial) { + this._host = host; + this._host.addController(this); + this._initEventHandlers(config); + } + + private _initEventHandlers(config: Partial) { + this._handlers = new Map(); + + for (const [type, callback] of Object.entries(config)) { + this._handlers.set(type.toLowerCase(), callback); + } + } + + public handleEvent(event: DragEvent) { + if (!this.enabled) return; + + if (this._handlers.has(event.type)) { + this._handlers.get(event.type)!.call(this._host, event); + } + } + + public hostConnected(): void { + this._host.draggable = this.enabled; + + for (const type of DragEvents) { + this._host.addEventListener(type, this); + } + } + + public hostDisconnected(): void { + for (const type of DragEvents) { + this._host.removeEventListener(type, this); + } + } +} + +export function addTileDragAndDrop( + host: IgcTileComponent, + config: Partial +) { + return new TileDragAndDropController(host, config); +} diff --git a/src/components/tile-manager/position.ts b/src/components/tile-manager/position.ts new file mode 100644 index 000000000..ef9ca45ed --- /dev/null +++ b/src/components/tile-manager/position.ts @@ -0,0 +1,91 @@ +import { partition } from '../common/util.js'; +import type IgcTileManagerComponent from './tile-manager.js'; +import type IgcTileComponent from './tile.js'; + +class TilesState { + public manager: IgcTileManagerComponent; + + private get _tiles(): IgcTileComponent[] { + return Array.from( + this.manager.querySelectorAll(':scope > igc-tile') + ); + } + + /** + * Returns the current tiles of the tile manager sorted by their position. + */ + public get tiles(): IgcTileComponent[] { + return this._tiles.toSorted((a, b) => a.position - b.position); + } + + constructor(manager: IgcTileManagerComponent) { + this.manager = manager; + } + + public assignPositions(): void { + let nextPosition = 0; + const [positionedTiles, nonPositionedTiles] = partition( + this._tiles, + (tile) => tile.position !== -1 + ); + + positionedTiles.sort((a, b) => a.position - b.position); + + for (const tile of positionedTiles) { + // Fill any unassigned slots before the next assigned tile's position + while (nextPosition < tile.position && nonPositionedTiles.length > 0) { + const nonPositionedTile = nonPositionedTiles.shift()!; + nonPositionedTile.position = nextPosition++; + } + + tile.position = nextPosition; + nextPosition = tile.position + 1; + } + + for (const tile of nonPositionedTiles) { + tile.position = nextPosition++; + } + } + + /** Updates the default (manual) slot of the tile manager with the current tiles. */ + public assignTiles(): void { + this.manager.renderRoot.querySelector('slot')!.assign(...this._tiles); + } + + public add(tile: IgcTileComponent): void { + const tiles = this.tiles; + + if (tile.position > -1) { + for (const each of tiles) { + if (each !== tile && each.position >= tile.position) { + each.position++; + } + } + } else { + tile.position = tiles.length; + } + } + + public remove(tile: IgcTileComponent): void { + for (const each of this.tiles) { + if (each.position >= tile.position) { + each.position--; + } + } + } +} + +export function swapTiles(a: IgcTileComponent, b: IgcTileComponent): void { + [a.position, b.position] = [b.position, a.position]; +} + +export function isSameTile( + a?: IgcTileComponent | null, + b?: IgcTileComponent | null +): boolean { + return a != null && b != null && a === b; +} + +export function createTilesState(manager: IgcTileManagerComponent) { + return new TilesState(manager); +} diff --git a/src/components/tile-manager/resize-controller.ts b/src/components/tile-manager/resize-controller.ts new file mode 100644 index 000000000..ca20bde58 --- /dev/null +++ b/src/components/tile-manager/resize-controller.ts @@ -0,0 +1,230 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import type { Ref } from 'lit/directives/ref.js'; +import { findElementFromEventPath } from '../common/util.js'; + +export type ResizeMode = 'immediate' | 'deferred'; + +export type ResizeCallbackParams = { + event: PointerEvent; + state: { + initial: DOMRect; + current: DOMRect; + dx: number; + dy: number; + ghost: HTMLElement | null; + }; +}; + +type ResizeControllerCallback = (params: ResizeCallbackParams) => unknown; + +type ResizeControllerConfig = { + ref?: Ref; + mode?: ResizeMode; + deferredFactory?: (element?: HTMLElement) => HTMLElement; + start?: ResizeControllerCallback; + resize?: ResizeControllerCallback; + end?: ResizeControllerCallback; +}; + +class ResizeController implements ReactiveController { + private static auxiliaryEvents = [ + 'pointermove', + 'lostpointercapture', + ] as const; + + private static createDefaultGhost(host: HTMLElement): HTMLElement { + const { width, height } = host.getBoundingClientRect(); + const ghostElement = document.createElement('div'); + + Object.assign(ghostElement.style, { + position: 'absolute', + top: 0, + left: 0, + zIndex: 1000, + background: 'pink', + opacity: 0.85, + width: `${width}px`, + height: `${height}px`, + }); + + return ghostElement; + } + + private _host: ReactiveControllerHost & HTMLElement; + private _config: ResizeControllerConfig = {}; + private _id!: number; + + private _ghost: HTMLElement | null = null; + protected _initialState!: DOMRect; + private _state!: DOMRect; + + protected get _element() { + return this._config?.ref ? this._config.ref.value! : this._host; + } + + constructor( + host: ReactiveControllerHost & HTMLElement, + config?: ResizeControllerConfig + ) { + this._host = host; + this._host.addController(this); + + this.setConfig(config); + } + + // Internal state helpers + + private _createGhost() { + if (this._config.mode !== 'deferred') { + return; + } + + this._ghost = this._config.deferredFactory + ? this._config.deferredFactory() + : ResizeController.createDefaultGhost(this._host); + this._host.append(this._ghost); + } + + private _disposeGhost() { + if (this._ghost) { + this._ghost.remove(); + this._ghost = null; + } + } + + private _setInitialState(event: PointerEvent) { + const resizableElement = this._host.querySelector('div[part~="base"]'); + + const rect = resizableElement!.getBoundingClientRect(); + this._initialState = structuredClone(rect); + this._state = rect; + this._id = event.pointerId; + } + + private _createCallbackParams(event: PointerEvent): ResizeCallbackParams { + return { + event, + state: { + initial: this._initialState, + current: this._state, + dx: this._state.width - this._initialState.width, + dy: this._state.height - this._initialState.height, + ghost: this._ghost, + }, + }; + } + + private _toggleSubsequentEvents(set: boolean) { + const method = set + ? this._host.addEventListener + : this._host.removeEventListener; + for (const type of ResizeController.auxiliaryEvents) { + method(type, this); + } + } + + private _shouldSkip(event: PointerEvent): boolean { + return !findElementFromEventPath((e) => e === this._element, event); + } + + // Event handlers + + private _handlePointerDown(event: PointerEvent) { + // Non-primary buttons are ignored + if (event.button) { + return; + } + + if (this._config?.start) { + this._setInitialState(event); + this._createGhost(); + + const params = this._createCallbackParams(event); + this._config.start.call(this._host, params); + + this._element.setPointerCapture(this._id); + this._toggleSubsequentEvents(true); + } + } + + private _handlePointerMove(event: PointerEvent) { + if (!this._element.hasPointerCapture(this._id)) { + return; + } + + // REVIEW: Sequencing + + if (this._config?.resize) { + const params = this._createCallbackParams(event); + this._config.resize.call(this._host, params); + this._state = params.state.current; + } + + const target = this._config.mode === 'deferred' ? this._ghost! : this._host; + + Object.assign(target.style, { + width: `${this._state.width}px`, + height: `${this._state.height}px`, + }); + } + + private _handlePointerEnd(event: PointerEvent) { + Object.assign(this._host.style, { + width: `${this._state.width}px`, + height: `${this._state.height}px`, + }); + + if (this._config?.end) { + this._config.end.call(this._host, this._createCallbackParams(event)); + } + + this.dispose(); + } + + public handleEvent(event: PointerEvent) { + if (this._shouldSkip(event)) { + return; + } + + switch (event.type) { + case 'touchstart': + return event.preventDefault(); + + case 'pointerdown': + return this._handlePointerDown(event); + case 'pointermove': + return this._handlePointerMove(event); + case 'lostpointercapture': + return this._handlePointerEnd(event); + } + } + + // Public API + + public setConfig(config?: ResizeControllerConfig) { + Object.assign(this._config, config); + } + + public dispose() { + this._disposeGhost(); + this._toggleSubsequentEvents(false); + this._element.releasePointerCapture(this._id); + } + + public hostConnected(): void { + this._host.addEventListener('pointerdown', this); + this._host.addEventListener('touchstart', this, { passive: false }); + } + + public hostDisconnected(): void { + this._host.removeEventListener('pointerdown', this); + this._host.removeEventListener('touchstart', this); + } +} + +export function addResizeController( + host: ReactiveControllerHost & HTMLElement, + config?: ResizeControllerConfig +) { + return new ResizeController(host, config); +} diff --git a/src/components/tile-manager/resize-element.ts b/src/components/tile-manager/resize-element.ts new file mode 100644 index 000000000..386f3a9c5 --- /dev/null +++ b/src/components/tile-manager/resize-element.ts @@ -0,0 +1,141 @@ +import { LitElement, html } from 'lit'; +import { property, state } from 'lit/decorators.js'; +import { type Ref, createRef, ref } from 'lit/directives/ref.js'; +import { + addKeybindings, + escapeKey, +} from '../common/controllers/key-bindings.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 IgcIconComponent from '../icon/icon.js'; +import { + type ResizeCallbackParams, + type ResizeMode, + addResizeController, +} from './resize-controller.js'; +import { styles } from './themes/resize.base.css.js'; +import { styles as shared } from './themes/shared/resize.common.css.js'; + +export interface IgcResizeComponentEventMap { + igcResizeStart: CustomEvent; + igcResize: CustomEvent; + igcResizeEnd: CustomEvent; + igcResizeCancel: CustomEvent; +} + +export default class IgcResizeComponent extends EventEmitterMixin< + IgcResizeComponentEventMap, + Constructor +>(LitElement) { + public static tagName = 'igc-resize'; + public static styles = [styles, shared]; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcResizeComponent, IgcIconComponent); + } + + private _resizeController: ReturnType; + + private _ghostFactory!: (element?: HTMLElement) => HTMLElement; + private _mode: ResizeMode = 'immediate'; + private _adorner: Ref = createRef(); + + @state() + private _state!: ResizeCallbackParams['state']; + + @state() + private _isResizing = false; + + @property() + public set mode(value: ResizeMode) { + this._mode = value; + this._resizeController.setConfig({ mode: value }); + } + + public get mode(): ResizeMode { + return this._mode; + } + + @property({ attribute: false }) + public set ghostFactory(func: (element?: HTMLElement) => HTMLElement) { + this._ghostFactory = func; + this._resizeController.setConfig({ deferredFactory: func }); + } + + public get ghostFactory() { + return this._ghostFactory; + } + + constructor() { + super(); + + this._resizeController = addResizeController(this, { + ref: this._adorner, + start: this._handleResizeStart, + resize: this._handleResize, + end: this._handleResizeEnd, + }); + + addKeybindings(this, { + skip: () => !this._isResizing, + bindingDefaults: { preventDefault: true }, + }).set(escapeKey, this._handleResizeCancel); + } + + private _setState(state: ResizeCallbackParams['state']) { + this._state = state; + } + + private _handleResizeStart(params: ResizeCallbackParams) { + params.event.preventDefault(); + this.emitEvent('igcResizeStart', { bubbles: false, detail: params }); + + this._isResizing = true; + this._setState(params.state); + this._adorner.value!.focus(); + } + + private _handleResize(params: ResizeCallbackParams) { + this._setState(params.state); + this.emitEvent('igcResize', { bubbles: false, detail: params }); + } + + private _handleResizeEnd(params: ResizeCallbackParams) { + this._isResizing = false; + this._setState(params.state); + this.emitEvent('igcResizeEnd', { bubbles: false, detail: params }); + } + + private _handleResizeCancel() { + this._isResizing = false; + this._resizeController.dispose(); + + const { width, height } = this._state.initial; + + Object.assign(this.style, { + width: `${width}px`, + height: `${height}px`, + }); + + this.emitEvent('igcResizeCancel', { bubbles: false }); + } + + protected override render() { + return html` +
+ +
+ +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-resize': IgcResizeComponent; + } +} diff --git a/src/components/tile-manager/serializer.ts b/src/components/tile-manager/serializer.ts new file mode 100644 index 000000000..0f7567d73 --- /dev/null +++ b/src/components/tile-manager/serializer.ts @@ -0,0 +1,84 @@ +import type IgcTileManagerComponent from './tile-manager.js'; + +export interface SerializedTile { + colSpan: number; + colStart: number | null; + disableDrag: boolean; + disableResize: boolean; + gridColumn: string; + gridRow: string; + maximized: boolean; + position: number; + rowSpan: number; + rowStart: number | null; + tileId: string | null; +} + +class TileManagerSerializer { + public tileManager: IgcTileManagerComponent; + + constructor(tileManager: IgcTileManagerComponent) { + this.tileManager = tileManager; + } + + public save(): SerializedTile[] { + return this.tileManager.tiles.map((tile) => { + const { gridColumn, gridRow } = getComputedStyle(tile); + + return { + colSpan: tile.colSpan, + colStart: tile.colStart, + disableDrag: tile.disableDrag, + disableResize: tile.disableResize, + gridColumn, + gridRow, + maximized: tile.maximized, + position: tile.position, + rowSpan: tile.rowSpan, + rowStart: tile.rowStart, + tileId: tile.tileId, + }; + }); + } + + public saveAsJSON(): string { + return JSON.stringify(this.save()); + } + + public load(tiles: SerializedTile[]): void { + const mapped = new Map(tiles.map((tile) => [tile.tileId, tile])); + + for (const tile of this.tileManager.tiles) { + if (!mapped.has(tile.tileId)) { + continue; + } + + const serialized = mapped.get(tile.tileId)!; + const properties = omit(serialized, 'gridColumn', 'gridRow'); + const styles = pick(serialized, 'gridColumn', 'gridRow'); + + Object.assign(tile, properties); + Object.assign(tile.style, styles); + } + } + + public loadFromJSON(data: string): void { + this.load(JSON.parse(data)); + } +} + +export function createSerializer(host: IgcTileManagerComponent) { + return new TileManagerSerializer(host); +} + +function pick(entry: T, ...props: Array) { + return Object.fromEntries( + Object.entries(entry).filter(([key, _]) => props.includes(key as keyof T)) + ); +} + +function omit(entry: T, ...props: Array) { + return Object.fromEntries( + Object.entries(entry).filter(([key, _]) => !props.includes(key as keyof T)) + ); +} diff --git a/src/components/tile-manager/themes/container.ts b/src/components/tile-manager/themes/container.ts new file mode 100644 index 000000000..47fdf838c --- /dev/null +++ b/src/components/tile-manager/themes/container.ts @@ -0,0 +1,54 @@ +import { css } from 'lit'; + +import type { Themes } from '../../../theming/types.js'; +// Dark Overrides +import { styles as bootstrapDark } from './dark/tile-manager.bootstrap.css.js'; +import { styles as fluentDark } from './dark/tile-manager.fluent.css.js'; +import { styles as indigoDark } from './dark/tile-manager.indigo.css.js'; +import { styles as materialDark } from './dark/tile-manager.material.css.js'; +import { styles as sharedDark } from './dark/tile-manager.shared.css.js'; +// Light Overrides +import { styles as bootstrapLight } from './light/tile-manager.bootstrap.css.js'; +import { styles as fluentLight } from './light/tile-manager.fluent.css.js'; +import { styles as indigoLight } from './light/tile-manager.indigo.css.js'; +import { styles as materialLight } from './light/tile-manager.material.css.js'; +import { styles as sharedLight } from './light/tile-manager.shared.css.js'; +// Shared Styles + +const light = { + shared: css` + ${sharedLight} + `, + bootstrap: css` + ${bootstrapLight} + `, + material: css` + ${materialLight} + `, + fluent: css` + ${fluentLight} + `, + indigo: css` + ${indigoLight} + `, +}; + +const dark = { + shared: css` + ${sharedDark} + `, + bootstrap: css` + ${bootstrapDark} + `, + material: css` + ${materialDark} + `, + fluent: css` + ${fluentDark} + `, + indigo: css` + ${indigoDark} + `, +}; + +export const all: Themes = { light, dark }; diff --git a/src/components/tile-manager/themes/dark/_themes.scss b/src/components/tile-manager/themes/dark/_themes.scss new file mode 100644 index 000000000..def23e3a5 --- /dev/null +++ b/src/components/tile-manager/themes/dark/_themes.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/dark/tile-manager' as *; + +$base: digest-schema($dark-base-tile-manager); +$material: digest-schema($dark-material-tile-manager); +$bootstrap: digest-schema($dark-bootstrap-tile-manager); +$fluent: digest-schema($dark-fluent-tile-manager); +$indigo: digest-schema($dark-indigo-tile-manager); diff --git a/src/components/tile-manager/themes/dark/tile-manager.bootstrap.scss b/src/components/tile-manager/themes/dark/tile-manager.bootstrap.scss new file mode 100644 index 000000000..78f430331 --- /dev/null +++ b/src/components/tile-manager/themes/dark/tile-manager.bootstrap.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/dark/tile-manager.fluent.scss b/src/components/tile-manager/themes/dark/tile-manager.fluent.scss new file mode 100644 index 000000000..fb1ce6204 --- /dev/null +++ b/src/components/tile-manager/themes/dark/tile-manager.fluent.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/dark/tile-manager.indigo.scss b/src/components/tile-manager/themes/dark/tile-manager.indigo.scss new file mode 100644 index 000000000..cf387e731 --- /dev/null +++ b/src/components/tile-manager/themes/dark/tile-manager.indigo.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/dark/tile-manager.material.scss b/src/components/tile-manager/themes/dark/tile-manager.material.scss new file mode 100644 index 000000000..3e6764ed7 --- /dev/null +++ b/src/components/tile-manager/themes/dark/tile-manager.material.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/dark/tile-manager.shared.scss b/src/components/tile-manager/themes/dark/tile-manager.shared.scss new file mode 100644 index 000000000..3aa756d43 --- /dev/null +++ b/src/components/tile-manager/themes/dark/tile-manager.shared.scss @@ -0,0 +1,6 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +:host { + @include css-vars-from-theme($base, 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/header.ts b/src/components/tile-manager/themes/header.ts new file mode 100644 index 000000000..7516661a0 --- /dev/null +++ b/src/components/tile-manager/themes/header.ts @@ -0,0 +1,33 @@ +import { css } from 'lit'; + +import type { Themes } from '../../../theming/types.js'; +// Shared Styles +import { styles as bootstrap } from './shared/header/tile-header.bootstrap.css.js'; +import { styles as fluent } from './shared/header/tile-header.fluent.css.js'; +import { styles as indigo } from './shared/header/tile-header.indigo.css.js'; + +const light = { + bootstrap: css` + ${bootstrap} + `, + fluent: css` + ${fluent} + `, + indigo: css` + ${indigo} + `, +}; + +const dark = { + bootstrap: css` + ${bootstrap} + `, + fluent: css` + ${fluent} + `, + indigo: css` + ${indigo} + `, +}; + +export const all: Themes = { light, dark }; diff --git a/src/components/tile-manager/themes/light/_themes.scss b/src/components/tile-manager/themes/light/_themes.scss new file mode 100644 index 000000000..541ba8f51 --- /dev/null +++ b/src/components/tile-manager/themes/light/_themes.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/light/tile-manager' as *; + +$base: digest-schema($light-tile-manager); +$material: digest-schema($material-tile-manager); +$bootstrap: digest-schema($bootstrap-tile-manager); +$fluent: digest-schema($fluent-tile-manager); +$indigo: digest-schema($indigo-tile-manager); diff --git a/src/components/tile-manager/themes/light/tile-manager.bootstrap.scss b/src/components/tile-manager/themes/light/tile-manager.bootstrap.scss new file mode 100644 index 000000000..78f430331 --- /dev/null +++ b/src/components/tile-manager/themes/light/tile-manager.bootstrap.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/light/tile-manager.fluent.scss b/src/components/tile-manager/themes/light/tile-manager.fluent.scss new file mode 100644 index 000000000..fb1ce6204 --- /dev/null +++ b/src/components/tile-manager/themes/light/tile-manager.fluent.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/light/tile-manager.indigo.scss b/src/components/tile-manager/themes/light/tile-manager.indigo.scss new file mode 100644 index 000000000..cf387e731 --- /dev/null +++ b/src/components/tile-manager/themes/light/tile-manager.indigo.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/light/tile-manager.material.scss b/src/components/tile-manager/themes/light/tile-manager.material.scss new file mode 100644 index 000000000..3e6764ed7 --- /dev/null +++ b/src/components/tile-manager/themes/light/tile-manager.material.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff($base, $theme), 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/light/tile-manager.shared.scss b/src/components/tile-manager/themes/light/tile-manager.shared.scss new file mode 100644 index 000000000..3aa756d43 --- /dev/null +++ b/src/components/tile-manager/themes/light/tile-manager.shared.scss @@ -0,0 +1,6 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +:host { + @include css-vars-from-theme($base, 'ig-tile-manager'); +} diff --git a/src/components/tile-manager/themes/resize.base.scss b/src/components/tile-manager/themes/resize.base.scss new file mode 100644 index 000000000..ae4e3d1d8 --- /dev/null +++ b/src/components/tile-manager/themes/resize.base.scss @@ -0,0 +1,34 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + --resize-display: none; + + display: contents; +} + +[part='resize-base'] { + position: relative; + height: 100%; +} + +[part='trigger'] { + display: var(--resize-display); + cursor: nwse-resize; + position: absolute; + bottom: rem(2px); + right: rem(2px); + z-index: 1; + + igc-icon { + $icon-size: rem(9px); + + --size: #{$icon-size} !important; + --ig-icon-size: #{$icon-size}; + --igx-icon-size: #{$icon-size}; + } +} + +[part='trigger']:focus { + outline: none; +} \ No newline at end of file diff --git a/src/components/tile-manager/themes/shared/header/tile-header.bootstrap.scss b/src/components/tile-manager/themes/shared/header/tile-header.bootstrap.scss new file mode 100644 index 000000000..e10312dc8 --- /dev/null +++ b/src/components/tile-manager/themes/shared/header/tile-header.bootstrap.scss @@ -0,0 +1,11 @@ +@use 'styles/utilities' as *; + +[part='header'] { + padding-block: sizable(rem(8px), rem(9px), rem(9px)); +} + +::slotted([slot='title']) { + @include type-style('h5'); + + margin: 0; +} \ No newline at end of file diff --git a/src/components/tile-manager/themes/shared/header/tile-header.common.scss b/src/components/tile-manager/themes/shared/header/tile-header.common.scss new file mode 100644 index 000000000..f7685381f --- /dev/null +++ b/src/components/tile-manager/themes/shared/header/tile-header.common.scss @@ -0,0 +1,18 @@ +@use 'styles/utilities' as *; +@use '../../light/themes' as *; + +$theme: $material; + +[part='header'] { + background: var-get($theme, 'tile-background'); + border-top-left-radius: var-get($theme, 'border-radius'); + border-top-right-radius: var-get($theme, 'border-radius'); +} + +[part='title'] { + color: var-get($theme, 'title-color'); +} + +igc-divider { + --color: var-get($theme, 'divider-color'); +} \ No newline at end of file diff --git a/src/components/tile-manager/themes/shared/header/tile-header.fluent.scss b/src/components/tile-manager/themes/shared/header/tile-header.fluent.scss new file mode 100644 index 000000000..4cc3296a1 --- /dev/null +++ b/src/components/tile-manager/themes/shared/header/tile-header.fluent.scss @@ -0,0 +1,5 @@ +@use 'styles/utilities' as *; + +[part='header'] { + padding-block: sizable(rem(8px), rem(12px), rem(12px)); +} \ No newline at end of file diff --git a/src/components/tile-manager/themes/shared/header/tile-header.indigo.scss b/src/components/tile-manager/themes/shared/header/tile-header.indigo.scss new file mode 100644 index 000000000..dad3e1afb --- /dev/null +++ b/src/components/tile-manager/themes/shared/header/tile-header.indigo.scss @@ -0,0 +1,5 @@ +@use 'styles/utilities' as *; + +[part='header'] { + padding-block: sizable(rem(8px), rem(14px), rem(14px)); +} diff --git a/src/components/tile-manager/themes/shared/resize.common.scss b/src/components/tile-manager/themes/shared/resize.common.scss new file mode 100644 index 000000000..132e7d0be --- /dev/null +++ b/src/components/tile-manager/themes/shared/resize.common.scss @@ -0,0 +1,10 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $material; + +[part='trigger'] { + igc-icon { + color: var-get($theme, 'resize-indicator'); + } +} \ No newline at end of file diff --git a/src/components/tile-manager/themes/shared/tile-manager.common.scss b/src/components/tile-manager/themes/shared/tile-manager.common.scss new file mode 100644 index 000000000..d4f78eba3 --- /dev/null +++ b/src/components/tile-manager/themes/shared/tile-manager.common.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $material; + +[part='base'] { + background: var-get($theme, 'tile-manager-background'); +} \ No newline at end of file diff --git a/src/components/tile-manager/themes/shared/tile/tile.common.scss b/src/components/tile-manager/themes/shared/tile/tile.common.scss new file mode 100644 index 000000000..135b169c5 --- /dev/null +++ b/src/components/tile-manager/themes/shared/tile/tile.common.scss @@ -0,0 +1,53 @@ +@use 'styles/utilities' as *; +@use '../../light/themes' as *; + +$theme: $material; + +:host { + --component-size: var(--ig-size, #{var-get($theme, 'default-size')}); +} + +[part~='base'] { + background: var-get($theme, 'tile-background'); + border-radius: var-get($theme, 'border-radius'); + box-shadow: var-get($theme, 'resting-elevation'); + + &:hover { + &::after { + content: ''; + position: absolute; + pointer-events: none; + border-radius: inherit; + inset: 0; + width: 100%; + height: 100%; + border: rem(1px) solid var-get($theme, 'hover-border-color'); + } + } +} + +[part~='dragging'] { + opacity: .6; + box-shadow: var-get($theme, 'drag-elevation'); +} + +[part~='base'][part~='resizing']{ + opacity: .3; + + &:hover { + &::after { + display: none; + } + } +} + +[part='content-container'] { + background: var-get($theme, 'content-background'); + color: var-get($theme, 'content-color'); +} + +[part='ghost'] { + border-radius: var-get($theme, 'border-radius'); + border: rem(1px) solid var-get($theme, 'ghost-border'); + background: var-get($theme, 'overlay-background'); +} diff --git a/src/components/tile-manager/themes/shared/tile/tile.indigo.scss b/src/components/tile-manager/themes/shared/tile/tile.indigo.scss new file mode 100644 index 000000000..dc4603202 --- /dev/null +++ b/src/components/tile-manager/themes/shared/tile/tile.indigo.scss @@ -0,0 +1,24 @@ +@use 'styles/utilities' as *; +@use '../../light/themes' as *; + +$theme: $indigo; + +[part~='base'] { + border: rem(1px) solid var-get($theme, 'border-color'); + + &:hover { + border: rem(1px) solid var-get($theme, 'hover-border-color'); + + &::after { + display: none; + } + } +} + +[part='content-container'] { + ::slotted(*) { + @include type-style('body-2'); + + margin: 0; + } +} \ No newline at end of file diff --git a/src/components/tile-manager/themes/tile-header.base.scss b/src/components/tile-manager/themes/tile-header.base.scss new file mode 100644 index 000000000..9626a12d3 --- /dev/null +++ b/src/components/tile-manager/themes/tile-header.base.scss @@ -0,0 +1,24 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +[part='header'] { + display: flex; + align-items: center; + gap: sizable(rem(12px), rem(16px), rem(16px)); + padding-block: sizable(rem(8px), rem(13px), rem(13px)); + padding-inline: sizable(rem(12px), rem(16px), rem(20px)); +} + +::slotted([slot='title']) { + @include type-style('h6'); + @include ellipsis(); + + margin-bottom: 0; + min-width: 2ch; +} + +[part='actions'] { + display: flex; + gap: rem(8px); + margin-inline-start: auto; +} \ No newline at end of file diff --git a/src/components/tile-manager/themes/tile-manager.base.scss b/src/components/tile-manager/themes/tile-manager.base.scss new file mode 100644 index 000000000..429508876 --- /dev/null +++ b/src/components/tile-manager/themes/tile-manager.base.scss @@ -0,0 +1,26 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + + --ig-min-col-width: 200px; + --ig-min-row-height: 200px; +} + +[part~='base'] { + display: grid; + position: relative; + padding: rem(20px); + width: 100%; + height: 100%; + grid-template-columns: repeat(var(--ig-column-count, auto-fit), minmax(var(--ig-min-col-width), 1fr)); + grid-auto-rows: minmax(var(--ig-min-row-height), auto); + grid-gap: rem(10px); + grid-auto-flow: dense; + overflow: auto hidden; +} + +[part~='maximized-tile'] { + overflow: clip; +} diff --git a/src/components/tile-manager/themes/tile.base.scss b/src/components/tile-manager/themes/tile.base.scss new file mode 100644 index 000000000..271f50bcc --- /dev/null +++ b/src/components/tile-manager/themes/tile.base.scss @@ -0,0 +1,78 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + display: block; + position: relative; + grid-column: var(--ig-col-start, auto) / span var(--ig-col-span, 1); + grid-row: var(--ig-row-start, auto) / span var(--ig-row-span, 1); +} + +:host(:hover) { + igc-resize { + --resize-display: inline-flex; + } +} + +:host([part='resizing']), +:host([part='dragging']) { + igc-resize { + --resize-display: none; + } +} + +[part~='tile-container'] { + width: 100%; + height: 100%; +} + +[part~='base'] { + width: 100%; + height: 100%; + overflow: hidden; +} + +[part='content-container'] { + display: block; + border-radius: inherit; + + ::slotted(*) { + @include type-style('body-1'); + + margin: 0; + } +} + +:host([maximized]) { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 10; + + // Fix for Firefox + grid-column: unset; + grid-row: unset; + + [part~='base'] { + width: 100%; + height: 100%; + } +} + +[part='ghost'] { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform: scale(0); + + // transition: transform 0.5s ease-in-out; + z-index: 10; +} + +[part~='drag-over'] { + opacity: 0.5; + pointer-events: none; +} diff --git a/src/components/tile-manager/themes/tile.ts b/src/components/tile-manager/themes/tile.ts new file mode 100644 index 000000000..0d735ac13 --- /dev/null +++ b/src/components/tile-manager/themes/tile.ts @@ -0,0 +1,19 @@ +import { css } from 'lit'; + +import type { Themes } from '../../../theming/types.js'; +// Shared Styles +import { styles as indigo } from './shared/tile/tile.indigo.css.js'; + +const light = { + indigo: css` + ${indigo} + `, +}; + +const dark = { + indigo: css` + ${indigo} + `, +}; + +export const all: Themes = { light, dark }; diff --git a/src/components/tile-manager/tile-dnd.spec.ts b/src/components/tile-manager/tile-dnd.spec.ts new file mode 100644 index 000000000..f831557f7 --- /dev/null +++ b/src/components/tile-manager/tile-dnd.spec.ts @@ -0,0 +1,184 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { spy } from 'sinon'; + +import { range } from 'lit/directives/range.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { first, last } from '../common/util.js'; +import { + simulateDragEnd, + simulateDragOver, + simulateDragStart, + simulateDrop, +} from '../common/utils.spec.js'; +import IgcTileManagerComponent from './tile-manager.js'; +import type IgcTileComponent from './tile.js'; + +describe('Tile drag and drop', () => { + before(() => { + defineComponents(IgcTileManagerComponent); + }); + + let tileManager: IgcTileManagerComponent; + + function getTiles() { + return Array.from(tileManager.querySelectorAll('igc-tile')); + } + // function getTileBaseWrapper(element: IgcTileComponent) { + // return element.renderRoot.querySelector('[part~="base"]')!; + // } + + function getTileBaseWrapper(element: IgcTileComponent) { + const resizeElement = element.renderRoot.querySelector('igc-resize'); + + if (resizeElement) { + return resizeElement.querySelector('[part~="base"]')!; + } + + return element.renderRoot.querySelector('[part~="base"]')!; + } + + function createTileManager() { + const result = Array.from(range(5)).map( + (i) => html` + + +

Tile ${i + 1}

+
+ +
+

Content in tile ${i + 1}

+
+
+ ` + ); + return html`${result}`; + } + + describe('Tile drag behavior', () => { + beforeEach(async () => { + tileManager = await fixture(createTileManager()); + }); + + const dragAndDrop = async (draggedTile: Element, dropTarget: Element) => { + simulateDragStart(draggedTile); + simulateDragOver(dropTarget); + simulateDrop(dropTarget); + simulateDragEnd(draggedTile); + await elementUpdated(tileManager); + }; + + it("should correctly fire 'dragStart' event", async () => { + const eventSpy = spy(tileManager, 'emitEvent'); + + const tile = first(tileManager.tiles); + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', 'Tile data for drag operation'); + + simulateDragStart(tile); + await elementUpdated(tileManager); + + expect(eventSpy).calledOnceWithExactly('igcTileDragStarted', { + detail: tile, + }); + }); + + it('should adjust reflected tiles positions in slide mode', async () => { + const tiles = getTiles(); + const draggedTile = first(tiles); + const dropTarget = tiles[1]; + + expect(tileManager.dragMode).to.equal('slide'); + tileManager.tiles.forEach((tile, index) => { + expect(tile.id).to.equal(`tile${index}`); + }); + expect(draggedTile.position).to.equal(0); + expect(dropTarget.position).to.equal(1); + + await dragAndDrop(draggedTile, dropTarget); + + const expectedIdsAfterDrag = [ + 'tile1', + 'tile0', + 'tile2', + 'tile3', + 'tile4', + ]; + tileManager.tiles.forEach((tile, index) => { + expect(tile.id).to.equal(expectedIdsAfterDrag[index]); + }); + expect(draggedTile.position).to.equal(1); + expect(dropTarget.position).to.equal(0); + }); + + it('should not change order when dragging a tile onto itself in slide mode', async () => { + const initialTiles = tileManager.tiles; + const tile = first(tileManager.tiles); + + expect(tileManager.dragMode).to.equal('slide'); + expect(tileManager.tiles[0].id).to.equal('tile0'); + expect(tileManager.tiles[1].id).to.equal('tile1'); + + await dragAndDrop(tile, tile); + + expect(getTiles()).eql(initialTiles); + expect(tileManager.tiles[0].id).to.equal('tile0'); + expect(tileManager.tiles[1].id).to.equal('tile1'); + }); + + it('should swap the dragged tile with the drop target in swap mode', async () => { + const tiles = getTiles(); + const draggedTile = first(tiles); + const dropTarget = last(tiles); + + expect(tileManager.dragMode).to.equal('slide'); + expect(tileManager.tiles[0].id).to.equal('tile0'); + expect(tileManager.tiles[4].id).to.equal('tile4'); + expect(draggedTile.position).to.equal(0); + expect(dropTarget.position).to.equal(4); + + tileManager.dragMode = 'swap'; + await dragAndDrop(draggedTile, dropTarget); + + expect(tileManager.tiles[0].id).to.equal('tile4'); + expect(tileManager.tiles[4].id).to.equal('tile0'); + expect(draggedTile.position).to.equal(4); + expect(dropTarget.position).to.equal(0); + }); + + it('should not change order when dragging a tile onto itself in swap mode', async () => { + const initialTiles = tileManager.tiles; + const tile = first(tileManager.tiles); + + expect(tileManager.dragMode).to.equal('slide'); + expect(tileManager.tiles[0].id).to.equal('tile0'); + expect(tileManager.tiles[1].id).to.equal('tile1'); + + tileManager.dragMode = 'swap'; + await dragAndDrop(tile, tile); + + expect(getTiles()).eql(initialTiles); + expect(tileManager.tiles[0].id).to.equal('tile0'); + expect(tileManager.tiles[1].id).to.equal('tile1'); + }); + + it('should prevent dragging when `disableDrag` is true', async () => { + const draggedTile = last(tileManager.tiles); + const dropTarget = first(tileManager.tiles); + const eventSpy = spy(tileManager, 'emitEvent'); + const tileWrapper = getTileBaseWrapper(draggedTile); + + expect(tileWrapper.getAttribute('part')).to.include('draggable'); + + draggedTile.disableDrag = true; + await elementUpdated(tileManager); + + expect(tileWrapper.getAttribute('part')).to.not.include('draggable'); + + await dragAndDrop(draggedTile, dropTarget); + + expect(eventSpy).not.calledWith('igcTileDragStarted'); + expect(tileManager.tiles[0].id).to.equal('tile0'); + expect(tileManager.tiles[4].id).to.equal('tile4'); + }); + }); +}); diff --git a/src/components/tile-manager/tile-header.ts b/src/components/tile-manager/tile-header.ts new file mode 100644 index 000000000..e30512456 --- /dev/null +++ b/src/components/tile-manager/tile-header.ts @@ -0,0 +1,130 @@ +import { consume } from '@lit/context'; +import { LitElement, html } from 'lit'; +import { themes } from '../../theming/theming-decorator.js'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { type TileContext, tileContext } from '../common/context.js'; +import { registerComponent } from '../common/definitions/register.js'; +import IgcDividerComponent from '../divider/divider.js'; +import { all } from './themes/header.js'; +import { styles as shared } from './themes/shared/header/tile-header.common.css.js'; +import { styles } from './themes/tile-header.base.css.js'; + +/** A container for tile's header + * @element igc-tile-header + * + * @slot title - Renders the tile title + * @slot actions - Renders the tile actions + * + * @csspart header - The tile header container + * @csspart title - The tile title container + * @csspart actions - The tile actions container + */ +@themes(all) +export default class IgcTileHeaderComponent extends LitElement { + public static readonly tagName = 'igc-tile-header'; + public static override styles = [styles, shared]; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcTileHeaderComponent, + IgcIconButtonComponent, + IgcDividerComponent + ); + } + + @consume({ context: tileContext, subscribe: true }) + private _tileContext?: TileContext; + + private get _tile() { + return this._tileContext?.instance; + } + + private get _isMaximized() { + return this._tile ? this._tile.maximized : false; + } + + private get _isFullscreen() { + return this._tile ? this._tile.fullscreen : false; + } + + protected override createRenderRoot() { + const root = super.createRenderRoot(); + root.addEventListener('slotchange', () => this.requestUpdate()); + return root; + } + + private handleFullscreen() { + if (this._tileContext) { + this._tileContext.setFullscreenState(!this._isFullscreen, true); + // REVIEW: Leave the `requestUpdate` call or trigger through `setValue` from the tile context? + this.requestUpdate(); + } + } + + private handleMaximize() { + if (!this.emitMaximizedEvent()) { + return; + } + + if (this._tile) { + this._tile.maximized = !this._tile.maximized; + // REVIEW: Leave the `requestUpdate` call or trigger through `setValue` from the tile context? + this.requestUpdate(); + } + } + + private emitMaximizedEvent() { + return this._tile?.emitEvent('igcTileMaximize', { + detail: { tile: this._tile, state: this._tile.maximized }, + cancelable: true, + }); + } + + protected _renderAction({ + icon, + handler, + }: { + icon: string; + handler: () => unknown; + }) { + return html` + + `; + } + + protected override render() { + const maximize = { + icon: this._isMaximized ? 'collapse_content' : 'expand_content', + handler: this.handleMaximize, + }; + const fullscreen = { + icon: this._isFullscreen ? 'fullscreen_exit' : 'fullscreen', + handler: this.handleFullscreen, + }; + + return html` +
+ +
+ ${this._renderAction(maximize)} ${this._renderAction(fullscreen)} + +
+
+ + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-tile-header': IgcTileHeaderComponent; + } +} diff --git a/src/components/tile-manager/tile-manager.spec.ts b/src/components/tile-manager/tile-manager.spec.ts new file mode 100644 index 000000000..80ae318b7 --- /dev/null +++ b/src/components/tile-manager/tile-manager.spec.ts @@ -0,0 +1,723 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { range } from 'lit/directives/range.js'; +import { match, restore, spy, stub } from 'sinon'; +import IgcIconButtonComponent from '../button/icon-button.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { first } from '../common/util.js'; +import { simulateClick } from '../common/utils.spec.js'; +import IgcTileHeaderComponent from './tile-header.js'; +import IgcTileManagerComponent from './tile-manager.js'; +import IgcTileComponent from './tile.js'; + +describe('Tile Manager component', () => { + before(() => { + defineComponents(IgcTileManagerComponent); + }); + + let tileManager: IgcTileManagerComponent; + + function getTileManagerBase() { + return tileManager.renderRoot.querySelector('[part="base"]')!; + } + + function getTileManagerSlot() { + return tileManager.renderRoot.querySelector('slot')!; + } + + function getTiles() { + return Array.from(tileManager.querySelectorAll('igc-tile')); + } + + function getActionButtons(tile: IgcTileComponent) { + const header = tile.querySelector(IgcTileHeaderComponent.tagName); + return ( + header?.shadowRoot?.querySelectorAll(IgcIconButtonComponent.tagName) || [] + ); + } + + // function getTileBaseWrapper(element: IgcTileComponent) { + // return element.renderRoot.querySelector('[part~="base"]')!; + // } + + function createTileManager() { + const result = Array.from(range(5)).map( + (i) => html` + + +

Tile ${i + 1}

+
+ +
+

Content in tile ${i + 1}

+
+
+ ` + ); + return html`${result}`; + } + + // function assertTileIsInert(element: IgcTileComponent) { + // expect(element.renderRoot.querySelector('#base')!.inert).to.be + // .true; + // } + + describe('Initialization', () => { + beforeEach(async () => { + tileManager = await fixture(html` + + + + Tile Header 1 + +

Content 1

+
+ + + Tile Header 2 + +

Content 2

+
+
+ `); + }); + + it('passes the a11y audit', async () => { + await expect(tileManager).dom.to.be.accessible(); + await expect(tileManager).shadowDom.to.be.accessible(); + }); + + // TODO: Add an initialization test with non-defined column count and minimum dimension constraints + it('is correctly initialized with its default component state', () => { + // TODO: Add checks for other settings when implemented + expect(tileManager.columnCount).to.equal(0); + expect(tileManager.dragMode).to.equal('slide'); + expect(tileManager.minColumnWidth).to.equal(undefined); + expect(tileManager.minRowHeight).to.equal(undefined); + expect(tileManager.tiles).lengthOf(2); + }); + + it('should render properly', () => { + expect(tileManager).dom.to.equal( + ` + + + Tile Header 1 + +

Content 1

+
+ + + Tile Header 2 + +

Content 2

+
+
` + ); + + expect(tileManager).shadowDom.to.equal( + `
+ +
` + ); + }); + + it('should slot user provided content in the tile', () => { + const tiles = Array.from( + tileManager.querySelectorAll(IgcTileComponent.tagName) + ); + + expect(tiles[0]).dom.to.equal( + ` + + Tile Header 1 + +

Content 1

+
` + ); + + expect(tiles[0]).shadowDom.to.equal( + ` +
+ +
+ +
+
+
` + ); + }); + + it('should slot user provided content for tile header', () => { + // TODO: Add test for the actions slot + const tileHeaders = Array.from( + tileManager.querySelectorAll(IgcTileHeaderComponent.tagName) + ); + + expect(tileHeaders[0]).dom.to.equal( + ` + Tile Header 1 + ` + ); + + expect(tileHeaders[0]).shadowDom.to.equal( + `
+ +
+ + + + + +
+
+ + ` + ); + }); + }); + + describe('Column spans', async () => { + beforeEach(async () => { + tileManager = await fixture(createTileManager()); + }); + + it('should render tile manager with correct number of children', async () => { + expect(tileManager.tiles).lengthOf(5); + }); + + it('each tile should have correct grid area (col and row span)', async () => { + expect( + tileManager.tiles.every( + ({ style: { gridColumn, gridRow } }) => + gridColumn === '' && gridRow === '' + ) + ).to.be.true; + }); + + it("should check tile manager's row and column template style props", async () => { + const style = getComputedStyle(getTileManagerBase()); + + expect(style.gridTemplateColumns).to.equal( + '234.656px 234.656px 234.656px 0px 0px' + ); + + tileManager.columnCount = 15; + await elementUpdated(tileManager); + + expect(style.gridTemplateColumns).to.equal( + '200px 200px 200px 200px 200px 200px 200px 200px 200px 200px 200px 200px 200px 200px 200px' + ); + }); + + it('should respect tile row and col start properties', async () => { + const tile = tileManager.tiles[2]; + tile.colStart = 7; + tile.rowStart = 5; + + await elementUpdated(tile); + + expect(getComputedStyle(tile).gridArea).to.equal( + '5 / 7 / span 5 / span 5' + ); + }); + }); + + describe('Manual slot assignment', () => { + beforeEach(async () => { + tileManager = await fixture(html` + + +
+ +
+ `); + }); + + it('should only accept `igc-tile` elements', async () => { + const slot = getTileManagerSlot(); + expect(slot.assignedElements()).eql(tileManager.tiles); + }); + + it('should update the slot when tile is added', async () => { + const slot = getTileManagerSlot(); + const newTile = document.createElement('igc-tile'); + newTile.id = 'tile3'; + + tileManager.appendChild(newTile); + await tileManager.updateComplete; + + expect(slot.assignedElements()).lengthOf(3); + expect(slot.assignedElements()[2].id).to.equal('tile3'); + }); + + it('should update the slot when a tile is removed', async () => { + const slot = getTileManagerSlot(); + const tiles = getTiles(); + + tileManager.removeChild(tiles[0]); + await tileManager.updateComplete; + + expect(slot.assignedElements()).lengthOf(1); + expect(slot.assignedElements()[0].id).to.equal('tile2'); + }); + }); + + describe('Tile state change behavior', () => { + let tile: any; + + beforeEach(async () => { + tileManager = await fixture(createTileManager()); + tile = first(tileManager.tiles); + + // Mock `requestFullscreen` + tile.requestFullscreen = stub().callsFake(() => { + Object.defineProperty(document, 'fullscreenElement', { + value: tile, + configurable: true, + }); + return Promise.resolve(); + }); + + // Mock `exitFullscreen` + Object.defineProperty(document, 'exitFullscreen', { + value: stub().callsFake(() => { + Object.defineProperty(document, 'fullscreenElement', { + value: null, + configurable: true, + }); + return Promise.resolve(); + }), + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(document, 'fullscreenElement', { + value: null, + configurable: true, + }); + + restore(); + }); + + it('should correctly change fullscreen state on button click', async () => { + const btnFullscreen = getActionButtons(tile)[1]; + simulateClick(btnFullscreen); + await elementUpdated(tileManager); + + expect(tile.requestFullscreen).to.have.been.calledOnce; + expect(document.exitFullscreen).to.not.have.been.called; + expect(tile.fullscreen).to.be.true; + + simulateClick(btnFullscreen); + await elementUpdated(tileManager); + + expect(document.exitFullscreen).to.have.been.calledOnce; + expect(tile.fullscreen).to.be.false; + }); + + it('should correctly fire `igcTileFullscreen` event', async () => { + const tile = first(tileManager.tiles); + const eventSpy = spy(tile, 'emitEvent'); + const fullscreenButton = getActionButtons(tile)[1]; + + simulateClick(fullscreenButton!); + await elementUpdated(tileManager); + + expect(eventSpy).calledWith('igcTileFullscreen', { + detail: { tile: tile, state: true }, + cancelable: true, + }); + expect(tile.fullscreen).to.be.true; + + simulateClick(fullscreenButton!); + await elementUpdated(tileManager); + + expect(eventSpy).calledWith('igcTileFullscreen', { + detail: { tile: tile, state: false }, + cancelable: true, + }); + expect(tile.fullscreen).to.be.false; + }); + + it('can cancel `igcTileFullscreen` event', async () => { + const tile = first(tileManager.tiles); + const eventSpy = spy(tile, 'emitEvent'); + const fullscreenButton = getActionButtons(tile)[1]; + + tile.addEventListener('igcTileFullscreen', (ev: CustomEvent) => { + ev.preventDefault(); + }); + + simulateClick(fullscreenButton!); + await elementUpdated(tileManager); + + expect(eventSpy).to.have.been.calledWith( + 'igcTileFullscreen', + match({ + detail: { tile: tile, state: true }, + cancelable: true, + }) + ); + expect(tile.fullscreen).to.be.false; + expect(tile.requestFullscreen).not.to.have.been.called; + }); + + it('should update fullscreen property on fullscreenchange (e.g. Esc key is pressed)', async () => { + tile.fullscreen = true; + + // Mock the browser removing fullscreen element and firing a fullscreenchange event + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + value: null, + }); + tile.dispatchEvent(new Event('fullscreenchange')); + await elementUpdated(tileManager); + + expect(tile.fullscreen).to.be.false; + }); + + it('should properly switch the icons on fullscreen state change.', async () => { + const tile = first(tileManager.tiles); + const btnFullscreen = getActionButtons(tile)[1]; + + expect(btnFullscreen.name).equals('fullscreen'); + + simulateClick(btnFullscreen); + await elementUpdated(tileManager); + expect(btnFullscreen.name).equals('fullscreen_exit'); + + simulateClick(btnFullscreen); + await elementUpdated(tileManager); + expect(btnFullscreen.name).equals('fullscreen'); + }); + + it('should correctly fire `igcTileMaximize` event on clicking Maximize button', async () => { + const tile = first(tileManager.tiles); + const eventSpy = spy(tile, 'emitEvent'); + const btnMaximize = getActionButtons(tile)[0]; + + simulateClick(btnMaximize); + await elementUpdated(tile); + await elementUpdated(tileManager); + + expect(eventSpy).calledWith('igcTileMaximize'); + // TODO: Fix the state in the event arguments + // expect(eventSpy).to.have.been.calledWith( + // 'igcTileMaximize', + // { + // detail: { tile: tile, state: true }, + // cancelable: true, + // } + // ); + + expect(tile.maximized).to.be.true; + + simulateClick(btnMaximize); + await elementUpdated(tileManager); + + expect(eventSpy).to.have.been.calledTwice; + expect(eventSpy).calledWith('igcTileMaximize'); + // TODO: Fix the state in the event arguments + // expect(eventSpy).calledWith('igcTileMaximize', { + // detail: { tile: tile, state: false }, + // cancelable: true, + // }); + + expect(tile.maximized).to.be.false; + }); + + it('can cancel `igcTileMaximize` event', async () => { + const tile = first(tileManager.tiles); + const eventSpy = spy(tile, 'emitEvent'); + + tile.addEventListener('igcTileMaximize', (ev) => { + ev.preventDefault(); + }); + + const btnMaximize = getActionButtons(tile)[0]; + simulateClick(btnMaximize); + await elementUpdated(tileManager); + + expect(eventSpy).calledWith('igcTileMaximize'); + // TODO: Fix the state in the event arguments + // expect(eventSpy).calledOnceWithExactly('igcTileMaximize', { + // detail: { tile: tile, state: true }, + // cancelable: true, + // }); + + expect(tile.maximized).to.be.false; + }); + + it('should properly switch the icons on maximized state change.', async () => { + const tile = first(tileManager.tiles); + const btnMaximize = getActionButtons(tile)[0]; + + expect(btnMaximize.name).equals('expand_content'); + simulateClick(btnMaximize); + await elementUpdated(tileManager); + + expect(btnMaximize.name).equals('collapse_content'); + }); + }); + + describe('Serialization', () => { + beforeEach(async () => { + tileManager = await fixture(html` + + Tile content 1 + + Tile content 2 + + + `); + }); + + it('should serialize each tile with correct properties', async () => { + const serializedData = JSON.parse(tileManager.saveLayout()); + const expectedData = [ + { + colSpan: 1, + colStart: null, + disableDrag: false, + disableResize: false, + gridColumn: 'auto / span 1', + gridRow: 'auto / span 1', + maximized: false, + position: 0, + rowSpan: 1, + rowStart: null, + tileId: 'custom-id1', + }, + { + colSpan: 10, + colStart: 8, + disableDrag: true, + disableResize: true, + gridColumn: '8 / span 10', + gridRow: '7 / span 7', + maximized: false, + position: 1, + rowSpan: 7, + rowStart: 7, + tileId: 'custom-id2', + }, + ]; + + expect(serializedData).to.deep.equal(expectedData); + }); + + it('should deserialize tiles with proper properties values', async () => { + const tilesData = [ + { + colSpan: 5, + colStart: 1, + disableDrag: true, + disableResize: true, + gridColumn: '1 / span 5', + gridRow: '1 / span 5', + maximized: false, + position: 0, + rowSpan: 5, + rowStart: 1, + tileId: 'custom-id1', + }, + { + colSpan: 3, + colStart: null, + disableDrag: false, + disableResize: false, + gridColumn: 'span 3', + gridRow: 'span 3', + maximized: false, + position: 1, + rowSpan: 3, + rowStart: null, + tileId: 'custom-id2', + }, + { + colSpan: 3, + colStart: null, + disableDrag: false, + disableResize: false, + gridColumn: 'span 3', + gridRow: 'span 3', + maximized: false, + position: 2, + rowSpan: 3, + rowStart: null, + tileId: 'no-match-id', + }, + ]; + + tileManager.loadLayout(JSON.stringify(tilesData)); + await elementUpdated(tileManager); + + const tiles = tileManager.tiles; + expect(tiles).lengthOf(2); + + expect(tiles[0].colSpan).to.equal(5); + expect(tiles[0].colStart).to.equal(1); + expect(tiles[0].disableDrag).to.equal(true); + expect(tiles[0].disableResize).to.equal(true); + expect(tiles[0].maximized).to.be.false; + expect(tiles[0].position).to.equal(0); + expect(tiles[0].rowSpan).to.equal(5); + expect(tiles[0].rowStart).to.equal(1); + expect(tiles[0].tileId).to.equal('custom-id1'); + + expect(tiles[1].colSpan).to.equal(3); + expect(tiles[1].colStart).to.be.null; + expect(tiles[1].disableDrag).to.be.false; + expect(tiles[1].disableResize).to.be.false; + expect(tiles[1].maximized).to.be.false; + expect(tiles[1].position).to.equal(1); + expect(tiles[1].rowSpan).to.equal(3); + expect(tiles[1].rowStart).to.be.null; + expect(tiles[1].tileId).to.equal('custom-id2'); + + const firstTileStyles = window.getComputedStyle(tiles[0]); + const secondTileStyles = window.getComputedStyle(tiles[1]); + + expect(firstTileStyles.gridColumn).to.equal('1 / span 5'); + expect(firstTileStyles.gridRow).to.equal('1 / span 5'); + expect(secondTileStyles.gridColumn).to.equal('span 3'); + expect(secondTileStyles.gridRow).to.equal('span 3'); + }); + }); + + describe('API', () => { + beforeEach(async () => { + tileManager = await fixture(createTileManager()); + }); + + it('should automatically assign unique `tileId` for tiles', async () => { + const newTile = document.createElement('igc-tile'); + const existingIds = Array.from(tileManager.tiles).map( + (tile) => tile.tileId + ); + + tileManager.appendChild(newTile); + await elementUpdated(tileManager); + + expect(newTile.tileId).to.match(/^tile-\d+$/); + expect(existingIds).not.to.include(newTile.tileId); + expect(tileManager.tiles).lengthOf(6); + }); + + it('should preserve the `tileId` if one is already set', async () => { + const tile = document.createElement('igc-tile'); + tile.tileId = 'custom-id'; + + tileManager.appendChild(tile); + await elementUpdated(tileManager); + + const matchingTiles = tileManager.tiles.filter( + (tile) => tile.tileId === 'custom-id' + ); + + expect(matchingTiles).lengthOf(1); + }); + + it('should update the tiles collection when a tile is added to the light DOM', async () => { + const newTile = document.createElement('igc-tile') as IgcTileComponent; + newTile.id = 'tile5'; + + tileManager.appendChild(newTile); + await elementUpdated(tileManager); + + expect(tileManager.tiles).lengthOf(6); + expect(tileManager.tiles[5].id).to.equal('tile5'); + }); + + it('should update the tiles collection when a tile is removed from the light DOM', async () => { + const tileToRemove = getTiles()[0]; + + tileManager.removeChild(tileToRemove); + await elementUpdated(tileManager); + + expect(tileManager.tiles).lengthOf(4); + expect(tileManager.tiles[0].id).to.equal('tile1'); + }); + + it('should automatically assign proper position', async () => { + tileManager.tiles.forEach((tile, index) => { + expect(tile.position).to.equal(index); + expect(tile.style.order).to.equal(index.toString()); + }); + }); + + it('should set proper CSS order based on position', async () => { + const firstTile = first(getTiles()); + firstTile.position = 6; + + await elementUpdated(tileManager); + + expect(firstTile.style.order).to.equal('6'); + expect(tileManager.tiles[4].position).to.equal(6); + }); + + it('should properly handle tile addition with specified position', async () => { + const newTile = document.createElement('igc-tile'); + newTile.position = 3; + tileManager.append(newTile); + await elementUpdated(tileManager); + + const tiles = getTiles(); + expect(tiles[5]).to.equal(newTile); + expect(tiles[5].position).to.equal(3); + expect(tiles[4].position).to.equal(5); + }); + + it('should adjust positions correctly when a tile is removed', async () => { + const removedTile = getTiles()[2]; + tileManager.removeChild(removedTile); + await elementUpdated(tileManager); + + const tiles = tileManager.tiles; + expect(tiles).to.not.include(removedTile); + tiles.forEach((tile, index) => { + expect(tile.position).to.equal(index); + }); + }); + }); +}); diff --git a/src/components/tile-manager/tile-manager.ts b/src/components/tile-manager/tile-manager.ts new file mode 100644 index 000000000..fd5acbadd --- /dev/null +++ b/src/components/tile-manager/tile-manager.ts @@ -0,0 +1,253 @@ +import { ContextProvider } from '@lit/context'; +import { LitElement, html } from 'lit'; +import { property } from 'lit/decorators.js'; +import { type StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import { themes } from '../../theming/theming-decorator.js'; +import { tileManagerContext } from '../common/context.js'; +import { + type MutationControllerParams, + 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, + findElementFromEventPath, + partNameMap, +} from '../common/util.js'; +import { createTilesState, isSameTile, swapTiles } from './position.js'; +import { createSerializer } from './serializer.js'; +import { all } from './themes/container.js'; +import { styles as shared } from './themes/shared/tile-manager.common.css.js'; +import { styles } from './themes/tile-manager.base.css.js'; +import IgcTileComponent from './tile.js'; + +// REVIEW: WIP +export interface IgcTileManagerComponentEventMap { + igcTileDragStarted: CustomEvent; +} + +/** + * The tile manager component enables the dynamic arrangement, resizing, and interaction of tiles. + * + * @element igc-tile-manager + * + * @fires igcTileDragStarted - Fired when an owning tile begins a drag operation. + */ +@themes(all) +export default class IgcTileManagerComponent extends EventEmitterMixin< + IgcTileManagerComponentEventMap, + Constructor +>(LitElement) { + public static readonly tagName = 'igc-tile-manager'; + public static styles = [styles, shared]; + + protected static shadowRootOptions: ShadowRootInit = { + mode: 'open', + slotAssignment: 'manual', + }; + + /* blazorSuppress */ + public static register() { + registerComponent(IgcTileManagerComponent, IgcTileComponent); + } + + private _internalStyles: StyleInfo = {}; + + private _columnCount = 0; + private _minColWidth?: string; + private _minRowHeight?: string; + private _draggedItem: IgcTileComponent | null = null; + + private _serializer = createSerializer(this); + private _tilesState = createTilesState(this); + + private _context = new ContextProvider(this, { + context: tileManagerContext, + initialValue: { + instance: this, + draggedItem: this._draggedItem, + }, + }); + + private _setManagerContext() { + this._context.setValue( + { instance: this, draggedItem: this._draggedItem }, + true + ); + } + + private _observerCallback({ + changes: { added, removed }, + }: MutationControllerParams) { + const ownAdded = added.filter( + ({ target }) => target.closest(this.tagName) === this + ); + const ownRemoved = removed.filter( + ({ target }) => target.closest(this.tagName) === this + ); + + for (const remove of ownRemoved) { + this._tilesState.remove(remove.node); + } + + for (const added of ownAdded) { + this._tilesState.add(added.node); + } + + this._tilesState.assignTiles(); + } + + /** + * Determines whether the tiles slide or swap on drop. + * @attr drag-mode + */ + @property({ attribute: 'drag-mode' }) + public dragMode: 'slide' | 'swap' = 'slide'; + + /** + * Sets the number of columns for the tile manager. + * Setting value <= than zero will trigger a responsive layout. + * + * @attr column-count + * @default 0 + */ + @property({ type: Number, attribute: 'column-count' }) + public set columnCount(value: number) { + this._columnCount = Math.max(0, asNumber(value)); + Object.assign(this._internalStyles, { + '--ig-column-count': this._columnCount || undefined, + }); + } + + public get columnCount(): number { + return this._columnCount; + } + + /** + * Sets the minimum width for a column unit in the tile manager. + * @attr min-column-width + */ + @property({ attribute: 'min-column-width' }) + public set minColumnWidth(value: string | undefined) { + this._minColWidth = value ?? undefined; + Object.assign(this._internalStyles, { + '--ig-min-col-width': this._minColWidth, + }); + } + + public get minColumnWidth(): string | undefined { + return this._minColWidth; + } + + /** + * Sets the minimum height for a row unit in the tile manager. + * @attr min-row-height + */ + @property({ attribute: 'min-row-height' }) + public set minRowHeight(value: string | undefined) { + this._minRowHeight = value ?? undefined; + Object.assign(this._internalStyles, { + '--ig-min-row-height': this._minRowHeight, + }); + } + + public get minRowHeight(): string | undefined { + return this._minRowHeight; + } + + /** + * Gets the tiles sorted by their position in the layout. + * @attr + */ + public get tiles() { + return this._tilesState.tiles; + } + + constructor() { + super(); + + createMutationController(this, { + callback: this._observerCallback, + filter: [IgcTileComponent.tagName], + config: { + childList: true, + }, + }); + } + + protected override firstUpdated() { + this._tilesState.assignPositions(); + this._tilesState.assignTiles(); + } + + private handleTileDragStart({ detail }: CustomEvent) { + this.emitEvent('igcTileDragStarted', { detail }); + this._draggedItem = detail; + this._setManagerContext(); + } + + private handleTileDragEnd() { + if (this._draggedItem) { + this._draggedItem = null; + this._setManagerContext(); + } + } + + private handleDragOver(event: DragEvent) { + event.preventDefault(); // Allow dropping + } + + private handleDrop(event: DragEvent) { + event.preventDefault(); + + const draggedItem = this._draggedItem; + const target = findElementFromEventPath( + IgcTileComponent.tagName, + event + ); + + if ( + !isSameTile(draggedItem, target) && + this.dragMode === 'swap' && + !target?.disableDrag + ) { + swapTiles(draggedItem!, target!); + } + } + + public saveLayout(): string { + return this._serializer.saveAsJSON(); + } + + public loadLayout(data: string): void { + this._serializer.loadFromJSON(data); + } + + protected override render() { + const parts = partNameMap({ + base: true, + 'maximized-tile': this.tiles.some((tile) => tile.maximized), + }); + + return html` +
+ +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-tile-manager': IgcTileManagerComponent; + } +} diff --git a/src/components/tile-manager/tile-resize.spec.ts b/src/components/tile-manager/tile-resize.spec.ts new file mode 100644 index 000000000..7eccfa037 --- /dev/null +++ b/src/components/tile-manager/tile-resize.spec.ts @@ -0,0 +1,215 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { spy } from 'sinon'; + +import { range } from 'lit/directives/range.js'; +import { escapeKey } from '../common/controllers/key-bindings.js'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { first } from '../common/util.js'; +import { + simulateKeyboard, + simulateLostPointerCapture, + simulatePointerDown, + simulatePointerMove, +} from '../common/utils.spec.js'; +import IgcTileManagerComponent from './tile-manager.js'; +import type IgcTileComponent from './tile.js'; + +describe('Tile resize', () => { + before(() => { + defineComponents(IgcTileManagerComponent); + }); + + let tileManager: IgcTileManagerComponent; + + function getTileBaseWrapper(element: IgcTileComponent) { + return element.renderRoot.querySelector('[part~="base"]')!; + } + + function createTileManager() { + const result = Array.from(range(5)).map( + (i) => html` + + +

Tile ${i + 1}

+
+ +
+

Content in tile ${i + 1}

+
+
+ ` + ); + return html`${result}`; + } + + // TODO: Review and modify the tests to correspond to the new resize logic + describe('Tile resize behavior', () => { + beforeEach(async () => { + tileManager = await fixture(createTileManager()); + }); + + it('should create a ghost element on resize start', async () => { + const tile = first(tileManager.tiles); + // const eventSpy = spy(tile, 'emitEvent'); + + await elementUpdated(tile); + const resizer = tile.renderRoot.querySelector('igc-resize')!; + const resizeHandle = resizer?.shadowRoot?.querySelector('igc-icon'); + const resizerChildren = resizer.children; + expect(resizerChildren).lengthOf(1); + + simulatePointerDown(resizeHandle!); + await elementUpdated(resizeHandle!); + + expect(resizerChildren).lengthOf(2); + const ghostElement = resizerChildren[1]; + expect(ghostElement).to.not.be.null; + + simulatePointerMove(resizeHandle!, { clientX: 10, clientY: 10 }); + // expect(eventSpy).calledWith('igcResizeStart'); + // expect(eventSpy).to.have.been.calledWith('igcResizeStart' + // // { + // // detail: tile, + // // cancelable: true, + // // } + // ); + }); + + it('should update ghost element styles during pointer move', async () => { + const tile = first(tileManager.tiles); + // const eventSpy = spy(tile, 'emitEvent'); + const { x, y, width, height } = tile.getBoundingClientRect(); + // const resizeHandle = tile.shadowRoot!.querySelector('.resize-handle'); + + const resizer = tile.renderRoot.querySelector('igc-resize')!; + const resizeHandle = resizer?.shadowRoot?.querySelector('igc-icon'); + + simulatePointerDown(resizeHandle!); + await elementUpdated(resizeHandle!); + + simulatePointerMove(resizeHandle!, { + clientX: x + width * 2, + clientY: y + height * 2, + }); + await elementUpdated(resizeHandle!); + + // expect(eventSpy).calledWith('igcResizeStart'); + // expect(eventSpy.getCall(0)).calledWith('igcResizeStart', { + // detail: tile, + // cancelable: true, + // }); + + // expect(eventSpy.getCall(1)).calledWith('igcResizeMove', { + // detail: tile, + // cancelable: true, + // }); + + // TODO Fix or remove that check when the resize interaction is finalized + // const ghostElement = tileManager.querySelector( + // '#resize-ghost' + // ) as HTMLElement; + // expect(ghostElement.style.gridColumn).to.equal('span 9'); + // expect(ghostElement.style.gridRow).to.equal('span 9'); + }); + + xit('should set the styles on the tile and remove the ghost element on resize end', async () => { + const tile = first(tileManager.tiles); + const eventSpy = spy(tile, 'emitEvent'); + + const { x, y, width, height } = tile.getBoundingClientRect(); + const resizeHandle = tile.shadowRoot!.querySelector('.resize-handle'); + + simulatePointerDown(resizeHandle!); + await elementUpdated(resizeHandle!); + + simulatePointerMove(resizeHandle!, { + clientX: x + width * 2, + clientY: y + height * 2, + }); + await elementUpdated(resizeHandle!); + + let ghostElement = tileManager.querySelector('#resize-ghost'); + const ghostGridColumn = (ghostElement! as HTMLElement).style.gridColumn; + const ghostGridRow = (ghostElement! as HTMLElement).style.gridRow; + + simulateLostPointerCapture(resizeHandle!); + await elementUpdated(resizeHandle!); + + expect(eventSpy).calledWith('igcResizeEnd', { + detail: tile, + cancelable: true, + }); + + ghostElement = tileManager.querySelector('#resize-ghost'); + expect(tile.style.gridColumn).to.equal(ghostGridColumn); + expect(tile.style.gridRow).to.equal(ghostGridRow); + expect(ghostElement).to.be.null; + }); + + xit('should cancel resize by pressing ESC key', async () => { + const tile = first(tileManager.tiles); + const { x, y, width, height } = tile.getBoundingClientRect(); + const resizeHandle = tile.shadowRoot!.querySelector('.resize-handle')!; + + simulatePointerDown(resizeHandle); + await elementUpdated(resizeHandle); + + simulatePointerMove(resizeHandle!, { + clientX: x + width * 2, + clientY: y + height * 2, + }); + await elementUpdated(resizeHandle); + + let ghostElement = tileManager.querySelector('#resize-ghost'); + expect(ghostElement).not.to.be.null; + + simulateKeyboard(resizeHandle, escapeKey); + await elementUpdated(resizeHandle); + + ghostElement = tileManager.querySelector('#resize-ghost'); + expect(ghostElement).to.be.null; + expect(tile.style.gridColumn).to.equal(''); + expect(tile.style.gridRow).to.equal(''); + }); + + xit('should not resize when `disableResize` is true', async () => { + const tile = first(tileManager.tiles); + const { x, y, width, height } = tile.getBoundingClientRect(); + const resizeHandle = tile.shadowRoot!.querySelector( + '.resize-handle' + )! as HTMLElement; + const eventSpy = spy(tile, 'emitEvent'); + const tileWrapper = getTileBaseWrapper(tile); + + expect(tileWrapper.getAttribute('part')).to.include('resizable'); + expect(resizeHandle.hasAttribute('hidden')).to.be.false; + + tile.disableResize = true; + await elementUpdated(tile); + + expect(tileWrapper.getAttribute('part')).to.not.include('resizable'); + expect(resizeHandle.hasAttribute('hidden')).to.be.true; + + simulatePointerDown(resizeHandle); + await elementUpdated(resizeHandle); + + expect(eventSpy).not.calledWith('igcResizeStart'); + + simulatePointerMove(resizeHandle!, { + clientX: x + width * 2, + clientY: y + height * 2, + }); + await elementUpdated(tile); + + expect(eventSpy).not.calledWith('igcResizeMove'); + + const ghostElement = tileManager.querySelector('#resize-ghost'); + expect(ghostElement).to.be.null; + + simulateLostPointerCapture(resizeHandle!); + await elementUpdated(resizeHandle!); + + expect(eventSpy).not.calledWith('igcResizeEnd'); + }); + }); +}); diff --git a/src/components/tile-manager/tile-util.ts b/src/components/tile-manager/tile-util.ts new file mode 100644 index 000000000..fb1830512 --- /dev/null +++ b/src/components/tile-manager/tile-util.ts @@ -0,0 +1,95 @@ +export class ResizeUtil { + public static calculateSnappedHeight( + deltaY: number, + startingY: number, + rowHeights: number[], + rowGap: number, + initialTop: number, + initialHeight: number + ): number { + const snappedHeight = startingY + deltaY; + const rowsAbove = + ResizeUtil.calculate(initialTop, rowHeights, rowGap).targetIndex || 0; + const res = ResizeUtil.calculate(snappedHeight, rowHeights, rowGap)!; + const accumulatedHeight = res.accumulatedHeight; + const startRowIndex = res.targetIndex; + const aboveRowsHeight = ResizeUtil.accumulateHeight( + rowsAbove, + rowHeights, + rowGap + ); + + let result = initialHeight; + let previousRowsHeight = ResizeUtil.accumulateHeight( + startRowIndex, + rowHeights, + rowGap + ); + + if (deltaY > 0) { + const rowHeight = rowHeights[startRowIndex]; + const halfwayThreshold = previousRowsHeight + rowGap + rowHeight / 2; + + if (snappedHeight >= halfwayThreshold) { + result = accumulatedHeight - aboveRowsHeight; + } else { + result = initialHeight + deltaY; + } + + result = result <= 0 ? rowHeights[startRowIndex - 1] : result; + } else if (deltaY < 0) { + previousRowsHeight = + previousRowsHeight === 0 ? rowHeights[0] : previousRowsHeight; + const currentRowHeight = rowHeights[startRowIndex]; + const halfwayThreshold = + accumulatedHeight - currentRowHeight / 2 - rowGap; + + if (startRowIndex !== 0 && startRowIndex >= startRowIndex - 1) { + if (snappedHeight <= halfwayThreshold) { + result = + accumulatedHeight - currentRowHeight - rowGap - aboveRowsHeight; + } else { + result = rowHeights + .slice(rowsAbove, startRowIndex) + .reduce((sum, height) => sum + height, 0); + } + } else { + result = snappedHeight - aboveRowsHeight; + } + } + + return result; + } + + private static calculate( + initialTop: number, + rowHeights: number[], + rowGap: number + ): any { + let targetIndex = 0; + let accumulatedHeight = 0; + + for (let i = 0; i < rowHeights.length; i++) { + accumulatedHeight += rowHeights[i] + (i > 0 ? rowGap : 0); + if (initialTop <= accumulatedHeight) { + targetIndex = i; + break; + } + } + + return { targetIndex, accumulatedHeight }; + } + + private static accumulateHeight( + rowIndex: number, + rowHeights: number[], + rowGap: number + ): number { + let accumulatedHeight = 0; + for (let i = 0; i < rowIndex; i++) { + accumulatedHeight += rowHeights[i] + rowGap; + } + + return accumulatedHeight; + } +} diff --git a/src/components/tile-manager/tile.ts b/src/components/tile-manager/tile.ts new file mode 100644 index 000000000..730021829 --- /dev/null +++ b/src/components/tile-manager/tile.ts @@ -0,0 +1,605 @@ +import { ContextProvider, consume } from '@lit/context'; +import { LitElement, type PropertyValues, html } from 'lit'; +import { property, query, state } from 'lit/decorators.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { themes } from '../../theming/theming-decorator.js'; +import { + type TileManagerContext, + tileContext, + tileManagerContext, +} from '../common/context.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, createCounter, partNameMap } from '../common/util.js'; +import { addFullscreenController } from './controllers/fullscreen.js'; +import { addTileDragAndDrop } from './controllers/tile-dnd.js'; +import { isSameTile, swapTiles } from './position.js'; +import type { ResizeCallbackParams } from './resize-controller.js'; +import IgcResizeComponent from './resize-element.js'; +import { styles as shared } from './themes/shared/tile/tile.common.css.js'; +import { styles } from './themes/tile.base.css.js'; +import { all } from './themes/tile.js'; +import IgcTileHeaderComponent from './tile-header.js'; +import { ResizeUtil } from './tile-util.js'; + +type IgcTileChangeState = { + tile: IgcTileComponent; + state: boolean; +}; + +// REVIEW: Decide whether to re-emit the events from the manager of leave them up to bubble naturally +export interface IgcTileComponentEventMap { + igcTileFullscreen: CustomEvent; + igcTileMaximize: CustomEvent; + tileDragStart: CustomEvent; + tileDragEnd: CustomEvent; + igcResizeStart: CustomEvent; + igcResizeMove: CustomEvent; + igcResizeEnd: CustomEvent; +} + +/** + * The tile component is used within the `igc-tile-manager` as a container + * for displaying various types of information. + * + * @element igc-tile + * + * @fires igcTileFullscreen - Fired when tile fullscreen state changes. + * @fires igcTileMaximize - Fired when tile maximize state changes. + * @fires igcResizeStart - Fired when tile begins resizing. + * @fires igcResizeMove - Fired when tile is being resized. + * @fires igcResizeEnd - Fired when tile finishes resizing. + */ +@themes(all) +export default class IgcTileComponent extends EventEmitterMixin< + IgcTileComponentEventMap, + Constructor +>(LitElement) { + public static readonly tagName = 'igc-tile'; + public static styles = [styles, shared]; + + /* blazorSuppress */ + public static register() { + registerComponent( + IgcTileComponent, + IgcTileHeaderComponent, + IgcResizeComponent + ); + } + + private static readonly increment = createCounter(); + + private _dragController = addTileDragAndDrop(this, { + dragStart: this.handleDragStart, + dragEnd: this.handleDragEnd, + dragEnter: this.handleDragEnter, + dragLeave: this.handleDragLeave, + dragOver: this.handleDragOver, + drop: this.handleDragLeave, + }); + + private _fullscreenController = addFullscreenController( + this, + this.emitFullScreenEvent + ); + + private _colSpan = 1; + private _rowSpan = 1; + private _colStart: number | null = null; + private _rowStart: number | null = null; + private _position = -1; + private _disableDrag = false; + private _maximized = false; + private _initialPointerX: number | null = null; + private _initialPointerY: number | null = null; + private _dragCounter = 0; + private _dragGhost: HTMLElement | null = null; + private _dragImage: HTMLElement | null = null; + private _cachedStyles: { + columnCount?: number; + minWidth?: number; + minHeight?: number; + background?: string; + border?: string; + borderRadius?: string; + rowHeights?: number[]; + initialTop?: number; + } = {}; + + // Tile manager context properties and helpers + + @consume({ context: tileManagerContext, subscribe: true }) + private _managerContext?: TileManagerContext; + + private get _draggedItem(): IgcTileComponent | null { + return this._managerContext?.draggedItem ?? null; + } + + private get _isSlideMode(): boolean { + return this._managerContext + ? this._managerContext.instance.dragMode === 'slide' + : true; + } + + @query('[part="ghost"]', true) + public _ghost!: HTMLElement; + + @query('[part~="base"]', true) + public _tileContent!: HTMLElement; + + @state() + private _isDragging = false; + + @state() + private _hasDragOver = false; + + @state() + private _isResizing = false; + + /** + * The unique identifier of the tile. + * @attr + */ + @property({ attribute: 'tile-id', type: String, reflect: true }) + public tileId: string | null = null; + + @property({ type: Number }) + public set colSpan(value: number) { + this._colSpan = Math.max(1, asNumber(value)); + this.style.setProperty('--ig-col-span', this._colSpan.toString()); + } + + public get colSpan(): number { + return this._colSpan; + } + + @property({ type: Number }) + public set rowSpan(value: number) { + this._rowSpan = Math.max(1, asNumber(value)); + this.style.setProperty('--ig-row-span', this._rowSpan.toString()); + } + + public get rowSpan(): number { + return this._rowSpan; + } + + @property({ type: Number }) + public set colStart(value: number) { + this._colStart = Math.max(0, asNumber(value)) || null; + this.style.setProperty( + '--ig-col-start', + this._colStart ? this._colStart.toString() : null + ); + } + + public get colStart(): number | null { + return this._colStart; + } + + @property({ type: Number }) + public set rowStart(value: number) { + this._rowStart = Math.max(0, asNumber(value)) || null; + this.style.setProperty( + '--ig-row-start', + this._rowStart ? this._rowStart.toString() : null + ); + } + + public get rowStart(): number | null { + return this._rowStart; + } + + /** + * Indicates whether the tile occupies the whole screen. + * @attr fullscreen + */ + @property({ type: Boolean, reflect: true }) + public set fullscreen(value: boolean) { + this._fullscreenController.setState(value); + } + + public get fullscreen(): boolean { + return this._fullscreenController.fullscreen; + } + + /** + * Indicates whether the tile occupies all available space within the layout. + * @attr maximized + */ + @property({ type: Boolean, reflect: true }) + public set maximized(value: boolean) { + this._maximized = value; + + if (this._managerContext) { + this._managerContext.instance.requestUpdate(); + } + } + + public get maximized() { + return this._maximized; + } + + /** + * Indicates whether the tile can be dragged. + * @attr disable-drag + */ + @property({ attribute: 'disable-drag', type: Boolean, reflect: true }) + public set disableDrag(value: boolean) { + this._disableDrag = value; + this._dragController.enabled = !this._disableDrag; + } + + public get disableDrag() { + return this._disableDrag; + } + + /** + * Indicates whether the tile can be resized. + * @attr disable-resize + */ + @property({ type: Boolean, reflect: true, attribute: 'disable-resize' }) + public disableResize = false; + + /** + * Gets/sets the tile's visual position in the layout. + * Corresponds to the CSS order property. + * @attr + */ + @property({ type: Number }) + public set position(value: number) { + this._position = Number(value); + this.style.order = `${this._position}`; + } + + public get position() { + return this._position; + } + + constructor() { + super(); + + new ContextProvider(this, { + context: tileContext, + initialValue: { + instance: this, + setFullscreenState: (fullscreen, isUserTriggered) => + this._fullscreenController.setState(fullscreen, isUserTriggered), + }, + }); + } + + public override connectedCallback() { + super.connectedCallback(); + this.tileId = this.tileId || `tile-${IgcTileComponent.increment()}`; + } + + protected override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + const parts = partNameMap({ + dragging: this._isDragging, + resizing: this._isResizing, + }); + + if (parts.trim()) { + this.setAttribute('part', parts); + } else { + this.removeAttribute('part'); + } + } + + private emitFullScreenEvent(state: boolean) { + return this.emitEvent('igcTileFullscreen', { + detail: { tile: this, state }, + cancelable: true, + }); + } + + private assignDragImage(e: DragEvent) { + const rect = this.getBoundingClientRect(); + const offsetX = e.clientX - rect.left; + const offsetY = e.clientY - rect.top; + const compStyles = getComputedStyle(this); + + this._dragImage = this.cloneNode(true) as HTMLElement; + Object.assign(this._dragImage.style, { + width: compStyles.width, + height: compStyles.height, + position: 'absolute', + top: '-99999px', + left: '-99999px', + }); + + document.body.append(this._dragImage); + + e.dataTransfer!.setDragImage(this._dragImage, offsetX, offsetY); + e.dataTransfer!.effectAllowed = 'move'; + } + + private handleDragStart(e: DragEvent) { + this.assignDragImage(e); + + this.emitEvent('tileDragStart', { detail: this }); + this._dragGhost = this.ghostFactory(); + this._dragGhost.inert = true; + + if (this._dragGhost) { + this.append(this._dragGhost); + } + this._isDragging = true; + } + + private handleDragEnter() { + this._dragCounter++; + this._hasDragOver = true; + } + + private handleDragOver() { + if (!this._draggedItem) { + return; + } + + if (isSameTile(this, this._draggedItem)) { + this._tileContent.style.visibility = 'hidden'; + if (this._dragGhost) { + Object.assign(this._dragGhost.style, { + visibility: 'visible', + }); + } + } else if (this._isSlideMode) { + swapTiles(this, this._draggedItem!); + } + } + + private handleDragLeave() { + this._dragCounter--; + + // The drag leave is fired on entering a child element + // so we need to check if the dragged item is actually leaving the tile + if (this._dragCounter === 0) { + this._hasDragOver = false; + } + } + + private handleDragEnd() { + this.emitEvent('tileDragEnd', { detail: this }); + this._isDragging = false; + + if (this._dragGhost) { + this._dragGhost.remove(); + this._dragGhost = null; + } + + if (this._dragImage) { + this._dragImage.remove(); + this._dragImage = null; + } + + this._tileContent.style.visibility = 'visible'; + } + + private cacheStyles() { + //use util + const computedStyle = getComputedStyle(this); + const parentWrapper = + this.parentElement!.shadowRoot!.querySelector('[part="base"]')!; + + const rowHeights = getComputedStyle(parentWrapper) + .gridTemplateRows.split(' ') + .map((height) => Number.parseFloat(height.trim())); + + this._cachedStyles = { + columnCount: Number.parseFloat( + computedStyle.getPropertyValue('--ig-column-count') + ), + background: computedStyle.getPropertyValue('--placeholder-background'), + border: computedStyle.getPropertyValue('--ghost-border'), + borderRadius: computedStyle.getPropertyValue('--border-radius'), + minWidth: Number.parseFloat( + computedStyle.getPropertyValue('--ig-min-col-width') + ), + minHeight: Number.parseFloat( + computedStyle.getPropertyValue('--ig-min-row-height') + ), + rowHeights, + initialTop: parentWrapper.getBoundingClientRect().top, + }; + } + + private _handleResizeStart(event: CustomEvent) { + const ghostElement = event.detail.state.ghost; + this._initialPointerX = event.detail.event.clientX; + this._initialPointerY = event.detail.event.clientY; + + if (ghostElement) { + ghostElement.style.minWidth = `${this._cachedStyles.minWidth!}px`; + ghostElement.style.minHeight = `${this._cachedStyles.minHeight!}px`; + } + } + + private _handleResize(event: CustomEvent) { + this._isResizing = true; + + const ghostElement = event.detail.state.current; + const rowHeights = this._cachedStyles.rowHeights!; + + if (ghostElement) { + const deltaX = event.detail.event.clientX - this._initialPointerX!; + const deltaY = event.detail.event.clientY - this._initialPointerY!; + const columnGap = 10; + + const snappedWidth = this._calculateSnappedWidth( + deltaX, + event.detail.state.initial.width, + columnGap + ); + + const actualTop = this._cachedStyles.initialTop! + window.scrollY; + const initialTop = event.detail.state.initial.top + window.scrollY; + const actualBottom = event.detail.state.initial.bottom + window.scrollY; + + const startingY = actualBottom - actualTop; + + const snappedHeight = ResizeUtil.calculateSnappedHeight( + deltaY, + startingY, + rowHeights, + columnGap, + initialTop, + event.detail.state.initial.height + ); + + ghostElement.width = snappedWidth; + ghostElement.height = snappedHeight; + } + } + + private _calculateSnappedWidth( + deltaX: number, + initialWidth: number, + gap: number + ): number { + const newSize = initialWidth + deltaX; + const tileManager = + this.closest('igc-tile-manager')!.shadowRoot!.querySelector( + "[part~='base']" + )!; + const styles = getComputedStyle(tileManager); + + const colWidth = + Number.parseFloat( + styles.getPropertyValue('grid-template-columns').split(' ')[0] + ) || this._cachedStyles.minWidth!; + const totalSpan = Math.round(newSize / (colWidth + gap)); + const snappedWidth = totalSpan * colWidth + (totalSpan - 1) * gap; + return snappedWidth < colWidth ? colWidth : snappedWidth; + } + + private _handleResizeEnd(event: CustomEvent) { + const state = event.detail.state; + const width = state.current.width; // - state.current.x; + const height = state.current.height; // - state.current.y; + + const resizeElement = event.target as HTMLElement; + + const parentWrapper = + this.parentElement!.shadowRoot!.querySelector('[part="base"]')!; + const tileManagerRect = parentWrapper.getBoundingClientRect(); + const computedStyles = getComputedStyle(parentWrapper); + + tileManagerRect.height -= + Number.parseFloat(computedStyles.paddingTop) + + Number.parseFloat(computedStyles.paddingBottom); + tileManagerRect.width -= + Number.parseFloat(computedStyles.paddingLeft) + + Number.parseFloat(computedStyles.paddingRight); + + const gridTemplateColumnsWidth = Number.parseFloat( + computedStyles.getPropertyValue('grid-template-columns').split(' ')[0] + ); + const targetWidth = this._cachedStyles.columnCount + ? tileManagerRect.width / this._cachedStyles.columnCount! + : gridTemplateColumnsWidth; + + let colSpan = Math.round(width / targetWidth); + colSpan = Math.max(1, colSpan); + + const minHeight = this._cachedStyles.minHeight!; + let rowSpan = Math.round(height / minHeight); + rowSpan = Math.max(1, rowSpan); + + // REVIEW + Object.assign(resizeElement.style, { + width: '', + height: '', + }); + + Object.assign(this.style, { + gridRow: `span ${rowSpan}`, + gridColumn: `span ${colSpan}`, + }); + + this._isResizing = false; + this._initialPointerX = null; + this._initialPointerY = null; + this._cachedStyles = {}; + } + + // REVIEW + protected ghostFactory = () => { + this.cacheStyles(); + + const ghost = document.createElement('div'); + Object.assign(ghost.style, { + position: 'absolute', + top: 0, + left: 0, + zIndex: 1000, + background: this._cachedStyles.background, + border: `1px solid ${this._cachedStyles.border}`, + borderRadius: this._cachedStyles.borderRadius, + width: '100%', + height: '100%', + gridRow: '', + gridColumn: '', + }); + + return ghost; + }; + + protected renderContent() { + const parts = partNameMap({ + base: true, + 'drag-over': this._hasDragOver, + fullscreen: this.fullscreen, + draggable: !this.disableDrag, + dragging: this._isDragging, + resizable: !this.disableResize, + resizing: this._isResizing, + maximized: this.maximized, + }); + + const styles = { + '--ig-col-span': this._colSpan, + '--ig-row-span': this._rowSpan, + '--ig-col-start': this._colStart, + '--ig-row-start': this._rowStart, + }; + + return html` +
+ +
+ +
+
+ `; + } + + protected override render() { + const renderResize = + this.disableResize || this.maximized || this.fullscreen; + + return renderResize + ? this.renderContent() + : html` + + ${this.renderContent()} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-tile': IgcTileComponent; + } +} diff --git a/src/index.ts b/src/index.ts index be2d74a98..014f3c393 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,9 @@ export { default as IgcSliderLabelComponent } from './components/slider/slider-l export { default as IgcTabsComponent } from './components/tabs/tabs.js'; export { default as IgcTabComponent } from './components/tabs/tab.js'; export { default as IgcTabPanelComponent } from './components/tabs/tab-panel.js'; +export { default as IgcTileComponent } from './components/tile-manager/tile.js'; +export { default as IgcTileHeaderComponent } from './components/tile-manager/tile-header.js'; +export { default as IgcTileManagerComponent } from './components/tile-manager/tile-manager.js'; export { default as IgcToastComponent } from './components/toast/toast.js'; export { default as IgcToggleButtonComponent } from './components/button-group/toggle-button.js'; export { default as IgcSwitchComponent } from './components/checkbox/switch.js'; diff --git a/stories/avatar.stories.ts b/stories/avatar.stories.ts index b164e4d66..eeea737db 100644 --- a/stories/avatar.stories.ts +++ b/stories/avatar.stories.ts @@ -63,11 +63,6 @@ interface IgcAvatarArgs { } type Story = StoryObj; -registerIcon( - 'home', - 'https://unpkg.com/material-design-icons@3.0.1/action/svg/production/ic_home_24px.svg' -); - // endregion export const Image: Story = { diff --git a/stories/story.ts b/stories/story.ts index aa8c1d07e..4bae96e33 100644 --- a/stories/story.ts +++ b/stories/story.ts @@ -40,3 +40,19 @@ export function formControls() { `; } + +function randomBetween(min: number, max: number): number { + if (!Number.isFinite(min) || !Number.isFinite(max)) { + throw new RangeError('pass in finite numbers'); + } + if (max < min) { + throw new RangeError('max is less than min'); + } + const x = Math.random(); + const y = min * (1 - x) + max * x; + return y >= min && y < max ? y : min; +} + +export function randomIntBetween(min: number, max: number): number { + return Math.floor(randomBetween(Math.ceil(min), Math.floor(max) + 1)); +} diff --git a/stories/tile-manager.stories.ts b/stories/tile-manager.stories.ts new file mode 100644 index 000000000..f2721d076 --- /dev/null +++ b/stories/tile-manager.stories.ts @@ -0,0 +1,752 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; +import { html } from 'lit'; +import { map } from 'lit/directives/map.js'; +import { range } from 'lit/directives/range.js'; + +import { + IgcAvatarComponent, + IgcButtonComponent, + IgcCalendarComponent, + IgcCardActionsComponent, + IgcCardComponent, + IgcCardContentComponent, + IgcCardMediaComponent, + IgcChipComponent, + IgcDatePickerComponent, + IgcDividerComponent, + IgcIconButtonComponent, + IgcIconComponent, + IgcInputComponent, + IgcLinearProgressComponent, + IgcListComponent, + IgcListItemComponent, + IgcRatingComponent, + IgcRippleComponent, + IgcStepComponent, + IgcStepperComponent, + IgcTabPanelComponent, + IgcTabsComponent, + type IgcTileComponent, + IgcTileManagerComponent, + defineComponents, + registerIcon, +} from 'igniteui-webcomponents'; +import { disableStoryControls, randomIntBetween } from './story.js'; + +defineComponents( + IgcTileManagerComponent, + IgcIconComponent, + IgcButtonComponent, + IgcRatingComponent, + IgcInputComponent, + IgcStepComponent, + IgcStepperComponent, + IgcLinearProgressComponent, + IgcDatePickerComponent, + IgcChipComponent, + IgcCalendarComponent, + IgcListComponent, + IgcTabPanelComponent, + IgcListItemComponent, + IgcAvatarComponent, + IgcTabsComponent, + IgcCardComponent, + IgcCardMediaComponent, + IgcCardActionsComponent, + IgcIconButtonComponent, + IgcRippleComponent, + IgcDividerComponent, + IgcCardContentComponent +); + +// region default +const metadata: Meta = { + title: 'TileManager', + component: 'igc-tile-manager', + parameters: { + docs: { + description: { + component: + 'The tile manager component enables the dynamic arrangement, resizing, and interaction of tiles.', + }, + }, + actions: { handles: ['igcTileDragStarted'] }, + }, + argTypes: { + dragMode: { + type: '"slide" | "swap"', + description: 'Determines whether the tiles slide or swap on drop.', + options: ['slide', 'swap'], + control: { type: 'inline-radio' }, + table: { defaultValue: { summary: 'slide' } }, + }, + columnCount: { + type: 'number', + description: + 'Sets the number of columns for the tile manager.\nSetting value <= than zero will trigger a responsive layout.', + control: 'number', + table: { defaultValue: { summary: '0' } }, + }, + minColumnWidth: { + type: 'string', + description: + 'Sets the minimum width for a column unit in the tile manager.', + control: 'text', + }, + minRowHeight: { + type: 'string', + description: + 'Sets the minimum height for a row unit in the tile manager.', + control: 'text', + }, + }, + args: { dragMode: 'slide', columnCount: 0 }, +}; + +export default metadata; + +interface IgcTileManagerArgs { + /** Determines whether the tiles slide or swap on drop. */ + dragMode: 'slide' | 'swap'; + /** + * Sets the number of columns for the tile manager. + * Setting value <= than zero will trigger a responsive layout. + */ + columnCount: number; + /** Sets the minimum width for a column unit in the tile manager. */ + minColumnWidth: string; + /** Sets the minimum height for a row unit in the tile manager. */ + minRowHeight: string; +} +type Story = StoryObj; + +// endregion + +registerIcon( + 'home', + 'https://unpkg.com/material-design-icons@3.0.1/action/svg/production/ic_home_24px.svg' +); + +const tiles = Array.from( + map( + range(10), + (i) => html` + + +

Tile ${i + 1} Title

+ +
+ +

Text in Tile ${i + 1}

+
+ +
+
+ ` + ) +); + +const pictures = Array.from(range(25)).map(() => ({ + width: randomIntBetween(300, 600), + height: randomIntBetween(300, 600), +})); + +const date = new Date(); + +export const AutoInfer: Story = { + argTypes: disableStoryControls(metadata), + render: (args) => html` + + + ${pictures.map( + ({ width, height }) => html` + +
+ picture +
+
+ ` + )} +
+ `, +}; + +export const FinDashboard: Story = { + render: (args) => html` + + + + Good morning, John + + + +

Total net worth: $123,000

+
+ + + + Spendings + + +

$10,230

+
+ + + + Spendings + + + + + H +

Hotel

+ Jun 21, 06:15 +
+ - 400,00 $ +
+
+ + + ATM +

Cash at ATM 000000

+ 14:40 +
+ - 140$ +
+
+
+
+ + + + Your Progress + + + +

Total net worth: $123,000

+
+ + + + Income Source + + + +

Total net worth: $123,000

+
+ + + + Income + + + +

Total net worth: $123,000

+
+ + + + Notifications + + + +

Total net worth: $123,000

+
+ + + + Incomes & Expenses + + + +

Total net worth: $123,000

+
+ + + + Assets + + + +

Total net worth: $123,000

+
+
+ + + Toggle Tile 2 Resizing + + + Toggle Tile 1 Fullscreen prop + + `, +}; + +export const FinDashboard1: Story = { + render: (args) => html` + + + + + + + Good morning, John + + +
+ +

Total net worth: $123,000

+

Spending Overview

+
+ + + + + Daily + Weekly + Monthly + + + + + + + + + + H +

Hotel

+ Jun 21, 06:15 +
+ - 400,00 $ +
+
+ + + ATM +

Cash at ATM 000000

+ 14:40 +
+ - 140$ +
+
+ + + U +

Utilities

+ 21/06/2021 16:00 +
+ - 200$ +
+
+ + + Total amount spent: $740 + +
+ +
+ Details tab panel + Custom tab panel +
+
+ + + + Accounts + + + + + +

+ EUR +

+
+ + +

+ USD +

+
+ + +

+ BTC +

+
+
+
+ + + + Your Cards + + + + + + Add Card + + + + MC +

Standard **0000

+ Expires on 11/26 +
+ + VISA +

Rose gold **0000

+ Expires on 11/24 +
+ + VISA +

Virtual card **0000

+ Expires on 10/22 +
+
+
+
+
+ + + + Latest Transactions + + + + AMZN +

Money added via **0000

+ 14:40 +
+ + 2000$ +
+
+ + SET +

Sports Event Tickets

+ Jun 21, 06:15, Declined because your card is inactive +
+ 1017,08 $ + 900,08 $ +
+
+ + AT +

Airplane Tickets

+ Jun 21, 06:15, Declined because your card is inactive +
+ 985,00 $ + 980,00 $ +
+
+
+
+ + + + Get Verified + + + Want to spend more and enjoy the full experience? Get verified today to lift your limits. + + + + Personal Details +
+
+ + +
+ NEXT +
+
+ + Address Details +
+
+ + +
+ PREVIOUS + NEXT +
+
+ + KYC Verification +
+ PREVIOUS + RESET +
+
+
+
+ `, +}; + +export const Default: Story = { + render: (args) => html` + + +

This text won't be displayed in Tile Manager

+ ${tiles} +
+ + + Toggle Tile 2 Resizing + + `, +}; + +function toggleMaximizedTile() { + const tile = document.querySelector('#max-tile')!; + tile.maximized = !tile.maximized; +} + +function disableTileResize() { + const tileManager = + document.querySelector('igc-tile-manager')!; + tileManager.tiles[1].disableResize = !tileManager.tiles[1].disableResize; +} + +function toggleFullscreen() { + const tileManager = + document.querySelector('igc-tile-manager')!; + tileManager.tiles[1].fullscreen = !tileManager.tiles[1].fullscreen; +} + +function cancelStateChangeEvent(e: CustomEvent) { + e.preventDefault(); +} + +export const Maximized: Story = { + render: (args) => html` + + + +

I am Maximized

+ Toggle maximized state +
+ + +

I am not maximized and will be under the maximized tile

+
+
+ `, +}; + +function addTile() { + const tileManager = + document.querySelector('#tile-manager1')!; + const tiles = tileManager.querySelectorAll('igc-tile'); + const newTile = document.createElement('igc-tile'); + const content = document.createElement('h2'); + content.textContent = `Tile ${tileManager.tiles.length + 1}`; + newTile.position = 0; + newTile.append(content); + // tileManager.appendChild(newTile); + tileManager.insertBefore(newTile, tiles[3]); +} + +function removeTile() { + const tileManager = + document.querySelector('#tile-manager1')!; + const firstTile = tileManager.querySelector('igc-tile:first-of-type'); + + if (firstTile) { + firstTile.remove(); + } +} + +export const DynamicTiles: Story = { + render: (args) => html` + Add Tile + Remove Tile + + +

Tile1

+
+ +

Tile2

+
+ +

Tile3

+
+ +

Tile4

+
+ +

Tile5

+
+ +

Tile6

+
+
+ `, +}; + +let serializedData: string; + +function saveTileManager() { + const tileManager = + document.querySelector('#tile-manager1')!; + + serializedData = tileManager.saveLayout(); +} + +function loadTileManager() { + const tileManager = + document.querySelector('#tile-manager1')!; + + tileManager.loadLayout(serializedData); +} + +export const Serialization: Story = { + render: (args) => html` + Save Layout + Load Layout + Add Tile + Remove Tile + + + Header 1 +

Tile1

+
+ +

Tile2

+
+ +

Tile3

+
+
+ `, +};