diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7014a6c..b76d362e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added -- New File Input Component(`igc-file-input`) +- File Input component - Exposed more public API type aliases for component property types like `ButtonVariant`, `PickerMode`, `StepperOrientation`, `HorizontalTransitionAnimation` (carousel and horizontal stepper) and more. +- Tooltip component ### Deprecated - Some event argument types have been renamed for consistency: diff --git a/src/animations/player.ts b/src/animations/player.ts index 9bb856aaf..9d1041c57 100644 --- a/src/animations/player.ts +++ b/src/animations/player.ts @@ -71,6 +71,15 @@ class AnimationController implements ReactiveController { ); } + public async playExclusive(animation: AnimationReferenceMetadata) { + const [_, event] = await Promise.all([ + this.stopAll(), + this.play(animation), + ]); + + return event.type === 'finish'; + } + public hostConnected() {} } diff --git a/src/animations/presets/scale/index.ts b/src/animations/presets/scale/index.ts new file mode 100644 index 000000000..3e5d6a890 --- /dev/null +++ b/src/animations/presets/scale/index.ts @@ -0,0 +1,18 @@ +import { EaseOut } from '../../easings.js'; +import { animation } from '../../types.js'; + +const baseOptions: KeyframeAnimationOptions = { + duration: 350, + easing: EaseOut.Quad, +}; + +const scaleInCenter = (options = baseOptions) => + animation( + [ + { transform: 'scale(0)', opacity: 0 }, + { transform: 'scale(1)', opacity: 1 }, + ], + options + ); + +export { scaleInCenter }; diff --git a/src/components/common/definitions/defineAllComponents.ts b/src/components/common/definitions/defineAllComponents.ts index b51832d23..54a88bb73 100644 --- a/src/components/common/definitions/defineAllComponents.ts +++ b/src/components/common/definitions/defineAllComponents.ts @@ -63,6 +63,7 @@ import IgcTextareaComponent from '../../textarea/textarea.js'; import IgcTileManagerComponent from '../../tile-manager/tile-manager.js'; import IgcTileComponent from '../../tile-manager/tile.js'; import IgcToastComponent from '../../toast/toast.js'; +import IgcTooltipComponent from '../../tooltip/tooltip.js'; import IgcTreeItemComponent from '../../tree/tree-item.js'; import IgcTreeComponent from '../../tree/tree.js'; import { defineComponents } from './defineComponents.js'; @@ -136,6 +137,7 @@ const allComponents: IgniteComponent[] = [ IgcTextareaComponent, IgcTileComponent, IgcTileManagerComponent, + IgcTooltipComponent, ]; export function defineAllComponents() { diff --git a/src/components/common/util.ts b/src/components/common/util.ts index d13c7d73c..17a0adb28 100644 --- a/src/components/common/util.ts +++ b/src/components/common/util.ts @@ -292,10 +292,36 @@ export function isString(value: unknown): value is string { return typeof value === 'string'; } +export function isObject(value: unknown): value is object { + return value != null && typeof value === 'object'; +} + +export function isEventListenerObject(x: unknown): x is EventListenerObject { + return isObject(x) && 'handleEvent' in x; +} + +export function addWeakEventListener( + element: Element, + event: string, + listener: EventListenerOrEventListenerObject, + options?: AddEventListenerOptions | boolean +): void { + const weakRef = new WeakRef(listener); + const wrapped = (evt: Event) => { + const handler = weakRef.deref(); + + return isEventListenerObject(handler) + ? handler.handleEvent(evt) + : handler?.(evt); + }; + + element.addEventListener(event, wrapped, options); +} + /** - * Returns whether a given collection has at least one member. + * Returns whether a given collection is empty. */ -export function isEmpty( +export function isEmpty( x: ArrayLike | Set | Map ): boolean { return 'length' in x ? x.length < 1 : x.size < 1; diff --git a/src/components/common/utils.spec.ts b/src/components/common/utils.spec.ts index e185bc0b7..120c32e8b 100644 --- a/src/components/common/utils.spec.ts +++ b/src/components/common/utils.spec.ts @@ -202,6 +202,14 @@ export function simulatePointerLeave( ); } +export function simulateFocus(node: Element) { + node.dispatchEvent(new FocusEvent('focus')); +} + +export function simulateBlur(node: Element) { + node.dispatchEvent(new FocusEvent('blur')); +} + export function simulatePointerDown( node: Element, options?: PointerEventInit, diff --git a/src/components/popover/popover.ts b/src/components/popover/popover.ts index 58fb030e5..18eaa5e41 100644 --- a/src/components/popover/popover.ts +++ b/src/components/popover/popover.ts @@ -1,8 +1,12 @@ import { type Middleware, + type MiddlewareData, + type Placement, + arrow, autoUpdate, computePosition, flip, + inline, limitShift, offset, shift, @@ -14,6 +18,7 @@ import { property, query, queryAssignedElements } from 'lit/decorators.js'; import { watch } from '../common/decorators/watch.js'; import { registerComponent } from '../common/definitions/register.js'; import { + first, getElementByIdFromRoot, isEmpty, isString, @@ -59,7 +64,7 @@ export default class IgcPopoverComponent extends LitElement { private dispose?: ReturnType; private target?: Element; - @query('#container', true) + @query('#container') private _container!: HTMLElement; @queryAssignedElements({ slot: 'anchor', flatten: true }) @@ -72,6 +77,23 @@ export default class IgcPopoverComponent extends LitElement { @property() public anchor?: Element | string; + /** + * Element to render as an "arrow" element for the current popover. + */ + @property({ attribute: false }) + public arrow: HTMLElement | null = null; + + /** Additional offset to apply to the arrow element if enabled. */ + @property({ type: Number, attribute: 'arrow-offset' }) + public arrowOffset = 0; + + /** + * Improves positioning for inline reference elements that span over multiple lines. + * Useful for tooltips or similar components. + */ + @property({ type: Boolean, reflect: true }) + public inline = false; + /** * When enabled this changes the placement of the floating element in order to keep it * in view along the main axis. @@ -110,8 +132,14 @@ export default class IgcPopoverComponent extends LitElement { @property({ type: Boolean, reflect: true }) public shift = false; + /** + * Virtual padding for the resolved overflow detection offsets in pixels. + */ + @property({ type: Number, attribute: 'shift-padding' }) + public shiftPadding = 0; + @watch('anchor') - protected async anchorChange() { + protected anchorChange() { const newTarget = isString(this.anchor) ? getElementByIdFromRoot(this, this.anchor) : this.anchor; @@ -127,11 +155,15 @@ export default class IgcPopoverComponent extends LitElement { this.open ? this.show() : this.hide(); } + @watch('arrow', { waitUntilFirstUpdate: true }) + @watch('arrowOffset', { waitUntilFirstUpdate: true }) @watch('flip', { waitUntilFirstUpdate: true }) + @watch('inline', { waitUntilFirstUpdate: true }) @watch('offset', { waitUntilFirstUpdate: true }) @watch('placement', { waitUntilFirstUpdate: true }) @watch('sameWidth', { waitUntilFirstUpdate: true }) @watch('shift', { waitUntilFirstUpdate: true }) + @watch('shiftPadding', { waitUntilFirstUpdate: true }) protected floatingPropChange() { this._updateState(); } @@ -151,7 +183,10 @@ export default class IgcPopoverComponent extends LitElement { } protected show() { - if (!this.target) return; + if (!this.target) { + return; + } + this._showPopover(); this.dispose = autoUpdate( @@ -187,14 +222,23 @@ export default class IgcPopoverComponent extends LitElement { middleware.push(offset(this.offset)); } + if (this.inline) { + middleware.push(inline()); + } + if (this.shift) { middleware.push( shift({ + padding: this.shiftPadding, limiter: limitShift(), }) ); } + if (this.arrow) { + middleware.push(arrow({ element: this.arrow })); + } + if (this.flip) { middleware.push(flip()); } @@ -222,25 +266,60 @@ export default class IgcPopoverComponent extends LitElement { } private async _updatePosition() { - if (!this.open || !this.target) { + if (!(this.open && this.target)) { return; } - const { x, y } = await computePosition(this.target, this._container, { - placement: this.placement ?? 'bottom-start', - middleware: this._createMiddleware(), - strategy: 'fixed', - }); + const { x, y, middlewareData, placement } = await computePosition( + this.target, + this._container, + { + placement: this.placement ?? 'bottom-start', + middleware: this._createMiddleware(), + strategy: 'fixed', + } + ); Object.assign(this._container.style, { left: 0, top: 0, transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`, }); + + this._positionArrow(placement, middlewareData); + } + + private _positionArrow(placement: Placement, data: MiddlewareData) { + if (!(data.arrow && this.arrow)) { + return; + } + + const { x, y } = data.arrow; + + // The current placement of the popover along the x/y axis + const currentPlacement = first(placement.split('-')); + + // The opposite side where the arrow element should render based on the `currentPlacement` + const staticSide = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', + }[currentPlacement]!; + + this.arrow.part = currentPlacement; + + Object.assign(this.arrow.style, { + left: x != null ? `${roundByDPR(x + this.arrowOffset)}px` : '', + top: y != null ? `${roundByDPR(y + this.arrowOffset)}px` : '', + [staticSide]: '-4px', + }); } private _anchorSlotChange() { - if (this.anchor || isEmpty(this._anchors)) return; + if (this.anchor || isEmpty(this._anchors)) { + return; + } this.target = this._anchors[0]; this._updateState(); diff --git a/src/components/tooltip/controller.ts b/src/components/tooltip/controller.ts new file mode 100644 index 000000000..71bbca99a --- /dev/null +++ b/src/components/tooltip/controller.ts @@ -0,0 +1,297 @@ +import type { ReactiveController } from 'lit'; +import { + addWeakEventListener, + getElementByIdFromRoot, + isString, +} from '../common/util.js'; +import service from './service.js'; +import type IgcTooltipComponent from './tooltip.js'; + +class TooltipController implements ReactiveController { + //#region Internal properties and state + + private static readonly _listeners = [ + 'pointerenter', + 'pointerleave', + ] as const; + + private readonly _host: IgcTooltipComponent; + private readonly _options: TooltipCallbacks; + + private _hostAbortController: AbortController | null = null; + private _anchorAbortController: AbortController | null = null; + + private _showTriggers = new Set(['pointerenter']); + private _hideTriggers = new Set(['pointerleave', 'click']); + + private _anchor: WeakRef | null = null; + private _initialAnchor: WeakRef | null = null; + + private _isTransient = false; + private _open = false; + + //#endregion + + //#region Public properties + + /** Whether the tooltip is in shown state. */ + public get open(): boolean { + return this._open; + } + + /** Sets the shown state of the current tooltip. */ + public set open(value: boolean) { + this._open = value; + + if (this._open) { + this._addTooltipListeners(); + service.add(this._host, this._options.onEscape); + } else { + if (this._isTransient) { + this._isTransient = false; + this.setAnchor(this._initialAnchor?.deref()); + } + + this._removeTooltipListeners(); + service.remove(this._host); + } + } + + /** + * Returns the current tooltip anchor target if any. + */ + public get anchor(): TooltipAnchor { + return this._isTransient + ? this._anchor?.deref() + : this._initialAnchor?.deref(); + } + + /** + * Returns the current set of hide triggers as a comma-separated string. + */ + public get hideTriggers(): string { + return Array.from(this._hideTriggers).join(); + } + + /** + * Sets a new set of hide triggers from a comma-separated string. + * + * @remarks + * If the tooltip already has an `anchor` bound it will remove the old + * set of triggers from it and rebind it with the new one. + */ + public set hideTriggers(value: string) { + this._hideTriggers = parseTriggers(value); + this._removeAnchorListeners(); + this._addAnchorListeners(); + } + + /** + * Returns the current set of show triggers as a comma-separated string. + */ + public get showTriggers(): string { + return Array.from(this._showTriggers).join(); + } + + /** + * Sets a new set of show triggers from a comma-separated string. + * + * @remarks + * If the tooltip already has an `anchor` bound it will remove the old + * set of triggers from it and rebind it with the new one. + */ + public set showTriggers(value: string) { + this._showTriggers = parseTriggers(value); + this._removeAnchorListeners(); + this._addAnchorListeners(); + } + + //#endregion + + constructor(tooltip: IgcTooltipComponent, options: TooltipCallbacks) { + this._host = tooltip; + this._options = options; + this._host.addController(this); + } + + //#region Internal event listeners state + + private _addAnchorListeners(): void { + const anchor = this.anchor; + + if (!anchor) { + return; + } + + this._anchorAbortController = new AbortController(); + const signal = this._anchorAbortController.signal; + + for (const each of this._showTriggers) { + addWeakEventListener(anchor, each, this, { passive: true, signal }); + } + + for (const each of this._hideTriggers) { + addWeakEventListener(anchor, each, this, { passive: true, signal }); + } + } + + private _removeAnchorListeners(): void { + this._anchorAbortController?.abort(); + this._anchorAbortController = null; + } + + private _addTooltipListeners(): void { + this._hostAbortController = new AbortController(); + const signal = this._hostAbortController.signal; + + for (const event of TooltipController._listeners) { + this._host.addEventListener(event, this, { passive: true, signal }); + } + } + + private _removeTooltipListeners(): void { + this._hostAbortController?.abort(); + this._hostAbortController = null; + } + + //#endregion + + //#region Event handlers + + private async _handleTooltipEvent(event: Event): Promise { + switch (event.type) { + case 'pointerenter': + await this._options.onShow.call(this._host); + break; + case 'pointerleave': + await this._options.onHide.call(this._host); + break; + default: + return; + } + } + + private async _handleAnchorEvent(event: Event): Promise { + if (!this._open && this._showTriggers.has(event.type)) { + await this._options.onShow.call(this._host); + } + + if (this._open && this._hideTriggers.has(event.type)) { + await this._options.onHide.call(this._host); + } + } + + /** @internal */ + public handleEvent(event: Event): void { + if (event.target === this._host) { + this._handleTooltipEvent(event); + } else if (event.target === this._anchor?.deref()) { + this._handleAnchorEvent(event); + } else if (event.target === this._initialAnchor?.deref()) { + this.open = false; + this._handleAnchorEvent(event); + } + } + + //#endregion + + private _dispose(): void { + this._removeAnchorListeners(); + this._removeTooltipListeners(); + service.remove(this._host); + this._anchor = null; + this._initialAnchor = null; + } + + //#region Public API + + /** + * Removes all triggers from the previous `anchor` target and rebinds the current + * sets back to the new value if it exists. + */ + public setAnchor(value: TooltipAnchor | string, transient = false): void { + const newAnchor = isString(value) + ? getElementByIdFromRoot(this._host, value) + : value; + + if (this._anchor?.deref() === newAnchor) { + return; + } + + // Tooltip `show()` method called with a target. Set to hidden state. + if (transient && this._open) { + this.open = false; + } + + if (this._anchor?.deref() !== this._initialAnchor?.deref()) { + this._removeAnchorListeners(); + } + + this._anchor = newAnchor ? new WeakRef(newAnchor) : null; + this._isTransient = transient; + this._addAnchorListeners(); + } + + public resolveAnchor(value: TooltipAnchor | string): void { + const resolvedElement = isString(value) + ? getElementByIdFromRoot(this._host, value) + : value; + + this._initialAnchor = resolvedElement ? new WeakRef(resolvedElement) : null; + this.setAnchor(resolvedElement); + } + + //#endregion + + //#region ReactiveController interface + + /** @internal */ + public hostConnected(): void { + this.resolveAnchor(this._host.anchor); + } + + /** @internal */ + public hostDisconnected(): void { + this._dispose(); + } + + //#endregion +} + +function parseTriggers(string: string): Set { + return new Set( + (string ?? '').split(TooltipRegexes.triggers).filter((s) => s.trim()) + ); +} + +export const TooltipRegexes = Object.freeze({ + /** Used for parsing the strings passed in the tooltip `show/hide-trigger` properties. */ + triggers: /[,\s]+/, + + /** Matches horizontal `PopoverPlacement` start positions. */ + horizontalStart: /^(left|right)-start$/, + + /** Matches horizontal `PopoverPlacement` end positions. */ + horizontalEnd: /^(left|right)-end$/, + + /** Matches vertical `PopoverPlacement` start positions. */ + start: /start$/, + + /** Matches vertical `PopoverPlacement` end positions. */ + end: /end$/, +}); + +export function addTooltipController( + host: IgcTooltipComponent, + options: TooltipCallbacks +): TooltipController { + return new TooltipController(host, options); +} + +type TooltipAnchor = Element | null | undefined; + +type TooltipCallbacks = { + onShow: (event?: Event) => unknown; + onHide: (event?: Event) => unknown; + onEscape: (event?: Event) => unknown; +}; diff --git a/src/components/tooltip/service.ts b/src/components/tooltip/service.ts new file mode 100644 index 000000000..70f5f7b0e --- /dev/null +++ b/src/components/tooltip/service.ts @@ -0,0 +1,57 @@ +import { isServer } from 'lit'; +import { escapeKey } from '../common/controllers/key-bindings.js'; +import { isEmpty, last } from '../common/util.js'; +import type IgcTooltipComponent from './tooltip.js'; + +type TooltipHideCallback = () => unknown; + +class TooltipEscapeCallbacks { + private _collection = new Map(); + + private _setListener(state = true): void { + /* c8 ignore next 3 */ + if (isServer) { + return; + } + + if (isEmpty(this._collection)) { + state + ? globalThis.addEventListener('keydown', this) + : globalThis.removeEventListener('keydown', this); + } + } + + public add( + instance: IgcTooltipComponent, + hideCallback: TooltipHideCallback + ): void { + if (this._collection.has(instance)) { + return; + } + + this._setListener(); + this._collection.set(instance, hideCallback); + } + + public remove(instance: IgcTooltipComponent): void { + if (!this._collection.has(instance)) { + return; + } + + this._collection.delete(instance); + this._setListener(false); + } + + /** @internal */ + public async handleEvent(event: KeyboardEvent): Promise { + if (event.key !== escapeKey) { + return; + } + + const [tooltip, callback] = last(Array.from(this._collection.entries())); + await callback?.call(tooltip); + } +} + +const service = new TooltipEscapeCallbacks(); +export default service; diff --git a/src/components/tooltip/themes/dark/_themes.scss b/src/components/tooltip/themes/dark/_themes.scss new file mode 100644 index 000000000..0b307f40e --- /dev/null +++ b/src/components/tooltip/themes/dark/_themes.scss @@ -0,0 +1,7 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/dark/tooltip' as *; + +$material: digest-schema($dark-material-tooltip); +$bootstrap: digest-schema($dark-bootstrap-tooltip); +$fluent: digest-schema($dark-fluent-tooltip); +$indigo: digest-schema($dark-indigo-tooltip); diff --git a/src/components/tooltip/themes/dark/tooltip.bootstrap.scss b/src/components/tooltip/themes/dark/tooltip.bootstrap.scss new file mode 100644 index 000000000..f7749c4a6 --- /dev/null +++ b/src/components/tooltip/themes/dark/tooltip.bootstrap.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $bootstrap; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme), 'ig-tooltip'); +} diff --git a/src/components/tooltip/themes/dark/tooltip.fluent.scss b/src/components/tooltip/themes/dark/tooltip.fluent.scss new file mode 100644 index 000000000..78a2bf57a --- /dev/null +++ b/src/components/tooltip/themes/dark/tooltip.fluent.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $fluent; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme), 'ig-tooltip'); +} diff --git a/src/components/tooltip/themes/dark/tooltip.indigo.scss b/src/components/tooltip/themes/dark/tooltip.indigo.scss new file mode 100644 index 000000000..d373697ad --- /dev/null +++ b/src/components/tooltip/themes/dark/tooltip.indigo.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $indigo; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme), 'ig-tooltip'); +} diff --git a/src/components/tooltip/themes/dark/tooltip.material.scss b/src/components/tooltip/themes/dark/tooltip.material.scss new file mode 100644 index 000000000..afc7fdb5a --- /dev/null +++ b/src/components/tooltip/themes/dark/tooltip.material.scss @@ -0,0 +1,9 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; +@use '../light/themes' as light; + +$theme: $material; + +:host { + @include css-vars-from-theme(diff(light.$base, $theme), 'ig-tooltip'); +} diff --git a/src/components/tooltip/themes/light/_themes.scss b/src/components/tooltip/themes/light/_themes.scss new file mode 100644 index 000000000..89c9ed9c2 --- /dev/null +++ b/src/components/tooltip/themes/light/_themes.scss @@ -0,0 +1,8 @@ +@use 'styles/utilities' as *; +@use 'igniteui-theming/sass/themes/schemas/components/light/tooltip' as *; + +$base: digest-schema($light-tooltip); +$material: digest-schema($material-tooltip); +$bootstrap: digest-schema($bootstrap-tooltip); +$fluent: digest-schema($fluent-tooltip); +$indigo: digest-schema($indigo-tooltip); diff --git a/src/components/tooltip/themes/light/tooltip.bootstrap.scss b/src/components/tooltip/themes/light/tooltip.bootstrap.scss new file mode 100644 index 000000000..174983d52 --- /dev/null +++ b/src/components/tooltip/themes/light/tooltip.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-tooltip'); +} diff --git a/src/components/tooltip/themes/light/tooltip.fluent.scss b/src/components/tooltip/themes/light/tooltip.fluent.scss new file mode 100644 index 000000000..12e11b091 --- /dev/null +++ b/src/components/tooltip/themes/light/tooltip.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-tooltip'); +} diff --git a/src/components/tooltip/themes/light/tooltip.indigo.scss b/src/components/tooltip/themes/light/tooltip.indigo.scss new file mode 100644 index 000000000..2e70733c9 --- /dev/null +++ b/src/components/tooltip/themes/light/tooltip.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-tooltip'); +} diff --git a/src/components/tooltip/themes/light/tooltip.material.scss b/src/components/tooltip/themes/light/tooltip.material.scss new file mode 100644 index 000000000..24764ffe6 --- /dev/null +++ b/src/components/tooltip/themes/light/tooltip.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-tooltip'); +} diff --git a/src/components/tooltip/themes/light/tooltip.shared.scss b/src/components/tooltip/themes/light/tooltip.shared.scss new file mode 100644 index 000000000..c6375dd44 --- /dev/null +++ b/src/components/tooltip/themes/light/tooltip.shared.scss @@ -0,0 +1,6 @@ +@use 'styles/utilities' as *; +@use 'themes' as *; + +:host { + @include css-vars-from-theme($base, 'ig-tooltip'); +} diff --git a/src/components/tooltip/themes/shared/tooltip.common.scss b/src/components/tooltip/themes/shared/tooltip.common.scss new file mode 100644 index 000000000..e39e2eabe --- /dev/null +++ b/src/components/tooltip/themes/shared/tooltip.common.scss @@ -0,0 +1,43 @@ +@use 'styles/utilities' as *; +@use '../light/themes' as *; + +$theme: $material; +$transparent-border: rem(4px) solid transparent; +$color-border: rem(4px) solid var-get($theme, 'background'); + +[part='base'] { + background: var-get($theme, 'background'); + color: var-get($theme, 'text-color'); + border-radius: var-get($theme, 'border-radius'); + box-shadow: var-get($theme, 'elevation'); +} + +#arrow { + &[part='top'], + &[part='bottom'] { + border-left: $transparent-border; + border-right: $transparent-border; + } + + &[part='top'] { + border-top: $color-border; + } + + &[part='bottom'] { + border-bottom: $color-border; + } + + &[part='left'], + &[part='right'] { + border-top: $transparent-border; + border-bottom: $transparent-border; + } + + &[part='left'] { + border-left: $color-border; + } + + &[part='right'] { + border-right: $color-border; + } +} diff --git a/src/components/tooltip/themes/shared/tooltip.indigo.scss b/src/components/tooltip/themes/shared/tooltip.indigo.scss new file mode 100644 index 000000000..de01c7105 --- /dev/null +++ b/src/components/tooltip/themes/shared/tooltip.indigo.scss @@ -0,0 +1,5 @@ +@use 'styles/utilities' as *; + +[part="base"] { + @include type-style('subtitle-2'); +} diff --git a/src/components/tooltip/themes/themes.ts b/src/components/tooltip/themes/themes.ts new file mode 100644 index 000000000..73e80b6df --- /dev/null +++ b/src/components/tooltip/themes/themes.ts @@ -0,0 +1,54 @@ +import { css } from 'lit'; + +import type { Themes } from '../../../theming/types.js'; +import { styles as indigo } from './shared/tooltip.indigo.css.js'; + +// Dark Overrides +import { styles as bootstrapDark } from './dark/tooltip.bootstrap.css.js'; +import { styles as fluentDark } from './dark/tooltip.fluent.css.js'; +import { styles as indigoDark } from './dark/tooltip.indigo.css.js'; +import { styles as materialDark } from './dark/tooltip.material.css.js'; +// Light Overrides +import { styles as bootstrapLight } from './light/tooltip.bootstrap.css.js'; +import { styles as fluentLight } from './light/tooltip.fluent.css.js'; +import { styles as indigoLight } from './light/tooltip.indigo.css.js'; +import { styles as materialLight } from './light/tooltip.material.css.js'; +import { styles as shared } from './light/tooltip.shared.css.js'; + +const light = { + shared: css` + ${shared} + `, + bootstrap: css` + ${bootstrapLight} + `, + material: css` + ${materialLight} + `, + fluent: css` + ${fluentLight} + `, + indigo: css` + ${indigo} ${indigoLight} + `, +}; + +const dark = { + shared: css` + ${shared} + `, + bootstrap: css` + ${bootstrapDark} + `, + material: css` + ${materialDark} + `, + fluent: css` + ${fluentDark} + `, + indigo: css` + ${indigo} ${indigoDark} + `, +}; + +export const all: Themes = { light, dark }; diff --git a/src/components/tooltip/themes/tooltip.base.scss b/src/components/tooltip/themes/tooltip.base.scss new file mode 100644 index 000000000..05edfb62d --- /dev/null +++ b/src/components/tooltip/themes/tooltip.base.scss @@ -0,0 +1,40 @@ +@use 'styles/common/component'; +@use 'styles/utilities' as *; + +:host { + position: fixed; + display: flex; + align-items: center; + text-align: center; +} + +[part="base"] { + @include type-style('body-2'); + + padding: rem(4px) rem(8px); + font-size: rem(10px); + font-weight: 600; + line-height: rem(16px); + text-align: start; + max-width: 200px; + display: flex; + align-items: flex-start; + gap: rem(8px); + position: relative; +} + +#arrow { + position: absolute; + width: 0; + height: 0; +} + +igc-popover::part(container) { + background-color: transparent; +} + +slot[name='close-button'] { + igc-icon { + --component-size: 1; + } +} diff --git a/src/components/tooltip/tooltip.spec.ts b/src/components/tooltip/tooltip.spec.ts new file mode 100644 index 000000000..f6fdfd2a5 --- /dev/null +++ b/src/components/tooltip/tooltip.spec.ts @@ -0,0 +1,893 @@ +import { + elementUpdated, + expect, + fixture, + html, + nextFrame, +} from '@open-wc/testing'; +import { type SinonFakeTimers, spy, useFakeTimers } from 'sinon'; +import { defineComponents } from '../common/definitions/defineComponents.js'; +import { + finishAnimationsFor, + simulateBlur, + simulateClick, + simulateFocus, + simulatePointerEnter, + simulatePointerLeave, +} from '../common/utils.spec.js'; +import IgcTooltipComponent from './tooltip.js'; + +describe('Tooltip', () => { + let anchor: HTMLButtonElement; + let tooltip: IgcTooltipComponent; + let clock: SinonFakeTimers; + + const DIFF_OPTIONS = { + ignoreAttributes: ['anchor'], + }; + + const endTick = (tick: number) => tick + 180; + + const DEFAULT_SHOW_DELAY = 200; + const DEFAULT_HIDE_DELAY = 300; + + before(() => { + defineComponents(IgcTooltipComponent); + }); + + async function showComplete(instance: IgcTooltipComponent = tooltip) { + finishAnimationsFor(instance.shadowRoot!); + await elementUpdated(instance); + await nextFrame(); + } + + async function hideComplete(instance: IgcTooltipComponent = tooltip) { + await elementUpdated(instance); + finishAnimationsFor(instance.shadowRoot!); + await nextFrame(); + await nextFrame(); + } + + describe('Initialization Tests', () => { + beforeEach(async () => { + const container = await fixture(createDefaultTooltip()); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + }); + + it('is defined', () => { + expect(tooltip).to.exist; + }); + + it('is accessible', async () => { + await expect(tooltip).to.be.accessible(); + await expect(tooltip).shadowDom.to.be.accessible(); + }); + + it('is accessible in sticky mode', async () => { + tooltip.sticky = true; + tooltip.open = true; + await elementUpdated(tooltip); + + await expect(tooltip).to.be.accessible(); + await expect(tooltip).shadowDom.to.be.accessible(); + }); + + it('is correctly initialized with its default component state', () => { + expect(tooltip.dir).to.be.empty; + expect(tooltip.open).to.be.false; + expect(tooltip.disableArrow).to.be.false; + expect(tooltip.offset).to.equal(6); + expect(tooltip.placement).to.equal('top'); + expect(tooltip.anchor).to.be.undefined; + expect(tooltip.showTriggers).to.equal('pointerenter'); + expect(tooltip.hideTriggers).to.equal('pointerleave,click'); + expect(tooltip.showDelay).to.equal(200); + expect(tooltip.hideDelay).to.equal(300); + expect(tooltip.message).to.equal(''); + }); + + it('should render a default arrow', () => { + const arrow = tooltip.shadowRoot!.querySelector('#arrow'); + expect(arrow).not.to.be.null; + }); + + it('is correctly rendered both in shown/hidden states', async () => { + expect(tooltip.open).to.be.false; + + expect(tooltip).dom.to.equal('It works!'); + expect(tooltip).shadowDom.to.equal( + ` +
+ +
+
+
` + ); + + tooltip.open = true; + await elementUpdated(tooltip); + + expect(tooltip).dom.to.equal('It works!'); + expect(tooltip).shadowDom.to.equal( + ` +
+ +
+
+
` + ); + }); + + it('is initially open on first render', async () => { + const container = await fixture(createDefaultTooltip(true)); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + + expect(tooltip).to.exist; + expect(tooltip.open).to.be.true; + }); + + it('is initially open on first render with target', async () => { + const container = await fixture(createTooltipWithTarget(true)); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + + expect(tooltip).to.exist; + expect(tooltip.open).to.be.true; + }); + }); + + describe('Properties Tests', () => { + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); + const container = await fixture(createTooltipWithTarget()); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + anchor = container.querySelector('button')!; + }); + + afterEach(() => { + clock.restore(); + }); + + it('should set target via `anchor` property', async () => { + const template = html` +
+ + + + I am a tooltip +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + + const first = document.querySelector('#first') as HTMLButtonElement; + const second = document.querySelector('#second') as HTMLButtonElement; + const third = document.querySelector('#third') as HTMLButtonElement; + + // If no anchor is provided. + // Considers the first preceding sibling that is an element as the target. + simulatePointerEnter(third); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.false; + + simulatePointerLeave(third); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + + // By providing an IDREF + tooltip.anchor = first.id; + await elementUpdated(tooltip); + + simulatePointerEnter(first); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulatePointerLeave(first); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + + // By providing an Element + simulatePointerEnter(second); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.false; + + tooltip.anchor = second; + await elementUpdated(tooltip); + + simulatePointerEnter(second); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + }); + + it('should show/hide the arrow via the `disableArrow` property', async () => { + expect(tooltip.disableArrow).to.be.false; + expect(tooltip.shadowRoot!.querySelector('#arrow')).to.exist; + + tooltip.disableArrow = true; + await elementUpdated(tooltip); + + expect(tooltip.disableArrow).to.be.true; + expect(tooltip.shadowRoot!.querySelector('#arrow')).to.be.null; + }); + + it('should show/hide the arrow via the `disable-arrow` attribute', async () => { + const template = html` +
+ + I am a tooltip +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + + expect(tooltip.disableArrow).to.be.true; + expect(tooltip.shadowRoot!.querySelector('#arrow')).to.be.null; + }); + + it('should provide content via the `message` property', async () => { + const template = html` +
+ + +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + const defaultSlot = + tooltip.renderRoot.querySelector('slot:not([name])'); + + const content1 = defaultSlot + ?.assignedNodes({ flatten: true }) + .map((x) => x.textContent); + expect(content1).to.include('Hello'); + + const message = 'New Message!'; + tooltip.message = message; + await elementUpdated(tooltip); + + const content2 = defaultSlot + ?.assignedNodes({ flatten: true }) + .map((x) => x.textContent); + expect(content2).to.include(message); + }); + + it('slotted content takes priority over the `message` property', async () => { + const template = html` +
+ + + + +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + const defaultSlot = + tooltip.renderRoot.querySelector('slot:not([name])'); + + expect(defaultSlot).not.to.be.null; + expect(defaultSlot?.assignedElements()[0].matches('button')).to.be.true; + }); + + it('should render a default close button when in `sticky` mode', async () => { + tooltip.sticky = true; + await elementUpdated(tooltip); + + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + expect(tooltip).shadowDom.to.equal( + ` +
+ + + + +
+
+
` + ); + }); + + it('should render custom content for close button when in `sticky` mode', async () => { + const template = html` +
+ + +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + + tooltip.sticky = true; + tooltip.open = true; + await elementUpdated(tooltip); + + const defaultSlot = + tooltip.renderRoot.querySelector('slot:not([name])'); + const closeButtonSlot = tooltip.renderRoot.querySelector( + 'slot[name="close-button"]' + ); + + const defaultSlotContent = defaultSlot + ?.assignedNodes({ flatten: true }) + .map((x) => x.textContent); + const closeButtonContent = closeButtonSlot + ?.assignedNodes({ flatten: true }) + .map((x) => x.textContent); + + expect(defaultSlotContent).to.include('Hello'); + expect(closeButtonContent).to.include('Close'); + expect(closeButtonSlot?.assignedElements()[0].matches('button')).to.be + .true; + }); + + it('hide triggers should not close the tooltip when in `sticky` mode', async () => { + tooltip.sticky = true; + await elementUpdated(tooltip); + + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulatePointerLeave(anchor); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.true; + }); + + it('should close the tooltip when in `sticky` mode by clicking the default close button', async () => { + tooltip.sticky = true; + tooltip.open = true; + await elementUpdated(tooltip); + + expect(tooltip.open).to.be.true; + + const closeIcon = tooltip.shadowRoot!.querySelector('igc-icon')!; + + simulateClick(closeIcon); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('should close the tooltip when in `sticky` mode by pressing the `Esc` key', async () => { + tooltip.sticky = true; + tooltip.open = true; + await elementUpdated(tooltip); + + expect(tooltip.open).to.be.true; + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + }); + + describe('Methods` Tests', () => { + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); + const container = await fixture(createTooltipWithTarget()); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + anchor = container.querySelector('button')!; + }); + + afterEach(() => { + clock.restore(); + }); + + it('calls `show` and `hide` methods successfully and returns proper values', async () => { + expect(tooltip.open).to.be.false; + + // hide tooltip when already hidden + let animation = await tooltip.hide(); + expect(animation).to.be.false; + expect(tooltip.open).to.be.false; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + + // show tooltip when hidden + animation = await tooltip.show(); + expect(animation).to.be.true; + expect(tooltip.open).to.be.true; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + + // show tooltip when already shown + animation = await tooltip.show(); + expect(animation).to.be.false; + expect(tooltip.open).to.be.true; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + + // hide tooltip when shown + animation = await tooltip.hide(); + expect(animation).to.be.true; + expect(tooltip.open).to.be.false; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + }); + + it('calls `toggle` method successfully and returns proper values', async () => { + expect(tooltip.open).to.be.false; + + let animation = await tooltip.toggle(); + expect(animation).to.be.true; + expect(tooltip.open).to.be.true; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + + animation = await tooltip.toggle(); + expect(animation).to.be.true; + expect(tooltip.open).to.be.false; + expect(tooltip).dom.to.equal( + 'It works!', + DIFF_OPTIONS + ); + }); + + it('calls `show` with a new target, switches anchor, and resets anchor on hide', async () => { + const buttons = Array.from( + tooltip.parentElement!.querySelectorAll('button') + ); + + const [defaultAnchor, transientAnchor] = buttons; + + let result = await tooltip.show(defaultAnchor); + expect(result).to.be.true; + expect(tooltip.open).to.be.true; + + result = await tooltip.show(transientAnchor); + expect(result).to.be.true; + expect(tooltip.open).to.be.true; + + result = await tooltip.hide(); + expect(result).to.be.true; + expect(tooltip.open).to.be.false; + + // the transient anchor should not reopen the tooltip once its hidden + simulatePointerEnter(transientAnchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.false; + + simulatePointerEnter(defaultAnchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + }); + + it('should be able to pass and IDREF to `show` method', async () => { + const eventSpy = spy(tooltip, 'emitEvent'); + + const [_, transientAnchor] = Array.from( + tooltip.parentElement!.querySelectorAll('button') + ); + + transientAnchor.id = 'custom-target'; + + const result = await tooltip.show('custom-target'); + expect(result).to.be.true; + expect(tooltip.open).to.be.true; + expect(eventSpy.callCount).to.equal(0); + }); + + it('should correctly handle open state and events between default and transient anchors', async () => { + const eventSpy = spy(tooltip, 'emitEvent'); + + const [defaultAnchor, transientAnchor] = Array.from( + tooltip.parentElement!.querySelectorAll('button') + ); + + const result = await tooltip.show(transientAnchor); + expect(result).to.be.true; + expect(tooltip.open).to.be.true; + expect(eventSpy.callCount).to.equal(0); + + simulatePointerEnter(defaultAnchor); + // Trigger on the initial default anchor. Tooltip must be hidden. + expect(tooltip.open).to.be.false; + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + expect(eventSpy).calledWith('igcOpening', { + cancelable: true, + detail: defaultAnchor, + }); + + expect(eventSpy).calledWith('igcOpened', { + cancelable: false, + detail: defaultAnchor, + }); + }); + }); + + describe('Behaviors', () => { + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); + const container = await fixture(createTooltipWithTarget()); + anchor = container.querySelector('button')!; + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + }); + + afterEach(() => { + clock.restore(); + }); + + it('default triggers', async () => { + expect(tooltip.showTriggers).to.equal('pointerenter'); + expect(tooltip.hideTriggers).to.equal('pointerleave,click'); + + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulatePointerLeave(anchor); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('custom triggers via property', async () => { + tooltip.showTriggers = 'focus, pointerenter'; + tooltip.hideTriggers = 'blur, click'; + + simulateFocus(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulateBlur(anchor); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulateClick(anchor); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('custom triggers via attribute', async () => { + const template = html` +
+ + I am a tooltip +
+ `; + const container = await fixture(template); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + anchor = container.querySelector('button')!; + + simulateFocus(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulateBlur(anchor); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + + simulateClick(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + simulateBlur(anchor); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('pointerenter over tooltip prevents hiding and pointerleave triggers hiding', async () => { + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(); + expect(tooltip.open).to.be.true; + + // Move cursor from anchor to tooltip + simulatePointerLeave(anchor); + await nextFrame(); + simulatePointerEnter(tooltip); + await nextFrame(); + + expect(tooltip.open).to.be.true; + + // Move cursor outside the tooltip + simulatePointerLeave(tooltip); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(); + expect(tooltip.open).to.be.false; + }); + + it('should show/hide the tooltip based on `showDelay` and `hideDelay`', async () => { + tooltip.showDelay = tooltip.hideDelay = 400; + simulatePointerEnter(anchor); + await clock.tickAsync(399); + expect(tooltip.open).to.be.false; + + await clock.tickAsync(1); + await showComplete(tooltip); + expect(tooltip.open).to.be.true; + + simulatePointerLeave(anchor); + await clock.tickAsync(endTick(399)); + expect(tooltip.open).to.be.true; + + await clock.tickAsync(1); + await hideComplete(tooltip); + expect(tooltip.open).to.be.false; + }); + }); + + describe('Events', () => { + let eventSpy: ReturnType; + + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); + const container = await fixture(createTooltipWithTarget()); + tooltip = container.querySelector(IgcTooltipComponent.tagName)!; + anchor = container.querySelector('button')!; + eventSpy = spy(tooltip, 'emitEvent'); + }); + + afterEach(() => { + clock.restore(); + }); + + const verifyStateAndEventSequence = ( + state: { open: boolean } = { open: false } + ) => { + expect(tooltip.open).to.equal(state.open); + expect(eventSpy.callCount).to.equal(2); + expect(eventSpy.firstCall).calledWith( + state.open ? 'igcOpening' : 'igcClosing', + { cancelable: true, detail: anchor } + ); + expect(eventSpy.secondCall).calledWith( + state.open ? 'igcOpened' : 'igcClosed', + { cancelable: false, detail: anchor } + ); + }; + + it('events are correctly emitted on user interaction', async () => { + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(tooltip); + verifyStateAndEventSequence({ open: true }); + + eventSpy.resetHistory(); + + simulatePointerLeave(anchor); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(tooltip); + verifyStateAndEventSequence({ open: false }); + }); + + it('can cancel -ing events', async () => { + tooltip.addEventListener('igcOpening', (e) => e.preventDefault(), { + once: true, + }); + + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(tooltip); + + expect(tooltip.open).to.be.false; + expect(eventSpy).calledOnceWith('igcOpening', { + cancelable: true, + detail: anchor, + }); + + eventSpy.resetHistory(); + + tooltip.open = true; + await elementUpdated(tooltip); + + tooltip.addEventListener('igcClosing', (e) => e.preventDefault(), { + once: true, + }); + + simulatePointerLeave(anchor); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(tooltip); + + expect(tooltip.open).to.be.true; + expect(eventSpy).calledOnceWith('igcClosing', { + cancelable: true, + detail: anchor, + }); + }); + + it('fires `igcClosed` when tooltip is hidden via Escape key', async () => { + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(tooltip); + + eventSpy.resetHistory(); + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(tooltip); + + expect(tooltip.open).to.be.false; + expect(eventSpy.callCount).to.equal(1); + expect(eventSpy.firstCall).calledWith('igcClosed', { + cancelable: false, + detail: anchor, + }); + }); + }); + + describe('Keyboard interactions', () => { + beforeEach(async () => { + clock = useFakeTimers({ toFake: ['setTimeout'] }); + }); + + afterEach(() => { + clock.restore(); + }); + + it('pressing Escape in an active page hides the tooltip', async () => { + const container = await fixture(createTooltips()); + const [anchor, _] = container.querySelectorAll('button'); + const [tooltip, __] = container.querySelectorAll( + IgcTooltipComponent.tagName + ); + + simulatePointerEnter(anchor); + await clock.tickAsync(DEFAULT_SHOW_DELAY); + await showComplete(tooltip); + + expect(tooltip.open).to.be.true; + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(tooltip); + + expect(tooltip.open).to.be.false; + }); + + it('pressing Escape in an active page with multiple opened tooltips hides the last shown', async () => { + const container = await fixture(createTooltips()); + const [first, last] = container.querySelectorAll( + IgcTooltipComponent.tagName + ); + + first.show(); + last.show(); + + expect(first.open).to.be.true; + expect(last.open).to.be.true; + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(first); + await hideComplete(last); + + expect(last.open).to.be.false; + expect(first.open).to.be.true; + + document.documentElement.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + bubbles: true, + composed: true, + }) + ); + + await clock.tickAsync(endTick(DEFAULT_HIDE_DELAY)); + await hideComplete(first); + await hideComplete(last); + + expect(last.open).to.be.false; + expect(first.open).to.be.false; + }); + }); +}); + +function createDefaultTooltip(isOpen = false) { + return html` +
+ + It works! +
+ `; +} + +function createTooltipWithTarget(isOpen = false) { + return html` +
+ + + It works! +
+ `; +} + +function createTooltips() { + return html` +
+ + First + + Second +
+ `; +} diff --git a/src/components/tooltip/tooltip.ts b/src/components/tooltip/tooltip.ts new file mode 100644 index 000000000..efa01527f --- /dev/null +++ b/src/components/tooltip/tooltip.ts @@ -0,0 +1,446 @@ +import { LitElement, html, nothing } from 'lit'; +import { property, query } from 'lit/decorators.js'; +import { createRef, ref } from 'lit/directives/ref.js'; +import { EaseOut } from '../../animations/easings.js'; +import { addAnimationController } from '../../animations/player.js'; +import { fadeOut } from '../../animations/presets/fade/index.js'; +import { scaleInCenter } from '../../animations/presets/scale/index.js'; +import { themes } from '../../theming/theming-decorator.js'; +import { watch } from '../common/decorators/watch.js'; +import { registerComponent } from '../common/definitions/register.js'; +import type { Constructor } from '../common/mixins/constructor.js'; +import { EventEmitterMixin } from '../common/mixins/event-emitter.js'; +import { asNumber, isLTR } from '../common/util.js'; +import IgcIconComponent from '../icon/icon.js'; +import IgcPopoverComponent, { + type PopoverPlacement, +} from '../popover/popover.js'; +import { TooltipRegexes, addTooltipController } from './controller.js'; +import { styles as shared } from './themes/shared/tooltip.common.css'; +import { all } from './themes/themes.js'; +import { styles } from './themes/tooltip.base.css.js'; + +export interface IgcTooltipComponentEventMap { + igcOpening: CustomEvent; + igcOpened: CustomEvent; + igcClosing: CustomEvent; + igcClosed: CustomEvent; +} + +type TooltipStateOptions = { + show: boolean; + withDelay?: boolean; + withEvents?: boolean; +}; + +/** + * Provides a way to display supplementary information related to an element when a user interacts with it (e.g., hover, focus). + * It offers features such as placement customization, delays, sticky mode, and animations. + * + * @element igc-tooltip + * + * @slot - Default slot of the tooltip component. + * @slot close-button - Slot for custom sticky-mode close action (e.g., an icon/button). + * + * @csspart base - The wrapping container of the tooltip content. + * + * @fires igcOpening - Emitted before the tooltip begins to open. Can be canceled to prevent opening. + * @fires igcOpened - Emitted after the tooltip has successfully opened and is visible. + * @fires igcClosing - Emitted before the tooltip begins to close. Can be canceled to prevent closing. + * @fires igcClosed - Emitted after the tooltip has been fully removed from view. + */ +@themes(all) +export default class IgcTooltipComponent extends EventEmitterMixin< + IgcTooltipComponentEventMap, + Constructor +>(LitElement) { + public static readonly tagName = 'igc-tooltip'; + public static styles = [styles, shared]; + + /* blazorSuppress */ + public static register(): void { + registerComponent( + IgcTooltipComponent, + IgcPopoverComponent, + IgcIconComponent + ); + } + + private readonly _internals: ElementInternals; + + private readonly _controller = addTooltipController(this, { + onShow: this._showOnInteraction, + onHide: this._hideOnInteraction, + onEscape: this._hideOnEscape, + }); + + private readonly _containerRef = createRef(); + private readonly _player = addAnimationController(this, this._containerRef); + + private readonly _showAnimation = scaleInCenter({ + duration: 150, + easing: EaseOut.Quad, + }); + + private readonly _hideAnimation = fadeOut({ + duration: 75, + easing: EaseOut.Sine, + }); + + private _timeoutId?: number; + private _autoHideDelay = 180; + private _showDelay = 200; + private _hideDelay = 300; + + @query('#arrow') + private _arrowElement!: HTMLElement; + + private get _arrowOffset() { + if (this.placement.includes('-')) { + // Horizontal start | end placement + + if (TooltipRegexes.horizontalStart.test(this.placement)) { + return -8; + } + + if (TooltipRegexes.horizontalEnd.test(this.placement)) { + return 8; + } + + // Vertical start | end placement + + if (TooltipRegexes.start.test(this.placement)) { + return isLTR(this) ? -8 : 8; + } + + if (TooltipRegexes.end.test(this.placement)) { + return isLTR(this) ? 8 : -8; + } + } + + return 0; + } + + /** + * Whether the tooltip is showing. + * + * @attr open + * @default false + */ + @property({ type: Boolean, reflect: true }) + public set open(value: boolean) { + this._controller.open = value; + } + + public get open(): boolean { + return this._controller.open; + } + + /** + * Whether to disable the rendering of the arrow indicator for the tooltip. + * + * @attr disable-arrow + * @default false + */ + @property({ attribute: 'disable-arrow', type: Boolean, reflect: true }) + public disableArrow = false; + + /** + * The offset of the tooltip from the anchor in pixels. + * + * @attr offset + * @default 6 + */ + @property({ type: Number }) + public offset = 6; + + /** + * Where to place the floating element relative to the parent anchor element. + * + * @attr placement + * @default top + */ + @property() + public placement: PopoverPlacement = 'top'; + + /** + * An element instance or an IDREF to use as the anchor for the tooltip. + * + * @attr anchor + */ + @property() + public anchor?: Element | string; + + /** + * Which event triggers will show the tooltip. + * Expects a comma separate string of different event triggers. + * + * @attr show-triggers + * @default pointerenter + */ + @property({ attribute: 'show-triggers' }) + public set showTriggers(value: string) { + this._controller.showTriggers = value; + } + + public get showTriggers(): string { + return this._controller.showTriggers; + } + + /** + * Which event triggers will hide the tooltip. + * Expects a comma separate string of different event triggers. + * + * @attr hide-triggers + * @default pointerleave, click + */ + @property({ attribute: 'hide-triggers' }) + public set hideTriggers(value: string) { + this._controller.hideTriggers = value; + } + + public get hideTriggers(): string { + return this._controller.hideTriggers; + } + + /** + * Specifies the number of milliseconds that should pass before showing the tooltip. + * + * @attr show-delay + * @default 200 + */ + @property({ attribute: 'show-delay', type: Number }) + public set showDelay(value: number) { + this._showDelay = Math.max(0, asNumber(value)); + } + + public get showDelay(): number { + return this._showDelay; + } + + /** + * Specifies the number of milliseconds that should pass before hiding the tooltip. + * + * @attr hide-delay + * @default 300 + */ + @property({ attribute: 'hide-delay', type: Number }) + public set hideDelay(value: number) { + this._hideDelay = Math.max(0, asNumber(value)); + } + + public get hideDelay(): number { + return this._hideDelay; + } + + /** + * Specifies a plain text as tooltip content. + * + * @attr message + */ + @property() + public message = ''; + + /** + * Specifies if the tooltip remains visible until the user closes it via the close button or Esc key. + * + * @attr sticky + * @default false + */ + @property({ type: Boolean, reflect: true }) + public sticky = false; + + constructor() { + super(); + + this._internals = this.attachInternals(); + this._internals.role = 'tooltip'; + this._internals.ariaAtomic = 'true'; + this._internals.ariaLive = 'polite'; + } + + protected override firstUpdated(): void { + if (this.open) { + this.updateComplete.then(() => { + this._player.playExclusive(this._showAnimation); + this.requestUpdate(); + }); + } + } + + @watch('anchor') + protected _onAnchorChange(): void { + this._controller.resolveAnchor(this.anchor); + } + + @watch('sticky') + protected _onStickyChange(): void { + this._internals.role = this.sticky ? 'status' : 'tooltip'; + } + + private _emitEvent(name: keyof IgcTooltipComponentEventMap): boolean { + return this.emitEvent(name, { + cancelable: name === 'igcOpening' || name === 'igcClosing', + detail: this._controller.anchor, + }); + } + + private async _applyTooltipState({ + show, + withDelay = false, + withEvents = false, + }: TooltipStateOptions): Promise { + if (show === this.open) { + return false; + } + + if (withEvents && !this._emitEvent(show ? 'igcOpening' : 'igcClosing')) { + return false; + } + + const commitStateChange = async () => { + if (show) { + this.open = true; + } + + // Make the tooltip ignore most interactions while the animation + // is running. In the rare case when the popover overlaps its anchor + // this will prevent looping between the anchor and tooltip handlers. + this.inert = true; + + const animationComplete = await this._player.playExclusive( + show ? this._showAnimation : this._hideAnimation + ); + + this.inert = false; + this.open = show; + + if (animationComplete && withEvents) { + this._emitEvent(show ? 'igcOpened' : 'igcClosed'); + } + + return animationComplete; + }; + + if (withDelay) { + clearTimeout(this._timeoutId); + + return new Promise(() => { + this._timeoutId = setTimeout( + async () => await commitStateChange(), + show ? this.showDelay : this.hideDelay + ); + }); + } + + return commitStateChange(); + } + + /** + * Shows the tooltip if not already showing. + * If a target is provided, sets it as a transient anchor. + */ + public async show(target?: Element | string): Promise { + if (target) { + this._stopTimeoutAndAnimation(); + this._controller.setAnchor(target, true); + } + + return await this._applyTooltipState({ show: true }); + } + + /** Hides the tooltip if not already hidden. */ + public async hide(): Promise { + return await this._applyTooltipState({ show: false }); + } + + /** Toggles the tooltip between shown/hidden state */ + public async toggle(): Promise { + return await (this.open ? this.hide() : this.show()); + } + + protected _showWithEvent(): Promise { + return this._applyTooltipState({ + show: true, + withDelay: true, + withEvents: true, + }); + } + + protected _hideWithEvent(): Promise { + return this._applyTooltipState({ + show: false, + withDelay: true, + withEvents: true, + }); + } + + private _showOnInteraction(): void { + this._stopTimeoutAndAnimation(); + this._showWithEvent(); + } + + private _stopTimeoutAndAnimation(): void { + clearTimeout(this._timeoutId); + this._player.stopAll(); + } + + private _setAutoHide(): void { + this._stopTimeoutAndAnimation(); + + this._timeoutId = setTimeout( + this._hideWithEvent.bind(this), + this._autoHideDelay + ); + } + + private _hideOnInteraction(): void { + if (!this.sticky) { + this._setAutoHide(); + } + } + + private async _hideOnEscape(): Promise { + await this.hide(); + this._emitEvent('igcClosed'); + } + + protected override render() { + return html` + +
+ ${this.message} + ${this.sticky + ? html` + + + + ` + : nothing} + ${this.disableArrow ? nothing : html`
`} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'igc-tooltip': IgcTooltipComponent; + } +} diff --git a/src/index.ts b/src/index.ts index a9739192f..8207887ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,7 @@ export { default as IgcTreeComponent } from './components/tree/tree.js'; export { default as IgcTreeItemComponent } from './components/tree/tree-item.js'; export { default as IgcStepperComponent } from './components/stepper/stepper.js'; export { default as IgcStepComponent } from './components/stepper/step.js'; +export { default as IgcTooltipComponent } from './components/tooltip/tooltip.js'; // definitions export { defineComponents } from './components/common/definitions/defineComponents.js'; @@ -116,6 +117,7 @@ export type { IgcTabsComponentEventMap } from './components/tabs/tabs.js'; export type { IgcTextareaComponentEventMap } from './components/textarea/textarea.js'; export type { IgcTileComponentEventMap } from './components/tile-manager/tile.js'; export type { IgcTreeComponentEventMap } from './components/tree/tree.common.js'; +export type { IgcTooltipComponentEventMap } from './components/tooltip/tooltip.js'; // Public types export type * from './components/types.js'; diff --git a/stories/tooltip.stories.ts b/stories/tooltip.stories.ts new file mode 100644 index 000000000..757bcb931 --- /dev/null +++ b/stories/tooltip.stories.ts @@ -0,0 +1,560 @@ +import type { Meta, StoryObj } from '@storybook/web-components'; + +import { + IgcAvatarComponent, + IgcButtonComponent, + IgcCardComponent, + IgcIconComponent, + IgcInputComponent, + IgcTooltipComponent, + type PopoverPlacement, + defineComponents, + registerIcon, +} from 'igniteui-webcomponents'; +import { html } from 'lit'; +import { disableStoryControls } from './story.js'; + +defineComponents( + IgcButtonComponent, + IgcInputComponent, + IgcTooltipComponent, + IgcCardComponent, + IgcAvatarComponent, + IgcIconComponent +); + +// region default +const metadata: Meta = { + title: 'Tooltip', + component: 'igc-tooltip', + parameters: { + docs: { + description: { + component: + 'Provides a way to display supplementary information related to an element when a user interacts with it (e.g., hover, focus).\nIt offers features such as placement customization, delays, sticky mode, and animations.', + }, + }, + actions: { + handles: ['igcOpening', 'igcOpened', 'igcClosing', 'igcClosed'], + }, + }, + argTypes: { + open: { + type: 'boolean', + description: 'Whether the tooltip is showing.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + disableArrow: { + type: 'boolean', + description: + 'Whether to disable the rendering of the arrow indicator for the tooltip.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + offset: { + type: 'number', + description: 'The offset of the tooltip from the anchor in pixels.', + control: 'number', + table: { defaultValue: { summary: '6' } }, + }, + placement: { + type: '"top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "right" | "right-start" | "right-end" | "left" | "left-start" | "left-end"', + description: + 'Where to place the floating element relative to the parent anchor element.', + options: [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'right', + 'right-start', + 'right-end', + 'left', + 'left-start', + 'left-end', + ], + control: { type: 'select' }, + table: { defaultValue: { summary: 'top' } }, + }, + anchor: { + type: 'Element | string', + description: + 'An element instance or an IDREF to use as the anchor for the tooltip.', + options: ['Element', 'string'], + control: { type: 'inline-radio' }, + }, + showTriggers: { + type: 'string', + description: + 'Which event triggers will show the tooltip.\nExpects a comma separate string of different event triggers.', + control: 'text', + table: { defaultValue: { summary: 'pointerenter' } }, + }, + hideTriggers: { + type: 'string', + description: + 'Which event triggers will hide the tooltip.\nExpects a comma separate string of different event triggers.', + control: 'text', + table: { defaultValue: { summary: 'pointerleave, click' } }, + }, + showDelay: { + type: 'number', + description: + 'Specifies the number of milliseconds that should pass before showing the tooltip.', + control: 'number', + table: { defaultValue: { summary: '200' } }, + }, + hideDelay: { + type: 'number', + description: + 'Specifies the number of milliseconds that should pass before hiding the tooltip.', + control: 'number', + table: { defaultValue: { summary: '300' } }, + }, + message: { + type: 'string', + description: 'Specifies a plain text as tooltip content.', + control: 'text', + table: { defaultValue: { summary: '' } }, + }, + sticky: { + type: 'boolean', + description: + 'Specifies if the tooltip remains visible until the user closes it via the close button or Esc key.', + control: 'boolean', + table: { defaultValue: { summary: 'false' } }, + }, + }, + args: { + open: false, + disableArrow: false, + offset: 6, + placement: 'top', + showTriggers: 'pointerenter', + hideTriggers: 'pointerleave, click', + showDelay: 200, + hideDelay: 300, + message: '', + sticky: false, + }, +}; + +export default metadata; + +interface IgcTooltipArgs { + /** Whether the tooltip is showing. */ + open: boolean; + /** Whether to disable the rendering of the arrow indicator for the tooltip. */ + disableArrow: boolean; + /** The offset of the tooltip from the anchor in pixels. */ + offset: number; + /** Where to place the floating element relative to the parent anchor element. */ + placement: + | 'top' + | 'top-start' + | 'top-end' + | 'bottom' + | 'bottom-start' + | 'bottom-end' + | 'right' + | 'right-start' + | 'right-end' + | 'left' + | 'left-start' + | 'left-end'; + /** An element instance or an IDREF to use as the anchor for the tooltip. */ + anchor: Element | string; + /** + * Which event triggers will show the tooltip. + * Expects a comma separate string of different event triggers. + */ + showTriggers: string; + /** + * Which event triggers will hide the tooltip. + * Expects a comma separate string of different event triggers. + */ + hideTriggers: string; + /** Specifies the number of milliseconds that should pass before showing the tooltip. */ + showDelay: number; + /** Specifies the number of milliseconds that should pass before hiding the tooltip. */ + hideDelay: number; + /** Specifies a plain text as tooltip content. */ + message: string; + /** Specifies if the tooltip remains visible until the user closes it via the close button or Esc key. */ + sticky: boolean; +} +type Story = StoryObj; + +// endregion + +registerIcon( + 'home', + 'https://unpkg.com/material-design-icons@3.0.1/action/svg/production/ic_home_24px.svg' +); + +export const Basic: Story = { + render: (args) => html` +
+ Hover over me +
+ + + Hello from the tooltip! + + `, +}; + +const Positions = ['top', 'bottom', 'left', 'right'].flatMap((each) => [ + each, + `${each}-start`, + `${each}-end`, +]) as Array; + +export const Positioning: Story = { + argTypes: disableStoryControls(metadata), + render: () => html` + + +
+

