Skip to content
Open
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
117 changes: 117 additions & 0 deletions packages/ui/src/__test__/tooltips.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//

import { type IntlString } from '@hcengineering/platform'
import { get } from 'svelte/store'
import { closeTooltip, tooltip, tooltipstore } from '../tooltips'

// svelte/store is shipped as an ES module only, provide a minimal synchronous replacement
jest.mock('svelte/store', () => {
type Subscriber = (value: any) => void
interface Store {
subscribe: (run: Subscriber) => () => void
}
const get = (store: Store): any => {
let value: any
store.subscribe((v) => (value = v))()
return value
}
const writable = (initial: any): Store & { set: Subscriber, update: (fn: (v: any) => any) => void } => {
let value = initial
const subscribers = new Set<Subscriber>()
const set = (v: any): void => {
value = v
subscribers.forEach((s) => {
s(value)
})
}
return {
set,
update: (fn) => {
set(fn(value))
},
subscribe: (run) => {
subscribers.add(run)
run(value)
return () => subscribers.delete(run)
}
}
}
const derived = (source: Store, fn: (v: any) => any): Store => ({
subscribe: (run) =>
source.subscribe((v) => {
run(fn(v))
})
})
return { get, writable, derived }
})

const label = 'JPEG' as IntlString

function hover (node: HTMLElement): void {
node.dispatchEvent(new MouseEvent('mousemove'))
jest.runOnlyPendingTimers()
}

describe('tooltip action', () => {
beforeEach(() => {
jest.useFakeTimers()
closeTooltip()
})

afterEach(() => {
closeTooltip()
jest.useRealTimers()
})

it('shows the tooltip label on hover', () => {
const node = document.createElement('span')
tooltip(node, { label })
hover(node)
expect(get(tooltipstore).label).toBe(label)
expect(get(tooltipstore).element).toBe(node)
})

it('shows a tooltip that becomes available after mount', () => {
const node = document.createElement('span')
const action = tooltip(node, undefined)
hover(node)
expect(get(tooltipstore).label).toBeUndefined()

action.update({ label })
hover(node)
expect(get(tooltipstore).label).toBe(label)
})

it('does not show a stale label after the tooltip is removed', () => {
const node = document.createElement('span')
const action = tooltip(node, { label })
action.update(undefined)
expect(() => {
hover(node)
}).not.toThrow()
expect(get(tooltipstore).label).toBeUndefined()
})

it('closes the shown tooltip when it is removed', () => {
const node = document.createElement('span')
const action = tooltip(node, { label })
hover(node)
expect(get(tooltipstore).label).toBe(label)

action.update(undefined)
expect(get(tooltipstore).label).toBeUndefined()
})
})
18 changes: 12 additions & 6 deletions packages/ui/src/tooltips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,20 @@ export const tooltipstore = derived(modalStore, (modals) => {
})

let toHandler: any
function hasTooltip (options: LabelAndProps | undefined): options is LabelAndProps {
return options !== undefined && (options.label !== undefined || options.component !== undefined)
}

export function tooltip (node: HTMLElement, options?: LabelAndProps): any {
if (options === undefined || (options.label === undefined && options.component === undefined)) {
// No tooltip
// TODO: Fix reactive options update in this case
return {}
}
let opt = options
const show = (): void => {
if (!hasTooltip(opt)) return
const shown = !!(storedValue.label !== undefined || storedValue.component !== undefined)
if (!shown) {
if (opt?.kind !== 'submenu' || opt.timeout !== undefined) {
clearTimeout(toHandler)
toHandler = setTimeout(() => {
if (!hasTooltip(opt)) return
showTooltip(
opt.label,
node,
Expand Down Expand Up @@ -74,14 +75,19 @@ export function tooltip (node: HTMLElement, options?: LabelAndProps): any {
}
}
const hide = (): void => {
if (!hasTooltip(opt)) return
clearTimeout(toHandler)
}
node.addEventListener('mouseleave', hide)
node.addEventListener('mousemove', show)
return {
update (options: LabelAndProps) {
update (options?: LabelAndProps) {
opt = options
if (node !== storedValue.element) return
if (!hasTooltip(opt)) {
closeTooltip()
return
}
const shown = !!(storedValue.label !== undefined || storedValue.component !== undefined)
if (shown) {
showTooltip(
Expand Down
4 changes: 3 additions & 1 deletion plugins/view-resources/src/components/EnumPresenter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@
},
{ limit: 1 }
)

$: selectedLabel = items?.find((p) => p.id === value)?.label
</script>

{#if onChange !== undefined && type !== undefined && !readonly}
<span use:tooltip={value ? { label: getEmbeddedLabel(value) } : undefined}>
<span use:tooltip={selectedLabel ? { label: getEmbeddedLabel(selectedLabel) } : undefined}>
<DropdownLabels
bind:selected={value}
{items}
Expand Down