// eslint-disable-next-line prettier/prettier import type {AnchorAlignment, AnchorSide} from '@primer/behaviors' import {getAnchoredPosition} from '@primer/behaviors' const TOOLTIP_OPEN_CLASS = 'tooltip-open' const TOOLTIP_ARROW_EDGE_OFFSET = 6 type Direction = 'n' | 's' | 'e' | 'w' | 'ne' | 'se' | 'nw' | 'sw' const DIRECTION_CLASSES = [ 'tooltip-n', 'tooltip-s', 'tooltip-e', 'tooltip-w', 'tooltip-ne', 'tooltip-se', 'tooltip-nw', 'tooltip-sw' ] class ToolTipElement extends HTMLElement { styles() { return ` :host { position: absolute; z-index: 1000000; padding: .5em .75em; font: normal normal 11px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; -webkit-font-smoothing: subpixel-antialiased; color: var(--color-fg-on-emphasis); text-align: center; text-decoration: none; text-shadow: none; text-transform: none; letter-spacing: normal; word-wrap: break-word; white-space: pre; background: var(--color-neutral-emphasis-plus); border-radius: 6px; opacity: 0; max-width: 250px; word-wrap: break-word; white-space: normal; width: max-content; } :host:before{ position: absolute; z-index: 1000001; color: var(--color-neutral-emphasis-plus); content: ""; border: 6px solid transparent; opacity: 0 } @keyframes tooltip-appear { from { opacity: 0 } to { opacity: 1 } } :host:after{ position: absolute; display: block; right: 0; left: 0; height: 12px; content: "" } :host(.${TOOLTIP_OPEN_CLASS}), :host(.${TOOLTIP_OPEN_CLASS}):before { animation-name: tooltip-appear; animation-duration: .1s; animation-fill-mode: forwards; animation-timing-function: ease-in; animation-delay: .4s } :host(.tooltip-s):before, :host(.tooltip-n):before { right: 50%; margin-right: -${TOOLTIP_ARROW_EDGE_OFFSET}px; } :host(.tooltip-s):before, :host(.tooltip-se):before, :host(.tooltip-sw):before { bottom: 100%; border-bottom-color: var(--color-neutral-emphasis-plus) } :host(.tooltip-s):after, :host(.tooltip-se):after, :host(.tooltip-sw):after { bottom: 100% } :host(.tooltip-n):before, :host(.tooltip-ne):before, :host(.tooltip-nw):before { top: 100%; border-top-color: var(--color-neutral-emphasis-plus) } :host(.tooltip-n):after, :host(.tooltip-ne):after, :host(.tooltip-nw):after { top: 100% } :host(.tooltip-se):before, :host(.tooltip-ne):before { left: 0; margin-left: ${TOOLTIP_ARROW_EDGE_OFFSET}px; } :host(.tooltip-sw):before, :host(.tooltip-nw):before { right: 0; margin-right: ${TOOLTIP_ARROW_EDGE_OFFSET}px; } :host(.tooltip-w):before { top: 50%; bottom: 50%; left: 100%; margin-top: -6px; border-left-color: var(--color-neutral-emphasis-plus) } :host(.tooltip-e):before { top: 50%; right: 100%; bottom: 50%; margin-top: -6px; border-right-color: var(--color-neutral-emphasis-plus) } ` } #abortController: AbortController | undefined #align: AnchorAlignment = 'center' #side: AnchorSide = 'outside-bottom' #allowUpdatePosition = false get htmlFor(): string { return this.getAttribute('for') || '' } set htmlFor(value: string) { this.setAttribute('for', value) } get type(): 'description' | 'label' { const type = this.getAttribute('data-type') return type === 'label' ? 'label' : 'description' } set type(value: 'description' | 'label') { this.setAttribute('data-type', value) } get direction(): Direction { return (this.getAttribute('data-direction') || 's') as Direction } set direction(value: Direction) { this.setAttribute('data-direction', value) } get control(): HTMLElement | null { return this.ownerDocument.getElementById(this.htmlFor) } set visibilityHidden(value: true | false) { this.style.visibility = value ? 'hidden' : '' } get visibilityHidden() { return this.style.visibility === 'hidden' } connectedCallback() { if (!this.shadowRoot) { const shadow = this.attachShadow({mode: 'open'}) // eslint-disable-next-line github/no-inner-html shadow.innerHTML = ` ` } this.visibilityHidden = true this.#allowUpdatePosition = true if (!this.id) { this.id = `tooltip-${Date.now()}-${(Math.random() * 10000).toFixed(0)}` } if (!this.control) return this.setAttribute('role', 'tooltip') this.#abortController?.abort() this.#abortController = new AbortController() const {signal} = this.#abortController this.addEventListener('mouseleave', this, {signal}) this.control.addEventListener('mouseenter', this, {signal}) this.control.addEventListener('mouseleave', this, {signal}) this.control.addEventListener('focus', this, {signal}) this.control.addEventListener('blur', this, {signal}) this.ownerDocument.addEventListener('keydown', this, {signal}) this.#update() } disconnectedCallback() { this.#abortController?.abort() } handleEvent(event: Event) { if (!this.control) return // Ensures that tooltip stays open when hovering between tooltip and element // WCAG Success Criterion 1.4.13 Hoverable if ((event.type === 'mouseenter' || event.type === 'focus') && this.visibilityHidden) { this.visibilityHidden = false } else if (event.type === 'blur') { this.visibilityHidden = true } else if ( event.type === 'mouseleave' && (event as MouseEvent).relatedTarget !== this.control && (event as MouseEvent).relatedTarget !== this ) { this.visibilityHidden = true } else if (event.type === 'keydown' && (event as KeyboardEvent).key === 'Escape' && !this.visibilityHidden) { this.visibilityHidden = true } } static observedAttributes = ['data-type', 'data-direction', 'id', 'style'] #update() { if (this.visibilityHidden) { this.classList.remove(TOOLTIP_OPEN_CLASS, ...DIRECTION_CLASSES) } else { this.classList.add(TOOLTIP_OPEN_CLASS) for (const tooltip of this.ownerDocument.querySelectorAll(this.tagName)) { if (tooltip !== this) tooltip.visibilityHidden = true } this.#updatePosition() } } attributeChangedCallback(name: string, oldValue: string, newValue: string) { if (name === 'id' || name === 'data-type') { if (!this.id || !this.control) return if (this.type === 'label') { this.control.setAttribute('aria-labelledby', this.id) this.setAttribute('aria-hidden', 'true') } else { let describedBy = this.control.getAttribute('aria-describedby') describedBy ? (describedBy = `${describedBy} ${this.id}`) : (describedBy = this.id) this.control.setAttribute('aria-describedby', describedBy) } } else if (this.isConnected && name === 'style' && (oldValue && oldValue.includes('visibility')) || (newValue && newValue.includes('visibility'))) { this.#update() } else if (name === 'data-direction') { this.classList.remove(...DIRECTION_CLASSES) const direction = this.direction if (direction === 'n') { this.#align = 'center' this.#side = 'outside-top' } else if (direction === 'ne') { this.#align = 'start' this.#side = 'outside-top' } else if (direction === 'e') { this.#align = 'center' this.#side = 'outside-right' } else if (direction === 'se') { this.#align = 'start' this.#side = 'outside-bottom' } else if (direction === 's') { this.#align = 'center' this.#side = 'outside-bottom' } else if (direction === 'sw') { this.#align = 'end' this.#side = 'outside-bottom' } else if (direction === 'w') { this.#align = 'center' this.#side = 'outside-left' } else if (direction === 'nw') { this.#align = 'end' this.#side = 'outside-top' } } } #updatePosition() { if (!this.control) return if (!this.#allowUpdatePosition || this.visibilityHidden) return const TOOLTIP_OFFSET = 10 this.style.left = `0px` // Ensures we have reliable tooltip width in `getAnchoredPosition` const position = getAnchoredPosition(this, this.control, { side: this.#side, align: this.#align, anchorOffset: TOOLTIP_OFFSET }) const anchorSide = position.anchorSide const align = position.anchorAlign this.style.top = `${position.top}px` this.style.left = `${position.left}px` let direction: Direction = 's' if (anchorSide === 'outside-left') { direction = 'w' } else if (anchorSide === 'outside-right') { direction = 'e' } else if (anchorSide === 'outside-top') { if (align === 'center') { direction = 'n' } else if (align === 'start') { direction = 'ne' } else { direction = 'nw' } } else { if (align === 'center') { direction = 's' } else if (align === 'start') { direction = 'se' } else { direction = 'sw' } } this.classList.add(`tooltip-${direction}`) } } if (!window.customElements.get('tool-tip')) { window.ToolTipElement = ToolTipElement window.customElements.define('tool-tip', ToolTipElement) } declare global { interface Window { ToolTipElement: typeof ToolTipElement } }