Supported placements

+
+ ${Positions.map( + (pos) => html` + +
+ ${pos} +
+
+ ` + )} + `, +}; + +export const Inline: Story = { + render: () => html` + + +
+ CSS Logo +

+ Cascading Style Sheets (CSS) is a + style sheet language + used for specifying the presentation and styling of a document written + in a markup language such as + HTML or + XML + (including XML dialects such as SVG, MathML or XHTML). CSS is a + cornerstone technology of the World Wide Web, alongside HTML and + JavaScript. +

+
+ + +
+

+ Extensible Markup Language (XML) is a markup language + and file format for storing, transmitting, and reconstructing data. It + defines a set of rules for encoding documents in a format that is both + human-readable and machine-readable. The World Wide Web Consortium's + XML 1.0 Specification of 1998 and several other related + specifications—all of them free open standards—define XML. +

+ XML Logo +
+
+ + +

+ A style sheet language, or + style language, is a computer language that expresses + the presentation of structured documents. One attractive feature of + structured documents is that the content can be reused in many contexts + and presented in various ways. Different style sheets can be attached to + the logical structure to produce different presentations. +

+
+ + +
+

+ Hypertext Markup Language (HTML) is the standard markup language for + documents designed to be displayed in a web browser. It defines the + content and structure of web content. It is often assisted by + technologies such as Cascading Style Sheets (CSS) and scripting + languages such as JavaScript, a programming language. +

