diff --git a/src/dev/pages/button-area/button-area.ejs b/src/dev/pages/button-area/button-area.ejs index 82bebade8..7bfd20228 100644 --- a/src/dev/pages/button-area/button-area.ejs +++ b/src/dev/pages/button-area/button-area.ejs @@ -9,7 +9,7 @@ - Like + Like @@ -29,7 +29,7 @@ - Like + Like diff --git a/src/dev/pages/drawer/drawer-dismissible-right.ejs b/src/dev/pages/drawer/drawer-dismissible-right.ejs index 792c24648..b94f1c13b 100644 --- a/src/dev/pages/drawer/drawer-dismissible-right.ejs +++ b/src/dev/pages/drawer/drawer-dismissible-right.ejs @@ -23,7 +23,7 @@
Header
- + Inbox diff --git a/src/dev/pages/drawer/drawer-dismissible.ejs b/src/dev/pages/drawer/drawer-dismissible.ejs index 94f850904..3192a78b6 100644 --- a/src/dev/pages/drawer/drawer-dismissible.ejs +++ b/src/dev/pages/drawer/drawer-dismissible.ejs @@ -23,7 +23,7 @@
Header
- + Inbox diff --git a/src/dev/pages/drawer/drawer-mini-hover-right.ejs b/src/dev/pages/drawer/drawer-mini-hover-right.ejs index 094db6707..bacbd60a8 100644 --- a/src/dev/pages/drawer/drawer-mini-hover-right.ejs +++ b/src/dev/pages/drawer/drawer-mini-hover-right.ejs @@ -8,7 +8,7 @@

Toolbar

- + Inbox diff --git a/src/dev/pages/drawer/drawer-mini-hover.ejs b/src/dev/pages/drawer/drawer-mini-hover.ejs index 142a33719..024fd1ac3 100644 --- a/src/dev/pages/drawer/drawer-mini-hover.ejs +++ b/src/dev/pages/drawer/drawer-mini-hover.ejs @@ -8,7 +8,7 @@

Toolbar

