diff --git a/packages/design-system/package.json b/packages/design-system/package.json index e2c0d3499d..00c3d2b683 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -101,9 +101,8 @@ }, "dependencies": { "@emoji-mart/data": "^1.2.1", - "@popperjs/core": "^2.11.5", + "@floating-ui/dom": "^1.7.5", "@vueuse/core": "^14.0.0", - "deepmerge": "^4.2.2", "emoji-mart": "^5.6.0", "focus-trap": "^7.8.0", "focus-trap-vue": "^4.0.1", @@ -111,7 +110,6 @@ "lodash-es": "^4.17.21", "luxon": "^3.5.0", "portal-vue": "^3.0.0", - "tippy.js": "^6.3.7", "vue-inline-svg": "^4.0.0", "vue-router": "^5.0.0", "vue-select": "^4.0.0-beta.6", diff --git a/packages/design-system/src/components/OcDrop/OcDrop.spec.ts b/packages/design-system/src/components/OcDrop/OcDrop.spec.ts index 2a29e271af..874b70ee8c 100644 --- a/packages/design-system/src/components/OcDrop/OcDrop.spec.ts +++ b/packages/design-system/src/components/OcDrop/OcDrop.spec.ts @@ -2,8 +2,14 @@ import { defaultPlugins, mount, shallowMount } from '@opencloud-eu/web-test-help import Drop from './OcDrop.vue' import { computed, nextTick } from 'vue' import { useIsMobile } from '../../composables' +import { computePosition, ComputePositionReturn } from '@floating-ui/dom' +import { flushPromises } from '@vue/test-utils' vi.mock('../../composables/useIsMobile') +vi.mock('@floating-ui/dom', async (importOriginal) => ({ + ...(await importOriginal()), + computePosition: vi.fn(() => ({})) +})) const dom = ({ position = 'auto', @@ -35,10 +41,12 @@ describe('OcDrop', () => { })) }) - it('handles dropId prop', () => { + it('handles dropId prop', async () => { for (let i = 0; i < 5; i++) { const wrapper = shallowMount(Drop) - expect(wrapper.attributes().id).toBe(`oc-drop-${i + 1}`) + wrapper.vm.show() + await nextTick() + expect(wrapper.find('div').attributes().id).toBe(`oc-drop-${i + 1}`) } for (let i = 0; i < 5; i++) { @@ -48,67 +56,61 @@ describe('OcDrop', () => { dropId: id } }) - expect(wrapper.attributes().id).toBe(id) + wrapper.vm.show() + await nextTick() + expect(wrapper.find('div').attributes().id).toBe(id) } }) - describe('tippy', () => { - it('inits tippy', async () => { + describe('floating UI', () => { + it('applies the calculated position values for the drop', async () => { + vi.useFakeTimers() + vi.mocked(computePosition).mockResolvedValue({ x: 2, y: 3 } as ComputePositionReturn) const { wrapper } = dom() - await nextTick() - - const drop = wrapper.findComponent({ name: 'oc-drop' }) - const tippy = drop.vm.tippyInstance - - expect(tippy).toBeTruthy() - expect(tippy.reference).toBe(wrapper.find('#trigger').element) - expect(tippy.props.content).toBe(drop.vm.$refs.drop) - }) - - it('updates tippy', async () => { - const { wrapper } = dom() - - await wrapper.setData({ - position: 'left', - mode: 'hover' - }) - const drop = wrapper.findComponent({ name: 'oc-drop' }) - const tippy = drop.vm.tippyInstance + drop.vm.show() await nextTick() - - expect(tippy.props.placement).toBe('left') - expect(tippy.props.trigger).toBe('mouseenter focus') + expect(wrapper.find('.oc-card').exists()).toBeTruthy() + vi.runAllTimers() + await flushPromises() + expect(wrapper.find('.oc-drop').attributes('style')).toContain('left: 2px;') + expect(wrapper.find('.oc-drop').attributes('style')).toContain('top: 3px;') }) - - it('renders tippy', async () => { - const { wrapper } = dom() - await nextTick() - const trigger = wrapper.find('#trigger') - const wait = async () => { - await wrapper.vm.$nextTick() - return new Promise((resolve) => setTimeout(resolve, 100)) - } - - await trigger.trigger('click') // show - await wait() - expect(wrapper.findComponent(Drop).exists()).toBeTruthy() - expect(trigger.attributes()['aria-expanded']).toBe('true') - expect(wrapper.element).toMatchSnapshot() - - await trigger.trigger('click') // hide - await wait() - expect(trigger.attributes()['aria-expanded']).toBe('false') - expect(wrapper.element).toMatchSnapshot() - - await wrapper.setData({ - mode: 'hover' + describe('mode', () => { + it('registers a click handler on the anchor in click mode', async () => { + vi.useFakeTimers() + vi.mocked(computePosition).mockResolvedValue({ x: 0, y: 0 } as ComputePositionReturn) + const { wrapper } = dom({ mode: 'click' }) + await wrapper.find('#trigger').trigger('click') + vi.runAllTimers() + await flushPromises() + expect(wrapper.find('.oc-drop').exists()).toBeTruthy() + }) + it('registers a mouseenter and /-leave handlers on the anchor in hover mode', async () => { + vi.useFakeTimers() + vi.mocked(computePosition).mockResolvedValue({ x: 0, y: 0 } as ComputePositionReturn) + const { wrapper } = dom({ mode: 'hover' }) + const trigger = wrapper.find('#trigger') + + await trigger.trigger('mouseenter') + vi.runAllTimers() + await flushPromises() + expect(wrapper.find('.oc-drop').exists()).toBeTruthy() + + await trigger.trigger('mouseleave') + vi.runAllTimers() + await flushPromises() + expect(wrapper.find('.oc-drop').exists()).toBeFalsy() + }) + it('does not register any event handler on the anchor in manual mode', async () => { + vi.useFakeTimers() + vi.mocked(computePosition).mockResolvedValue({ x: 0, y: 0 } as ComputePositionReturn) + const { wrapper } = dom({ mode: 'manual' }) + await wrapper.find('#trigger').trigger('click') + vi.runAllTimers() + await flushPromises() + expect(wrapper.find('.oc-drop').exists()).toBeFalsy() }) - - await trigger.trigger('mouseenter') // show - await wait() - expect(trigger.attributes()['aria-expanded']).toBe('true') - expect(wrapper.element).toMatchSnapshot() }) }) diff --git a/packages/design-system/src/components/OcDrop/OcDrop.vue b/packages/design-system/src/components/OcDrop/OcDrop.vue index cb43cdb7c4..259ccb573a 100644 --- a/packages/design-system/src/components/OcDrop/OcDrop.vue +++ b/packages/design-system/src/components/OcDrop/OcDrop.vue @@ -11,23 +11,41 @@ > -
- - - - -
+ diff --git a/packages/design-system/src/components/OcDrop/__snapshots__/OcDrop.spec.ts.snap b/packages/design-system/src/components/OcDrop/__snapshots__/OcDrop.spec.ts.snap deleted file mode 100644 index 6e077773eb..0000000000 --- a/packages/design-system/src/components/OcDrop/__snapshots__/OcDrop.spec.ts.snap +++ /dev/null @@ -1,159 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`OcDrop > tippy > renders tippy 1`] = ` -
-

- trigger -

-
- -
-
-`; - -exports[`OcDrop > tippy > renders tippy 2`] = ` -
- - -
-`; - -exports[`OcDrop > tippy > renders tippy 3`] = ` -
-

- trigger -

-
- -
-
-`; diff --git a/packages/design-system/src/components/OcDrop/useEventListeners.ts b/packages/design-system/src/components/OcDrop/useEventListeners.ts new file mode 100644 index 0000000000..1e16b67070 --- /dev/null +++ b/packages/design-system/src/components/OcDrop/useEventListeners.ts @@ -0,0 +1,44 @@ +interface EventListenerEntry { + target: Element | Document + type: string + handler: EventListener + options?: AddEventListenerOptions | boolean + category: 'anchor' | 'drop' | 'document' +} + +export const useEventListeners = () => { + const eventListeners: EventListenerEntry[] = [] + + const registerEventListener = ( + target: Element | Document, + type: string, + handler: EventListener, + category: EventListenerEntry['category'], + options?: AddEventListenerOptions | boolean + ) => { + target.addEventListener(type, handler, options) + eventListeners.push({ target, type, handler, options, category }) + } + + const unregisterEventListeners = (categories?: EventListenerEntry['category'][]) => { + if (!categories) { + eventListeners.forEach(({ target, type, handler, options }) => { + target.removeEventListener(type, handler, options) + }) + eventListeners.length = 0 + return + } + + const toRemove = eventListeners.filter((l) => categories.includes(l.category)) + toRemove.forEach(({ target, type, handler, options }) => { + target.removeEventListener(type, handler, options) + }) + eventListeners.splice( + 0, + eventListeners.length, + ...eventListeners.filter((l) => !categories.includes(l.category)) + ) + } + + return { registerEventListener, unregisterEventListeners } +} diff --git a/packages/design-system/src/directives/OcTooltip.ts b/packages/design-system/src/directives/OcTooltip.ts index dd0dac2d54..70b0e7cb58 100644 --- a/packages/design-system/src/directives/OcTooltip.ts +++ b/packages/design-system/src/directives/OcTooltip.ts @@ -1,94 +1,135 @@ -import tippy, { Instance } from 'tippy.js' -import merge from 'deepmerge' -import __logger from '../utils/logger' - -export const hideOnEsc = { - name: 'hideOnEsc', - defaultValue: true, - fn({ hide }: Instance) { - const onKeyDown = (e: KeyboardEvent) => { - if (e.code === 'Escape') { - hide() - } - } +import { computePosition, offset, flip, shift, arrow } from '@floating-ui/dom' +import { DirectiveBinding } from 'vue' - return { - onShow: () => { - document.addEventListener('keydown', onKeyDown) - }, - onHide: () => { - document.removeEventListener('keydown', onKeyDown) - } - } - } +interface TooltipData { + tooltipEl: HTMLElement | null + showHandler: () => void + hideHandler: () => void + clickHandler: () => void + escapeHandler: (e: KeyboardEvent) => void } -export const customProps = { - name: 'customProps', - defaultValue: true, - fn(instance: Instance) { - return { - onCreate() { - instance.popper.setAttribute('aria-hidden', 'true') - instance.popper.classList.add('oc-tooltip') - } +const tooltipMap = new WeakMap() + +const showTooltip = async (el: HTMLElement, content: string) => { + const data = tooltipMap.get(el) + if (!data || data.tooltipEl) { + return + } + + const tooltipEl = document.createElement('div') + tooltipEl.setAttribute('role', 'tooltip') + tooltipEl.textContent = content + + const arrowEl = document.createElement('div') + arrowEl.classList.add('arrow') + tooltipEl.appendChild(arrowEl) + + document.body.appendChild(tooltipEl) + data.tooltipEl = tooltipEl + + const { x, y, placement, middlewareData } = await computePosition(el, tooltipEl, { + placement: 'top', + middleware: [offset(8), flip(), shift({ padding: 5 }), arrow({ element: arrowEl })] + }) + + // set tooltip position + Object.assign(tooltipEl.style, { + left: `${x}px`, + top: `${y}px` + }) + + if (middlewareData.arrow) { + // set arrow position + const { x: arrowX, y: arrowY } = middlewareData.arrow + const side = placement.split('-')[0] + + const staticSide: Record = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right' } + + Object.assign(arrowEl.style, { + left: arrowX != null ? `${arrowX}px` : '', + top: arrowY != null ? `${arrowY}px` : '', + [staticSide[side]]: '-4px' + }) } } -export const destroy = (_tippy: Instance) => { - if (!_tippy) { +const hideTooltip = (el: HTMLElement) => { + const data = tooltipMap.get(el) + if (!data || !data.tooltipEl) { return } - try { - _tippy.destroy() - } catch (e) { - __logger(e) - } + data.tooltipEl.remove() + data.tooltipEl = null } -const initOrUpdate = ( - el: HTMLElement & { tooltip: Instance }, - { value = {} }: Record -) => { - if (Object.prototype.toString.call(value) !== '[object Object]') { - value = { content: value } +const destroy = (el: HTMLElement) => { + const data = tooltipMap.get(el) + if (!data) { + return } - if ((value.content !== 0 && !value.content) || value.content === '') { - destroy(el.tooltip) - el.tooltip = null + hideTooltip(el) + + el.removeEventListener('mouseenter', data.showHandler) + el.removeEventListener('focus', data.showHandler) + el.removeEventListener('mouseleave', data.hideHandler) + el.removeEventListener('blur', data.hideHandler) + el.removeEventListener('click', data.clickHandler) + document.removeEventListener('keydown', data.escapeHandler) + + tooltipMap.delete(el) +} + +const initOrUpdate = (el: HTMLElement, { value }: DirectiveBinding) => { + if (!value || value === '') { + destroy(el) return } - const props = merge.all([ - { - ignoreAttributes: true, - interactive: false, - aria: { - content: null, - expanded: false - } - }, - value - ]) - - if (!el.tooltip) { - el.tooltip = tippy(el, { - ...props, - zIndex: 10000, - plugins: [hideOnEsc, customProps] - }) + const existingTooltip = tooltipMap.get(el) + if (existingTooltip && existingTooltip.tooltipEl) { + const contentEl = existingTooltip.tooltipEl + if (contentEl) { + contentEl.textContent = value + } return } - el.tooltip.setProps(props) + const showHandler = () => showTooltip(el, value) + const hideHandler = () => hideTooltip(el) + const clickHandler = () => hideTooltip(el) + const escapeHandler = (e: KeyboardEvent) => { + if (e.code === 'Escape') { + hideTooltip(el) + } + } + + tooltipMap.set(el, { + tooltipEl: null, + showHandler, + hideHandler, + clickHandler, + escapeHandler + }) + + el.addEventListener('mouseenter', showHandler) + el.addEventListener('mouseleave', hideHandler) + el.addEventListener('focus', showHandler) + el.addEventListener('blur', hideHandler) + el.addEventListener('click', clickHandler) + document.addEventListener('keydown', escapeHandler) } export default { name: 'OcTooltip', beforeMount: initOrUpdate, updated: initOrUpdate, - unmounted: (el: HTMLElement & { tooltip: any }) => destroy(el.tooltip) + unmounted: destroy } diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index 13597b1fc4..fee3ef0dd6 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -10,7 +10,6 @@ import * as directives from './directives' // fonts must be imported here to ensure they are included in the build import './styles/fonts.scss' import './styles/layers.css' -import 'tippy.js/dist/tippy.css' let gettextInstance: ReturnType | null = null diff --git a/packages/design-system/src/styles/layers.css b/packages/design-system/src/styles/layers.css index da0f41f2e9..f9c2bc6d2d 100644 --- a/packages/design-system/src/styles/layers.css +++ b/packages/design-system/src/styles/layers.css @@ -80,8 +80,17 @@ @apply p-2 mb-1; } - .oc-tooltip .tippy-content { - @apply text-sm break-all; + [role="tooltip"] { + @apply rounded px-2 py-1 max-w-xs absolute top-0 left-0 break-all z-[10000]; + background-color: var(--oc-role-inverse-surface); + color: var(--oc-role-inverse-on-surface); + font-size: 0.875rem; + line-height: 1.25rem; + } + + [role="tooltip"] .arrow { + @apply absolute size-2 bg-inherit; + transform: rotate(45deg); } } diff --git a/packages/web-app-admin-settings/src/components/Groups/GroupsList.vue b/packages/web-app-admin-settings/src/components/Groups/GroupsList.vue index 8e7fa94cce..7bba706af6 100644 --- a/packages/web-app-admin-settings/src/components/Groups/GroupsList.vue +++ b/packages/web-app-admin-settings/src/components/Groups/GroupsList.vue @@ -20,7 +20,7 @@ :hover="true" padding-x="medium" @sort="handleSort" - @contextmenu-clicked="showContextMenuOnRightClick" + @contextmenu-clicked="(el, event, item) => showContextMenuOnRightClick(event, item)" @highlight="rowClicked" >