From 195382dd77f936d0f1f60cc13b185d70b684b00a Mon Sep 17 00:00:00 2001 From: untemps Date: Wed, 2 Feb 2022 14:07:33 +0100 Subject: [PATCH 1/2] feat: Add missing a11y features, #11 --- README.md | 30 +++++------ src/Tooltip.js | 31 +++++++++-- src/__tests__/useTooltip.test.js | 88 ++++++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7511605..e95d505 100644 --- a/README.md +++ b/README.md @@ -110,21 +110,21 @@ yarn add @untemps/svelte-use-tooltip ## API -| Props | Type | Default | Description | -|---------------------------|---------|---------|-----------------------------------------------------------------------------------------------------------------| -| `content` | string | null | Text content to display in the tooltip. | -| `contentSelector` | string | null | Selector of the content to display in the tooltip. | -| `contentClone` | boolean | null | Flag to clone the content to display in the tooltip. If false, the content is removed from its previous parent. | -| `contentActions` | object | null | Configuration of the tooltip actions (see [Content Actions](#content-actions)). | -| `containerClassName` | string | null | Class name to apply to the tooltip container. | -| `position` | string | 'top' | Position of the tooltip. Available values: 'top', 'bottom', 'left', 'right' | -| `animated` | boolean | false | Flag to animate tooltip transitions. | -| `animationEnterClassName` | string | null | Class name to apply to the tooltip enter transition. | -| `animationLeaveClassName` | string | null | Class name to apply to the tooltip leave transition. | -| `enterDelay` | number | 0 | Delay before showing the tooltip in milliseconds. | -| `leaveDelay` | number | 0 | Delay before hiding the tooltip in milliseconds. | -| `offset` | number | 10 | Distance between the tooltip and the target in pixels. | -| `disabled` | boolean | false | Flag to disable the tooltip content. | +| Props | Type | Default | Description | +|---------------------------|---------|-------------------|-----------------------------------------------------------------------------------------------------------------| +| `content` | string | null | Text content to display in the tooltip. | +| `contentSelector` | string | null | Selector of the content to display in the tooltip. | +| `contentClone` | boolean | false | Flag to clone the content to display in the tooltip. If false, the content is removed from its previous parent. | +| `contentActions` | object | null | Configuration of the tooltip actions (see [Content Actions](#content-actions)). | +| `containerClassName` | string | '__tooltip' | Class name to apply to the tooltip container. | +| `position` | string | 'top' | Position of the tooltip. Available values: 'top', 'bottom', 'left', 'right' | +| `animated` | boolean | false | Flag to animate tooltip transitions. | +| `animationEnterClassName` | string | '__tooltip-enter' | Class name to apply to the tooltip enter transition. | +| `animationLeaveClassName` | string | '__tooltip-leave' | Class name to apply to the tooltip leave transition. | +| `enterDelay` | number | 0 | Delay before showing the tooltip in milliseconds. | +| `leaveDelay` | number | 0 | Delay before hiding the tooltip in milliseconds. | +| `offset` | number | 10 | Distance between the tooltip and the target in pixels. | +| `disabled` | boolean | false | Flag to disable the tooltip content. | ### Content Actions diff --git a/src/Tooltip.js b/src/Tooltip.js index 19407ad..be90f66 100644 --- a/src/Tooltip.js +++ b/src/Tooltip.js @@ -13,6 +13,7 @@ class Tooltip { #boundEnterHandler = null #boundLeaveHandler = null + #boundKeyDownHandler = null #target = null #content = null @@ -65,11 +66,11 @@ class Tooltip { this.#observer = new DOMObserver() + this.#createTooltip() + this.#target.title = '' this.#target.setAttribute('style', 'position: relative') - - this.#createTooltip() - this.#tooltip.setAttribute('class', this.#containerClassName || `__tooltip __tooltip-${this.#position}`) + this.#target.setAttribute('aria-describedby', 'tooltip') disabled ? this.#disableTarget() : this.#enableTarget() @@ -141,21 +142,29 @@ class Tooltip { #enableTarget() { this.#boundEnterHandler = this.#onTargetEnter.bind(this) this.#boundLeaveHandler = this.#onTargetLeave.bind(this) + this.#boundKeyDownHandler = this.#onTargetKeyDown.bind(this) this.#target.addEventListener('mouseenter', this.#boundEnterHandler) - this.#target.addEventListener('mouseleave', this.#boundLeaveHandler) + this.#target.addEventListener('focusin', this.#boundEnterHandler) } #disableTarget() { this.#target.removeEventListener('mouseenter', this.#boundEnterHandler) this.#target.removeEventListener('mouseleave', this.#boundLeaveHandler) + this.#target.removeEventListener('focusin', this.#boundEnterHandler) + this.#target.removeEventListener('focusout', this.#boundLeaveHandler) + this.#target.addEventListener('keydown', this.#boundKeyDownHandler) this.#boundEnterHandler = null this.#boundLeaveHandler = null + this.#boundKeyDownHandler = null } #createTooltip() { this.#tooltip = document.createElement('div') + this.#tooltip.setAttribute('id', 'tooltip') + this.#tooltip.setAttribute('class', this.#containerClassName || `__tooltip __tooltip-${this.#position}`) + this.#tooltip.setAttribute('role', 'tooltip') if (this.#contentSelector) { this.#observer @@ -325,11 +334,25 @@ class Tooltip { async #onTargetEnter() { await this.#waitForDelay(this.#enterDelay) await this.#appendTooltipToTarget() + + this.#target.addEventListener('mouseleave', this.#boundLeaveHandler) + this.#target.addEventListener('focusout', this.#boundLeaveHandler) + this.#target.addEventListener('keydown', this.#boundKeyDownHandler) } async #onTargetLeave() { await this.#waitForDelay(this.#leaveDelay) await this.#removeTooltipFromTarget() + + this.#target.removeEventListener('mouseleave', this.#boundLeaveHandler) + this.#target.removeEventListener('focusout', this.#boundLeaveHandler) + this.#target.removeEventListener('keydown', this.#boundKeyDownHandler) + } + + async #onTargetKeyDown(e) { + if (e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) { + await this.#onTargetLeave() + } } } diff --git a/src/__tests__/useTooltip.test.js b/src/__tests__/useTooltip.test.js index d4acf29..b05440b 100644 --- a/src/__tests__/useTooltip.test.js +++ b/src/__tests__/useTooltip.test.js @@ -17,14 +17,14 @@ describe('useTooltip', () => { new Promise(async (resolve) => { await fireEvent.mouseOver(target) // fireEvent.mouseEnter only works if mouseOver is triggered before await fireEvent.mouseEnter(target) - await _sleep(10) + await _sleep(1) resolve() }) const _leave = async () => new Promise(async (resolve) => { await fireEvent.mouseLeave(target) - await _sleep(10) + await _sleep(1) resolve() }) @@ -35,6 +35,34 @@ describe('useTooltip', () => { resolve() }) + const _focus = async () => + new Promise(async (resolve) => { + await fireEvent.focusIn(target) + await _sleep(1) + resolve() + }) + + const _blur = async () => + new Promise(async (resolve) => { + await fireEvent.focusOut(target) + await _sleep(1) + resolve() + }) + + const _focusAndBlur = async () => + new Promise(async (resolve) => { + await _focus() + await _blur() + resolve() + }) + + const _keyDown = async (key) => + new Promise(async (resolve) => { + await fireEvent.keyDown(target, key || { key: 'Escape', code: 'Escape', charCode: 27 }) + await _sleep(1) + resolve() + }) + beforeEach(() => { target = _createElement('target', { class: 'bar' }) template = _createElement('template') @@ -78,6 +106,13 @@ describe('useTooltip', () => { await _enterAndLeave() expect(template).not.toBeInTheDocument() }) + + it('Hides tooltip on escape key down', async () => { + action = useTooltip(target, options) + await _enter() + await _keyDown() + expect(template).not.toBeInTheDocument() + }) }) describe('update', () => { @@ -95,6 +130,27 @@ describe('useTooltip', () => { expect(newTemplate).toBeInTheDocument() }) }) + + describe('focus', () => { + it('Shows tooltip on focus in', async () => { + action = useTooltip(target, options) + await _focus() + expect(template).toBeInTheDocument() + }) + + it('Hides tooltip on focus out', async () => { + action = useTooltip(target, options) + await _focusAndBlur() + expect(template).not.toBeInTheDocument() + }) + + it('Hides tooltip on escape key down', async () => { + action = useTooltip(target, options) + await _focus() + await _keyDown() + expect(template).not.toBeInTheDocument() + }) + }) }) describe('useTooltip lifecycle', () => { @@ -109,20 +165,44 @@ describe('useTooltip', () => { describe('useTooltip props: content', () => { it('Displays text content', async () => { const content = 'Foo' - action = useTooltip(target, { ...options, contentSelector: null, content }) + action = useTooltip(target, { + ...options, + contentSelector: null, + content, + }) await _enter() expect(target).toHaveTextContent(content) }) it('Displays content element over text', async () => { const content = 'Foo' - action = useTooltip(target, { ...options, content }) + action = useTooltip(target, { + ...options, + content, + }) await _enter() expect(target).not.toHaveTextContent(content) expect(template).toBeInTheDocument() }) }) + describe('useTooltip props: contentClone', () => { + it('Does not clone content element', async () => { + action = useTooltip(target, options) + await _sleep(1) + expect(template).not.toBeVisible() + }) + + it('Clones content element', async () => { + action = useTooltip(target, { + ...options, + contentClone: true, + }) + await _sleep(1) + expect(template).toBeVisible() + }) + }) + describe('useTooltip props: contentActions', () => { it('Triggers callback on tooltip click', async () => { action = useTooltip(target, options) From 04d5477dfdeb0781e239ef688cfa1b1ab56181d3 Mon Sep 17 00:00:00 2001 From: untemps Date: Thu, 3 Feb 2022 22:14:44 +0100 Subject: [PATCH 2/2] fix: Fix minor issues, #11 --- src/Tooltip.js | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/Tooltip.js b/src/Tooltip.js index be90f66..c6e7ac3 100644 --- a/src/Tooltip.js +++ b/src/Tooltip.js @@ -145,7 +145,10 @@ class Tooltip { this.#boundKeyDownHandler = this.#onTargetKeyDown.bind(this) this.#target.addEventListener('mouseenter', this.#boundEnterHandler) + this.#target.addEventListener('mouseleave', this.#boundLeaveHandler) this.#target.addEventListener('focusin', this.#boundEnterHandler) + this.#target.addEventListener('focusout', this.#boundLeaveHandler) + window.addEventListener('keydown', this.#boundKeyDownHandler) } #disableTarget() { @@ -153,7 +156,7 @@ class Tooltip { this.#target.removeEventListener('mouseleave', this.#boundLeaveHandler) this.#target.removeEventListener('focusin', this.#boundEnterHandler) this.#target.removeEventListener('focusout', this.#boundLeaveHandler) - this.#target.addEventListener('keydown', this.#boundKeyDownHandler) + window.removeEventListener('keydown', this.#boundKeyDownHandler) this.#boundEnterHandler = null this.#boundLeaveHandler = null @@ -334,19 +337,11 @@ class Tooltip { async #onTargetEnter() { await this.#waitForDelay(this.#enterDelay) await this.#appendTooltipToTarget() - - this.#target.addEventListener('mouseleave', this.#boundLeaveHandler) - this.#target.addEventListener('focusout', this.#boundLeaveHandler) - this.#target.addEventListener('keydown', this.#boundKeyDownHandler) } async #onTargetLeave() { await this.#waitForDelay(this.#leaveDelay) await this.#removeTooltipFromTarget() - - this.#target.removeEventListener('mouseleave', this.#boundLeaveHandler) - this.#target.removeEventListener('focusout', this.#boundLeaveHandler) - this.#target.removeEventListener('keydown', this.#boundKeyDownHandler) } async #onTargetKeyDown(e) {