+ HTML5 Logo +
+
+ `, +}; + +export const Triggers: Story = { + render: () => html` + +
+ + +

Default triggers

+
+ +

+ Hovering over the button bellow will show the default configuration + of a tooltip component which is pointer enter for + showing the tooltip and pointer leave or + click for hiding once shown. +

+ + Hover over me + + + I am show on pointer enter and hidden on pointer leave and/or click. + +
+
+ + + +

Focus based

+
+ +

+ In this instance, the tooltip is bound to show on its anchor + focus and will hide when its anchor is + blurred. +

+

Try to navigate with a Tab key to the anchor to see the effect.

+ + Focus me + + + I am shown on focus and hidden on blur. + +
+
+ + + +

Same trigger(s) for showing and hiding

+
+ +

+ The same trigger can be bound to both show and hide the tooltip. The + button below has its tooltip bound to show/hide on + click. +

+ + Click + + + I am show on click and will hide on anchor click. + +
+
+ + + +

Keyboard interactions

+
+ +

+ Keyboard interactions are also supported. The button below has its + tooltip bound to show on a keypress and hide on a + keypress or blur. +

+ +

Try it out by focusing the button and pressing a key.

+ + Press a key + + + I am shown on a keypress and will hide on a keypress or blur. + +
+
+ + + +

Custom events

+
+ +

+ The tooltip supports any DOM event including custom ones. Try typing + a value in the input below and then "commit" it by blurring the + input. The tooltip will be shown when the + igcChange event is fired from the input. +

+ + + + + Value changed! + +
+
+
+ `, +}; + +export const Default: Story = { + render: () => html` + Hover over me + +

Showing a tooltip!

+
+ `, +}; + +function createDynamicTooltip() { + const tooltip = document.createElement('igc-tooltip'); + tooltip.message = `Created on demand at ${new Date().toLocaleTimeString()}`; + tooltip.anchor = 'dynamic-target'; + tooltip.id = 'dynamic'; + + const previousTooltip = document.querySelector('#dynamic'); + const target = document.querySelector('#dynamic-target')!; + + previousTooltip + ? previousTooltip.replaceWith(tooltip) + : target.after(tooltip); + + tooltip.show(); +} + +export const DynamicTooltip: Story = { + render: () => html` + Create tooltip + Target of the dynamic tooltip + `, +}; + +export const SharedTooltipMultipleAnchors: Story = { + render: () => { + return html` +
+ Default Anchor + Transient 1 + Transient 2 + Transient 3 + Switch default anchor to be Transient 1 +
+ + + This is a shared tooltip! + + `; + }, +};