- + Inbox diff --git a/src/dev/pages/drawer/drawer-mini.ejs b/src/dev/pages/drawer/drawer-mini.ejs index a8d19ad18..2b67f5b84 100644 --- a/src/dev/pages/drawer/drawer-mini.ejs +++ b/src/dev/pages/drawer/drawer-mini.ejs @@ -8,22 +8,22 @@
Toolbar
- - + + + Inbox Inbox + Sent Outgoing + Drafts Drafts - Inbox - Sent - Drafts
diff --git a/src/dev/pages/drawer/drawer-modal.ejs b/src/dev/pages/drawer/drawer-modal.ejs index 26f737d90..d3b30f2ad 100644 --- a/src/dev/pages/drawer/drawer-modal.ejs +++ b/src/dev/pages/drawer/drawer-modal.ejs @@ -6,7 +6,7 @@ - + Inbox diff --git a/src/dev/pages/drawer/drawer-permanent.ejs b/src/dev/pages/drawer/drawer-permanent.ejs index ae408c1e6..380aa2351 100644 --- a/src/dev/pages/drawer/drawer-permanent.ejs +++ b/src/dev/pages/drawer/drawer-permanent.ejs @@ -23,7 +23,7 @@
Header
- + - Dismiss + Dismiss \ No newline at end of file diff --git a/src/lib/calendar/calendar-dom-utils.ts b/src/lib/calendar/calendar-dom-utils.ts index dabf28fd7..c54ad9c9b 100644 --- a/src/lib/calendar/calendar-dom-utils.ts +++ b/src/lib/calendar/calendar-dom-utils.ts @@ -160,9 +160,8 @@ export function getEventElement(event: ICalendarEvent, overflow?: boolean): HTML /** Returns a tooltip element. */ export function getTooltip(content: string): HTMLElement { - const tooltip: ITooltipComponent = document.createElement('forge-tooltip'); - tooltip.text = content; - tooltip.setAttribute('aria-hidden', 'true'); + const tooltip = document.createElement('forge-tooltip'); + tooltip.textContent = content; return tooltip; } diff --git a/src/lib/checkbox/_core.scss b/src/lib/checkbox/_core.scss index 20d7645a6..fb73a7428 100644 --- a/src/lib/checkbox/_core.scss +++ b/src/lib/checkbox/_core.scss @@ -5,6 +5,7 @@ @mixin host { display: inline-block; + -webkit-tap-highlight-color: transparent; } @mixin checkbox { diff --git a/src/lib/color-picker/color-picker.html b/src/lib/color-picker/color-picker.html index 998a07700..c7b37a7cd 100644 --- a/src/lib/color-picker/color-picker.html +++ b/src/lib/color-picker/color-picker.html @@ -74,7 +74,7 @@ - Change color format + Change color format diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 0c1a48698..6a20509ff 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,9 +1,11 @@ import type { IBaseComponent } from './core/base/base-component'; +import { supportsHover } from './core/utils/feature-detection'; export const COMPONENT_NAME_PREFIX = 'forge-'; export const KEYSTROKE_DEBOUNCE_THRESHOLD = 500; export const ICON_CLASS_NAME = 'tyler-icons'; export const CDN_BASE_URL = 'https://cdn.forge.tylertech.com/'; +export const canUserHoverElements = supportsHover(); /** A method symbol that gets the submitted value of a form-associated component. */ export const getFormValue = Symbol('getFormValue'); diff --git a/src/lib/core/mixins/interactions/longpress/with-longpress-listener.ts b/src/lib/core/mixins/interactions/longpress/with-longpress-listener.ts index 4d14cfa2b..0ad717cc3 100644 --- a/src/lib/core/mixins/interactions/longpress/with-longpress-listener.ts +++ b/src/lib/core/mixins/interactions/longpress/with-longpress-listener.ts @@ -1,4 +1,4 @@ -import { AbstractConstructor, MixinBase } from '../../../../constants'; +import { AbstractConstructor, canUserHoverElements, MixinBase } from '../../../../constants'; /** * The delay in milliseconds before a longpress event is detected. @@ -16,6 +16,11 @@ export declare abstract class WithLongpressListenerContract { */ protected abstract _onLongpress(): void; + /** + * Called after a longpress event has been detected, but after the user has released the pointer. + */ + protected _onLongpressEnd(evt: PointerEvent | TouchEvent): void; + /** * Starts listening for longpress events. */ @@ -28,7 +33,7 @@ export declare abstract class WithLongpressListenerContract { } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function WithLongpressListener>(base: TBase) { +export function WithLongpressListener>(base: TBase = (class {} as unknown) as TBase) { abstract class LongpressListener extends base { private _longpressTimeout: number | undefined; private _longpressStartListener = this._onLongpressStart.bind(this); @@ -41,11 +46,13 @@ export function WithLongpressListener>(base: TBa protected abstract _onLongpress(): void; protected _startLongpressListener(el: HTMLElement): void { - el.addEventListener('pointerdown', this._longpressStartListener); + const type = canUserHoverElements ? 'pointerdown' : 'touchstart'; + el.addEventListener(type, this._longpressStartListener); } protected _stopLongpressListener(el: HTMLElement): void { - el.removeEventListener('pointerdown', this._longpressStartListener); + const type = canUserHoverElements ? 'pointerdown' : 'touchstart'; + el.removeEventListener(type, this._longpressStartListener); this._unlistenLongpressEnd(el); } @@ -55,6 +62,10 @@ export function WithLongpressListener>(base: TBa this._longpressTimeout = window.setTimeout(() => { this._onLongpress(); + if (!canUserHoverElements) { + navigator.vibrate(1); + } + // We need to prevent any ghost click events from firing after a longpress is detected (evt.target as HTMLElement).addEventListener('click', this._longpressClickPrevent, { capture: true, once: true }); }, this._longpressDelay); @@ -64,26 +75,35 @@ export function WithLongpressListener>(base: TBa evt.stopPropagation(); } - private _onLongpressEnd(evt: PointerEvent): void { + protected _onLongpressEnd(evt: PointerEvent | TouchEvent): void { this._clearTimeout(); this._unlistenLongpressEnd(evt.target as HTMLElement); } - private _onLongpressContextMenu(evt: PointerEvent): void { + private _onLongpressContextMenu(evt: PointerEvent | TouchEvent): void { this._clearTimeout(); (evt.target as HTMLElement).removeEventListener('click', this._longpressClickPrevent, { capture: true }); + this._unlistenLongpressEnd(evt.target as HTMLElement); } private _listenLongpressEnd(el: HTMLElement): void { - el.addEventListener('pointerup', this._longpressEndListener); - el.addEventListener('pointercancel', this._longpressEndListener); - el.addEventListener('contextmenu', this._longpressContextMenuListener); + if (!canUserHoverElements) { + el.addEventListener('touchend', this._longpressEndListener); + } else { + el.addEventListener('pointerup', this._longpressEndListener); + el.addEventListener('pointercancel', this._longpressEndListener); + el.addEventListener('contextmenu', this._longpressContextMenuListener); + } } private _unlistenLongpressEnd(el: HTMLElement): void { - el.removeEventListener('pointerup', this._longpressEndListener); - el.removeEventListener('pointercancel', this._longpressEndListener); - el.removeEventListener('contextmenu', this._longpressContextMenuListener); + if (!canUserHoverElements) { + el.removeEventListener('touchend', this._longpressEndListener); + } else { + el.removeEventListener('pointerup', this._longpressEndListener); + el.removeEventListener('pointercancel', this._longpressEndListener); + el.removeEventListener('contextmenu', this._longpressContextMenuListener); + } } private _clearTimeout(): void { diff --git a/src/lib/core/styles/tokens/tooltip/_tokens.scss b/src/lib/core/styles/tokens/tooltip/_tokens.scss new file mode 100644 index 000000000..3722d8938 --- /dev/null +++ b/src/lib/core/styles/tokens/tooltip/_tokens.scss @@ -0,0 +1,48 @@ +@use 'sass:map'; +@use '../../utils'; +@use '../../theme'; +@use '../../shape'; +@use '../../spacing'; +@use '../../animation'; + +$tokens: ( + // Base + background: utils.module-val(tooltip, background, theme.variable(surface-inverse)), + color: utils.module-val(tooltip, color, theme.variable(on-surface-inverse)), + shape: utils.module-val(tooltip, shape, shape.variable(medium)), + padding: utils.module-val(tooltip, padding, spacing.variable(xsmall)), + padding-block: utils.module-ref(tooltip, padding-block, padding), + padding-inline: utils.module-ref(tooltip, padding-inline, padding), + width: utils.module-val(tooltip, width, max-content), + max-width: utils.module-val(tooltip, max-width, 320px), + elevation: utils.module-val(tooltip, elevation, theme.variable(popup-elevation)), + content-align: utils.module-val(tooltip, content-align, center), + + // Border + border-width: utils.module-val(tooltip, border-width, 0), + border-style: utils.module-val(tooltip, border-style, solid), + border-color: utils.module-val(tooltip, border-color, theme.variable(outline)), + + // Animation + animation-timing: utils.module-val(tooltip, animation-timing, animation.variable(easing-decelerate)), + animation-duration: utils.module-val(tooltip, animation-duration, animation.variable(duration-short3)), + animation-offset: utils.module-val(tooltip, animation-offset, 24px), + + // Arrow + arrow-size: utils.module-val(tooltip, arrow-size, 8px), + arrow-height: utils.module-ref(tooltip, arrow-height, arrow-size), + arrow-width: utils.module-ref(tooltip, arrow-width, arrow-size), + arrow-shape: utils.module-val(tooltip, arrow-shape, shape.variable(small)), + arrow-rotation: utils.module-val(tooltip, arrow-rotation, 0deg), + arrow-top-rotation: utils.module-val(tooltip, arrow-top-rotation, 315deg), + arrow-right-rotation: utils.module-val(tooltip, arrow-right-rotation, 45deg), + arrow-bottom-rotation: utils.module-val(tooltip, arrow-bottom-rotation, 135deg), + arrow-left-rotation: utils.module-val(tooltip, arrow-left-rotation, 225deg), +) !default; + +/// +/// Gets a token from the token map. +/// +@function get($name) { + @return map.get($tokens, $name); +} diff --git a/src/lib/core/utils/feature-detection.ts b/src/lib/core/utils/feature-detection.ts index 6badeddc4..8a0f8b34a 100644 --- a/src/lib/core/utils/feature-detection.ts +++ b/src/lib/core/utils/feature-detection.ts @@ -1,3 +1,5 @@ +import { Platform } from '@tylertech/forge-core'; + /** * Detects if the browser supports the `popover` attribute. * @returns {boolean} @@ -16,3 +18,14 @@ export function supportsPopover(): boolean { export function supportsElementInternalsAria(): boolean { return ElementInternals.prototype.hasOwnProperty('role'); } + +/** + * Detects if the browser supports the hovering elements as the users primary input mechanism. + * @returns {boolean} + */ +export function supportsHover(): boolean { + // TODO: hover media query is not working in CI headless chrome, so we are using the Platform.isMobile flag for now. + // This should be reverted once we switch to using puppeteer or playwright for testing in CI. + // return window.matchMedia('(hover: hover)').matches; + return !Platform.isMobile; +} diff --git a/src/lib/drawer/mini-drawer/_mixins.scss b/src/lib/drawer/mini-drawer/_mixins.scss index 1e23ff52e..8a26ad780 100644 --- a/src/lib/drawer/mini-drawer/_mixins.scss +++ b/src/lib/drawer/mini-drawer/_mixins.scss @@ -43,8 +43,6 @@ @include theme.css-custom-property(width, --forge-drawer-mini-width, variables.$width); @include theme.z-index(surface); - transform: translateX(0); - transition-property: transform, width; transition: width 200ms mdc-animation.$standard-curve-timing-function; } diff --git a/src/lib/forge.scss b/src/lib/forge.scss index 866ef09c4..39b4754bd 100644 --- a/src/lib/forge.scss +++ b/src/lib/forge.scss @@ -6,7 +6,6 @@ // Required global component styles @use './table/forge-table'; -@use './tooltip/forge-tooltip'; @use './ripple/forge-ripple'; @use './quantity-field/forge-quantity-field'; diff --git a/src/lib/icon-button/icon-button-component-delegate.ts b/src/lib/icon-button/icon-button-component-delegate.ts index 087652e89..3cab53579 100644 --- a/src/lib/icon-button/icon-button-component-delegate.ts +++ b/src/lib/icon-button/icon-button-component-delegate.ts @@ -33,7 +33,7 @@ export class IconButtonComponentDelegate extends BaseComponentDelegate implements IO this._rootElement.style.removeProperty('left'); this._rootElement.style.removeProperty('display'); + this._component.arrowElement?.removeAttribute('style'); + // Remove dynamic position attribute this._component.removeAttribute(OVERLAY_CONSTANTS.attributes.POSITION_PLACEMENT); diff --git a/src/lib/overlay/overlay-constants.ts b/src/lib/overlay/overlay-constants.ts index 77a3c8d05..6ec3a7719 100644 --- a/src/lib/overlay/overlay-constants.ts +++ b/src/lib/overlay/overlay-constants.ts @@ -2,7 +2,7 @@ import { COMPONENT_NAME_PREFIX } from '../constants'; import { supportsPopover } from '../core'; import { PositionPlacement } from '../core/utils/position-utils'; -const elementName = `${COMPONENT_NAME_PREFIX}overlay`; +const elementName = `${COMPONENT_NAME_PREFIX}overlay` as const; const observedAttributes = { ANCHOR: 'anchor', diff --git a/src/lib/paginator/paginator.html b/src/lib/paginator/paginator.html index 571ebc239..0f938d942 100644 --- a/src/lib/paginator/paginator.html +++ b/src/lib/paginator/paginator.html @@ -7,26 +7,26 @@
- + - Go to the first page + Go to the first page - + - Go to the previous page + Go to the previous page
- + - Go to the next page + Go to the next page - + - Go to the last page + Go to the last page \ No newline at end of file diff --git a/src/lib/popover/popover.test.ts b/src/lib/popover/popover.test.ts index e92e09adc..d782b40ac 100644 --- a/src/lib/popover/popover.test.ts +++ b/src/lib/popover/popover.test.ts @@ -1392,6 +1392,7 @@ class PopoverHarness { await sendMouse({ type: 'down', button: 'left' }); await timer(delay); await sendMouse({ type: 'up', button: 'left' }); + await this.hoverOutside(); } public async longpressStopBeforeDelay(): Promise { diff --git a/src/lib/radio/radio/_core.scss b/src/lib/radio/radio/_core.scss index 627adda48..3bd61f144 100644 --- a/src/lib/radio/radio/_core.scss +++ b/src/lib/radio/radio/_core.scss @@ -5,6 +5,7 @@ @mixin host { display: inline-block; + -webkit-tap-highlight-color: transparent; } @mixin radio { diff --git a/src/lib/switch/_core.scss b/src/lib/switch/_core.scss index 65b034bbb..7c71379df 100644 --- a/src/lib/switch/_core.scss +++ b/src/lib/switch/_core.scss @@ -22,6 +22,7 @@ $_handle-on-translate: calc(#{token(track-width)} - #{$_track-border-radius} * 2 @mixin host { display: inline-block; + -webkit-tap-highlight-color: transparent; } @mixin switch { diff --git a/src/lib/table/table-utils.ts b/src/lib/table/table-utils.ts index bb79f5a9e..99a539833 100644 --- a/src/lib/table/table-utils.ts +++ b/src/lib/table/table-utils.ts @@ -18,7 +18,7 @@ import { } from '@tylertech/forge-core'; import { CHECKBOX_CONSTANTS, ICheckboxComponent } from '../checkbox'; import { EXPANSION_PANEL_CONSTANTS, IExpansionPanelComponent } from '../expansion-panel'; -import { TooltipComponent, TOOLTIP_CONSTANTS } from '../tooltip'; +import { TooltipComponent, ITooltipComponent } from '../tooltip'; import { TABLE_CONSTANTS } from './table-constants'; import { TableRow } from './table-row'; import { CellAlign, IColumnConfiguration, IColumnData, ITableConfiguration, SortDirection, TableFilterDelegateFactory, TableFilterListener, TableHeaderSelectAllTemplate, TableTemplateBuilder, TableViewTemplate, TableSelectTooltipCallback, ITableTemplateBuilderResult, TableViewTemplateBuilder } from './types'; @@ -145,10 +145,10 @@ export class TableUtils { /** * Creates a `forge-tooltip` for multi sort column headers */ - private static _createMultisortTooltip(): TooltipComponent { - const tooltip = document.createElement(TOOLTIP_CONSTANTS.elementName) as TooltipComponent; + private static _createMultisortTooltip(): ITooltipComponent { + const tooltip = document.createElement('forge-tooltip'); tooltip.textContent = 'Ctrl + click to sort multiple columns'; - tooltip.position = 'bottom'; + tooltip.placement = 'bottom'; tooltip.delay = 0; return tooltip; } diff --git a/src/lib/theme/_theme-dark.scss b/src/lib/theme/_theme-dark.scss index 78bf7d82e..fcde604b1 100644 --- a/src/lib/theme/_theme-dark.scss +++ b/src/lib/theme/_theme-dark.scss @@ -2,12 +2,12 @@ @use '../core/styles/border'; @use '../popover'; +@use '../tooltip'; @use '../backdrop/mixins' as backdrop-mixins; @use '../backdrop/variables' as backdrop-variables; @use '../skeleton/mixins' as skeleton-mixins; @use '../skeleton/variables' as skeleton-variables; -@use '../tooltip/mixins' as tooltip-mixins; @use '../toast/mixins' as toast-mixins; @use '../toast/variables' as toast-variables; @use '../badge/mixins' as badge-mixins; @@ -30,6 +30,11 @@ @include popover.provide-theme(( border-width: border.variable(thin) // Popovers use thin borders in dark theme to increase contrast )); + @include tooltip.provide-theme(( + background: theme.variable(surface-bright), + color: theme.variable(text-high), + border-width: border.variable(thin) // Tooltips use thin borders in dark theme to increase contrast + )); @include backdrop-mixins.provide-theme(backdrop-variables.$theme-values-dark); @include skeleton-mixins.provide-theme(skeleton-variables.$theme-values-dark); @include toast-mixins.provide-theme(toast-variables.$theme-values-dark); diff --git a/src/lib/tooltip/_animations.scss b/src/lib/tooltip/_animations.scss new file mode 100644 index 000000000..cd96c6fcf --- /dev/null +++ b/src/lib/tooltip/_animations.scss @@ -0,0 +1,13 @@ +@use './token-utils' as *; + +@keyframes slidein { + from { + opacity: 0; + transform: translateX(#{token(slidein-x, custom)}) translateY(#{token(slidein-y, custom)}); + } + + to { + opacity: 1; + transform: translateX(0) translateY(0); + } +} diff --git a/src/lib/tooltip/_core.scss b/src/lib/tooltip/_core.scss new file mode 100644 index 000000000..fc645c91c --- /dev/null +++ b/src/lib/tooltip/_core.scss @@ -0,0 +1,81 @@ +@use '../core/styles/typography'; +@use './token-utils' as *; +@use '../utils/mixins' as utils; + +@forward './token-utils'; + +@mixin host { + display: contents; + pointer-events: none; +} + +@mixin base { + @include typography.style(body1); + + position: relative; + + background: #{token(background)}; + color: #{token(color)}; + + border-radius: #{token(shape)}; + border-width: #{token(border-width)}; + border-style: #{token(border-style)}; + border-color: #{token(border-color)}; + padding-block: #{token(padding-block)}; + padding-inline: #{token(padding-inline)}; + box-shadow: #{token(elevation)}; + + width: #{token(width)}; + max-width: #{token(max-width)}; + pointer-events: none; + text-align: #{token(content-align)}; + line-height: normal; + + animation-duration: #{token(animation-duration)}; + animation-timing-function: #{token(animation-timing)}; + animation-name: slidein; + animation-fill-mode: forwards; +} + +@mixin arrow { + position: absolute; + contain: strict; + + background-color: inherit; + height: #{token(arrow-height)}; + width: #{token(arrow-width)}; + box-shadow: inherit; + border: inherit; + border-end-start-radius: #{token(arrow-shape)}; + + rotate: #{token(arrow-rotation, custom)}; + clip-path: #{token(arrow-clip-path, custom)}; +} + +@mixin arrow-placement-top { + @include override(arrow-rotation, arrow-top-rotation); + + margin-block-end: #{token(border-width)}; +} + +@mixin arrow-placement-right { + @include override(arrow-rotation, arrow-right-rotation); + + margin-inline-start: #{token(border-width)}; +} + +@mixin arrow-placement-bottom { + @include override(arrow-rotation, arrow-bottom-rotation); + + margin-block-start: #{token(border-width)}; +} + +@mixin arrow-placement-left { + @include override(arrow-rotation, arrow-left-rotation); + + margin-inline-end: #{token(border-width)}; +} + +@mixin visually-hidden { + @include utils.visually-hidden; +} diff --git a/src/lib/tooltip/_mixins.scss b/src/lib/tooltip/_mixins.scss deleted file mode 100644 index aed1b5638..000000000 --- a/src/lib/tooltip/_mixins.scss +++ /dev/null @@ -1,93 +0,0 @@ -@use '../theme'; -@use '../utils/mixins' as utils; -@use './variables'; - -@mixin provide-theme($theme) { - @include theme.theme-properties(tooltip, $theme, variables.$theme-values); -} - -@mixin core-styles() { - .forge-tooltip { - @include base; - - &--open { - @include open; - } - - &--top { - @include top; - } - - &--right { - @include right; - } - - &--bottom { - @include bottom; - } - - &--left { - @include left; - } - } - - .forge-tooltip-host { - @include parent; - } -} - -@mixin host() { - @include utils.visually-hidden; - - transform: translateX(-9999px) translateY(-9999px); -} - -@mixin base() { - @include theme.css-custom-property(background-color, --forge-tooltip-theme-background, variables.$background); - @include theme.css-custom-property(color, --forge-tooltip-theme-on-background, variables.$on-background); - @include theme.css-custom-property(font-size, --forge-tooltip-font-size, variables.$font-size); - @include theme.css-custom-property(max-width, --forge-tooltip-max-width, variables.$max-width); - @include theme.z-index(tooltip); - - position: absolute; - top: auto; - right: auto; - bottom: auto; - left: auto; - box-sizing: border-box; - outline: none; - border-radius: variables.$border-radius; - pointer-events: none; - overflow: hidden; - text-overflow: ellipsis; - padding: variables.$padding-top-bottom variables.$padding-left-right; - display: inline-block; - opacity: 0; - transform: scale(0); - transition: opacity variables.$transition-duration variables.$transition-timing-function, transform variables.$transition-duration variables.$transition-timing-function; - will-change: opacity transform; -} - -@mixin open() { - transform: scale(1); - opacity: 1; -} - -@mixin top() { - transform-origin: bottom; -} -@mixin right() { - transform-origin: left; -} - -@mixin bottom() { - transform-origin: top; -} - -@mixin left() { - transform-origin: right; -} - -@mixin parent() { - position: relative; -} diff --git a/src/lib/tooltip/_token-utils.scss b/src/lib/tooltip/_token-utils.scss new file mode 100644 index 000000000..3da14b2ea --- /dev/null +++ b/src/lib/tooltip/_token-utils.scss @@ -0,0 +1,26 @@ +@use '../core/styles/utils'; +@use '../core/styles/tokens/tooltip/tokens'; +@use '../core/styles/tokens/token-utils'; + +$_module: tooltip; +$_tokens: tokens.$tokens; + +@mixin provide-theme($theme) { + @include token-utils.provide-theme($_module, $_tokens, $theme); +} + +@function token($name, $type: token) { + @return token-utils.token($_module, $_tokens, $name, $type); +} + +@function declare($token) { + @return token-utils.declare($_module, $token); +} + +@mixin override($token, $token-or-value, $type: token) { + @include token-utils.override($_module, $_tokens, $token, $token-or-value, $type); +} + +@mixin tokens($includes: null, $excludes: null) { + @include token-utils.tokens($_module, $_tokens, $includes, $excludes); +} diff --git a/src/lib/tooltip/_variables.scss b/src/lib/tooltip/_variables.scss deleted file mode 100644 index a2f7f705a..000000000 --- a/src/lib/tooltip/_variables.scss +++ /dev/null @@ -1,13 +0,0 @@ -$max-width: 256px !default; -$background: rgba(97, 97, 97, 0.9) !default; -$on-background: #ffffff !default; -$border-radius: 4px !default; -$font-size: 10px !default; -$transition-duration: 120ms !default; -$transition-timing-function: cubic-bezier(0, 0, 0.2, 1) !default; -$padding-top-bottom: 6px !default; -$padding-left-right: 8px !default; -$theme-values: ( - background: $background, - on-background: $on-background -); diff --git a/src/lib/tooltip/build.json b/src/lib/tooltip/build.json deleted file mode 100644 index f7224f76c..000000000 --- a/src/lib/tooltip/build.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "../../../node_modules/@tylertech/forge-cli/config/build-schema.json", - "extends": "../build.json", - "stylesheets": [ - "./forge-tooltip.scss" - ] -} diff --git a/src/lib/tooltip/forge-tooltip.scss b/src/lib/tooltip/forge-tooltip.scss deleted file mode 100644 index 69e1b5edf..000000000 --- a/src/lib/tooltip/forge-tooltip.scss +++ /dev/null @@ -1,7 +0,0 @@ -@use './mixins'; - -forge-tooltip { - @include mixins.host; -} - -@include mixins.core-styles; diff --git a/src/lib/tooltip/index.scss b/src/lib/tooltip/index.scss new file mode 100644 index 000000000..98a38c0d9 --- /dev/null +++ b/src/lib/tooltip/index.scss @@ -0,0 +1 @@ +@forward './core'; diff --git a/src/lib/tooltip/index.ts b/src/lib/tooltip/index.ts index 9307d8cea..53c5cb1e2 100644 --- a/src/lib/tooltip/index.ts +++ b/src/lib/tooltip/index.ts @@ -5,7 +5,6 @@ import { TooltipComponent } from './tooltip'; export * from './tooltip-adapter'; export * from './tooltip-constants'; export * from './tooltip-foundation'; -export * from './tooltip-utils'; export * from './tooltip'; export function defineTooltipComponent(): void { diff --git a/src/lib/tooltip/tooltip-adapter.ts b/src/lib/tooltip/tooltip-adapter.ts index ccd6481cc..a7d71db3f 100644 --- a/src/lib/tooltip/tooltip-adapter.ts +++ b/src/lib/tooltip/tooltip-adapter.ts @@ -1,160 +1,160 @@ -import { removeAllChildren, removeElement, matchesSelectors } from '@tylertech/forge-core'; +import { getShadowElement, randomChars } from '@tylertech/forge-core'; +import { setDefaultAria } from '../constants'; +import { locateElementById } from '../core/utils/utils'; import { BaseAdapter, IBaseAdapter } from '../core/base/base-adapter'; -import { PopupPlacement } from '../popup'; +import { IOverlayComponent, OVERLAY_CONSTANTS } from '../overlay'; import { ITooltipComponent } from './tooltip'; -import { attachTooltip } from './tooltip-utils'; +import { TOOLTIP_CONSTANTS } from './tooltip-constants'; export interface ITooltipAdapter extends IBaseAdapter { - hasTargetElement(): boolean; - hasTooltipElement(): boolean; - isTargetElementConnected(): boolean; - initializeTargetElement(selector: string): void; - destroy(identifier: string | null): void; - initializeAccessibility(identifier: string): void; - setTextContent(text: string): void; - addTargetEventListener(type: string, listener: (evt: MouseEvent) => void): void; - removeTargetEventListener(type: string, listener: (evt: MouseEvent) => void): void; - showTooltip(position: PopupPlacement, content?: HTMLElement | Text): void; - getInnerText(): string; - hideTooltip(): void; - getTooltipElement(): HTMLElement | null; + readonly anchorElement: HTMLElement | null; + syncAria(): void; + detachAria(): void; + setAnchorElement(element: HTMLElement | null): void; + tryLocateAnchorElement(id: string): void; + addAnchorListener(type: string, listener: EventListener, opts?: AddEventListenerOptions): void; + removeAnchorListener(type: string, listener: EventListener): void; + addLightDismissListener(listener: EventListener): void; + removeLightDismissListener(listener: EventListener): void; + show(): void; + hide(): void; } -/** - * The DOM adapter for the tooltip component. - */ export class TooltipAdapter extends BaseAdapter implements ITooltipAdapter { - private _targetElement: HTMLElement | null; - private _tooltipElement: HTMLElement | null = null; + private _contentElement: HTMLElement; + private _arrowElement: HTMLElement; + private _anchorElement: HTMLElement | null = null; + private _overlayElement: IOverlayComponent | null = null; constructor(component: ITooltipComponent) { super(component); + this._contentElement = getShadowElement(this._component, TOOLTIP_CONSTANTS.selectors.CONTENT); + this._arrowElement = getShadowElement(this._component, TOOLTIP_CONSTANTS.selectors.ARROW); } - public initializeTargetElement(selector: string): void { - this._targetElement = this._getTargetElement(selector); + public get anchorElement(): HTMLElement | null { + return this._anchorElement; } - public initializeAccessibility(identifier: string): void { - if (this._targetElement && !this._targetElement.hasAttribute('aria-describedby')) { - this._targetElement.setAttribute('aria-describedby', identifier); + public syncAria(): void { + const role = this._component.type === 'description' ? 'tooltip' : null; + this._component[setDefaultAria]({ role }); + this._component[setDefaultAria]({ ariaHidden: 'true' }, { setAttribute: !this._component.hasAttribute('aria-hidden') }); + + if (this._anchorElement) { + if (this._component.type === 'label' && this._anchorElement.hasAttribute('aria-labelledby')) { + return; + } + if (this._component.type === 'description' && this._anchorElement.hasAttribute('aria-describedby')) { + return; + } + + if (!this._component.hasAttribute('id') && this._component.type !== 'presentation') { + this._component.id = `forge-tooltip-${randomChars()}`; + } + + switch (this._component.type) { + case 'description': + this._anchorElement.setAttribute('aria-describedby', this._component.id); + break; + case 'label': + this._anchorElement.setAttribute('aria-labelledby', this._component.id); + break; + } } } - public hasTargetElement(): boolean { - return !!this._targetElement; + public detachAria(): void { + if (this._component.type === 'description' && this._anchorElement?.getAttribute('aria-describedby') === this._component.id) { + this._anchorElement?.removeAttribute('aria-describedby'); + } + + if (this._component.type === 'label' && this._anchorElement?.getAttribute('aria-labelledby') === this._component.id) { + this._anchorElement?.removeAttribute('aria-labelledby'); + } } - public hasTooltipElement(): boolean { - return !!this._tooltipElement; + public setAnchorElement(element: HTMLElement | null): void { + this._anchorElement = element; } - public isTargetElementConnected(): boolean { - return !!this._targetElement && this._targetElement.isConnected; + public tryLocateAnchorElement(id: string): void { + this._anchorElement = this._tryFindAnchorElement(id); } - public destroy(identifier: string | null): void { - if (this._targetElement && this._targetElement.getAttribute('aria-describedby') === identifier) { - this._targetElement.removeAttribute('aria-describedby'); - } + public addAnchorListener(type: string, listener: EventListener, opts?: AddEventListenerOptions): void { + this._anchorElement?.addEventListener(type, listener, opts); } - /** - * Sets the text content of the host element to the provided text. - * @param text The text content. - */ - public setTextContent(text: string): void { - removeAllChildren(this._component); - if (text) { - this._component.appendChild(document.createTextNode(text)); - } + public removeAnchorListener(type: string, listener: EventListener): void { + this._anchorElement?.removeEventListener(type, listener); } - /** - * Adds an event listener to the target element. - * @param targetElement The target element instance. - * @param type The event type. - * @param listener The event listener. - */ - public addTargetEventListener(type: string, listener: (evt: MouseEvent) => void): void { - if (this._targetElement) { - this._targetElement.addEventListener(type, listener); - } + public addLightDismissListener(listener: EventListener): void { + this._overlayElement?.addEventListener(OVERLAY_CONSTANTS.events.LIGHT_DISMISS, listener); } - /** - * Removes an event listener from the target element. - * @param type The event type. - * @param listener The event listener. - */ - public removeTargetEventListener(type: string, listener: (evt: MouseEvent) => void): void { - if (this._targetElement) { - this._targetElement.removeEventListener(type, listener); - } + public removeLightDismissListener(listener: EventListener): void { + this._overlayElement?.removeEventListener(OVERLAY_CONSTANTS.events.LIGHT_DISMISS, listener); } - /** - * Displays the tooltip around the target element based on the provided configuration. - * @param position The position. - * @param content The tooltip content. - */ - public showTooltip(position: PopupPlacement, content?: HTMLElement | Text): void { - if (!this._targetElement) { - return; + public show(): void { + // Tooltips are shown above all content via + // We do this by dynamically creating an overlay element and appending it to the shadow root + // then we move the tooltip content into the overlay element so that it can be presented. + if (!this._overlayElement) { + this._overlayElement = document.createElement(OVERLAY_CONSTANTS.elementName); + this._overlayElement.setAttribute('part', 'root:overlay'); } - if (!content) { - const child = this._getTooltipContent(); - content = child.cloneNode(true) as HTMLElement | Text; + this._overlayElement.placement = this._component.placement; + this._overlayElement.anchorElement = this._anchorElement; + this._overlayElement.arrowElement = this._arrowElement; + this._overlayElement.offset = { mainAxis: this._component.offset }; + this._overlayElement.flip = this._component.flip; + + if (this._component.fallbackPlacements) { + this._overlayElement.fallbackPlacements = this._component.fallbackPlacements; } - const isEmptyTextNode = content.nodeType === 3 && (!content.textContent || content.textContent.trim().length === 0); - const isEmptyNode = !content || isEmptyTextNode; - if (isEmptyNode) { - return; + if (this._component.boundaryElement) { + this._overlayElement.boundaryElement = this._component.boundaryElement; + } else if (this._component.boundary) { + const boundaryEl = locateElementById(this._component, this._component.boundary); + this._overlayElement.boundaryElement = boundaryEl; + } else { + this._overlayElement.boundaryElement = null; } - this._tooltipElement = attachTooltip(this._targetElement, position, content); - } + this._component.shadowRoot?.appendChild(this._overlayElement); + this._overlayElement.appendChild(this._contentElement); - public getInnerText(): string { - return this._component.innerText; + this._overlayElement.open = true; } - /** - * Removes the tooltip from the DOM. - * @param tooltipElement The target element instance. - */ - public hideTooltip(): void { - if (this._tooltipElement) { - removeElement(this._tooltipElement); - this._tooltipElement = null; + public hide(): void { + // Move the tooltip content back into the component, and remove the overlay element to hide the tooltip visually + // Tooltips are still accessible when hidden, so we don't need to do anything else. + if (this._overlayElement) { + this._overlayElement.open = false; } - } - - public getTooltipElement(): HTMLElement | null { - return this._tooltipElement; + this._component.shadowRoot?.appendChild(this._contentElement); + this._overlayElement?.remove(); } /** - * Gets the target element based on the provided CSS selector. - * @param {string | undefined} selector The target element selector. + * Attempts to find an element with the given id. If no element is found, the previous sibling or parent element is returned. + * + * For backwards compatibility we allow for `id` to be a selector string, so that is evaluated if no element is found for the id. */ - private _getTargetElement(selector: string | undefined): HTMLElement | null { - if (selector) { - if (this._component.parentElement) { - if (matchesSelectors(this._component.parentElement, selector)) { - return this._component.parentElement; - } - return this._component.parentElement.querySelector(selector); + private _tryFindAnchorElement(id: string): HTMLElement | null { + if (id) { + const rootNode = this._component.getRootNode() as Document | ShadowRoot; + const targetEl = rootNode.getElementById(id) ?? rootNode.querySelector(id); + if (targetEl) { + return targetEl; } - } else { - return (this._component.previousElementSibling || this._component.parentElement) as HTMLElement; } - return null; - } - - private _getTooltipContent(): Node { - return this._component.firstElementChild || this._component.firstChild || document.createTextNode(''); + return (this._component.previousElementSibling ?? this._component.parentElement) as HTMLElement; } } diff --git a/src/lib/tooltip/tooltip-constants.ts b/src/lib/tooltip/tooltip-constants.ts index 3aff89d10..e03aeab34 100644 --- a/src/lib/tooltip/tooltip-constants.ts +++ b/src/lib/tooltip/tooltip-constants.ts @@ -1,46 +1,56 @@ import { COMPONENT_NAME_PREFIX } from '../constants'; -import { PopupPlacement } from '../popup'; +import { PositionPlacement } from '../core/utils/position-utils'; +import { OverlayFlipState } from '../overlay/overlay-constants'; const elementName: keyof HTMLElementTagNameMap = `${COMPONENT_NAME_PREFIX}tooltip`; -const attributes = { - TEXT: 'text', - TARGET: 'target', +const observedAttributes = { + ID: 'id', + OPEN: 'open', + TYPE: 'type', + ANCHOR: 'anchor', + TARGET: 'target', // deprecated + PLACEMENT: 'placement', + POSITION: 'position', // deprecated DELAY: 'delay', - POSITION: 'position', - HOST: 'forge-tooltip-host' -}; + OFFSET: 'offset', + FLIP: 'flip', + BOUNDARY: 'boundary', + FALLBACK_PLACEMENTS: 'fallback-placements', + TRIGGER_TYPE: 'trigger-type' +} as const; -const classes = { - TOOLTIP: 'forge-tooltip', - TOOLTIP_OPEN: 'forge-tooltip--open', - TOOLTIP_TOP: 'forge-tooltip--top', - TOOLTIP_RIGHT: 'forge-tooltip--right', - TOOLTIP_BOTTOM: 'forge-tooltip--bottom', - TOOLTIP_LEFT: 'forge-tooltip--left' -}; - -const selectors = { - HOST: `[${attributes.HOST}]` -}; +const attributes = { + ...observedAttributes +} as const; const numbers = { - DEFAULT_DELAY: 500, - LONGPRESS_THRESHOLD: 750, LONGPRESS_VISIBILITY_DURATION: 3000 }; -const strings = { - DEFAULT_POSITION: 'right' +const defaults = { + DELAY: 500, + OFFSET: 4, + FLIP: 'auto' as OverlayFlipState, + TYPE: 'presentation' as TooltipType, + PLACEMENT: 'right' as TooltipPlacement, + TRIGGER_TYPES: ['hover'] as TooltipTriggerType[] }; +const selectors = { + CONTENT: '.forge-tooltip', + ARROW: '.arrow' +} as const; + export const TOOLTIP_CONSTANTS = { elementName, + observedAttributes, attributes, - classes, - selectors, numbers, - strings + defaults, + selectors }; -export type TooltipBuilder = () => HTMLElement; +export type TooltipType = 'presentation' | 'label' | 'description'; +export type TooltipPlacement = PositionPlacement; +export type TooltipTriggerType = 'hover' | 'longpress' | 'focus'; diff --git a/src/lib/tooltip/tooltip-foundation.ts b/src/lib/tooltip/tooltip-foundation.ts index 85b10ff97..de735ee56 100644 --- a/src/lib/tooltip/tooltip-foundation.ts +++ b/src/lib/tooltip/tooltip-foundation.ts @@ -1,309 +1,396 @@ -import { ICustomElementFoundation, throttle, Platform, isDefined } from '@tylertech/forge-core'; - +import { ICustomElementFoundation } from '@tylertech/forge-core'; import { ITooltipAdapter } from './tooltip-adapter'; -import { TOOLTIP_CONSTANTS, TooltipBuilder } from './tooltip-constants'; -import { PopupPlacement } from '../popup'; +import { TOOLTIP_CONSTANTS, TooltipPlacement, TooltipTriggerType, TooltipType } from './tooltip-constants'; +import { WithLongpressListener } from '../core/mixins/interactions/longpress/with-longpress-listener'; +import { canUserHoverElements } from '../constants'; +import { OverlayFlipState } from '../overlay/overlay-constants'; +import { PositionPlacement } from '../core/utils/position-utils'; export interface ITooltipFoundation extends ICustomElementFoundation { - text: string; - builder: TooltipBuilder | undefined; - target: string; - delay: number; - position: PopupPlacement; open: boolean; - hide(): void; - tooltipElement: HTMLElement | null; + type: TooltipType; + placement: `${TooltipPlacement}`; + delay: number; + anchor: string; + anchorElement: HTMLElement | null; + offset: number; + flip: OverlayFlipState; + boundary: string | null; + boundaryElement: HTMLElement | null; + fallbackPlacements: PositionPlacement[] | null; + triggerType: TooltipTriggerType | TooltipTriggerType[]; + syncTooltipAria(): void; } -/** - * The foundation class behind the tooltip component. - */ -export class TooltipFoundation implements ITooltipFoundation { - private static _tooltipIdentifier = 0; - private _identifier: string | null; - private _text: string; - private _builder: TooltipBuilder | undefined; - private _target: string; - private _delay = TOOLTIP_CONSTANTS.numbers.DEFAULT_DELAY; - private _position: PopupPlacement = TOOLTIP_CONSTANTS.strings.DEFAULT_POSITION as PopupPlacement; - private _mouseOverTimeout: number | undefined; - private _isOpen = false; - private _touchTimeout: number | undefined; - private _mouseOverListener: (evt: MouseEvent) => void; - private _mouseOutListener: (evt: MouseEvent) => void; - private _touchStartListener: (evt: MouseEvent) => void; - private _touchEndListener: (evt: MouseEvent) => void; - private _scrollListener: (evt: Event) => void; - private _clickListener: (evt: MouseEvent) => void; - private _mouseDownListener: (evt: MouseEvent) => void; - private _dragListener: (evt: DragEvent) => void; +const BaseClass = WithLongpressListener(); + +export class TooltipFoundation extends BaseClass implements ITooltipFoundation { + private _open = false; + private _type: TooltipType = TOOLTIP_CONSTANTS.defaults.TYPE; + private _anchor: string; + private _delay = TOOLTIP_CONSTANTS.defaults.DELAY; + private _placement: TooltipPlacement = TOOLTIP_CONSTANTS.defaults.PLACEMENT; + private _offset = TOOLTIP_CONSTANTS.defaults.OFFSET; + private _flip: OverlayFlipState = TOOLTIP_CONSTANTS.defaults.FLIP; + private _boundary: string | null = null; + private _boundaryElement: HTMLElement | null = null; + private _fallbackPlacements: PositionPlacement[] | null = null; + private _triggerTypes: TooltipTriggerType[] = TOOLTIP_CONSTANTS.defaults.TRIGGER_TYPES; + + // Hover trigger type + private _hoverStartListener: (evt: MouseEvent) => void = this._onHoverStart.bind(this); + private _hoverEndListener: (evt: MouseEvent) => void = this._onHoverEnd.bind(this); + private _hoverTimeout: number | undefined; + + // Focus trigger type + private _focusListener: (evt: FocusEvent) => void = this._onFocus.bind(this); + private _blurListener: (evt: FocusEvent) => void = this._onBlur.bind(this); + + // Longpress trigger type + private _longpressVisibilityTimeout: number | undefined; + + // Dismiss/hide triggers + private _scrollListener: (evt: Event) => void = this._onTryHide.bind(this); + private _mouseDownListener: (evt: MouseEvent) => void = this._onTryHide.bind(this); + private _dragListener: (evt: DragEvent) => void = this._onTryHide.bind(this); + private _lightDismissListener: (evt: Event) => void = this._onTryHide.bind(this); constructor(private _adapter: ITooltipAdapter) { - this._mouseOverListener = evt => this._onMouseOver(evt); - this._mouseOutListener = evt => this._onMouseOut(evt); - this._touchStartListener = evt => this._onTouchStart(evt); - this._touchEndListener = evt => this._onTouchEnd(evt); - this._scrollListener = throttle((evt: Event) => this._onScroll(evt), 100); - this._clickListener = () => this._targetInteracted(); - this._mouseDownListener = () => this._targetInteracted(); - this._dragListener = () => this._targetInteracted(); + super(); } public initialize(): void { - this._adapter.initializeTargetElement(this._target); + if (!this._adapter.anchorElement) { + this._adapter.tryLocateAnchorElement(this._anchor); + } + + this._adapter.syncAria(); + this._attachAnchorListeners(); - if (!this._adapter.hasTargetElement()) { - throw new Error('Unable to locate target element.'); + if (this._open) { + this._show(); } + } - // Set a unique id for this tooltip (unless it already has one) - this._identifier = this._adapter.getHostAttribute('id'); - if (!this._identifier) { - this._identifier = this._getNextIdentifier(); - this._adapter.setHostAttribute('id', this._identifier); + public destroy(): void { + if (this._open) { + this._hide(); } + this._adapter.detachAria(); + this._detachAnchorListeners(); + } - this._addTargetListeners(); - this._adapter.initializeAccessibility(this._identifier); + public syncTooltipAria(): void { + this._adapter.syncAria(); } - public disconnect(): void { - if (this._mouseOverTimeout) { - window.clearTimeout(this._mouseOverTimeout); - this._mouseOverTimeout = undefined; - } - if (this._touchTimeout) { - window.clearTimeout(this._touchTimeout); - this._touchTimeout = undefined; - } - this._adapter.destroy(this._identifier); - if (this._isOpen) { - this.hide(); + private _attachAnchorListeners(): void { + if (!this._adapter.anchorElement) { + return; } - if (this._adapter.hasTargetElement()) { - this._removeTargetListeners(); + + const triggerTypes = [...this._triggerTypes]; + + // If the users input mechanism doesn't support hover, then we need to force longpress as their alternative + /* c8 ignore next 4 */ + if (!canUserHoverElements) { + triggerTypes.splice(triggerTypes.indexOf('hover'), 1); + triggerTypes.push('longpress'); } - } - /** - * Generates the next available tooltip identifier. - */ - private _getNextIdentifier(): string { - return `forge-tooltip-${TooltipFoundation._tooltipIdentifier++}`; + const triggerInitializers: Record void> = { + 'hover': () => this._adapter.addAnchorListener('mouseenter', this._hoverStartListener), + 'longpress': () => this._startLongpressListener(this._adapter.anchorElement as HTMLElement), + 'focus': () => this._adapter.addAnchorListener('focusin', this._focusListener) + }; + triggerTypes.forEach(triggerType => triggerInitializers[triggerType]()); } - /** Adds event listeners to the target element. */ - private _addTargetListeners(): void { - if (Platform.isMobile) { - this._adapter.addTargetEventListener('touchstart', this._touchStartListener); - this._adapter.addTargetEventListener('touchend', this._touchEndListener); - } else { - this._adapter.addTargetEventListener('mouseover', this._mouseOverListener); - this._adapter.addTargetEventListener('mouseout', this._mouseOutListener); + private _detachAnchorListeners(): void { + if (!this._adapter.anchorElement) { + return; } - this._adapter.addTargetEventListener('click', this._clickListener); - this._adapter.addTargetEventListener('mousedown', this._mouseDownListener); - this._adapter.addTargetEventListener('dragstart', this._dragListener); - } + const triggerTypes = [...this._triggerTypes]; - /** - * Removes the event listeners from the target element. - * @param targetElement The target element instance. - */ - private _removeTargetListeners(): void { - if (Platform.isMobile) { - this._adapter.removeTargetEventListener('touchstart', this._touchStartListener); - this._adapter.removeTargetEventListener('touchend', this._touchEndListener); - } else { - this._adapter.removeTargetEventListener('mouseover', this._mouseOverListener); - this._adapter.removeTargetEventListener('mouseout', this._mouseOutListener); + /* c8 ignore next 3 */ + if (!canUserHoverElements) { + triggerTypes.push('longpress'); } - this._adapter.removeTargetEventListener('click', this._clickListener); - this._adapter.removeTargetEventListener('mousedown', this._mouseDownListener); - this._adapter.removeTargetEventListener('dragstart', this._dragListener); + const triggerRemovers: Record void> = { + 'hover': () => { + this._adapter.removeAnchorListener('mouseenter', this._hoverStartListener); + this._adapter.removeAnchorListener('mousedown', this._hoverEndListener); + this._adapter.removeAnchorListener('mouseleave', this._hoverEndListener); + }, + 'longpress': () => this._stopLongpressListener(this._adapter.anchorElement as HTMLElement), + 'focus': () => { + this._adapter.removeAnchorListener('focusin', this._focusListener); + this._adapter.removeAnchorListener('focusout', this._blurListener); + } + }; + triggerTypes.forEach(triggerType => triggerRemovers[triggerType]()); } - /** - * Handles the touchstart event on the target element to detect long press. - */ - private _onTouchStart(evt: Event): void { - this._touchTimeout = window.setTimeout(() => { - this._show(); - window.setTimeout(() => this.hide(), TOOLTIP_CONSTANTS.numbers.LONGPRESS_VISIBILITY_DURATION); - this._touchTimeout = undefined; - }, TOOLTIP_CONSTANTS.numbers.LONGPRESS_THRESHOLD); + private _show(): void { + this._open = true; + this._adapter.show(); + this._attachDismissListeners(); + this._adapter.toggleHostAttribute(TOOLTIP_CONSTANTS.attributes.OPEN, this._open); } - - /** - * Handles the touchend event on the target element to cancel a long press action. - */ - private _onTouchEnd(evt: Event): void { - if (this._touchTimeout) { - this._clearTouchTimer(); - } + + private _hide(): void { + window.clearTimeout(this._hoverTimeout); + window.clearTimeout(this._longpressVisibilityTimeout); + + this._open = false; + this._adapter.hide(); + this._detachDismissListeners(); + this._adapter.toggleHostAttribute(TOOLTIP_CONSTANTS.attributes.OPEN, this._open); } - private _clearTouchTimer(): void { - window.clearTimeout(this._touchTimeout); - this._touchTimeout = undefined; + private _attachDismissListeners(): void { + this._adapter.addAnchorListener('mousedown', this._mouseDownListener); + this._adapter.addAnchorListener('dragstart', this._dragListener); + this._adapter.addDocumentListener('scroll', this._scrollListener); + this._adapter.addDocumentListener('wheel', this._scrollListener); + this._adapter.addLightDismissListener(this._lightDismissListener); + } + + private _detachDismissListeners(): void { + this._adapter.removeAnchorListener('mousedown', this._mouseDownListener); + this._adapter.removeAnchorListener('dragstart', this._dragListener); + this._adapter.removeDocumentListener('scroll', this._scrollListener); + this._adapter.removeDocumentListener('wheel', this._scrollListener); + this._adapter.removeLightDismissListener(this._lightDismissListener); } - /** - * Handles the mouseover event on the target element. - */ - private _onMouseOver(evt: MouseEvent): void { - if (this._isOpen || !this._adapter.hasTargetElement() || !this._adapter.isTargetElementConnected()) { + private _onHoverStart(_evt: MouseEvent): void { + /* c8 ignore next 3 */ + if (this._open) { return; } + this._adapter.addAnchorListener('mousedown', this._hoverEndListener); + this._adapter.addAnchorListener('mouseleave', this._hoverEndListener); + if (this._delay) { - this._mouseOverTimeout = window.setTimeout(() => { - this._show(); - this._mouseOverTimeout = undefined; + this._hoverTimeout = window.setTimeout(() => { + this._onTryShow(); }, this._delay); } else { - this._show(); + this._onTryShow(); } } - /** - * Handles the mouseout event on the target element. - */ - private _onMouseOut(evt: MouseEvent): void { - if (this._mouseOverTimeout) { - window.clearTimeout(this._mouseOverTimeout); - this._mouseOverTimeout = undefined; + private _onHoverEnd(_evt: MouseEvent): void { + this._adapter.removeAnchorListener('mousedown', this._hoverEndListener); + this._adapter.removeAnchorListener('mouseleave', this._hoverEndListener); + window.clearTimeout(this._hoverTimeout); + this._onTryHide(); + } + + private _onFocus(_evt: FocusEvent): void { + /* c8 ignore next 3 */ + if (this._open) { return; } - this.hide(); + this._adapter.addAnchorListener('focusout', this._blurListener); + this._onTryShow(); } - /** - * Displays the tooltip. - */ - private _show(): void { - let content: HTMLElement | undefined; + private _onBlur(_evt: FocusEvent): void { + this._adapter.removeAnchorListener('focusout', this._blurListener); + this._onTryHide(); + } - if (this._builder && typeof this._builder === 'function') { - content = this._builder(); - } + protected _onLongpress(): void { + this._onTryShow(); + } - this._adapter.showTooltip(this._position, content); + protected override _onLongpressEnd(evt: PointerEvent | TouchEvent): void { + super._onLongpressEnd(evt); - if (this._adapter.hasTooltipElement()) { - this._isOpen = true; - this._adapter.addWindowListener('scroll', this._scrollListener); - } + // We only start the timeout to hide the tooltip after the user lifts the pointer + this._longpressVisibilityTimeout = window.setTimeout(() => { + this._onTryHide(); + }, TOOLTIP_CONSTANTS.numbers.LONGPRESS_VISIBILITY_DURATION); } - /** - * Hides the tooltip. - */ - public hide(): void { - if (!this._isOpen) { - return; - } - if (this._touchTimeout) { - this._clearTouchTimer(); + private _onTryShow(): void { + if (!this._open) { + this._show(); } - this._isOpen = false; - this._adapter.removeWindowListener('scroll', this._scrollListener); - this._adapter.hideTooltip(); } - /** - * Handles scrolling events when the tooltip is open. - */ - private _onScroll(evt: Event): void { - if (!this._isOpen) { - return; + private _onTryHide(): void { + if (this._open) { + this._hide(); } - this.hide(); } - - private _targetInteracted(): void { - if (this._mouseOverTimeout) { - window.clearTimeout(this._mouseOverTimeout); - } - if (this._isOpen) { - this.hide(); + + public get open(): boolean { + return this._open; + } + public set open(value: boolean) { + value = Boolean(value); + if (this._open !== value) { + if (this._adapter.isConnected) { + if (!this._open) { + this._show(); + } else { + this._hide(); + } + } else { + this._open = value; + } } } - /** Gets/sets the tooltip text. */ - public get text(): string { - return this._text || this._adapter.getInnerText(); + public get type(): TooltipType { + return this._type; } - public set text(value: string) { - if (this._text !== value) { - this._text = value; - this._adapter.setTextContent(this._text); - this._adapter.setHostAttribute(TOOLTIP_CONSTANTS.attributes.TEXT, this._text); + public set type(value: TooltipType) { + value ??= TOOLTIP_CONSTANTS.defaults.TYPE; + if (this._type !== value) { + this._type = value; + if (this._adapter.isConnected) { + this._adapter.syncAria(); + } + this._adapter.toggleHostAttribute(TOOLTIP_CONSTANTS.attributes.TYPE, this._type !== TOOLTIP_CONSTANTS.defaults.TYPE, this._type); } } - /** Sets the tooltip builder function. */ - public get builder(): TooltipBuilder | undefined { - return this._builder; - } - public set builder(value: TooltipBuilder | undefined) { - this._builder = value; + public get anchor(): string { + return this._anchor; } - - /** Gets/sets the target element CSS selector. */ - public get target(): string { - return this._target; + public set anchor(value: string) { + if (this._anchor !== value) { + this._anchor = value; + + if (this._adapter.isConnected) { + this._detachAnchorListeners(); + this._adapter.detachAria(); + this._adapter.tryLocateAnchorElement(this._anchor); + this._adapter.syncAria(); + this._attachAnchorListeners(); + } + + this._adapter.toggleHostAttribute(TOOLTIP_CONSTANTS.attributes.ANCHOR, !!this._anchor, this._anchor); + } } - public set target(value: string) { - if (this._target !== value) { - this._target = value; - if (this._adapter.hasTargetElement()) { - this._removeTargetListeners(); + public get anchorElement(): HTMLElement | null { + return this._adapter.anchorElement; + } + public set anchorElement(element: HTMLElement | null) { + if (this._adapter.anchorElement !== element) { + if (this._adapter.isConnected) { + this._detachAnchorListeners(); + this._adapter.detachAria(); } - this._adapter.initializeTargetElement(this._target); + this._adapter.setAnchorElement(element); - if (this._adapter.hasTargetElement()) { - this._addTargetListeners(); + if (this._adapter.isConnected) { + this._adapter.syncAria(); + this._attachAnchorListeners(); } - - this._adapter.setHostAttribute(TOOLTIP_CONSTANTS.attributes.TARGET, isDefined(this._target) ? this._target : ''); } } - /** Gets/sets the interaction delay. */ public get delay(): number { return this._delay; } public set delay(value: number) { if (this._delay !== value) { this._delay = value; - this._adapter.setHostAttribute(TOOLTIP_CONSTANTS.attributes.DELAY, isDefined(this._delay) ? this._delay.toString() : ''); + this._adapter.setHostAttribute(TOOLTIP_CONSTANTS.attributes.DELAY, String(this._delay)); } } - /** Gets/sets the tooltip position. */ - public get position(): PopupPlacement { - return this._position; + public get placement(): TooltipPlacement { + return this._placement; } - public set position(value: PopupPlacement) { - if (this._position !== value) { - this._position = value; - this._adapter.setHostAttribute(TOOLTIP_CONSTANTS.attributes.POSITION, isDefined(this._position) ? this._position.toString() : ''); + public set placement(value: TooltipPlacement) { + value ??= TOOLTIP_CONSTANTS.defaults.PLACEMENT; + if (this._placement !== value) { + this._placement = value; + this._adapter.setHostAttribute(TOOLTIP_CONSTANTS.attributes.PLACEMENT, String(this._placement)); } } - public set open(value: boolean) { - this._show(); + public get offset(): number { + return this._offset; } - public get open(): boolean { - return this._isOpen; + public set offset(value: number) { + value ??= TOOLTIP_CONSTANTS.defaults.OFFSET; + if (this._offset !== value) { + this._offset = value; + this._adapter.toggleHostAttribute(TOOLTIP_CONSTANTS.attributes.OFFSET, this._offset !== TOOLTIP_CONSTANTS.defaults.OFFSET, String(this._offset)); + } + } + + public get flip(): OverlayFlipState { + return this._flip; + } + public set flip(value: OverlayFlipState) { + value ??= TOOLTIP_CONSTANTS.defaults.FLIP; + if (this._flip !== value) { + this._flip = value; + this._adapter.toggleHostAttribute(TOOLTIP_CONSTANTS.attributes.FLIP, this._flip !== TOOLTIP_CONSTANTS.defaults.FLIP, String(this._flip)); + } } - public get tooltipElement(): HTMLElement | null { - return this._adapter.getTooltipElement(); + public get boundary(): string | null { + return this._boundary; + } + public set boundary(value: string | null) { + if (this._boundary !== value) { + this._boundary = value; + this._adapter.toggleHostAttribute(TOOLTIP_CONSTANTS.attributes.BOUNDARY, !!this._boundary, this._boundary as string); + } + } + + public get boundaryElement(): HTMLElement | null { + return this._boundaryElement; + } + public set boundaryElement(element: HTMLElement | null) { + if (this._boundaryElement !== element) { + this._boundaryElement = element; + this._adapter.toggleHostAttribute(TOOLTIP_CONSTANTS.attributes.BOUNDARY, !!this._boundaryElement, this._boundaryElement?.id); + } + } + + public get fallbackPlacements(): PositionPlacement[] | null { + return this._fallbackPlacements; + } + public set fallbackPlacements(value: PositionPlacement[] | null) { + if (this._fallbackPlacements !== value) { + this._fallbackPlacements = value; + } + } + + public get triggerType(): TooltipTriggerType | TooltipTriggerType[] { + return this._triggerTypes.length === 1 ? this._triggerTypes[0] : this._triggerTypes; + } + public set triggerType(value: TooltipTriggerType | TooltipTriggerType[]) { + if (this._triggerTypes !== value) { + if (this._adapter.isConnected) { + this._detachAnchorListeners(); + } + + this._triggerTypes = Array.isArray(value) ? value : [value]; + this._triggerTypes = this._triggerTypes.filter(type => !!type); + + if (!this._triggerTypes.length) { + this._triggerTypes = TOOLTIP_CONSTANTS.defaults.TRIGGER_TYPES; + } + + if (this._adapter.isConnected) { + this._attachAnchorListeners(); + } + } } } diff --git a/src/lib/tooltip/tooltip-utils.ts b/src/lib/tooltip/tooltip-utils.ts deleted file mode 100644 index 0ceed81bc..000000000 --- a/src/lib/tooltip/tooltip-utils.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { IElementPosition, positionElementAsync } from '@tylertech/forge-core'; -import { PopupPlacement } from '../popup'; -import { TOOLTIP_CONSTANTS } from './tooltip-constants'; - -/** - * Attaches a positioned tooltip to the provided target element. - * @param targetElement The element to position the tooltip around. - * @param placement The placement of the tooltip relative to the target element. - * @param content The content of the tooltip. - */ -export function attachTooltip(targetElement: HTMLElement, placement: PopupPlacement, content: string | HTMLElement | Text): HTMLElement { - if (typeof content === 'string') { - content = document.createTextNode(content); - } - - const element = document.createElement('div'); - element.setAttribute('role', 'tooltip'); - element.classList.add(TOOLTIP_CONSTANTS.classes.TOOLTIP); - element.appendChild(content); - element.setAttribute('role', 'tooltip'); - element.setAttribute('aria-hidden', 'true'); - - const hostDocument = targetElement.ownerDocument || document; - hostDocument.body.appendChild(element); - - const offset: IElementPosition = { x: 0, y: 0 }; - const offsetAmount = 4; - - switch (placement) { - case 'top': - offset.y = -offsetAmount; - element.classList.add(TOOLTIP_CONSTANTS.classes.TOOLTIP_TOP); - break; - case 'right': - offset.x = offsetAmount; - element.classList.add(TOOLTIP_CONSTANTS.classes.TOOLTIP_RIGHT); - break; - case 'bottom': - offset.y = offsetAmount; - element.classList.add(TOOLTIP_CONSTANTS.classes.TOOLTIP_BOTTOM); - break; - case 'left': - offset.x = -offsetAmount; - element.classList.add(TOOLTIP_CONSTANTS.classes.TOOLTIP_LEFT); - break; - } - - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - element.classList.add(TOOLTIP_CONSTANTS.classes.TOOLTIP_OPEN); - positionElementAsync({ element, targetElement, placement, transform: false, offset }); - }); - }); - - return element; -} diff --git a/src/lib/tooltip/tooltip.html b/src/lib/tooltip/tooltip.html new file mode 100644 index 000000000..e35169927 --- /dev/null +++ b/src/lib/tooltip/tooltip.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/src/lib/tooltip/tooltip.scss b/src/lib/tooltip/tooltip.scss new file mode 100644 index 000000000..b87338327 --- /dev/null +++ b/src/lib/tooltip/tooltip.scss @@ -0,0 +1,102 @@ +@use './core' as *; +@use './animations'; + +// +// Host +// + +:host { + @include host; +} + +:host([hidden]) { + display: none; +} + +// +// Base +// + +.forge-tooltip { + @include tokens; +} + +.forge-tooltip { + @include base; +} + +// +// Tooltips are visually hidden by default so that `aria-labelledby` and `aria-describedby` can be used, but +// when they are open we remove the visually hidden styles and replace with the to visually +// render the floating tooltip text above all content, while still allowing the tooltip to be accessible to +// screen readers. +// + +:host(:not([open])) { + .forge-tooltip { + @include visually-hidden; + } +} + +// +// Arrow +// + +.arrow { + @include arrow; + + #{declare(arrow-translate-x)}: 0; + #{declare(arrow-translate-y)}: 0; + #{declare(arrow-clip-path)}: polygon(0 0, 0 100%, 100% 100%); +} + +// +// Animation / Arrow placement +// + +.forge-tooltip { + #{declare(slidein-x)}: 0; + #{declare(slidein-y)}: 0; +} + +forge-overlay[open] { + &[position-placement^=top] { + .forge-tooltip { + @include override(slidein-y, #{token(animation-offset)}, value); + } + + .arrow { + @include arrow-placement-top; + } + } + + &[position-placement^=right] { + .forge-tooltip { + @include override(slidein-x, calc(#{token(animation-offset)} * -1), value); + } + + .arrow { + @include arrow-placement-right; + } + } + + &[position-placement^=bottom] { + .forge-tooltip { + @include override(slidein-y, calc(#{token(animation-offset)} * -1), value); + } + + .arrow { + @include arrow-placement-bottom; + } + } + + &[position-placement^=left] { + .forge-tooltip { + @include override(slidein-x, #{token(animation-offset)}, value); + } + + .arrow { + @include arrow-placement-left; + } + } +} diff --git a/src/lib/tooltip/tooltip.test.ts b/src/lib/tooltip/tooltip.test.ts new file mode 100644 index 000000000..06ec1a1b1 --- /dev/null +++ b/src/lib/tooltip/tooltip.test.ts @@ -0,0 +1,777 @@ +import { expect } from '@esm-bundle/chai'; +import { nothing } from 'lit'; +import { elementUpdated, fixture, html } from '@open-wc/testing'; +import { sendMouse, sendKeys } from '@web/test-runner-commands'; +import { timer } from '@tylertech/forge-testing'; +import { LONGPRESS_TRIGGER_DELAY } from '../core/mixins/interactions/longpress/with-longpress-listener'; +import type { ITooltipComponent } from './tooltip'; +import type { IOverlayComponent } from '../overlay/overlay'; +import { OVERLAY_CONSTANTS } from '../overlay'; +import { TooltipPlacement, TooltipTriggerType, TooltipType, TOOLTIP_CONSTANTS } from './tooltip-constants'; + +import './tooltip'; + +describe('Tooltip', () => { + afterEach(async () => { + // Always reset mouse position to avoid initial hover state issues when a test starts + await sendMouse({ type: 'move', position: [0, 0] }); + }); + + describe('defaults', () => { + it('should have expected default values', async () => { + const harness = await createFixture(); + + expect(harness.tooltipElement.open).to.be.false; + expect(harness.tooltipElement.type).to.equal('presentation' satisfies TooltipType); + expect(harness.tooltipElement.anchor).to.be.undefined; + expect(harness.tooltipElement.anchorElement).to.equal(harness.anchorElement); + expect(harness.tooltipElement.placement).to.equal('right' satisfies TooltipPlacement); + expect(harness.tooltipElement.triggerType).to.equal('hover' satisfies TooltipTriggerType); + expect(harness.tooltipElement.delay).to.equal(TOOLTIP_CONSTANTS.defaults.DELAY); + expect(harness.tooltipElement.offset).to.equal(TOOLTIP_CONSTANTS.defaults.OFFSET); + }); + + it('should not be open by default', async () => { + const harness = await createFixture(); + + expect(harness.isOpen).to.be.false; + }); + + it('should open by default with open attribute', async () => { + const harness = await createFixture({ open: true }); + + expect(harness.isOpen).to.be.true; + }); + + it('should toggle open via property', async () => { + const harness = await createFixture(); + + harness.tooltipElement.open = true; + expect(harness.isOpen).to.be.true; + + harness.tooltipElement.open = false; + expect(harness.isOpen).to.be.false; + }); + }); + + describe('accessibility', () => { + it('should be presentational by default', async () => { + const harness = await createFixture(); + + expect(harness.tooltipElement.type).to.equal('presentation' satisfies TooltipType); + expect(harness.tooltipElement.hasAttribute(TOOLTIP_CONSTANTS.attributes.TYPE)).to.be.false; + expect(harness.tooltipElement.hasAttribute('role')).to.be.false; + expect(harness.tooltipElement.hasAttribute('aria-hidden')).to.be.true; + expect(harness.anchorElement.hasAttribute('aria-label')).to.be.false; + expect(harness.anchorElement.hasAttribute('aria-labelledby')).to.be.false; + expect(harness.anchorElement.hasAttribute('aria-describedby')).to.be.false; + }); + + it('should be accessible when closed with presentation type', async () => { + const harness = await createFixture(); + + expect(harness.isOpen).to.be.false; + expect(harness.tooltipElement.type).to.equal('presentation' satisfies TooltipType); + expect(harness.tooltipElement.hasAttribute('role')).to.be.false; + expect(harness.tooltipElement.hasAttribute('aria-hidden')).to.be.true; + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should be accessible when closed with label type', async () => { + const harness = await createFixture({ type: 'label' }); + + expect(harness.isOpen).to.be.false; + expect(harness.tooltipElement.type).to.equal('label' satisfies TooltipType); + expect(harness.anchorElement.getAttribute('aria-labelledby')).to.equal(harness.tooltipElement.id); + expect(harness.anchorElement.hasAttribute('aria-describedby')).to.be.false; + expect(harness.tooltipElement.hasAttribute('aria-hidden')).to.be.true; + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should be accessible when closed with description type', async () => { + const harness = await createFixture({ type: 'description' }); + + expect(harness.isOpen).to.be.false; + expect(harness.tooltipElement.type).to.equal('description' satisfies TooltipType); + expect(harness.anchorElement.getAttribute('aria-describedby')).to.equal(harness.tooltipElement.id); + expect(harness.anchorElement.hasAttribute('aria-labelledby')).to.be.false; + expect(harness.tooltipElement.hasAttribute('aria-hidden')).to.be.true; + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should be accessible when open with presentation type', async () => { + const harness = await createFixture({ open: true }); + + expect(harness.isOpen).to.be.true; + expect(harness.tooltipElement.hasAttribute('aria-hidden')).to.be.true; + expect(harness.tooltipElement.hasAttribute('role')).to.be.false; + expect(harness.anchorElement.hasAttribute('aria-labelledby')).to.be.false; + expect(harness.anchorElement.hasAttribute('aria-describedby')).to.be.false; + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should be accessible when open with label type', async () => { + const harness = await createFixture({ open: true, type: 'label' }); + + expect(harness.isOpen).to.be.true; + expect(harness.tooltipElement.hasAttribute('aria-hidden')).to.be.true; + expect(harness.tooltipElement.hasAttribute('role')).to.be.false; + expect(harness.anchorElement.hasAttribute('aria-describedby')).to.be.false; + expect(harness.anchorElement.getAttribute('aria-labelledby')).to.equal(harness.tooltipElement.id); + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should be accessible when open with description type', async () => { + const harness = await createFixture({ open: true, type: 'description' }); + + expect(harness.isOpen).to.be.true; + expect(harness.tooltipElement.hasAttribute('aria-hidden')).to.be.true; + expect(harness.tooltipElement.getAttribute('role')).to.equal('tooltip'); + expect(harness.anchorElement.hasAttribute('aria-labelledby')).to.be.false; + expect(harness.anchorElement.getAttribute('aria-describedby')).to.equal(harness.tooltipElement.id); + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should not override user-provided aria-labelledby', async () => { + const harness = await createFixture(); + + harness.tooltipElement.setAttribute('aria-labelledby', 'test-id'); + harness.anchorElement.setAttribute('aria-labelledby', 'test-id'); + harness.tooltipElement.type = 'label'; + + await elementUpdated(harness.tooltipElement); + + expect(harness.anchorElement.getAttribute('aria-labelledby')).to.equal('test-id'); + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should not override user-provided aria-describedby', async () => { + const harness = await createFixture(); + + harness.tooltipElement.setAttribute('aria-describedby', 'test-id'); + harness.anchorElement.setAttribute('aria-describedby', 'test-id'); + harness.tooltipElement.type = 'description'; + + await elementUpdated(harness.tooltipElement); + + expect(harness.anchorElement.getAttribute('aria-describedby')).to.equal('test-id'); + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should detach aria-labelledby when changing anchor elements', async () => { + const harness = await createFixture({ type: 'label' }); + + expect(harness.anchorElement.getAttribute('aria-labelledby')).to.equal(harness.tooltipElement.id); + + harness.tooltipElement.anchorElement = harness.altAnchorElement; + + expect(harness.anchorElement.hasAttribute('aria-labelledby')).to.be.false; + expect(harness.altAnchorElement.getAttribute('aria-labelledby')).to.equal(harness.tooltipElement.id); + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should detach aria-labelledby when changing anchor elements via id', async () => { + const harness = await createFixture({ type: 'label' }); + + expect(harness.anchorElement.getAttribute('aria-labelledby')).to.equal(harness.tooltipElement.id); + + harness.tooltipElement.anchor = harness.altAnchorElement.id; + + expect(harness.anchorElement.hasAttribute('aria-labelledby')).to.be.false; + expect(harness.altAnchorElement.getAttribute('aria-labelledby')).to.equal(harness.tooltipElement.id); + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should detach aria-labelledby when removing tooltip', async () => { + const harness = await createFixture({ type: 'label' }); + + expect(harness.anchorElement.getAttribute('aria-labelledby')).to.equal(harness.tooltipElement.id); + + harness.tooltipElement.remove(); + + expect(harness.anchorElement.hasAttribute('aria-labelledby')).to.be.false; + }); + + it('should detach aria-describedby when changing anchor elements', async () => { + const harness = await createFixture({ type: 'description' }); + + expect(harness.anchorElement.getAttribute('aria-describedby')).to.equal(harness.tooltipElement.id); + + harness.tooltipElement.anchorElement = harness.altAnchorElement; + + expect(harness.anchorElement.hasAttribute('aria-describedby')).to.be.false; + expect(harness.altAnchorElement.getAttribute('aria-describedby')).to.equal(harness.tooltipElement.id); + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should detach aria-describedby when changing anchor elements via id', async () => { + const harness = await createFixture({ type: 'description' }); + + expect(harness.anchorElement.getAttribute('aria-describedby')).to.equal(harness.tooltipElement.id); + + harness.tooltipElement.anchor = harness.altAnchorElement.id; + + expect(harness.anchorElement.hasAttribute('aria-describedby')).to.be.false; + expect(harness.altAnchorElement.getAttribute('aria-describedby')).to.equal(harness.tooltipElement.id); + await expect(harness.tooltipElement).to.be.accessible(); + }); + + it('should detach aria-describedby when removing tooltip', async () => { + const harness = await createFixture({ type: 'description' }); + + expect(harness.anchorElement.getAttribute('aria-describedby')).to.equal(harness.tooltipElement.id); + + harness.tooltipElement.remove(); + + expect(harness.anchorElement.hasAttribute('aria-describedby')).to.be.false; + }); + + it('should not remove user-provided aria-labelledby or aria-describedby attributes when removing tooltip if presentation type', async () => { + const harness = await createFixture(); + + harness.anchorElement.setAttribute('aria-labelledby', 'test-id'); + harness.anchorElement.setAttribute('aria-describedby', 'test-id'); + + harness.tooltipElement.remove(); + + expect(harness.anchorElement.getAttribute('aria-labelledby')).to.equal('test-id'); + expect(harness.anchorElement.getAttribute('aria-describedby')).to.equal('test-id'); + }); + }); + + describe('overlay', () => { + it('should proxy placement', async () => { + const harness = await createFixture(); + + harness.tooltipElement.placement = 'bottom'; + + expect(harness.tooltipElement.placement).to.equal('bottom'); + expect(harness.tooltipElement.getAttribute(OVERLAY_CONSTANTS.attributes.PLACEMENT)).to.equal('bottom'); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.placement).to.equal('bottom'); + }); + + it('should proxy offset', async () => { + const harness = await createFixture(); + + const offset = 10; + harness.tooltipElement.offset = offset; + + expect(harness.tooltipElement.offset).to.deep.equal(offset); + expect(harness.tooltipElement.getAttribute(TOOLTIP_CONSTANTS.attributes.OFFSET)).to.equal(`${offset}`); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.offset).to.deep.equal({ mainAxis: offset }); + }); + + it('should proxy flip', async () => { + const harness = await createFixture(); + + harness.tooltipElement.flip = 'main'; + + expect(harness.tooltipElement.flip).to.equal('main'); + expect(harness.tooltipElement.getAttribute(OVERLAY_CONSTANTS.attributes.FLIP)).to.equal('main'); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.flip).to.equal('main'); + }); + + it('should proxy boundary', async () => { + const harness = await createFixture(); + + const elId = 'alt-anchor'; + harness.tooltipElement.boundary = elId; + + expect(harness.tooltipElement.boundary).to.equal(elId); + expect(harness.tooltipElement.getAttribute(OVERLAY_CONSTANTS.attributes.BOUNDARY)).to.equal(elId); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.boundaryElement).to.equal(harness.altAnchorElement); + }); + + it('should proxy boundary element', async () => { + const harness = await createFixture(); + + const boundaryEl = document.createElement('div'); + + harness.tooltipElement.boundaryElement = boundaryEl; + + expect(harness.tooltipElement.boundaryElement).to.equal(boundaryEl); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.boundaryElement).to.equal(boundaryEl); + }); + + it('should proxy fallback placements', async () => { + const harness = await createFixture(); + + harness.tooltipElement.fallbackPlacements = ['top', 'bottom']; + + expect(harness.tooltipElement.fallbackPlacements).to.deep.equal(['top', 'bottom']); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.fallbackPlacements).to.deep.equal(['top', 'bottom']); + }); + + it('should proxy fallback placements via attribute', async () => { + const harness = await createFixture(); + + harness.tooltipElement.setAttribute(TOOLTIP_CONSTANTS.attributes.FALLBACK_PLACEMENTS, 'top,bottom'); + + expect(harness.tooltipElement.fallbackPlacements).to.deep.equal(['top', 'bottom']); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.fallbackPlacements).to.deep.equal(['top', 'bottom']); + }); + + it('should proxy anchorElement', async () => { + const harness = await createFixture(); + + harness.tooltipElement.anchorElement = harness.altAnchorElement; + + expect(harness.tooltipElement.anchorElement).to.equal(harness.altAnchorElement); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.anchorElement).to.equal(harness.altAnchorElement); + }); + + it('should proxy anchor', async () => { + const harness = await createFixture(); + + harness.tooltipElement.anchor = harness.altAnchorElement.id; + + expect(harness.tooltipElement.anchor).to.equal(harness.altAnchorElement.id); + + harness.tooltipElement.open = true; + + expect(harness.overlayElement?.anchorElement).to.equal(harness.altAnchorElement); + }); + + it('should proxy open', async () => { + const harness = await createFixture(); + + harness.tooltipElement.open = true; + + expect(harness.tooltipElement.open).to.be.true; + expect(harness.tooltipElement.hasAttribute(OVERLAY_CONSTANTS.attributes.OPEN)).to.be.true;; + + expect(harness.overlayElement?.open).to.be.true; + }); + }); + + describe('anchor', () => { + it('should set anchor to previous element sibling implicitly', async () => { + const harness = await createFixture(); + + expect(harness.tooltipElement.anchorElement).to.equal(harness.anchorElement); + }); + + it('should set anchorElement explicitly', async () => { + const harness = await createFixture(); + + harness.tooltipElement.anchorElement = harness.altAnchorElement; + + expect(harness.tooltipElement.anchorElement).to.equal(harness.altAnchorElement); + }); + + it('should set anchor via id reference explicitly', async () => { + const harness = await createFixture(); + + harness.tooltipElement.anchor = harness.altAnchorElement.id; + + expect(harness.tooltipElement.anchorElement).to.equal(harness.altAnchorElement); + expect(harness.tooltipElement.getAttribute(TOOLTIP_CONSTANTS.attributes.ANCHOR)).to.equal(harness.altAnchorElement.id); + }); + + it('should automatically set anchor to parent element if no previous sibling element exists', async () => { + const harness = await createFixture(); + + const newTooltip = document.createElement('forge-tooltip'); + harness.containerElement.insertAdjacentElement('afterbegin', newTooltip); + + expect(newTooltip.anchorElement).to.equal(harness.containerElement); + }); + }); + + describe('focus trigger type', () => { + it('should open when focusing the trigger button', async () => { + const harness = await createFixture({ triggerType: 'focus' }); + + harness.focusTrigger(); + + expect(harness.isOpen).to.be.true; + }); + + it('should open and close when focusing and blurring the trigger button', async () => { + const harness = await createFixture({ triggerType: 'focus' }); + + harness.focusTrigger(); + expect(harness.isOpen).to.be.true; + + await harness.blurTrigger(); + expect(harness.isOpen).to.be.false; + }); + }); + + describe('hover trigger type', () => { + it('should open when hovering the trigger button', async () => { + const harness = await createFixture({ triggerType: 'hover' }); + + expect(harness.isOpen).to.be.false; + + await harness.hoverTrigger(); + await timer(harness.tooltipElement.delay + 100); + + expect(harness.isOpen).to.be.true; + }); + + it('should open and close when hovering and unhovering the trigger button', async () => { + const harness = await createFixture({ triggerType: 'hover' }); + + await harness.hoverTrigger(); + await timer(harness.tooltipElement.delay + 100); + expect(harness.isOpen).to.be.true; + + await harness.hoverOutside(); + + expect(harness.isOpen).to.be.false; + }); + + it('should not close after open when hovering and unhovering the trigger before the delay elapses', async () => { + const harness = await createFixture({ triggerType: 'hover' }); + + expect(harness.isOpen).to.be.false; + + await harness.hoverTrigger(); + await timer(harness.tooltipElement.delay + 100); + expect(harness.isOpen).to.be.true; + + await harness.hoverOutside(); + await timer(harness.tooltipElement.delay / 2); + await harness.hoverTrigger(); + await timer(harness.tooltipElement.delay + 100); + + expect(harness.isOpen).to.be.true; + }); + + it('should use custom hover dismiss delay', async () => { + const harness = await createFixture({ triggerType: 'hover' }); + + const customDelay = 100; + harness.tooltipElement.delay = customDelay; + + expect(harness.tooltipElement.delay).to.equal(customDelay); + expect(harness.isOpen).to.be.false; + + await harness.hoverTrigger(); + await timer(harness.tooltipElement.delay + 100); + expect(harness.isOpen).to.be.true; + + await harness.hoverOutside(); + + expect(harness.isOpen).to.be.false; + }); + + it('should open immediately when hover delay is set to 0', async () => { + const harness = await createFixture({ triggerType: 'hover', delay: 0 }); + + expect(harness.isOpen).to.be.false; + + await harness.hoverTrigger(); + expect(harness.isOpen).to.be.true; + }); + }); + + describe('longpress trigger type', () => { + it('should open when longpressing the anchor', async () => { + const harness = await createFixture({ triggerType: 'longpress' }); + + await harness.longpressTrigger(); + + expect(harness.isOpen).to.be.true; + }); + + it('should close by click outside after longpressing to open', async () => { + const harness = await createFixture({ triggerType: 'longpress' }); + + await harness.longpressTrigger(); + expect(harness.isOpen).to.be.true; + + await harness.clickOutside(); + expect(harness.isOpen).to.be.false; + }); + + it('should not open when releasing the anchor before the longpress delay', async () => { + const harness = await createFixture({ triggerType: 'longpress' }); + + await harness.longpressStopBeforeDelay(); + expect(harness.isOpen).to.be.false; + }); + + it('should automatically hide after longpress visibility threshold', async () => { + const harness = await createFixture({ triggerType: 'longpress' }); + + await harness.longpressTrigger(); + expect(harness.isOpen).to.be.true; + + await timer(TOOLTIP_CONSTANTS.numbers.LONGPRESS_VISIBILITY_DURATION + 100); + expect(harness.isOpen).to.be.false; + }); + }); + + describe('multiple trigger types', () => { + it('should allow for providing multiple trigger types via attribute', async () => { + const harness = await createFixture(); + + harness.tooltipElement.setAttribute(TOOLTIP_CONSTANTS.attributes.TRIGGER_TYPE, 'hover,focus'); + + expect(harness.tooltipElement.triggerType).to.deep.equal(['hover', 'focus']); + }); + + it('should fall back to default trigger type of "hover" if trigger-type attribute is removed', async () => { + const harness = await createFixture({ triggerType: 'focus' }); + + harness.tooltipElement.removeAttribute(TOOLTIP_CONSTANTS.attributes.TRIGGER_TYPE); + + expect(harness.tooltipElement.triggerType).to.equal('hover'); + }); + + it('should fall back to default trigger type of "hover" if triggerType property is set to empty array', async () => { + const harness = await createFixture({ triggerType: 'focus' }); + + harness.tooltipElement.triggerType = []; + + expect(harness.tooltipElement.triggerType).to.equal('hover'); + }); + + it('should fall back to default trigger type of "hover" if triggerType property is set to null', async () => { + const harness = await createFixture({ triggerType: 'focus' }); + + harness.tooltipElement.triggerType = null as any; + + expect(harness.tooltipElement.triggerType).to.equal('hover'); + }); + + it('should open/close via focus when both focus and hover triggers to be specified together', async () => { + const harness = await createFixture(); + + harness.tooltipElement.setAttribute(TOOLTIP_CONSTANTS.attributes.TRIGGER_TYPE, 'focus,hover'); + + expect(harness.tooltipElement.triggerType).to.deep.equal(['focus', 'hover']); + + await harness.focusTrigger(); + expect(harness.isOpen).to.be.true; + + await harness.blurTrigger(); + expect(harness.isOpen).to.be.false; + }); + + it('should open/close via hover when both focus and hover triggers to be specified together', async () => { + const harness = await createFixture(); + + harness.tooltipElement.setAttribute(TOOLTIP_CONSTANTS.attributes.TRIGGER_TYPE, 'focus,hover'); + + expect(harness.tooltipElement.triggerType).to.deep.equal(['focus', 'hover']); + + await harness.hoverOutside(); + await harness.hoverTrigger(); + await timer(harness.tooltipElement.delay + 100); + expect(harness.isOpen).to.be.true; + + await harness.hoverOutside(); + + expect(harness.isOpen).to.be.false; + }); + }); + + describe('light dismiss', () => { + it('should dismiss when clicking outside the tooltip', async () => { + const harness = await createFixture({ open: true }); + + await harness.clickOutside(); + + expect(harness.isOpen).to.be.false; + }); + + it('should dismiss when pressing the escape key', async () => { + const harness = await createFixture({ open: true }); + + await harness.pressEscapeKey(); + + expect(harness.isOpen).to.be.false; + }); + }); + + describe('arrow', () => { + it('should show arrow element', async () => { + const harness = await createFixture({ open: true }); + + expect(harness.arrowElement).to.exist; + }); + }); + + describe('deprecated properties/attributes', () => { + it('should set placement when setting deprecated position property', async () => { + const harness = await createFixture(); + + harness.tooltipElement.position = 'bottom'; + + expect(harness.tooltipElement.position).to.equal('bottom'); + expect(harness.tooltipElement.placement).to.equal('bottom'); + expect(harness.tooltipElement.getAttribute(TOOLTIP_CONSTANTS.attributes.PLACEMENT)).to.equal('bottom'); + expect(harness.tooltipElement.hasAttribute('position')).to.be.false; + }); + + it('should set position when setting deprecated position attribute', async () => { + const harness = await createFixture(); + + harness.tooltipElement.setAttribute('position', 'bottom'); + + expect(harness.tooltipElement.position).to.equal('bottom'); + expect(harness.tooltipElement.placement).to.equal('bottom'); + expect(harness.tooltipElement.getAttribute(TOOLTIP_CONSTANTS.attributes.PLACEMENT)).to.equal('bottom'); + expect(harness.tooltipElement.getAttribute('position')).to.equal('bottom'); + }); + + it('should set anchor when setting deprecated target property', async () => { + const harness = await createFixture(); + + harness.tooltipElement.target = 'alt-anchor'; + + expect(harness.tooltipElement.target).to.equal('alt-anchor'); + expect(harness.tooltipElement.anchor).to.equal('alt-anchor'); + expect(harness.tooltipElement.getAttribute(TOOLTIP_CONSTANTS.attributes.ANCHOR)).to.equal('alt-anchor'); + expect(harness.tooltipElement.hasAttribute('target')).to.be.false; + }); + + it('should set anchor when setting deprecated target attribute', async () => { + const harness = await createFixture(); + + harness.tooltipElement.setAttribute('target', 'alt-anchor'); + + expect(harness.tooltipElement.target).to.equal('alt-anchor'); + expect(harness.tooltipElement.anchor).to.equal('alt-anchor'); + expect(harness.tooltipElement.getAttribute(TOOLTIP_CONSTANTS.attributes.ANCHOR)).to.equal('alt-anchor'); + expect(harness.tooltipElement.getAttribute('target')).to.equal('alt-anchor'); + }); + }); +}); + +class TooltipHarness { + constructor( + public tooltipElement: ITooltipComponent, + public anchorElement: HTMLButtonElement, + public altAnchorElement: HTMLButtonElement, + public containerElement: HTMLElement) {} + + public get contentElement(): HTMLElement { + return this.tooltipElement.shadowRoot?.querySelector(TOOLTIP_CONSTANTS.selectors.CONTENT) as HTMLElement; + } + + public get arrowElement(): HTMLElement { + return this.tooltipElement.shadowRoot?.querySelector(TOOLTIP_CONSTANTS.selectors.ARROW) as HTMLElement; + } + + public get overlayElement(): IOverlayComponent | null { + return this.tooltipElement.shadowRoot?.querySelector(OVERLAY_CONSTANTS.elementName) as IOverlayComponent; + } + + public get isOpen(): boolean { + return this.tooltipElement.open && + this.tooltipElement.hasAttribute(TOOLTIP_CONSTANTS.attributes.OPEN) && + !!this.overlayElement?.open; + } + + public async clickOutside(): Promise { + const { x, y, height, width } = this.contentElement.getBoundingClientRect(); + const mouseX = Math.round(x + width * 2); + const mouseY = Math.round(y + height * 2); + await sendMouse({ type: 'click', position: [mouseX, mouseY], button: 'left' }); + } + + public async clickTrigger(): Promise { + const { x, y, height, width } = this.anchorElement.getBoundingClientRect(); + const mouseX = Math.round(x + width / 2); + const mouseY = Math.round(y + height / 2); + await sendMouse({ type: 'click', position: [mouseX, mouseY], button: 'left' }); + } + + public async hoverOutside(): Promise { + await sendMouse({ type: 'move', position: [0, 0] }); + } + + public async hoverTrigger(): Promise { + const { x, y, height, width } = this.anchorElement.getBoundingClientRect(); + const mouseX = Math.round(x + width / 2); + const mouseY = Math.round(y + height / 2); + await sendMouse({ type: 'move', position: [mouseX, mouseY] }); + } + + public async longpressTrigger(delay = LONGPRESS_TRIGGER_DELAY): Promise { + await this.hoverTrigger(); + await sendMouse({ type: 'down', button: 'left' }); + await timer(delay); + await sendMouse({ type: 'up', button: 'left' }); + await this.hoverOutside(); + } + + public async longpressStopBeforeDelay(): Promise { + await this.hoverTrigger(); + await sendMouse({ type: 'down', button: 'left' }); + await timer(LONGPRESS_TRIGGER_DELAY / 2); + await sendMouse({ type: 'up', button: 'left' }); + await this.hoverOutside(); + } + + public focusTrigger(): void { + this.anchorElement.focus(); + } + + public blurTrigger(): void { + this.anchorElement.blur(); + } + + public async pressEscapeKey(): Promise { + await sendKeys({ press: 'Escape' }); + } +} + +interface ITooltipFixtureConfig { + open?: boolean; + type?: TooltipType; + triggerType?: TooltipTriggerType; + delay?: number; + offset?: number; +} + +async function createFixture({ open, type, triggerType, delay, offset }: ITooltipFixtureConfig = {}): Promise { + const container = await fixture(html` +
+ + + + Test tooltip content + +
+ `); + + const anchorEl = container.querySelector('#test-anchor') as HTMLButtonElement; + const altAnchorEl = container.querySelector('#alt-anchor') as HTMLButtonElement; + const tooltipEl = container.querySelector('forge-tooltip') as ITooltipComponent; + + return new TooltipHarness(tooltipEl, anchorEl, altAnchorEl, container); +} diff --git a/src/lib/tooltip/tooltip.ts b/src/lib/tooltip/tooltip.ts index 0ec4e40d6..70ad8d7cf 100644 --- a/src/lib/tooltip/tooltip.ts +++ b/src/lib/tooltip/tooltip.ts @@ -1,19 +1,35 @@ -import { CustomElement, coerceNumber, hideElementVisually, FoundationProperty } from '@tylertech/forge-core'; +import { CustomElement, coerceNumber, FoundationProperty, coerceBoolean, attachShadowTemplate } from '@tylertech/forge-core'; import { TooltipAdapter } from './tooltip-adapter'; import { TooltipFoundation } from './tooltip-foundation'; -import { TOOLTIP_CONSTANTS, TooltipBuilder } from './tooltip-constants'; -import { PopupPlacement } from '../popup'; -import { BaseComponent, IBaseComponent } from '../core/base/base-component'; +import { TooltipPlacement, TooltipTriggerType, TooltipType, TOOLTIP_CONSTANTS } from './tooltip-constants'; +import { BaseComponent } from '../core/base/base-component'; +import { OverlayComponent } from '../overlay/overlay'; +import { coerceStringToArray } from '../core/utils/utils'; +import { IWithDefaultAria, WithDefaultAria } from '../core/mixins/internals/with-default-aria'; +import { IWithElementInternals, WithElementInternals } from '../core/mixins/internals/with-element-internals'; +import { OverlayFlipState } from '../overlay/overlay-constants'; +import { PositionPlacement } from '../core/utils/position-utils'; -export interface ITooltipComponent extends IBaseComponent { - text: string; - builder: TooltipBuilder | undefined; +import template from './tooltip.html'; +import styles from './tooltip.scss'; + +export interface ITooltipComponent extends IWithDefaultAria, IWithElementInternals { + open: boolean; + type: TooltipType; + anchor: string; + anchorElement: HTMLElement | null; + /** @deprecated use `anchor` instead */ target: string; + placement: `${TooltipPlacement}`; + /** @deprecated use `placement` instead */ + position: `${TooltipPlacement}`; delay: number; - position: PopupPlacement; - open: boolean; - hide(): void; - tooltipElement: HTMLElement | null; + offset: number; + flip: OverlayFlipState; + boundary: string | null; + boundaryElement: HTMLElement | null; + fallbackPlacements: PositionPlacement[] | null; + triggerType: TooltipTriggerType | TooltipTriggerType[]; } declare global { @@ -22,86 +38,174 @@ declare global { } } +const BaseClass = WithDefaultAria(WithElementInternals(BaseComponent)); + /** - * The custom element class behind the `` element. - * * @tag forge-tooltip + * + * @summary Tooltips display information related to an element when the user hovers over an element. + * + * @property {boolean} open - Whether or not the tooltip is open. + * @property {TooltipType} type - The type of tooltip. Valid values are `presentation` (default), `label`, and `description`. + * @property {string} anchor - The id of the element that the tooltip is anchored to. + * @property {TooltipPlacement} placement - The placement of the tooltip relative to the anchor element. + * @property {number} delay - The delay in milliseconds before the tooltip is shown. + * @property {number} offset - The offset in pixels between the tooltip and the anchor element. + * @property {OverlayFlipState} - flip - How the tooltip should place itself if there is not enough space at the desired placement. + * @property {string | null} boundary - The id of the element that the tooltip should be constrained to. + * @property {HTMLElement | null} boundaryElement - The element that the tooltip should be constrained to. + * @property {PositionPlacement[] | null} fallbackPlacements - The fallback placements of the tooltip relative to the anchor element. + * @property {TooltipTriggerType | TooltipTriggerType[]} triggerType - The trigger type(s) that will open the tooltip. Valid values are `hover` (default), `longpress`, and `focus`. + * + * @attribute {boolean} open - Whether or not the tooltip is open. + * @attribute {TooltipType} type - The type of tooltip. Valid values are `presentation` (default), `label`, and `description`. + * @attribute {string} anchor - The id of the element that the tooltip is anchored to. + * @attribute {TooltipPlacement} placement - The placement of the tooltip relative to the anchor element. + * @attribute {number} delay - The delay in milliseconds before the tooltip is shown. + * @attribute {number} offset - The offset in pixels between the tooltip and the anchor element. + * @attribute {OverlayFlipState} flip - How the tooltip should place itself if there is not enough space at the desired placement. + * @attribute {string | null} boundary - The id of the element that the tooltip should be constrained to. + * @attribute {PositionPlacement[]} fallbackPlacements - The fallback placements of the tooltip relative to the anchor element. + * + * @cssproperty --forge-tooltip-background - The background color of the tooltip surface. + * @cssproperty --forge-tooltip-color - The text color of the tooltip surface. + * @cssproperty --forge-tooltip-shape - The shape of the tooltip surface. + * @cssproperty --forge-tooltip-padding - The padding of the tooltip surface. + * @cssproperty --forge-tooltip-padding-block - The block padding of the tooltip surface. + * @cssproperty --forge-tooltip-padding-inline - The inline padding of the tooltip surface. + * @cssproperty --forge-tooltip-max-width - The maximum width of the tooltip surface. + * @cssproperty --forge-tooltip-elevation - The elevation of the tooltip surface. + * @cssproperty --forge-tooltip-border-width - The border width of the tooltip surface. + * @cssproperty --forge-tooltip-border-style - The border style of the tooltip surface. + * @cssproperty --forge-tooltip-border-color - The border color of the tooltip surface. + * @cssproperty --forge-tooltip-animation-timing - The animation timing function of the tooltip surface. + * @cssproperty --forge-tooltip-animation-duration - The animation duration of the tooltip surface. + * @cssproperty --forge-tooltip-animation-offset - The animation offset of the tooltip surface. + * @cssproperty --forge-tooltip-arrow-size - The size of the tooltip arrow. + * @cssproperty --forge-tooltip-arrow-height - The height of the tooltip arrow. + * @cssproperty --forge-tooltip-arrow-width - The width of the tooltip arrow. + * @cssproperty --forge-tooltip-arrow-shape - The shape of the tooltip arrow. + * @cssproperty --forge-tooltip-arrow-clip-path - The clip path of the tooltip arrow. + * @cssproperty --forge-tooltip-arrow-rotation - The rotation of the tooltip arrow. + * @cssproperty --forge-tooltip-arrow-top-rotation - The rotation of the tooltip arrow when the tooltip is placed on top. + * @cssproperty --forge-tooltip-arrow-right-rotation - The rotation of the tooltip arrow when the tooltip is placed on the right. + * @cssproperty --forge-tooltip-arrow-bottom-rotation - The rotation of the tooltip arrow when the tooltip is placed on the bottom. + * @cssproperty --forge-tooltip-arrow-left-rotation- The rotation of the tooltip arrow when the tooltip is placed on the left. + * + * @slot - The content to display in the tooltip. + * + * @csspart surface - The tooltip surface. + * @csspart arrow - The tooltip arrow. + * @csspart overlay - The overlay surface. */ @CustomElement({ - name: TOOLTIP_CONSTANTS.elementName + name: TOOLTIP_CONSTANTS.elementName, + dependencies: [ + OverlayComponent + ] }) -export class TooltipComponent extends BaseComponent implements ITooltipComponent { +export class TooltipComponent extends BaseClass implements ITooltipComponent { public static get observedAttributes(): string[] { - return [ - TOOLTIP_CONSTANTS.attributes.TEXT, - TOOLTIP_CONSTANTS.attributes.TARGET, - TOOLTIP_CONSTANTS.attributes.DELAY, - TOOLTIP_CONSTANTS.attributes.POSITION - ]; + return Object.values(TOOLTIP_CONSTANTS.observedAttributes); } private _foundation: TooltipFoundation; constructor() { super(); + attachShadowTemplate(this, template, styles); this._foundation = new TooltipFoundation(new TooltipAdapter(this)); } public connectedCallback(): void { - hideElementVisually(this); - requestAnimationFrame(() => this._foundation.initialize()); + this._foundation.initialize(); } public disconnectedCallback(): void { - this._foundation.disconnect(); + this._foundation.destroy(); } public attributeChangedCallback(name: string, oldValue: string, newValue: string): void { switch (name) { - case TOOLTIP_CONSTANTS.attributes.TEXT: - this.text = newValue; + case TOOLTIP_CONSTANTS.observedAttributes.ID: + this._foundation.syncTooltipAria(); + break; + case TOOLTIP_CONSTANTS.observedAttributes.OPEN: + this.open = coerceBoolean(newValue); break; - case TOOLTIP_CONSTANTS.attributes.TARGET: - this.target = newValue; + case TOOLTIP_CONSTANTS.observedAttributes.TYPE: + this.type = newValue?.trim() ? newValue as TooltipType : TOOLTIP_CONSTANTS.defaults.TYPE; break; - case TOOLTIP_CONSTANTS.attributes.DELAY: + case TOOLTIP_CONSTANTS.observedAttributes.TARGET: + case TOOLTIP_CONSTANTS.observedAttributes.ANCHOR: + this.anchor = newValue; + break; + case TOOLTIP_CONSTANTS.observedAttributes.DELAY: this.delay = coerceNumber(newValue); break; - case TOOLTIP_CONSTANTS.attributes.POSITION: - this.position = newValue as PopupPlacement; + case TOOLTIP_CONSTANTS.observedAttributes.POSITION: + case TOOLTIP_CONSTANTS.observedAttributes.PLACEMENT: + this.placement = newValue as TooltipPlacement; + break; + case TOOLTIP_CONSTANTS.observedAttributes.OFFSET: + this.offset = coerceNumber(newValue); + break; + case TOOLTIP_CONSTANTS.observedAttributes.FLIP: + this.flip = newValue as OverlayFlipState; + break; + case TOOLTIP_CONSTANTS.observedAttributes.BOUNDARY: + this.boundary = newValue; + break; + case TOOLTIP_CONSTANTS.observedAttributes.FALLBACK_PLACEMENTS: + this.fallbackPlacements = newValue?.trim() ? coerceStringToArray(newValue) : null; + break; + case TOOLTIP_CONSTANTS.observedAttributes.TRIGGER_TYPE: + this.triggerType = newValue?.trim() ? coerceStringToArray(newValue) : TOOLTIP_CONSTANTS.defaults.TRIGGER_TYPES; break; } } - /** Gets/sets the tooltip text. */ @FoundationProperty() - public declare text: string; + public declare open: boolean; + + @FoundationProperty() + public declare type: TooltipType; - /** Sets the tooltip builder function for display complex tooltip content. */ @FoundationProperty() - public declare builder: TooltipBuilder | undefined; - - /** Gets/sets the target element selector. */ + public declare anchor: string; + @FoundationProperty() + public declare anchorElement: HTMLElement | null; + + /** @deprecated use `anchor` instead */ + @FoundationProperty({ name: 'anchor' }) public declare target: string; - /** The tooltip display delay in milliseconds. */ + @FoundationProperty() + public declare placement: `${TooltipPlacement}`; + + /** @deprecated use `placement` instead */ + @FoundationProperty({ name: 'placement' }) + public declare position: `${TooltipPlacement}`; + @FoundationProperty() public declare delay: number; - /** Gets/sets the position. */ @FoundationProperty() - public declare position: `${PopupPlacement}`; + public declare offset: number; - /** Gets the open state of the tooltip. */ @FoundationProperty() - public declare open: boolean; + public declare flip: OverlayFlipState; - @FoundationProperty({ set: false }) - public declare tooltipElement: HTMLElement | null; + @FoundationProperty() + public declare boundary: string | null; - /** Hides the tooltip if it's open. */ - public hide(): void { - this._foundation.hide(); - } + @FoundationProperty() + public declare boundaryElement: HTMLElement | null; + + @FoundationProperty() + public declare fallbackPlacements: PositionPlacement[] | null; + + @FoundationProperty() + public declare triggerType: TooltipTriggerType | TooltipTriggerType[]; } diff --git a/src/stories/src/components/button-area/code/button-area-default.ts b/src/stories/src/components/button-area/code/button-area-default.ts index eab55aace..2b9a6c668 100644 --- a/src/stories/src/components/button-area/code/button-area-default.ts +++ b/src/stories/src/components/button-area/code/button-area-default.ts @@ -8,10 +8,10 @@ export const ButtonAreaDefaultCodeHtml = () => { Heading Content - + - Favorite + Favorite @@ -52,10 +52,10 @@ export const ButtonAreaInExpansionPanelCodeHtml = () => { Heading Content - + - Favorite + Favorite diff --git a/src/stories/src/guides/getting-started.stories.mdx b/src/stories/src/guides/getting-started.stories.mdx index d312871cd..51843ea33 100644 --- a/src/stories/src/guides/getting-started.stories.mdx +++ b/src/stories/src/guides/getting-started.stories.mdx @@ -158,7 +158,6 @@ You can import the Forge stylesheets from the npm package as can be seen below: > > // Required global component styles (if applicable) > @use '@tylertech/forge/dist/table/forge-table.css'; -> @use '@tylertech/forge/dist/tooltip/forge-tooltip.css'; > @use '@tylertech/forge/dist/ripple/forge-ripple.css'; > @use '@tylertech/forge/dist/quantity-field/forge-quantity-field.css'; > diff --git a/src/test/spec/tooltip/tooltip.spec.ts b/src/test/spec/tooltip/tooltip.spec.ts deleted file mode 100644 index ac82e61f4..000000000 --- a/src/test/spec/tooltip/tooltip.spec.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { removeElement } from '@tylertech/forge-core'; -import { dispatchNativeEvent, tick, timer } from '@tylertech/forge-testing'; -import { ITooltipComponent, TOOLTIP_CONSTANTS, defineTooltipComponent } from '@tylertech/forge/tooltip'; -import { mockPlatform } from '../../utils'; - -interface ITestContext { - context: ITooltipTestContext; - uninstallMockPlatform?: () => void; -} - -interface ITooltipTestContext { - component: ITooltipComponent; - targetElement: HTMLElement; - open(): Promise; - getTooltipElement(): HTMLElement; - attach(): void; - appendTarget(): void; - destroy(): void; -} - -describe('TooltipComponent', function(this: ITestContext) { - beforeAll(function(this: ITestContext) { - defineTooltipComponent(); - }); - - describe('on desktop', function(this: ITestContext) { - afterEach(function(this: ITestContext) { - this.context.destroy(); - }); - - it('should show tooltip when mouse over with no delay', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.component.text = 'Tooltip text'; - this.context.component.delay = 0; - this.context.attach(); - await tick(); - - await this.context.open(); - const tooltipElement = await this.context.open(); - - expect(this.context.component.delay).toBe(0); - expect(tooltipElement).not.toBeNull(); - expect(tooltipElement.textContent).toBe(this.context.component.text); - }); - - it('should show tooltip when mouse over with delay', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.component.text = 'Tooltip text'; - this.context.attach(); - - await tick(); - dispatchNativeEvent(this.context.targetElement, 'mouseover'); - await timer(TOOLTIP_CONSTANTS.numbers.DEFAULT_DELAY); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).not.toBeNull(); - expect(tooltipElement.textContent).toBe(this.context.component.text); - }); - - it('should not show a tooltip if the tooltip component has been removed', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.component.text = 'Tooltip text'; - this.context.attach(); - - await tick(); - dispatchNativeEvent(this.context.targetElement, 'mouseover'); - - this.context.component.remove(); - await timer(TOOLTIP_CONSTANTS.numbers.DEFAULT_DELAY); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - }); - - it('should remove tooltip when mouse out', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.component.text = 'Tooltip text'; - this.context.component.delay = 0; - this.context.attach(); - - await tick(); - dispatchNativeEvent(this.context.targetElement, 'mouseover'); - - let tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).not.toBeNull(); - - dispatchNativeEvent(this.context.targetElement, 'mouseout'); - await tick(); - - tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - }); - - it('should show custom content in tooltip', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.component.builder = () => { - const div = document.createElement('div'); - const text = document.createTextNode('using builder function'); - div.appendChild(text); - return div; - }; - this.context.component.delay = 0; - this.context.attach(); - - await tick(); - dispatchNativeEvent(this.context.targetElement, 'mouseover'); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).not.toBeNull(); - expect(tooltipElement.children.length).toBe(1); - expect(tooltipElement.children[0].tagName.toLowerCase()).toBe('div'); - expect(tooltipElement.children[0].textContent).toBe('using builder function'); - }); - - it('should show tooltip when using target selector', async function(this: ITestContext) { - this.context = setupTextContext(false); - this.context.targetElement.id = 'some-element-id'; - this.context.appendTarget(); - await tick(); - - const targetSelector = '#some-element-id'; - this.context.component.target = targetSelector; - this.context.component.delay = 0; - this.context.attach(); - await tick(); - - this.context.component.open = true; - await timer(TOOLTIP_CONSTANTS.numbers.DEFAULT_DELAY); - await tick(); - - expect(this.context.component.target).toBe(targetSelector); - expect(this.context.component.tooltipElement).toBeTruthy(); - expect(this.context.component.tooltipElement?.textContent).toBe(this.context.component.text); - }); - - it('should throw when unable to find target element', async function(this: ITestContext) { - this.context = setupTextContext(false); - this.context.component.text = 'Tooltip text with target'; - this.context.component.target = '#some-random-id'; - this.context.component.delay = 0; - document.body.appendChild(this.context.component); - const onerror = spyOn(window, 'onerror'); - - await tick(); - - expect(onerror).toHaveBeenCalled(); - }); - - it('should hide tooltip manually', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await this.context.open(); - await timer(TOOLTIP_CONSTANTS.numbers.DEFAULT_DELAY); - this.context.component.hide(); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - }); - - it('should retrieve open state', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - - expect(this.context.component.open).toBeFalse(); - await this.context.open(); - - expect(this.context.component.open).toBeTrue(); - }); - - it('should set position to bottom', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - this.context.component.position = 'bottom'; - await this.context.open(); - - const tooltipElement = this.context.getTooltipElement(); - expect(this.context.component.position).toBe('bottom'); - expect(tooltipElement.classList.contains(TOOLTIP_CONSTANTS.classes.TOOLTIP_BOTTOM)); - }); - - it('should set position to top', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - this.context.component.position = 'top'; - await this.context.open(); - - const tooltipElement = this.context.getTooltipElement(); - expect(this.context.component.position).toBe('top'); - expect(tooltipElement.classList.contains(TOOLTIP_CONSTANTS.classes.TOOLTIP_TOP)); - }); - - it('should set position to left', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - this.context.component.position = 'left'; - await this.context.open(); - - const tooltipElement = this.context.getTooltipElement(); - expect(this.context.component.position).toBe('left'); - expect(tooltipElement.classList.contains(TOOLTIP_CONSTANTS.classes.TOOLTIP_LEFT)); - }); - - it('should hide tooltip when clicking target element', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await this.context.open(); - - this.context.targetElement.click(); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - expect(this.context.component.open).toBe(false); - }); - - it('should hide tooltip when mousedown event dispatches on target element', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await this.context.open(); - - dispatchNativeEvent(this.context.targetElement, 'mousedown'); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - expect(this.context.component.open).toBe(false); - }); - - it('should hide tooltip when dragstart event dispatches on target element', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await this.context.open(); - - dispatchNativeEvent(this.context.targetElement, 'dragstart'); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - expect(this.context.component.open).toBe(false); - }); - - it('should hide tooltip when scrolling window', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await this.context.open(); - - window.dispatchEvent(new Event('scroll')); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - expect(this.context.component.open).toBe(false); - }); - }); - - describe('on mobile', function(this: ITestContext) { - beforeEach(function(this: ITestContext) { - this.uninstallMockPlatform = mockPlatform('isMobile', true); - }); - - afterEach(function(this: ITestContext) { - this.context.destroy(); - if (typeof this.uninstallMockPlatform === 'function') { - this.uninstallMockPlatform(); - } - }); - - it('should open tooltip via touch events', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await tick(); - - this.context.targetElement.dispatchEvent(new TouchEvent('touchstart')); - await timer(TOOLTIP_CONSTANTS.numbers.LONGPRESS_THRESHOLD); - this.context.targetElement.dispatchEvent(new TouchEvent('touchend')); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).not.toBeNull(); - expect(this.context.component.open).toBeTrue(); - }); - - it('should not show tooltip if removed before threshold', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await tick(); - - this.context.targetElement.dispatchEvent(new TouchEvent('touchstart')); - await timer(TOOLTIP_CONSTANTS.numbers.LONGPRESS_THRESHOLD / 2); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - }); - - it('should not show tooltip if touchend is triggered before threshold', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await tick(); - - this.context.targetElement.dispatchEvent(new TouchEvent('touchstart')); - await timer(TOOLTIP_CONSTANTS.numbers.LONGPRESS_THRESHOLD / 2); - this.context.targetElement.dispatchEvent(new TouchEvent('touchend')); - - const tooltipElement = this.context.getTooltipElement(); - expect(tooltipElement).toBeNull(); - }); - - it('should remove tooltip after visibility duration', async function(this: ITestContext) { - this.context = setupTextContext(); - this.context.attach(); - await tick(); - - this.context.targetElement.dispatchEvent(new TouchEvent('touchstart')); - await timer(TOOLTIP_CONSTANTS.numbers.LONGPRESS_THRESHOLD); - - expect(this.context.getTooltipElement()).toBeTruthy(); - - await timer(TOOLTIP_CONSTANTS.numbers.LONGPRESS_VISIBILITY_DURATION); - - expect(this.context.getTooltipElement()).toBeFalsy(); - }); - }); - - function setupTextContext(appendTarget = true): ITooltipTestContext { - const targetElement = document.createElement('button'); - const component = document.createElement('forge-tooltip'); - - if (appendTarget) { - document.body.appendChild(targetElement); - } - - return { - component, - targetElement, - open: async () => { - await tick(); - targetElement.focus(); - dispatchNativeEvent(targetElement, 'mouseover'); - await timer(TOOLTIP_CONSTANTS.numbers.DEFAULT_DELAY); - return component.tooltipElement as HTMLElement; - }, - getTooltipElement: () => component.tooltipElement as HTMLElement, - attach: () => { - component.text = 'Tooltip text'; - targetElement.appendChild(component); - }, - appendTarget: () => document.body.appendChild(targetElement), - destroy: () => { - removeElement(targetElement); - removeElement(component); - - const tooltips = document.querySelectorAll(TOOLTIP_CONSTANTS.elementName); - if (tooltips.length) { - tooltips.forEach((tooltip: HTMLElement) => removeElement(tooltip)); - } - } - }; - } -}); diff --git a/web-test-runner.config.mjs b/web-test-runner.config.mjs index 0ab7b8a51..9efaef835 100644 --- a/web-test-runner.config.mjs +++ b/web-test-runner.config.mjs @@ -30,6 +30,12 @@ export default { concurrentBrowsers: 3, nodeResolve: true, testsFinishTimeout: 60000, + testFramework: { + config: { + timeout: 5000, + retries: 1, + }, + }, coverageConfig: { report: true, reportDir: '.coverage',