Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor Primer::Alpha::Tooltip to use popover #2111

Merged
merged 19 commits into from
Jul 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/beige-cougars-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@primer/view-components': minor
---

refactor Primer::Alpha::Tooltip to use popover

Changed components: Primer::Alpha::Tooltip
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
120 changes: 80 additions & 40 deletions app/components/primer/alpha/tool_tip.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type {AnchorAlignment, AnchorSide} from '@primer/behaviors'
import '@oddbird/popover-polyfill'
import {getAnchoredPosition} from '@primer/behaviors'

const TOOLTIP_OPEN_CLASS = 'tooltip-open'
const TOOLTIP_ARROW_EDGE_OFFSET = 6
const TOOLTIP_SR_ONLY_CLASS = 'sr-only'
const TOOLTIP_OFFSET = 10

type Direction = 'n' | 's' | 'e' | 'w' | 'ne' | 'se' | 'nw' | 'sw'

Expand All @@ -18,30 +19,39 @@ const DIRECTION_CLASSES = [
'tooltip-sw'
]

function focusOutListener() {
for (const tooltip of openTooltips) {
tooltip.hidePopover()
}
}

const tooltips = new Set<ToolTipElement>()
const openTooltips = new Set<ToolTipElement>()
class ToolTipElement extends HTMLElement {
styles() {
return `
:host {
position: absolute;
z-index: 1000000;
padding: .5em .75em;
padding: .5em .75em !important;
font: normal normal 11px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
-webkit-font-smoothing: subpixel-antialiased;
color: var(--color-fg-on-emphasis);
color: var(--color-fg-on-emphasis) !important;
keithamus marked this conversation as resolved.
Show resolved Hide resolved
text-align: center;
text-decoration: none;
text-shadow: none;
text-transform: none;
letter-spacing: normal;
word-wrap: break-word;
white-space: pre;
background: var(--color-neutral-emphasis-plus);
background: var(--color-neutral-emphasis-plus) !important;
border-radius: 6px;
border: 0 !important;
opacity: 0;
max-width: 250px;
word-wrap: break-word;
white-space: normal;
width: max-content;
width: max-content !important;
inset: var(--tool-tip-position-top, 0) auto auto var(--tool-tip-position-left, 0) !important;
overflow: visible !important;
}

:host:before{
Expand All @@ -55,7 +65,7 @@ class ToolTipElement extends HTMLElement {

@keyframes tooltip-appear {
from {
opacity: 0
opacity: 0;
}
to {
opacity: 1
Expand All @@ -71,8 +81,17 @@ class ToolTipElement extends HTMLElement {
content: ""
}

:host(.${TOOLTIP_OPEN_CLASS}),
:host(.${TOOLTIP_OPEN_CLASS}):before {
:host(:popover-open),
:host(:popover-open):before {
animation-name: tooltip-appear;
animation-duration: .1s;
animation-fill-mode: forwards;
animation-timing-function: ease-in;
animation-delay: .4s
}

:host(.\\:popover-open),
:host(.\\:popover-open):before {
animation-name: tooltip-appear;
animation-duration: .1s;
animation-fill-mode: forwards;
Expand Down Expand Up @@ -176,16 +195,22 @@ class ToolTipElement extends HTMLElement {
return this.ownerDocument.getElementById(this.htmlFor)
}

/* @deprecated */
set hiddenFromView(value: true | false) {
this.classList.toggle(TOOLTIP_SR_ONLY_CLASS, value)
if (this.isConnected) this.#update()
if (value) {
this.hidePopover()
} else {
this.showPopover()
}
}

/* @deprecated */
get hiddenFromView() {
return this.classList.contains(TOOLTIP_SR_ONLY_CLASS)
return !this.matches(':popover-open')
}

connectedCallback() {
tooltips.add(this)
this.#updateControlReference()
this.#updateDirection()
if (!this.shadowRoot) {
Expand All @@ -194,7 +219,7 @@ class ToolTipElement extends HTMLElement {
style.textContent = this.styles()
shadow.appendChild(document.createElement('slot'))
}
this.hiddenFromView = true
this.#update(false)
this.#allowUpdatePosition = true

if (!this.control) return
Expand All @@ -206,49 +231,68 @@ class ToolTipElement extends HTMLElement {
const {signal} = this.#abortController

this.addEventListener('mouseleave', this, {signal})
this.addEventListener('toggle', this, {signal})
this.control.addEventListener('mouseenter', this, {signal})
this.control.addEventListener('mouseleave', this, {signal})
this.control.addEventListener('focus', this, {signal})
this.control.addEventListener('blur', this, {signal})
this.control.addEventListener('mousedown', this, {signal})
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore popoverTargetElement is not in the type definition
this.control.popoverTargetElement?.addEventListener('beforetoggle', this, {
signal
})
this.ownerDocument.addEventListener('focusout', focusOutListener)
this.ownerDocument.addEventListener('keydown', this, {signal})
this.#update()
}

disconnectedCallback() {
tooltips.delete(this)
openTooltips.delete(this)
this.#abortController?.abort()
}

handleEvent(event: Event) {
async handleEvent(event: Event) {
if (!this.control) return
const showing = this.matches(':popover-open')

// Ensures that tooltip stays open when hovering between tooltip and element
// WCAG Success Criterion 1.4.13 Hoverable
if ((event.type === 'mouseenter' || event.type === 'focus') && this.hiddenFromView) {
this.hiddenFromView = false
} else if (event.type === 'blur') {
this.hiddenFromView = true
} else if (
const shouldShow = event.type === 'mouseenter' || event.type === 'focus'
const isMouseLeaveFromButton =
event.type === 'mouseleave' &&
(event as MouseEvent).relatedTarget !== this.control &&
(event as MouseEvent).relatedTarget !== this
) {
this.hiddenFromView = true
} else if (event.type === 'keydown' && (event as KeyboardEvent).key === 'Escape' && !this.hiddenFromView) {
this.hiddenFromView = true
const isEscapeKeydown = event.type === 'keydown' && (event as KeyboardEvent).key === 'Escape'
const isMouseDownOnButton = event.type === 'mousedown' && event.currentTarget === this.control
const isOpeningOtherPopover = event.type === 'beforetoggle' && event.currentTarget !== this
const shouldHide = isMouseLeaveFromButton || isEscapeKeydown || isMouseDownOnButton || isOpeningOtherPopover

await Promise.resolve()
if (!showing && shouldShow) {
this.showPopover()
} else if (showing && shouldHide) {
this.hidePopover()
}

if (event.type === 'toggle') {
this.#update((event as ToggleEvent).newState === 'open')
}
}

static observedAttributes = ['data-type', 'data-direction', 'id']

#update() {
if (this.hiddenFromView) {
this.classList.remove(TOOLTIP_OPEN_CLASS, ...DIRECTION_CLASSES)
} else {
this.classList.add(TOOLTIP_OPEN_CLASS)
for (const tooltip of this.ownerDocument.querySelectorAll<ToolTipElement>(this.tagName)) {
if (tooltip !== this) tooltip.hiddenFromView = true
#update(isOpen: boolean) {
if (isOpen) {
openTooltips.add(this)
this.classList.remove(TOOLTIP_SR_ONLY_CLASS)
for (const tooltip of tooltips) {
if (tooltip !== this) tooltip.hidePopover()
}
this.#updatePosition()
} else {
openTooltips.delete(this)
this.classList.remove(...DIRECTION_CLASSES)
this.classList.add(TOOLTIP_SR_ONLY_CLASS)
}
}

Expand Down Expand Up @@ -326,11 +370,7 @@ class ToolTipElement extends HTMLElement {

#updatePosition() {
if (!this.control) return
if (!this.#allowUpdatePosition || this.hiddenFromView) return

const TOOLTIP_OFFSET = 10

this.style.left = `0px` // Ensures we have reliable tooltip width in `getAnchoredPosition`
if (!this.#allowUpdatePosition || !this.matches(':popover-open')) return

const position = getAnchoredPosition(this, this.control, {
side: this.#side,
Expand All @@ -340,8 +380,8 @@ class ToolTipElement extends HTMLElement {
const anchorSide = position.anchorSide
const align = position.anchorAlign

this.style.top = `${position.top}px`
this.style.left = `${position.left}px`
this.style.setProperty('--tool-tip-position-top', `${position.top}px`)
this.style.setProperty('--tool-tip-position-left', `${position.left}px`)

let direction: Direction = 's'

Expand Down
1 change: 1 addition & 0 deletions app/components/primer/alpha/tooltip.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ def initialize(type:, for_id:, text:, direction: DIRECTION_DEFAULT, **system_arg
@system_arguments[:id] ||= self.class.generate_id
@system_arguments[:tag] = :"tool-tip"
@system_arguments[:for] = for_id
@system_arguments[:popover] = "manual"
@system_arguments[:classes] = class_names(
@system_arguments[:classes],
"sr-only"
Expand Down
7 changes: 7 additions & 0 deletions previews/primer/alpha/tooltip_preview.rb
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ def tooltip_with_icon_button(direction: :s, tooltip_text: "You can press a butto
render(Primer::Beta::IconButton.new(icon: :search, "aria-label": tooltip_text, tooltip_direction: direction))
end
# @!endgroup

# @label Tooltip inside Primer::Alpha::Overlay
def tooltip_inside_primer_overlay(direction: :s, tooltip_text: "You can press a button")
render_with_template(
locals: {}
)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<%= render(Primer::Alpha::Overlay.new(
title: "Menu",
role: :dialog,
)) do |d| %>
<% d.with_show_button do |b| %>
Open Overlay
<% b.with_tooltip(text: "Opens an overlay") %>
<% end %>
<% d.with_header do %>
An overlay
<% end %>
<% d.with_body do %>

<%= render(Primer::Beta::Button.new(id: "overlay-button")) do |b| %>
<% b.with_tooltip(text: "This is a tooltip in an Overlay") %>
A button
<% end %>

<% end %>
<% end %>
7 changes: 5 additions & 2 deletions test/playwright/snapshots.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-unused-vars */
/* eslint-disable no-console */
import {test, expect, Page} from '@playwright/test'
import {getPreviewURLs} from './helpers'
import type {ComponentPreviews} from './helpers'
Expand All @@ -19,14 +18,18 @@ test('Preview Json exists', () => {
test.describe('generate snapshots', () => {
for (const preview of previewsJson) {
for (const example of preview.examples) {
if (example.snapshot === "true") {
if (example.snapshot === 'true') {
test(example.preview_path, async ({page}) => {
await page.goto(`/rails/view_components/${example.preview_path}?theme=all`)
const defaultScreenshot = await page.locator('#component-preview').screenshot({animations: 'disabled'})
expect(defaultScreenshot).toMatchSnapshot([example.preview_path, 'default.png'])

// Focus state
await page.keyboard.press('Tab')

// Wait a bit for animations etc to resolve
await new Promise(resolve => setTimeout(resolve, 100))

const focusedScreenshot = await page.locator('#component-preview').screenshot({animations: 'disabled'})
expect(focusedScreenshot).toMatchSnapshot([example.preview_path, 'focused.png'])
})
Expand Down