import type { PropertyValues } from 'lit' import { css, html } from 'lit' import { property, query } from 'lit/decorators.js' import { when } from 'lit/directives/when.js' import simplebar_css from 'simplebar/dist/simplebar.css' import SimpleBar from 'simplebar' import type { Options } from 'simplebar' import type { DirectiveClass, DirectiveResult } from 'lit/directive' import { classMap } from 'lit/directives/class-map.js' import type { ComputePositionConfig, ComputePositionReturn, Placement } from '@floating-ui/dom' import { arrow, autoUpdate, computePosition, flip, limitShift, offset, shift } from '@floating-ui/dom' import { UsBaseElement } from '../base/UsBaseElement' import { customElementIfNotExist } from '../../utils/customElementIfNotExist' import { dispatchCustomEvent } from '../../upswot-components' import type { ITooltipProps } from './model/ITooltipProps' import vars_css from './vars.pcss' import UsTooltip_css from './tooltip.pcss' declare global { interface HTMLElementTagNameMap { 'us-tooltip': UsTooltip } } @customElementIfNotExist('us-tooltip') export class UsTooltip extends UsBaseElement implements ITooltipProps { constructor() { super() } static styles = [ UsBaseElement.styles, css([vars_css] as TemplateStringsArray | any), css([UsTooltip_css] as TemplateStringsArray | any), css([simplebar_css] as TemplateStringsArray | any), ] @property({ type: String }) corner: Placement = 'bottom' @property({ type: Boolean }) showArrow = false @property({ type: Boolean }) focusContent = false @property({ type: String }) scrolledMaxHeight = '140px' @property({ type: String }) scrolledWidth = '324px' @property({ type: String }) strategy: 'absolute' | 'fixed' = 'absolute' @property({ type: String }) widgetView = '' @property({ type: Boolean }) scrollable!: boolean @property({ type: Boolean }) parentControl!: boolean @property({ type: Boolean }) show = false @property({ type: Boolean }) isAnchorOverflowAuto = false @property({ type: Array }) offset = [0, 4] @property({ type: Number }) flipPadding = 100 @property({ type: String, attribute: 'data-testid', reflect: true }) dataTestId = 'tooltip' @property({ type: String }) titleText = '' @property({ type: String }) linkName = '' @property({ type: String }) linkHref = '' @query('#anchor') anchor!: HTMLDivElement @query('.tooltip') tooltip!: HTMLDivElement @query('#tooltipContent') tooltipContent!: HTMLDivElement @query('[data-simplebar]') simpleBarEl!: HTMLDivElement @query('.content-wrapper') contentWrapper!: HTMLDivElement @query('.tooltip__arrow') arrowEl!: HTMLDivElement | null cleanup: ReturnType | null = null simpleBar!: SimpleBar timeout!: ReturnType handleBlurBinded = this.handleBlur.bind(this) handleContentFocusoutBinded = this.handleContentFocusout.bind(this) handleActivatorFocusBinded = this.handleActivatorFocus.bind(this) get activator() { const slot = this.shadowRoot?.querySelector('slot[name="activator"]') as HTMLSlotElement return slot?.assignedElements()[0] as HTMLElement } get contentSlot() { const contentSlot = this.shadowRoot?.querySelector('slot[name="content"]') as HTMLSlotElement return contentSlot?.assignedElements()[0] as HTMLElement } firstUpdated() { if (this.scrollable) { this.initSimpleBar() this.initSizes() } } render() { return html`
this.changeShow(true)} @mouseleave=${() => this.changeShow(false)} @keydown=${this.handleKeydown} >
${when(this.show, () => html`
${when(this.titleText, () => html`

${this.titleText}

`)}
${when(this.linkHref && this.linkName, () => html` `)}
${when(this.showArrow, () => html`
`)}
`)}
` } updated(changedProperties: PropertyValues) { if ((changedProperties.has('show') || changedProperties.has('corner') || changedProperties.has('offset')) && this.show) this.showTooltip() if (changedProperties.has('show') && this.show && this.scrollable) { this.reInitSimpleBar() this.initSizes() } if ((changedProperties.has('scrolledWidth') || changedProperties.has('scrolledMaxHeight')) && this.scrollable) this.initSizes() if (changedProperties.has('show') && !this.show) { this.disposeSimpleBar() this.activator.tabIndex = 0 } if (changedProperties.has('show') && this.show) this.handleSlot() } disconnectedCallback() { this.disposeSimpleBar() this.cleanupFloatingUi() this.removeEventListener('blur', this.handleBlurBinded) this.contentSlot?.removeEventListener('focusout', this.handleContentFocusoutBinded) this.activator?.removeEventListener('focus', this.handleActivatorFocusBinded) this.activator?.removeEventListener('blur', this.handleBlurBinded) super.disconnectedCallback() } async changeShow(state: boolean) { if (this.parentControl) return if (state) { clearTimeout(this.timeout) this.show = true } else { this.timeout = setTimeout(() => { this.show = false dispatchCustomEvent('onBlur', true, this, true, false) }, 100) } } disposeSimpleBar() { this.simpleBar && this.simpleBar.unMount() } initSizes() { if (this.simpleBarEl) { this.simpleBarEl.style.maxHeight = this.scrolledMaxHeight this.simpleBarEl.style.width = this.scrolledWidth } } async handleBlur(event: Event) { if (this.focusContent) { if (event instanceof FocusEvent) { const nextElement = event.relatedTarget as HTMLElement if (!nextElement) this.changeShow(false) if (nextElement && !this.tooltip.contains(nextElement) && !this.contentSlot?.contains(nextElement)) this.changeShow(false) } } else { this.changeShow(false) } } handleContentFocusout(event: FocusEvent) { const nextEl = event.relatedTarget as HTMLElement if (!this.contentSlot?.contains(nextEl)) { this.activator.tabIndex = 0 this.changeShow(false) } } async handleActivatorFocus() { if (this.focusContent) { this.activator.tabIndex = -1 this.changeShow(true) await this.updateComplete this.contentWrapper.focus() } else { this.changeShow(true) } dispatchCustomEvent('onActivatorFocus', true, this, true, false) } handleSlot() { if (this.focusContent) { this.addEventListener('blur', this.handleBlurBinded) this.contentSlot?.addEventListener('focusout', this.handleContentFocusoutBinded) this.activator.addEventListener('focus', this.handleActivatorFocusBinded) } else { this.activator.addEventListener('focus', this.handleActivatorFocusBinded) this.activator.addEventListener('blur', this.handleBlurBinded) } } initSimpleBar() { if (this.simpleBarEl) this.simpleBar = new SimpleBar(this.simpleBarEl, { ariaLabel: 'simplebar-tooltip' } as Options) } async reInitSimpleBar() { await this.updateComplete this.disposeSimpleBar() this.initSimpleBar() } showTooltip() { if (this.anchor && this.tooltipContent) { if (this.cleanup) this.computePosition() else this.autoUpdatePosition() } } cleanupFloatingUi() { this.cleanup && this.cleanup() this.cleanup = null } getFloatingUiConfig(): Partial { const floatingUiConfig = { strategy: this.strategy, placement: this.corner, middleware: [ offset({ mainAxis: this.offset[1], crossAxis: this.offset[0] }), flip(), shift({ limiter: limitShift() }), ], } if (this.showArrow && this.arrowEl) floatingUiConfig.middleware.push(arrow({ element: this.arrowEl })) return floatingUiConfig } autoUpdatePosition() { this.cleanupFloatingUi() this.cleanup = autoUpdate(this.anchor, this.tooltipContent, () => this.computePosition()) } computePosition() { if (!this.show) return const options = this.getFloatingUiConfig() computePosition(this.anchor, this.tooltipContent, options).then((computedData) => { this.computeContentPosition(computedData) this.computeArrowPosition(computedData) }) } computeContentPosition({ x, y, placement }: ComputePositionReturn) { Object.assign(this.tooltipContent.style, { left: `${x}px`, top: `${y}px`, }) this.tooltipContent.dataset.popperPlacement = placement } computeArrowPosition({ middlewareData }: ComputePositionReturn) { if (this.arrowEl && middlewareData.arrow) { const { x, y } = middlewareData.arrow Object.assign(this.arrowEl.style, { left: x != null ? `${x}px` : '', top: y != null ? `${y}px` : '', }) } } getAnchorClasses(): DirectiveResult { return classMap({ 'anchor-overflow-auto': this.isAnchorOverflowAuto, }) } handleKeydown(event: KeyboardEvent) { event.stopPropagation() if (event.key === 'Enter') this.activator.click() } }