diff --git a/package.json b/package.json index b8a25ad1..469ccf63 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,11 @@ "glob": "^7.1.4", "jest": "24.8.0", "jest-cli": "24.8.0", + "lodash": "^4.17.15", "moment": "^2.24.0", "node-sass": "^4.12.0", "pre-commit": "^1.2.2", + "popper.js": "^1.16.0", "puppeteer": "1.17.0", "systemjs": "^3.1.6", "tabulator-tables": "^4.2.7", diff --git a/src/components.d.ts b/src/components.d.ts index d3fae0f2..3b05472b 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -17,6 +17,9 @@ import { import { OgListTemplateDefaultOptions, } from './components/og-list-template-default/og-list-template-default.interface'; +import { + OgTooltipConfig, +} from './components/og-tooltip/og-tooltip'; export namespace Components { interface OgButton { @@ -495,6 +498,68 @@ export namespace Components { */ 'value': boolean; } + interface OgTooltip { + /** + * The config property allows for overriding the tooltip´s default configuration. + */ + 'config': Partial; + /** + * Name of the event that fires immediately when a tooltip is first disabled. If .defaultPrevented() is used on the event then the tooltip will not be disabled. + */ + 'disableEventName': string; + /** + * When set to `true` the tooltip will be disabled. + */ + 'disabled': boolean; + /** + * Name of the event that is fired when a tooltip has finished being disabled. + */ + 'disabledEventName': string; + /** + * Name of the event that fires immediately when a tooltip is first enabled. If .defaultPrevented() is used on the event then the tooltip will not be enabled. + */ + 'enableEventName': string; + /** + * Name of the event that is fired when a tooltip has finished being enabled. + */ + 'enabledEventName': string; + /** + * Name of the event that is fired when the tooltip has finished being hidden from the user (will wait for CSS transitions to complete). + */ + 'hiddenEventName': string; + /** + * Name of the event that event is fired immediately when the hide instance method has been called. + */ + 'hideEventName': string; + /** + * Name of the event that is fired after the `og.tooltip.show` event when the tooltip template has been added to the DOM. + */ + 'insertedEventName': string; + /** + * It will override any other `title` settings and use this for the tooltip title. The value is watched so that it will update dynamically. + */ + 'ogTitle': string; + /** + * Name of the event that fires immediately when the show instance method is called. + */ + 'showEventName': string; + /** + * When set to `true`, the `og-tooltip` will toggle open. + */ + 'showTooltip': boolean; + /** + * Name of the event that is fired when the tooltip has been made visible to the user (will wait for CSS transitions to complete). + */ + 'shownEventName': string; + /** + * When tabindex is set to a negative value (smaller than `0`), the tooltip will work on hover, but it will not appear via keyboard raised focus. + */ + 'tabindex': string | number; + /** + * `.tooltip(Partial)` attaches a tooltip handler to a element. `.tooltip('show')` reveals an element´s tooltip. `.tooltip('hide')` hides an element´s tooltip. `.tooltip('toggle')` toggles an element´s tooltip. `.tooltip('enable')` gives an element’s tooltip the ability to be shown. `.tooltip('disable')` removes the ability for an element´s tooltip to be shown. The tooltip will only be able to be shown if it is re-enabled.. `.tooltip('toggleEnabled')` toggles the ability for an element´s tooltip to be shown or hidden. `.tooltip('update')` updates the position of an element´s tooltip. + */ + 'tooltip': (tooltipOptions: string | Partial) => Promise; + } } declare global { @@ -661,6 +726,12 @@ declare global { prototype: HTMLOgToggleSwitchElement; new (): HTMLOgToggleSwitchElement; }; + + interface HTMLOgTooltipElement extends Components.OgTooltip, HTMLStencilElement {} + var HTMLOgTooltipElement: { + prototype: HTMLOgTooltipElement; + new (): HTMLOgTooltipElement; + }; interface HTMLElementTagNameMap { 'og-button': HTMLOgButtonElement; 'og-calendar': HTMLOgCalendarElement; @@ -689,6 +760,7 @@ declare global { 'og-text-input': HTMLOgTextInputElement; 'og-textarea': HTMLOgTextareaElement; 'og-toggle-switch': HTMLOgToggleSwitchElement; + 'og-tooltip': HTMLOgTooltipElement; } } @@ -1268,6 +1340,64 @@ declare namespace LocalJSX { */ 'value'?: boolean; } + interface OgTooltip extends JSXBase.HTMLAttributes { + /** + * The config property allows for overriding the tooltip´s default configuration. + */ + 'config'?: Partial; + /** + * Name of the event that fires immediately when a tooltip is first disabled. If .defaultPrevented() is used on the event then the tooltip will not be disabled. + */ + 'disableEventName'?: string; + /** + * When set to `true` the tooltip will be disabled. + */ + 'disabled'?: boolean; + /** + * Name of the event that is fired when a tooltip has finished being disabled. + */ + 'disabledEventName'?: string; + /** + * Name of the event that fires immediately when a tooltip is first enabled. If .defaultPrevented() is used on the event then the tooltip will not be enabled. + */ + 'enableEventName'?: string; + /** + * Name of the event that is fired when a tooltip has finished being enabled. + */ + 'enabledEventName'?: string; + /** + * Name of the event that is fired when the tooltip has finished being hidden from the user (will wait for CSS transitions to complete). + */ + 'hiddenEventName'?: string; + /** + * Name of the event that event is fired immediately when the hide instance method has been called. + */ + 'hideEventName'?: string; + /** + * Name of the event that is fired after the `og.tooltip.show` event when the tooltip template has been added to the DOM. + */ + 'insertedEventName'?: string; + /** + * It will override any other `title` settings and use this for the tooltip title. The value is watched so that it will update dynamically. + */ + 'ogTitle'?: string; + /** + * Name of the event that fires immediately when the show instance method is called. + */ + 'showEventName'?: string; + /** + * When set to `true`, the `og-tooltip` will toggle open. + */ + 'showTooltip'?: boolean; + /** + * Name of the event that is fired when the tooltip has been made visible to the user (will wait for CSS transitions to complete). + */ + 'shownEventName'?: string; + /** + * When tabindex is set to a negative value (smaller than `0`), the tooltip will work on hover, but it will not appear via keyboard raised focus. + */ + 'tabindex'?: string | number; + } interface IntrinsicElements { 'og-button': OgButton; @@ -1297,6 +1427,7 @@ declare namespace LocalJSX { 'og-text-input': OgTextInput; 'og-textarea': OgTextarea; 'og-toggle-switch': OgToggleSwitch; + 'og-tooltip': OgTooltip; } } diff --git a/src/components/og-tooltip/og-tooltip.e2e.ts b/src/components/og-tooltip/og-tooltip.e2e.ts new file mode 100644 index 00000000..c0f53382 --- /dev/null +++ b/src/components/og-tooltip/og-tooltip.e2e.ts @@ -0,0 +1,11 @@ +import { newE2EPage } from '@stencil/core/testing'; + +describe('og-tooltip', () => { + it('renders', async () => { + const page = await newE2EPage(); + + await page.setContent(''); + const element = await page.find('og-tooltip'); + expect(element).toHaveClass('hydrated'); + }); +}); diff --git a/src/components/og-tooltip/og-tooltip.scss b/src/components/og-tooltip/og-tooltip.scss new file mode 100644 index 00000000..025e9deb --- /dev/null +++ b/src/components/og-tooltip/og-tooltip.scss @@ -0,0 +1,226 @@ +/** + * @prop --og-tooltip-BackgroundColor: Overall background color of the tooltip + * @prop --og-tooltip-BorderRadius: Border radius of the tooltip + * @prop --og-tooltip-Color: Text color within the tooltip + * @prop --og-tooltip-DropShadow: Drop-shadow filter of the tooltip + * @prop --og-tooltip-FontSize: Font size of the tooltip´s text + * @prop --og-tooltip-LineHeight: Line height of the tooltip´s text + * @prop --og-tooltip-Margin: Margin around the tooltip + * @prop --og-tooltip-Opacity: Opacity of the visible tooltip + * @prop --og-tooltip-Padding: Padding within the tooltip + * @prop --og-tooltip-Transition: Transition to apply when tooltip uses animation + * @prop --og-tooltip-ZIndex: Z-index value of the tooltip + * @prop --og-tooltip-arrow-Height: Height of the arrow element when applied on top or bottom + * @prop --og-tooltip-arrow-Width: Width of the arrow element when applied on top or bottom + */ + +// (this is the tooltip´s target element) +// ----------------------------------------------------------------------------- +:host, +og-tooltip { + display: inline-block; +} + +// Tooltip element (the bubble visually attached to it´s target) +// ----------------------------------------------------------------------------- +.og-tooltip { + // Interal vars trying to map external (:root) vars and provide a fallback + --_og-tooltip-BackgroundColor: var(--og-tooltip-BackgroundColor, var(--OG-TOOLTIP-BACKGROUND-COLOR, #{$og-tooltip-background-color})); + --_og-tooltip-BorderRadius: var(--og-tooltip-BorderRadius, #{$og-border-radius}); + --_og-tooltip-Color: var(--og-tooltip-Color, var(--OG-FONT-COLOR, inherit)); + --_og-tooltip-DropShadow: var(--og-tooltip-DropShadow, #{$og-tooltip-drop-shadow}); + --_og-tooltip-FontSize: var(--og-tooltip-FontSize, var(--OG-FONT-SIZE--S, 12px)); + --_og-tooltip-LineHeight: var(--og-tooltip-LineHeight, 1.5); + --_og-tooltip-Margin: var(--og-tooltip-Margin, 0); + --_og-tooltip-Opacity: var(--og-tooltip-Opacity, 1); + --_og-tooltip-Padding: var(--og-tooltip-Padding, 6px 8px); + --_og-tooltip-Transition: var(--og-tooltip-Transition, opacity 0.15s linear); + --_og-tooltip-ZIndex: var(--og-tooltip-ZIndex, #{$og-z-index-tooltip}); + --_og-tooltip-arrow-Height: var(--og-tooltip-arrow-Height, 4px); + --_og-tooltip-arrow-Width: var(--og-tooltip-arrow-Width, 10px); + + // Internal vars NOT meant to be overridden externally + --_og-tooltip-FlexDirection: column; + --_og-tooltip-MaxWidth: 75vw; + --_og-tooltip-MaxHeight: 50vw; + --_og-tooltip-arrow-Order: 0; + --_og-tooltip-arrow-BorderTopColor: transparent; + --_og-tooltip-arrow-BorderRightColor: transparent; + --_og-tooltip-arrow-BorderBottomColor: transparent; + --_og-tooltip-arrow-BorderLeftColor: transparent; + --_og-tooltip-arrow-BorderWidth: 0; + --_og-tooltip-arrow-MarginTop: auto; + --_og-tooltip-arrow-MarginRight: auto; + --_og-tooltip-arrow-MarginBottom: auto; + --_og-tooltip-arrow-MarginLeft: auto; + + position: absolute; + z-index: var(--_og-tooltip-ZIndex); + display: flex; + flex-direction: var(--_og-tooltip-FlexDirection); + font-size: var(--_og-tooltip-FontSize); + line-height: var(--_og-tooltip-LineHeight); + margin: var(--_og-tooltip-Margin); + filter: var(--_og-tooltip-DropShadow); + opacity: 0; + // Tame the overall tooltip dimension (e.g. viewport-width/height values) + max-width: var(--_og-tooltip-MaxWidth); + max-height: var(--_og-tooltip-MaxHeight); + + // show + &.show { + opacity: var(--_og-tooltip-Opacity); + } + + // fade + &.fade { + transition: var(--_og-tooltip-Transition); + + &:not(.show) { + opacity: 0; + } + } + + // top & bottom + &[class*="og-tooltip-top"], + &[class*="og-tooltip-bottom"], + &[class*="og-tooltip-auto"][x-placement^="top"], + &[class*="og-tooltip-auto"][x-placement^="bottom"] { + // start + &[class*="-start"], + &[x-placement$="-start"] { + --_og-tooltip-arrow-MarginLeft: var(--_og-tooltip-arrow-Width); + } + + // end + &[class*="-end"], + &[x-placement$="-end"] { + --_og-tooltip-arrow-MarginRight: var(--_og-tooltip-arrow-Width); + } + } + + // left & right + &[class*="og-tooltip-right"], + &[class*="og-tooltip-left"], + &[class*="og-tooltip-auto"][x-placement^="right"], + &[class*="og-tooltip-auto"][x-placement^="left"] { + --_og-tooltip-FlexDirection: row; + --_og-tooltip-arrow-Width: var(--og-tooltip-arrow-Height, 4px); + --_og-tooltip-arrow-Height: var(--og-tooltip-arrow-Width, 10px); + + // start + &[class*="-start"], + &[x-placement$="-start"] { + --_og-tooltip-arrow-MarginTop: var(--_og-tooltip-arrow-Width); + } + + // end + &[class*="-end"], + &[x-placement$="-end"] { + --_og-tooltip-arrow-MarginBottom: var(--_og-tooltip-arrow-Width); + } + } + + // top & left + &[class*="og-tooltip-top"], + &[class*="og-tooltip-left"], + &[class*="og-tooltip-auto"][x-placement^="top"], + &[class*="og-tooltip-auto"][x-placement^="left"] { + // Make the arrow appear after the content + --_og-tooltip-arrow-Order: 1; + } + + // top + &[class*="og-tooltip-top"], + &[class*="og-tooltip-auto"][x-placement^="top"] { + --_og-tooltip-arrow-BorderTopColor: var(--_og-tooltip-BackgroundColor); + --_og-tooltip-arrow-BorderWidth: var(--_og-tooltip-arrow-Height) calc(var(--_og-tooltip-arrow-Width) / 2) 0; + } + + // right + &[class*="og-tooltip-right"], + &[class*="og-tooltip-auto"][x-placement^="right"] { + --_og-tooltip-arrow-BorderRightColor: var(--_og-tooltip-BackgroundColor); + --_og-tooltip-arrow-BorderWidth: calc(var(--_og-tooltip-arrow-Height) / 2) var(--_og-tooltip-arrow-Width) calc(var(--_og-tooltip-arrow-Height) / 2) 0; + } + + // bottom + &[class*="og-tooltip-bottom"], + &[class*="og-tooltip-auto"][x-placement^="bottom"] { + --_og-tooltip-arrow-BorderBottomColor: var(--_og-tooltip-BackgroundColor); + --_og-tooltip-arrow-BorderWidth: 0 calc(var(--_og-tooltip-arrow-Width) / 2) var(--_og-tooltip-arrow-Height); + } + + // left + &[class*="og-tooltip-left"], + &[class*="og-tooltip-auto"][x-placement^="left"] { + --_og-tooltip-arrow-BorderLeftColor: var(--_og-tooltip-BackgroundColor); + --_og-tooltip-arrow-BorderWidth: calc(var(--_og-tooltip-arrow-Height) / 2) 0 calc(var(--_og-tooltip-arrow-Height) / 2) var(--_og-tooltip-arrow-Width); + } + +} + +// Tooltip content area +.og-tooltip__inner { + background-color: var(--_og-tooltip-BackgroundColor); + border-radius: var(--_og-tooltip-BorderRadius); + color: var(--_og-tooltip-Color); + padding: var(--_og-tooltip-Padding); + overflow: hidden; + word-break: break-all; + // Non standard for WebKit + word-break: break-word; +} + +// Tooltip arrow +.og-tooltip__arrow { + order: var(--_og-tooltip-arrow-Order); + display: block; + flex: none; + width: var(--_og-tooltip-arrow-Width); + height: var(--_og-tooltip-arrow-Height); + margin-top: var(--_og-tooltip-arrow-MarginTop); + margin-right: var(--_og-tooltip-arrow-MarginRight); + margin-bottom: var(--_og-tooltip-arrow-MarginBottom); + margin-left: var(--_og-tooltip-arrow-MarginLeft); + + &::before { + position: absolute; + content: ""; + border-top-color: var(--_og-tooltip-arrow-BorderTopColor); + border-right-color: var(--_og-tooltip-arrow-BorderRightColor); + border-bottom-color: var(--_og-tooltip-arrow-BorderBottomColor); + border-left-color: var(--_og-tooltip-arrow-BorderLeftColor); + border-width: var(--_og-tooltip-arrow-BorderWidth); + border-style: solid; + } +} + +// Detect if the user has requested that the system shall minimize the amount of +// animation or motion it uses +@media (prefers-reduced-motion: reduce) { + .og-tooltip.fade { + transition: none; + } +} + +// Make sure that the overall tooltip dimension has a relation to the +// (increasing) viewport size +@media (min-width: 768px) { + .og-tooltip { + --_og-tooltip-MaxWidth: 50vw; + --_og-tooltip-MaxHeight: 50vw; + } +} + +@media (min-width: 1200px) { + .og-tooltip { + --_og-tooltip-MaxWidth: 33vw; + } +} + +@media (min-width: 1800px) { + .og-tooltip { + --_og-tooltip-MaxWidth: 25vw; + } +} diff --git a/src/components/og-tooltip/og-tooltip.tsx b/src/components/og-tooltip/og-tooltip.tsx new file mode 100644 index 00000000..0e55b333 --- /dev/null +++ b/src/components/og-tooltip/og-tooltip.tsx @@ -0,0 +1,1195 @@ +/** + * ORGENIC-UI + * @license MIT + * See LICENSE file at https://github.com/orgenic/orgenic-ui/blob/master/LICENSE + **/ + +// Stencil core +import { + Component, + Element, + h, + Method, + Prop, + State, + Watch +} from '@stencil/core'; + +// 3rd party libraries +import _ from 'lodash'; +import Popper from 'popper.js'; + +// ORGENIC-UI helper classes +import getTransitionDurationFromElement from '../../utils/get-transition-duration-from-element'; +import ogCustomEvent from '../../utils/custom-event'; +import uuidv4 from '../../utils/uuidv4'; + +/** + * This interface describes the configuration of tooltip´s delay time for its + * appearance and disappearance. + * + * @export + * @interface OgTooltipDelay + */ +export interface OgTooltipDelay { + /** + * The delay value to show the tooltip (in ms). + * + * @type {number} + * @memberof OgTooltipDelay + */ + show: number; + + /** + * The delay value to hide the tooltip (in ms). + * + * @type {number} + * @memberof OgTooltipDelay + */ + hide: number; +} + +/** + * This interface describes the configuration of the tooltip. + * + * @export + * @interface OgTooltipConfig + */ +export interface OgTooltipConfig { + /** + * Whether or not to apply a CSS fade transition to the tooltip. + * + * @type {boolean} + * @memberof OgTooltipConfig + */ + animation: boolean; + + /** + * Overflow constraint boundary of the tooltip. Accepts the values of + * `viewport`, `window` or `scrollParent`. For more information refer to + * Popper.js's preventOverflow docs + * https://popper.js.org/docs/v1/#modifiers..preventOverflow + * + * @type {Popper.Boundary | Element} + * @memberof OgTooltipConfig + */ + boundary: Popper.Boundary | Element; + + /** + * Appends the tooltip to a specific element. Example: `container: 'body'`. + * This option is particularly useful in that it allows you to position the + * tooltip in the flow of the document near the triggering element - which + * will prevent the tooltip from floating away from the triggering element + * during a window resize. + * + * @type {any} + * @memberof OgTooltipConfig + */ + container: any; + + /** + * Delay showing and hiding the tooltip (ms) - does not apply to `manual` + * trigger type. If a number is supplied, delay is applied to both `hide` and + * `show`. Object structure is: `delay: { "show": 500, "hide": 100 }` + * + * @type {number | OgTooltipDelay} + * @memberof OgTooltipConfig + */ + delay: number | OgTooltipDelay; + + /** + * Delay removing the tooltip element from the DOM (ms). + * + * @type {number} + * @memberof OgTooltipConfig + */ + disposeTimeToWait: number; + + /** + * Allow HTML in the tooltip. If `true`, HTML tags in the tooltip's title will + * be rendered in the tooltip. If `false`, .innerText method will be used to + * insert content into the DOM. Use text if you're worried about XSS attacks. + * + * @type {boolean} + * @memberof OgTooltipConfig + */ + html: boolean; + + /** + * Offset of the tooltip relative to its target. For more information refer to + * Popper.js's offset docs https://popper.js.org/docs/v1/#modifiers..offset + * + * @type {number} + * @memberof OgTooltipConfig + */ + offset: number; + + /** + * Allow to specify which position Popper will use on fallback. For more + * information refer to Popper.js's behavior doc + * https://popper.js.org/docs/v1/#modifiers..flip.behavior + * + * @type {'flip' | 'clockwise' | 'counterclockwise' | Popper.Position[]} + * @memberof OgTooltipConfig + */ + fallbackPlacement: 'flip' | 'clockwise' | 'counterclockwise' | Popper.Position[]; + + /** + * How to position the tooltip. `auto|top|bottom|left|right(-start/-end)`. + * When `auto` is specified, it will dynamically position the tooltip. When a + * function is used to determine the placement, it is called with the tooltip + * DOM node as its first argument and the triggering element DOM node as its + * second argument. The this context is set to the tooltip instance. + * + * @type {Popper.Placement | Function} + * @memberof OgTooltipConfig + */ + placement: Popper.Placement | Function; + + /** + * Base HTML to use when creating the tooltip. The tooltip's title will be + * injected into the `.og-tooltip__inner`. `og-tooltip__arrow` will become the + * tooltip's arrow. The outermost wrapper element should have the + * `.og-tooltip` class and `role="tooltip"`. + * + * @type {string} + * @memberof OgTooltipConfig + */ + template: string; + + /** + * Default title value if `title` attribute isn't present. If a function is + * given, it will be called with its reference set to the element that the + * tooltip is attached to. + * + * @type {string | Element | Function} + * @memberof OgTooltipConfig + */ + title: string | Element | Function; + + /** + * How the tooltip is triggered (e.g. `hover`, `focus`). Accepts a whitespace + * separated list of strings. + * + * @type {string} + * @memberof OgTooltipConfig + */ + trigger: string; +} + +@Component({ + tag: 'og-tooltip', + styleUrl: 'og-tooltip.scss', + shadow: false +}) + +export class OgTooltip { + @Element() public tooltipEl: HTMLElement; + + /** + * Name of the event that fires immediately when the show instance method is + * called. + */ + @Prop({ mutable: true }) public showEventName: string = 'og.tooltip.show'; + + /** + * Name of the event that is fired when the tooltip has been made visible to + * the user (will wait for CSS transitions to complete). + */ + @Prop({ mutable: true }) public shownEventName: string = 'og.tooltip.shown'; + + /** + * Name of the event that event is fired immediately when the hide instance + * method has been called. + */ + @Prop({ mutable: true }) public hideEventName: string = 'og.tooltip.hide'; + + /** + * Name of the event that is fired when the tooltip has finished being hidden + * from the user (will wait for CSS transitions to complete). + */ + @Prop({ mutable: true }) public hiddenEventName: string = 'og.tooltip.hidden'; + + /** + * Name of the event that is fired after the `og.tooltip.show` event when the + * tooltip template has been added to the DOM. + */ + @Prop({ mutable: true }) public insertedEventName: string = 'og.tooltip.inserted'; + + /** + * Name of the event that fires immediately when a tooltip is first enabled. + * If .defaultPrevented() is used on the event then the tooltip will not be + * enabled. + */ + @Prop({ mutable: true }) public enableEventName: string = 'og.tooltip.enable'; + + /** + * Name of the event that is fired when a tooltip has finished being enabled. + */ + @Prop({ mutable: true }) public enabledEventName: string = 'og.tooltip.enabled'; + + /** + * Name of the event that fires immediately when a tooltip is first disabled. + * If .defaultPrevented() is used on the event then the tooltip will not be + * disabled. + */ + @Prop({ mutable: true }) public disableEventName: string = 'og.tooltip.disable'; + + /** + * Name of the event that is fired when a tooltip has finished being disabled. + */ + @Prop({ mutable: true }) public disabledEventName: string = 'og.tooltip.disabled'; + + /** + * The config property allows for overriding the tooltip´s default + * configuration. + */ + @Prop({ mutable: true }) public config: Partial = {}; + + /** + * When set to `true` the tooltip will be disabled. + */ + @Prop({ mutable: true }) public disabled: boolean = false; + + /** + * It will override any other `title` settings and use this for the tooltip + * title. The value is watched so that it will update dynamically. + */ + @Prop({ mutable: true }) public ogTitle: string = ''; + + /** + * When set to `true`, the `og-tooltip` will toggle open. + */ + @Prop({ mutable: true }) public showTooltip: boolean = false; + + /** + * When tabindex is set to a negative value (smaller than `0`), the tooltip + * will work on hover, but it will not appear via keyboard raised focus. + */ + @Prop({ mutable: true, reflectToAttr: true }) public tabindex: string | number = '0'; + + // States + @State() public activeTrigger: any; + @State() public disposeTimeout: any; + @State() public hoverState: 'in' | 'out'; + @State() public isEnabled: boolean; + @State() public popperHandle: Popper; + @State() public showHideTimeout: any; + @State() public tip: HTMLElement; + @State() public tooltipId: string; + + private tooltipDefaultConfig: OgTooltipConfig = { + animation: true, + boundary: 'scrollParent', + container: false, + delay: 0, + disposeTimeToWait: 0, + fallbackPlacement: 'flip', + html: false, + offset: 0, + placement: 'top', + template: '', + title: '', + trigger: 'hover focus' + } + + private tooltipAppliedPopperPlacementClasses: string[] = []; + private tooltipBaseClass: string = 'og-tooltip'; + private tooltipFadeClass: string = 'fade'; + private tooltipShowClass: string = 'show'; + + /** + * `.tooltip(Partial)` attaches a tooltip handler to a + * element. + * `.tooltip('show')` reveals an element´s tooltip. + * `.tooltip('hide')` hides an element´s tooltip. + * `.tooltip('toggle')` toggles an element´s tooltip. + * `.tooltip('enable')` gives an element’s tooltip the ability to be shown. + * `.tooltip('disable')` removes the ability for an element´s tooltip to be + * shown. The tooltip will only be able to be shown if it is re-enabled.. + * `.tooltip('toggleEnabled')` toggles the ability for an element´s tooltip to + * be shown or hidden. + * `.tooltip('update')` updates the position of an element´s tooltip. + */ + @Method() + public async tooltip(tooltipOptions: string | Partial): Promise { + return this.setupMethod(tooltipOptions); + } + + + + // Public methods + // --------------------------------------------------------------------------- + public componentWillLoad() { + if (this.tabindex === '-1') { + this.tabindex = -1; + } + + if (this.disabled) { + this.disableTooltip(); + return; + } + + if (!this.isEnabled) { + this.enableTooltip(); + } + + if (this.showTooltip === true) { + this.setInitialOpenState(); + } + } + + public componentDidUnload() { + this.disableTooltip(); + this.config = {}; + } + + public render(): HTMLElement { + return (); + } + + + + // Watches + // --------------------------------------------------------------------------- + @Watch('ogTitle') + public handleWatchOgTitle(newValue: string) { + if (!this.isEnabled) { + return; + } + + if (this.config.title !== newValue) { + this.config.title = newValue; + } + + if (this.isWithActiveTrigger() || this.tooltipHtmlElementHasClass(this.tooltipShowClass) || this.hoverState === 'in') { + const tooltip = this.getTooltipHtmlElement(); + const tooltipContentPlaceholder = tooltip.querySelector('.og-tooltip__inner'); + if (tooltipContentPlaceholder) { + this.setElementContent(tooltipContentPlaceholder, this.getTitle()); + if (this.popperHandle && this.popperHandle.scheduleUpdate) { + this.popperHandle.scheduleUpdate(); + } + } + } + } + + @Watch('disabled') + public handleDisabledWatch(newValue: boolean) { + if (this.disabled === newValue && !this.isEnabled === newValue) { + return; + } + + if (newValue === true) { + this.disableTooltip(); + return; + } + + this.enableTooltip(); + } + + @Watch('showTooltip') + public handleShowTooltipWatch(newValue: boolean) { + if (!this.isEnabled) { + return; + } + + if (newValue === true) { + this.enter(); + return; + } + + this.leave(); + } + + + + // Init + // --------------------------------------------------------------------------- + private setInitialOpenState() { + if (!this.isEnabled ) { + return; + } + + const hasAnimation = this.config.animation; + if (hasAnimation) { + this.setConfig({ animation: false }); + } + + // Listen to the defined triggers. + this.tooltipEl.addEventListener(this.shownEventName, () => { + if (_.includes(this.config.trigger, 'hover') && !_.includes(this.config.trigger, 'manual')) { + this.activeTrigger.hover = true; + this.hoverState = 'in'; + } else if (_.includes(this.config.trigger, 'focus') && !_.includes(this.config.trigger, 'manual')) { + this.activeTrigger.focus = true; + } + if (hasAnimation) { + this.setConfig(); + const tipEl = this.tip || this.createTooltipHtmlElement(); + tipEl.classList.add(this.tooltipFadeClass); + } + }, { once: true }); + this.enter(); + } + + + + // Event handing + // --------------------------------------------------------------------------- + private handleMouseEnter = (event) => { + this.enter(event); + } + + private handleFocusIn = (event) => { + this.enter(event); + } + + private handleMouseLeave = (event) => { + this.leave(event); + } + + private handleFocusOut = (event) => { + this.leave(event); + } + + private handlePopperOnCreate = (data: Popper.Data) => { + if (data.originalPlacement !== data.placement) { + this.handlePopperPlacementChange(data); + } + } + + private handlePopperOnUpdate = (data: Popper.Data) => { + this.handlePopperPlacementChange(data); + } + + private handlePopperPlacementChange(popperData: Popper.Data) { + const popperInstance = popperData.instance; + this.tip = popperInstance.popper as HTMLElement; + this.cleanTooltipHtmlElementClasses(); + this.addAttachmentClassToTooltipHtmlElement(popperData.placement); + } + + private setListeners() { + const triggers = _.split(_.toLower(this.config.trigger), ' '); + + if (_.includes(triggers, 'manual')) { + // hover and focus events are ignored if `manual` is included. + return; + } + + if (_.includes(triggers, 'hover')) { + this.tooltipAddEventListener('mouseenter', this.handleMouseEnter); + this.tooltipAddEventListener('mouseleave', this.handleMouseLeave); + } + if (_.includes(triggers, 'focus')) { + this.tooltipAddEventListener('focusin', this.handleFocusIn); + this.tooltipAddEventListener('focusout', this.handleFocusOut); + } + } + + + + // Config + // --------------------------------------------------------------------------- + private setConfig(overrideConfig: Partial = {}) { + this.config = {}; + const config: any = {}; + + if (_.has(overrideConfig, 'animation')) { + config.animation = this.getConfigBoolean(overrideConfig.animation); + } else if (_.has(this.tooltipEl.dataset, 'animation')) { + config.animation = this.getConfigBoolean(this.tooltipEl.dataset.animation); + } else { + config.animation = this.tooltipDefaultConfig.animation; + } + + if (_.has(overrideConfig, 'trigger')) { + config.trigger = overrideConfig.trigger; + } else if (_.has(this.tooltipEl.dataset, 'trigger')) { + config.trigger = this.tooltipEl.dataset.trigger; + } else { + config.trigger = this.tooltipDefaultConfig.trigger; + } + + const titleAttribute = this.tooltipEl.getAttribute('title'); + let newConfigTitle; + if (_.size(this.ogTitle) > 0) { + newConfigTitle = this.ogTitle; + } else if (titleAttribute) { + newConfigTitle = titleAttribute; + } else if (_.has(overrideConfig, 'title')) { + if (typeof overrideConfig.title === 'object' && overrideConfig.title.nodeValue) { + newConfigTitle = overrideConfig.title.nodeValue; + } else { + newConfigTitle = overrideConfig.title; + } + } else if (_.has(this.tooltipEl.dataset, 'title')) { + newConfigTitle = this.tooltipEl.dataset.title; + } else { + newConfigTitle = this.tooltipDefaultConfig.title; + } + if (_.isNumber(config.title)) { + newConfigTitle = _.toString(config.title); + } + config.title = newConfigTitle; + + let newConfigDelay; + if (_.has(overrideConfig, 'delay')) { + newConfigDelay = overrideConfig.delay; + } else if (_.has(this.tooltipEl.dataset, 'delay')) { + newConfigDelay = this.tooltipEl.dataset.delay; + } + if (_.isInteger(newConfigDelay)) { + config.delay = { + show: newConfigDelay, + hide: newConfigDelay, + }; + } else if (_.isObject(newConfigDelay)) { + config.delay = { + show: _.get(newConfigDelay, 'show', this.tooltipDefaultConfig.delay), + hide: _.get(newConfigDelay, 'hide', this.tooltipDefaultConfig.delay), + }; + } else if (_.isString(newConfigDelay) && _.size(newConfigDelay) > 0) { + const configDelayInteger = _.toInteger(newConfigDelay); + if (!_.isNaN(configDelayInteger)) { + config.delay = { + show: configDelayInteger, + hide: configDelayInteger, + }; + } else { + const configDelayObj = JSON.parse(newConfigDelay); + config.delay = { + show: _.get(configDelayObj, 'show', this.tooltipDefaultConfig.delay), + hide: _.get(configDelayObj, 'hide', this.tooltipDefaultConfig.delay), + }; + } + } else { + config.delay = { + show: this.tooltipDefaultConfig.delay, + hide: this.tooltipDefaultConfig.delay, + }; + } + + if (_.has(overrideConfig, 'html')) { + config.html = this.getConfigBoolean(overrideConfig.html); + } else if (_.has(this.tooltipEl.dataset, 'html')) { + config.html = this.getConfigBoolean(this.tooltipEl.dataset.html); + } else { + config.html = this.tooltipDefaultConfig.html; + } + + if (_.has(overrideConfig, 'placement')) { + config.placement = overrideConfig.placement; + } else if (_.has(this.tooltipEl.dataset, 'placement')) { + config.placement = this.tooltipEl.dataset.placement; + } else { + config.placement = this.tooltipDefaultConfig.placement; + } + + if (_.has(overrideConfig, 'offset')) { + config.offset = _.toInteger(overrideConfig.offset); + } else if (_.has(this.tooltipEl.dataset, 'offset')) { + config.offset = _.toInteger(this.tooltipEl.dataset.offset); + } else { + config.offset = this.tooltipDefaultConfig.offset; + } + if (_.isNaN(config.offset)) { + config.offset = this.tooltipDefaultConfig.offset; + } + + if (_.has(overrideConfig, 'container')) { + config.container = this.getConfigBoolean(overrideConfig.container); + } else if (_.has(this.tooltipEl.dataset, 'container')) { + config.container = this.getConfigBoolean(this.tooltipEl.dataset.container); + } else { + config.container = this.tooltipDefaultConfig.container; + } + + if (_.has(overrideConfig, 'fallbackPlacement')) { + config.fallbackPlacement = overrideConfig.fallbackPlacement; + } else if (_.has(this.tooltipEl.dataset, 'fallbackPlacement')) { + config.fallbackPlacement = this.tooltipEl.dataset.fallbackPlacement; + } else { + config.fallbackPlacement = this.tooltipDefaultConfig.fallbackPlacement; + } + + if (_.has(overrideConfig, 'boundary')) { + config.boundary = overrideConfig.boundary; + } else if (_.has(this.tooltipEl.dataset, 'boundary')) { + config.boundary = this.tooltipEl.dataset.boundary; + } else { + config.boundary = this.tooltipDefaultConfig.boundary; + } + + if (_.has(overrideConfig, 'disposeTimeToWait')) { + config.disposeTimeToWait = _.toInteger(overrideConfig.disposeTimeToWait); + } else if (_.has(this.tooltipEl.dataset, 'disposeTimeToWait')) { + config.disposeTimeToWait = _.toInteger(this.tooltipEl.dataset.disposeTimeToWait); + } else { + config.disposeTimeToWait = this.tooltipDefaultConfig.disposeTimeToWait; + } + if (_.isNaN(config.disposeTimeToWait)) { + config.disposeTimeToWait = this.tooltipDefaultConfig.disposeTimeToWait; + } + + if (_.has(overrideConfig, 'template')) { + config.template = overrideConfig.template; + } else if (_.has(this.tooltipEl.dataset, 'template')) { + config.template = this.tooltipEl.dataset.template; + } else { + config.template = this.tooltipDefaultConfig.template; + } + + this.config = config; + } + + private setupMethod(tooltipOptions: string | Partial): boolean | HTMLElement { + if (_.size(tooltipOptions) === 0) { + if (!this.isEnabled) { + this.enableTooltip(); + return true; + } + return this.tooltipEl; + } + + if (tooltipOptions === 'enable') { + this.enableTooltip(); + return true; + } + + if (tooltipOptions === 'disable') { + this.disableTooltip(); + return true; + } + + if (tooltipOptions === 'toggleEnabled') { + if (this.isEnabled) { + this.disableTooltip(); + } else { + this.enableTooltip(); + } + return true; + } + + if (tooltipOptions === 'show') { + if (!this.isEnabled) { + return null; + } + this.enter(); + return true; + } + + if (tooltipOptions === 'hide') { + this.activeTrigger = {}; + if (!this.isEnabled) { + return null; + } + this.leave(); + return true; + } + + if (tooltipOptions === 'toggle') { + if (!this.isEnabled) { + return null; + } + this.toggle(); + return true; + } + + if (tooltipOptions === 'update') { + if (this.popperHandle && this.popperHandle.scheduleUpdate) { + this.popperHandle.scheduleUpdate(); + return true; + } + return false; + } + + if (typeof tooltipOptions === 'object') { + if (this.isEnabled) { + this.disableTooltip(); + } + this.setConfig(tooltipOptions); + this.enableTooltip(); + return true; + } + + if (typeof tooltipOptions === 'string') { + throw new Error(`No method named "${tooltipOptions}"`); + } + return null; + + } + + + + // Tip HTMLElement + // --------------------------------------------------------------------------- + private createTooltipHtmlElement(): HTMLElement { + const tipAlreadyPresentInDom = document.getElementById(this.tooltipId); + if (tipAlreadyPresentInDom) { + return tipAlreadyPresentInDom; + } + + const container = !this.config.container ? document.body : document.querySelector(this.config.container); + const template = document.createElement('div'); + template.innerHTML = _.trim(this.config.template); + const innerTemplateTooltip = template.firstChild; + const newTip = container.appendChild(innerTemplateTooltip); + newTip.setAttribute('id', this.tooltipId); + return newTip; + } + + private removeTooltipHtmlElementFromDom() { + if (this.tip && this.tip.parentNode && this.hoverState !== 'in') { + this.tip.parentNode.removeChild(this.tip); + this.tip = null; + } + } + + private getTooltipHtmlElement(): HTMLElement { + clearTimeout(this.disposeTimeout); + this.tip = this.tip || this.createTooltipHtmlElement(); + return this.tip; + } + + private addAttachmentClassToTooltipHtmlElement(attachment: string) { + const tooltipAttachmentClass = `${this.tooltipBaseClass}-${attachment}`; + if (!_.includes(this.tooltipAppliedPopperPlacementClasses, tooltipAttachmentClass)) { + this.tooltipAppliedPopperPlacementClasses.push(tooltipAttachmentClass); + } + + const tooltip = this.getTooltipHtmlElement(); + tooltip.classList.add(tooltipAttachmentClass); + } + + private cleanTooltipHtmlElementClasses() { + const tooltip = this.getTooltipHtmlElement(); + const classesToRemove = _.intersection(this.tooltipAppliedPopperPlacementClasses, tooltip.classList); + classesToRemove.forEach(className => { + tooltip.classList.remove(className); + }); + } + + private tooltipHtmlElementHasClass(className: string): boolean { + if (!this.tip) { + return false; + } + + return this.tip.classList.contains(className); + } + + private setContent() { + const tooltip = this.getTooltipHtmlElement(); + const tooltipTitleEl = tooltip.querySelector('.og-tooltip__inner'); + if (tooltipTitleEl) { + this.setElementContent(tooltipTitleEl, this.getTitle()); + } + tooltip.classList.remove(this.tooltipFadeClass); + tooltip.classList.remove(this.tooltipShowClass); + } + + + + // Enable/Disable tooltip + // --------------------------------------------------------------------------- + private enableTooltip() { + if (this.disabled === true) { + return; + } + + const enableEvent = ogCustomEvent(this.tooltipEl, this.enableEventName); + if (enableEvent.defaultPrevented) { + return; + } + + this.isEnabled = true; + this.activeTrigger = {}; + this.hoverState = null; + this.tooltipId = uuidv4(); + this.tooltipEl.dataset.bsId = this.tooltipId; + this.tip = null; + if (_.size(this.config) === 0) { + this.setConfig(); + } + this.fixTitle(); + this.setListeners(); + window.requestAnimationFrame(() => { // trick to ensure all page updates are completed before running code + window.requestAnimationFrame(() => { // discussed here: https://www.youtube.com/watch?v=aCMbSyngXB4&t=11m + setTimeout(() => { + ogCustomEvent(this.tooltipEl, this.enabledEventName); + }, 0); + }); + }); + } + + private disableTooltip() { + const disableEvent = ogCustomEvent(this.tooltipEl, this.disableEventName); + if (disableEvent.defaultPrevented) { + return; + } + + this.isEnabled = false; + clearTimeout(this.showHideTimeout); + this.tooltipRemoveEventListener('mouseenter', this.handleMouseEnter); + this.tooltipRemoveEventListener('mouseleave', this.handleMouseLeave); + this.tooltipRemoveEventListener('focusin', this.handleFocusIn); + this.tooltipRemoveEventListener('focusout', this.handleFocusOut); + + const { originalTitle } = this.tooltipEl.dataset; + if (_.size(originalTitle) > 0) { + this.tooltipEl.title = originalTitle; + } + this.activeTrigger = {}; + this.hoverState = null; + clearTimeout(this.disposeTimeout); + this.removeTooltipHtmlElementFromDom(); + if (this.popperHandle && this.popperHandle.destroy) { + this.popperHandle.destroy(); + this.popperHandle = null; + } + window.requestAnimationFrame(() => { // trick to ensure all page updates are completed before running code + window.requestAnimationFrame(() => { // discussed here: https://www.youtube.com/watch?v=aCMbSyngXB4&t=11m + setTimeout(() => { + ogCustomEvent(this.tooltipEl, this.disabledEventName); + }, 0); + }); + }); + } + + + + // Enter/Leave + // --------------------------------------------------------------------------- + private enter(event: Event = null) { + // Determine the active trigger + if (event) { + if (event.type === 'focusin') { + this.activeTrigger.focus = true; + } else { + this.activeTrigger.hover = true; + } + } + + if (this.tooltipHtmlElementHasClass(this.tooltipShowClass) || this.hoverState === 'in') { + this.hoverState = 'in'; + return; + } + + clearTimeout(this.showHideTimeout); + + this.hoverState = 'in'; + + if (!this.config.delay || !this.config.delay.hasOwnProperty['show']) { + this.show(); + return; + } + + let showDelay = 0; + if (this.config.delay) { + if (this.config.delay.hasOwnProperty('show')) { + showDelay = this.config.delay['show']; + } else { + showDelay = this.config.delay as number; + } + } + this.showHideTimeout = setTimeout(() => { + if (this.hoverState === 'in') { + this.show(); + } + }, showDelay); + } + + private leave(event: any = null) { + if (event) { + if (event.type === 'focusout') { + this.activeTrigger.focus = false; + } else { + this.activeTrigger.hover = false; + } + } + + if (this.isWithActiveTrigger()) { + return; + } + + clearTimeout(this.showHideTimeout); + this.hoverState = 'out'; + + if (!this.config.delay || !this.config.delay.hasOwnProperty['hide']) { + this.hide(); + return; + } + + let hideDelay = 0; + if (this.config.delay) { + if (this.config.delay.hasOwnProperty('hide')) { + hideDelay = this.config.delay['hide']; + } else { + hideDelay = this.config.delay as number; + } + } + this.showHideTimeout = setTimeout(() => { + if (this.hoverState === 'out') { + this.hide(); + } + }, hideDelay); + } + + private toggle(event: any = null) { + if (!this.isEnabled) { + return; + } + if (event) { + this.activeTrigger.click = !this.activeTrigger.click; + if (this.isWithActiveTrigger()) { + this.enter(); + } else { + this.leave(); + } + } else if (this.tooltipHtmlElementHasClass(this.tooltipShowClass)) { + this.leave(); + } else { + this.enter(); + } + } + + + + // Show/Hide tooltip + // --------------------------------------------------------------------------- + private tooltipCanBeShown(): boolean { + if (!this.isEnabled) { + return false; + } + + const title = this.getTitle(); + if (title && _.isString(title) && title.toString().length > 0) { + return true; + } + + if (title && title instanceof Element) { + return true; + } + + return false; + } + + private show() { + if (this.tooltipEl.style.display === 'none') { + throw new Error('Please use show on visible elements'); + } + if (this.tooltipCanBeShown()) { + const showEvent = ogCustomEvent(this.tooltipEl, this.showEventName); + this.tooltipEl.setAttribute('aria-describedby', this.tooltipId); + const isInTheDom = this.tooltipEl.ownerDocument.documentElement.contains(this.tooltipEl); + if (showEvent.defaultPrevented || !isInTheDom) { + return; + } + + const tooltip = this.getTooltipHtmlElement(); + this.setContent(); + if (this.config.animation) { + tooltip.classList.add(this.tooltipFadeClass); + } + const placement = typeof this.config.placement === 'function' ? this.config.placement.call(this, tooltip, this.tooltipEl) : this.config.placement; + this.addAttachmentClassToTooltipHtmlElement(placement); + // the point of inserted event is to know when the tip is in the DOM but before it has been placed using popper + ogCustomEvent(this.tooltipEl, this.insertedEventName); + this.popperHandle = new Popper(this.tooltipEl, tooltip, { + placement: placement, + modifiers: { + offset: { + offset: this.config.offset, + }, + flip: { + behavior: this.config.fallbackPlacement, + }, + arrow: { + element: '.arrow', + }, + preventOverflow: { + boundariesElement: this.config.boundary, + }, + }, + onCreate: this.handlePopperOnCreate, + onUpdate: this.handlePopperOnUpdate, + }); + + tooltip.classList.add(this.tooltipShowClass); + + if (tooltip.classList.contains(this.tooltipFadeClass)) { + const transitionDuration = getTransitionDurationFromElement(tooltip); + setTimeout(() => { + this.showComplete(); + }, transitionDuration); + } else { + this.showComplete(); + } + } + } + + private showComplete() { + if (this.config.animation) { + this.fixTransition(); + } + + const previousHoverState = this.hoverState; + this.hoverState = null; + + window.requestAnimationFrame(() => { // trick to ensure all page updates are completed before running code + window.requestAnimationFrame(() => { // discussed here: https://www.youtube.com/watch?v=aCMbSyngXB4&t=11m + setTimeout(() => { + ogCustomEvent(this.tooltipEl, this.shownEventName); + }, 0); + }); + }); + + if (previousHoverState === 'out') { + this.leave(); + } + } + + private hide(callback: any = () => { }) { + const tooltip = this.getTooltipHtmlElement(); + const hideEvent = ogCustomEvent(this.tooltipEl, this.hideEventName); + if (hideEvent.defaultPrevented) { + return; + } + + tooltip.classList.remove(this.tooltipShowClass); + + this.activeTrigger.click = false; + this.activeTrigger.focus = false; + this.activeTrigger.hover = false; + this.tooltipEl.removeAttribute('aria-describedby'); + + if (tooltip.classList.contains(this.tooltipFadeClass)) { + const transitionDuration = getTransitionDurationFromElement(tooltip); + setTimeout(() => { + this.hideComplete(callback); + }, transitionDuration); + } else { + this.hideComplete(); + } + this.hoverState = null; + } + + private hideComplete(callback: any = () => { }) { + this.cleanTooltipHtmlElementClasses(); + + if (this.popperHandle && this.popperHandle.destroy) { + this.popperHandle.destroy(); + } + + this.disposeTimeout = setTimeout(() => { + this.removeTooltipHtmlElementFromDom(); + }, this.config.disposeTimeToWait); + + window.requestAnimationFrame(() => { // trick to ensure all page updates are completed before running code + window.requestAnimationFrame(() => { // discussed here: https://www.youtube.com/watch?v=aCMbSyngXB4&t=11m + setTimeout(() => { + ogCustomEvent(this.tooltipEl, this.hiddenEventName); + }, 0); + }); + }); + + callback(); + } + + // Title + // --------------------------------------------------------------------------- + private getTitle(): string | Element { + if (this.config.title) { + if (typeof this.config.title === 'function') { + const newTitle = this.config.title.call(this.tooltipEl); + return newTitle; + } + return this.config.title; + } + + if (this.tooltipEl.dataset.originalTitle) { + return this.tooltipEl.dataset.originalTitle; + } + + return ''; + } + + private fixTitle() { + if (_.size(this.tooltipEl.title) > 0) { + this.tooltipEl.dataset.originalTitle = this.tooltipEl.title || ''; + this.tooltipEl.title = ''; + } + } + + + + // Helpers + // --------------------------------------------------------------------------- + private tooltipAddEventListener(eventName, listenerFunction) { + this.tooltipRemoveEventListener(eventName, listenerFunction, () => { + this.tooltipEl.addEventListener(eventName, listenerFunction); + }); + } + + private tooltipRemoveEventListener(eventName, listenerFunction, callback = () => {}) { + this.tooltipEl.removeEventListener(eventName, listenerFunction); + callback(); + } + + private fixTransition() { + const tooltip = this.getTooltipHtmlElement(); + const initConfigAnimation = this.config.animation; + + if (tooltip.getAttribute('x-placement') !== null) { + return; + } + + tooltip.classList.remove(this.tooltipFadeClass); + this.config.animation = false; + this.hide(); + this.show(); + this.config.animation = initConfigAnimation; + } + + private getConfigBoolean(configValue: any): boolean { + if (configValue === true || + _.toLower(configValue) === 'true' || + configValue === 1 || + configValue === '1') { + return true; + } + + return false; + } + + private isWithActiveTrigger(): boolean { + const activeTriggerKeys = Object.keys(this.activeTrigger); + return activeTriggerKeys.some((triggerKey) => { + this.activeTrigger[triggerKey]; + }); + } + + private setElementContent(el: Element, content: Element | string) { + let castedContent; + + if (this.config.html) { + if (content instanceof Element) { + castedContent = content as Element; + el.appendChild(castedContent); + return; + } + castedContent = content as string; + el.innerHTML = castedContent; + } else { + if (content instanceof Element) { + castedContent = content as Element; + el.textContent = castedContent.innerText; + return; + } + castedContent = content as string; + el.textContent = castedContent; + } + } + +} diff --git a/src/examples/Tooltip.html b/src/examples/Tooltip.html new file mode 100644 index 00000000..508ada0b --- /dev/null +++ b/src/examples/Tooltip.html @@ -0,0 +1,143 @@ + + +
+ + Simple tooltip above +

HTML code:

+ + <og-tooltip data-title="A simple text title" data-placement="top">Simple tooltip above</og-tooltip> + +
+ + + Tooltip on hover only (no tab focus) +

HTML code:

+ + <og-tooltip data-title="A simple text title" data-placement="top" tabindex="-1">Tooltip on hover only (no tab focus)</og-tooltip> + +
+ + + +

+ +

+ +

HTML code:

+ + <og-tooltip id="tooltipWithDynamicTitle" data-title="Title" [og-title]="dynamicTitle" data-placement="right" tabindex="-1"><og-button label="Button with a tooltip"></og-button><og-tooltip> + +

Note: The way to correctly bind to 'dynamicTitle' depends on the framework in use. Since the button itself is focusable via keyboard (tab), we can set the tabindex of the tooltip to '-1' and will still be able to having it appear on keyboard focus.

+
+ + + Tooltip at bottom-end with HTML content +

HTML code:

+ + <og-tooltip og-title='<h3>Headline</h3><br /><span style="font-weight: bold">Bold text<span> and normal text.' data-html="true" data-placement="bottom-end">Tooltip at bottom-end with HTML content</og-tooltip> + +
+ + + Tooltip with config applied via code +

HTML code:

+ + <og-tooltip id="customTooltipOne">Tooltip with config applied via code</og-tooltip> + +

JS code:

+ + document.getElementById('customTooltipOne').addEventListener('og.tooltip.enabled', function(event) { + event.target.setAttribute('og-title', 'This tooltip title has been set via code'); + }); + +
+ + + Tooltip initially shown via code +

HTML code:

+ + <og-tooltip id="customTooltipTwo" data-title="Another custom tooltip">Tooltip initially shown via code</og-tooltip> + +

JS code:

+ + document.getElementById('customTooltipTwo').addEventListener('og.tooltip.enabled', function(event) { + event.target.tooltip('show'); + }); + +
+
+
+ + + + diff --git a/src/index.html b/src/index.html index 71a252fb..f8eea254 100644 --- a/src/index.html +++ b/src/index.html @@ -202,6 +202,7 @@

ORGENIC UI

Button List Combobox + Tooltip
@@ -247,7 +248,8 @@

ORGENIC UI

'toggle-switch': () => { loadHTML('./examples/ToggleSwitch.html', view); }, 'button': () => { loadHTML('./examples/Button.html', view); }, 'list': () => { loadHTML('./examples/List.html', view); }, - 'combobox': () => { loadHTML('./examples/Combobox.html', view); } + 'combobox': () => { loadHTML('./examples/Combobox.html', view); }, + 'tooltip': () => { loadHTML('./examples/Tooltip.html', view); } }); // set the default route diff --git a/src/styles/themes/dark.theme.scss b/src/styles/themes/dark.theme.scss index 72c19bf7..bc97af81 100644 --- a/src/styles/themes/dark.theme.scss +++ b/src/styles/themes/dark.theme.scss @@ -107,6 +107,11 @@ // ------------------------------------------------------------------------- --OG-MARGIN: 8px; --OG-PADDING: 8px; + + // ------------------------------------------------------------------------- + // COMPONENT : Tooltip + // ------------------------------------------------------------------------- + --OG-TOOLTIP-BACKGROUND-COLOR: hsla(210, 14%, 23%, 1); } @import 'font.scss'; diff --git a/src/styles/themes/light.theme.scss b/src/styles/themes/light.theme.scss index f42f325a..235b0ba0 100644 --- a/src/styles/themes/light.theme.scss +++ b/src/styles/themes/light.theme.scss @@ -107,6 +107,11 @@ // ------------------------------------------------------------------------- --OG-MARGIN: 8px; --OG-PADDING: 8px; + + // ------------------------------------------------------------------------- + // COMPONENT : Tooltip + // ------------------------------------------------------------------------- + --OG-TOOLTIP-BACKGROUND-COLOR: hsla(0, 0%, 100%, 1); } @import 'font.scss'; diff --git a/src/styles/variables/color.variable.scss b/src/styles/variables/color.variable.scss index d88d89fb..f648233e 100644 --- a/src/styles/variables/color.variable.scss +++ b/src/styles/variables/color.variable.scss @@ -70,3 +70,9 @@ $og-color-shade--100--20: hsla(200, 25%, 30%, 0.2); $og-color-shade--100--30: hsla(200, 25%, 30%, 0.3); $og-color-shade--100--50: hsla(200, 25%, 30%, 0.5); $og-color-shade--100--60: hsla(200, 25%, 30%, 0.6); + +// ------------------------------------------------------------------------- +// COMPONENT : Tooltip +// ------------------------------------------------------------------------- +$og-tooltip-background-color: hsla(0, 0%, 100%, 1); +$og-tooltip-drop-shadow: drop-shadow(0 0 8px rgba(0, 0, 0, .05)); diff --git a/src/styles/variables/sizing.variable.scss b/src/styles/variables/sizing.variable.scss index 46bcd9fc..977048e5 100644 --- a/src/styles/variables/sizing.variable.scss +++ b/src/styles/variables/sizing.variable.scss @@ -2,5 +2,10 @@ // BASIC VARIABLES : SIZING // ============================================================================= -$og-border-radius: 3px; -$og-border-width: 1px; +$og-z-index-dropdown: 1000; +$og-z-index-dialog: 1100; +$og-z-index-popover: 1200; +$og-z-index-tooltip: 1300; + +$og-border-radius: 3px; +$og-border-width: 1px; diff --git a/src/utils/custom-event.ts b/src/utils/custom-event.ts new file mode 100644 index 00000000..a80be9ce --- /dev/null +++ b/src/utils/custom-event.ts @@ -0,0 +1,46 @@ +function preventDefault() { + // needed for IE compatibility + Object.defineProperty(this, "defaultPrevented", { + get: () => true + }); +} + +// eslint-disable-next-line @typescript-eslint/promise-function-async +export default function ogCustomEvent(el: Element, eventName: string, payload = {}, relatedTarget = {}) { + const myPayload = Object.assign({}, { sentAtTime: new Date().getTime() }, payload); + let event; + + if (typeof (window as any).CustomEvent === "function") { + event = new CustomEvent(eventName, { + bubbles: true, + cancelable: true, + detail: myPayload + }); + } else { + const bubbles = true; + const cancelable = true; + event = document.createEvent("CustomEvent"); + event.initCustomEvent(eventName, bubbles, cancelable, { + detail: myPayload + }); + } + + Object.defineProperties(event, { + preventDefault: { + value: preventDefault, + writable: true + } + }); + + if (relatedTarget instanceof Element) { + Object.defineProperties(event, { + relatedTarget: { + value: relatedTarget, + writable: true + } + }); + } + + el.dispatchEvent(event); + return event; +} diff --git a/src/utils/get-transition-duration-from-element.ts b/src/utils/get-transition-duration-from-element.ts new file mode 100644 index 00000000..e314e692 --- /dev/null +++ b/src/utils/get-transition-duration-from-element.ts @@ -0,0 +1,26 @@ +import _ from 'lodash'; + +function transformTransitionDuration(stringDuration: string): number { + // always get the first duration + const durationArr = _.split(stringDuration, ','); + const timeInSeconds = _.toNumber(_.trimEnd(_.toLower(durationArr[0]), 's')); + if (_.isNaN(timeInSeconds)) { + return 0; + } + + return timeInSeconds * 1000; +} + +export default function getTransitionDurationFromElement(element: Element): number { + if (!element) { + return 0; + } + + const elementStyle = window.getComputedStyle(element, null); + const animationDuration = elementStyle.getPropertyValue('animation-duration'); + const transitionDuration = elementStyle.getPropertyValue('transition-duration'); + const durationArr = []; + durationArr.push(transformTransitionDuration(animationDuration)); + durationArr.push(transformTransitionDuration(transitionDuration)); + return _.max(durationArr); +} diff --git a/src/utils/uuidv4.ts b/src/utils/uuidv4.ts new file mode 100644 index 00000000..83b46288 --- /dev/null +++ b/src/utils/uuidv4.ts @@ -0,0 +1,7 @@ +export default function uuidv4(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +}