mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
Unify button tooltips across the app (#972)
* feat(tooltip): 2s open delay and shared tooltipAttrs helper Add a 2s hover open delay in TooltipPortal (single behaviour source) so tooltips no longer flash on quick pointer passes; hiding stays immediate. Add tooltipAttrs() to pair data-tooltip with a matching aria-label for buttons touched in the unification work. Covered by Vitest. * feat(tooltip): lower open delay to 1s 2s felt too long in testing; 1s gives the same anti-flash behaviour without making intentional hovers wait. * feat(tooltip): action tooltips on the artist overview Add tooltips describing the action to Last.fm, Wikipedia, Play All, Shuffle and Radio. Shuffle/Radio now show a tooltip on desktop too, not just mobile. Strings added to all 9 locales. * feat(tooltip): action tooltips on the album overview Add tooltips describing the action to the desktop Play, Artist Bio and Download (ZIP) buttons, matching the mobile layout. Strings added to all 9 locales. * feat(tooltip): action tooltips on the All Albums toolbar Add tooltips describing the action to the sort, year and genre filter buttons. SortDropdown gains an optional tooltip prop; the year and genre filter components carry their own, so the tooltips also appear on the other browse pages that reuse them. Strings added to all 9 locales. * feat(tooltip): action tooltips on song-list rows Add Play and Add-to-queue tooltips to the per-row icons in SongRow, used by the Tracks browse list, Search and Advanced Search. Also localizes the aria-labels, which were hardcoded English. New common.addToQueue in all 9 locales. * fix(tooltip): uniform tooltip placement on the Artists toolbar The favourite and multi-select buttons forced tooltips below while the view-mode buttons auto-flipped above, so the row looked inconsistent. Pin the view-mode buttons below too, matching the rest of the row and the Albums toolbar. * feat(tooltip): clarify and align the Advanced Search scope row Add a leading "Search in:" label and per-chip tooltips so the All/Artists/Albums/Songs row reads as a scope limiter. Drop the forced below-placement on the small star filter (used only here) so the favourites chip flips with the others instead of sitting alone below. Strings added to all 9 locales. * docs(changelog): tooltip unification (#972)
This commit is contained in:
committed by
GitHub
parent
82c414d7bc
commit
c119a32277
@@ -18,6 +18,7 @@ import { formatLongDuration } from '../utils/format/formatDuration';
|
||||
import { formatMb } from '../utils/format/formatBytes';
|
||||
import { sanitizeHtml } from '../utils/sanitizeHtml';
|
||||
import { OpenArtistRefInline } from './OpenArtistRefInline';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
/** True when the album artist label means "no single artist" — `getArtistInfo`
|
||||
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
|
||||
@@ -323,7 +324,12 @@ export default function AlbumHeader({
|
||||
) : (
|
||||
<div className="album-detail-actions">
|
||||
<div className="album-detail-actions-primary">
|
||||
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
id="album-play-all-btn"
|
||||
onClick={onPlayAll}
|
||||
{...tooltipAttrs(t('albumDetail.playTooltip'))}
|
||||
>
|
||||
<Play size={15} /> {t('common.play', 'Reproducir')}
|
||||
</button>
|
||||
{onShuffleAll && (
|
||||
@@ -361,7 +367,12 @@ export default function AlbumHeader({
|
||||
</div>
|
||||
|
||||
{showBioButton && (
|
||||
<button className="btn btn-surface" id="album-bio-btn" onClick={onBio}>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-bio-btn"
|
||||
onClick={onBio}
|
||||
{...tooltipAttrs(t('albumDetail.artistBioTooltip'))}
|
||||
>
|
||||
<Highlighter size={16} /> {t('albumDetail.artistBio')}
|
||||
</button>
|
||||
)}
|
||||
@@ -375,7 +386,12 @@ export default function AlbumHeader({
|
||||
<span className="download-progress-pct">{downloadProgress}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-surface" id="album-download-btn" onClick={onDownload}>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
id="album-download-btn"
|
||||
onClick={onDownload}
|
||||
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
|
||||
>
|
||||
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Check, Filter, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FilterQuickClear from './FilterQuickClear';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
type GenreRow = GenreFilterOption;
|
||||
|
||||
@@ -153,6 +154,7 @@ export default function GenreFilterBar({
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
{...tooltipAttrs(t('common.filterGenreTooltip'), { pos: 'bottom' })}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<Filter size={14} />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
|
||||
import { formatTrackTime } from '../utils/format/formatDuration';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
interface Props {
|
||||
song: SubsonicSong;
|
||||
@@ -79,14 +80,14 @@ function SongRow({ song, showBpm }: Props) {
|
||||
<button
|
||||
className="song-list-row-btn song-list-row-btn--play"
|
||||
onClick={(e) => { e.stopPropagation(); handlePlay(); }}
|
||||
aria-label="Play"
|
||||
{...tooltipAttrs(t('common.play'))}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
className="song-list-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); handleEnqueue(); }}
|
||||
aria-label="Enqueue"
|
||||
{...tooltipAttrs(t('common.addToQueue'))}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ArrowDownUp, Check } from 'lucide-react';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
export interface SortOption<V extends string> {
|
||||
value: V;
|
||||
@@ -12,9 +13,11 @@ interface Props<V extends string> {
|
||||
options: SortOption<V>[];
|
||||
onChange: (value: V) => void;
|
||||
ariaLabel?: string;
|
||||
/** Hover tooltip describing the action (shown below the trigger). */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel }: Props<V>) {
|
||||
export default function SortDropdown<V extends string>({ value, options, onChange, ariaLabel, tooltip }: Props<V>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popStyle, setPopStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
@@ -91,6 +94,7 @@ export default function SortDropdown<V extends string>({ value, options, onChang
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
{...(tooltip ? tooltipAttrs(tooltip, { pos: 'bottom' }) : {})}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}
|
||||
>
|
||||
<ArrowDownUp size={14} />
|
||||
|
||||
@@ -41,7 +41,6 @@ export default function StarFilterButton({ active, onChange, size = 'default' }:
|
||||
onClick={() => onChange(!active)}
|
||||
aria-pressed={active}
|
||||
data-tooltip={tooltip}
|
||||
data-tooltip-pos="bottom"
|
||||
style={{ fontSize: 12, padding: '4px 14px', display: 'inline-flex', alignItems: 'center', gap: '0.35rem' }}
|
||||
>
|
||||
<Star size={12} fill={active ? 'currentColor' : 'none'} />
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { act, fireEvent, screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
|
||||
import TooltipPortal from './TooltipPortal';
|
||||
|
||||
function Fixture() {
|
||||
return (
|
||||
<>
|
||||
<TooltipPortal />
|
||||
<button data-tooltip="Play this album">play</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('TooltipPortal open delay', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows the tooltip only after the 1s open delay', () => {
|
||||
renderWithProviders(<Fixture />);
|
||||
const btn = screen.getByText('play');
|
||||
|
||||
fireEvent.mouseOver(btn);
|
||||
expect(screen.queryByText('Play this album')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(999);
|
||||
});
|
||||
expect(screen.queryByText('Play this album')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
expect(screen.getByText('Play this album')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels the pending tooltip when the pointer leaves before the delay', () => {
|
||||
renderWithProviders(<Fixture />);
|
||||
const btn = screen.getByText('play');
|
||||
|
||||
fireEvent.mouseOver(btn);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
fireEvent.mouseOut(btn, { relatedTarget: document.body });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Play this album')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides immediately on mousedown', () => {
|
||||
renderWithProviders(<Fixture />);
|
||||
const btn = screen.getByText('play');
|
||||
|
||||
fireEvent.mouseOver(btn);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.getByText('Play this album')).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(btn);
|
||||
expect(screen.queryByText('Play this album')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,42 +8,87 @@ interface TooltipState {
|
||||
wrap: boolean;
|
||||
}
|
||||
|
||||
/** Pointer must rest on an anchor this long before the tooltip appears. */
|
||||
const TOOLTIP_OPEN_DELAY_MS = 1000;
|
||||
|
||||
export default function TooltipPortal() {
|
||||
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
|
||||
|
||||
const tooltipRef = useRef<TooltipState | null>(null);
|
||||
tooltipRef.current = tooltip;
|
||||
// Anchor counting down to show, plus its pending timer.
|
||||
const pendingAnchorRef = useRef<HTMLElement | null>(null);
|
||||
const pendingTimerRef = useRef<number | null>(null);
|
||||
// Anchor whose tooltip is currently visible.
|
||||
const shownAnchorRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onOver = (e: MouseEvent) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const text = target.getAttribute('data-tooltip');
|
||||
const clearPending = () => {
|
||||
if (pendingTimerRef.current !== null) {
|
||||
clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
pendingAnchorRef.current = null;
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
clearPending();
|
||||
shownAnchorRef.current = null;
|
||||
setTooltip(null);
|
||||
};
|
||||
|
||||
const showAnchor = (anchor: HTMLElement) => {
|
||||
const text = anchor.getAttribute('data-tooltip');
|
||||
if (!text) return;
|
||||
shownAnchorRef.current = anchor;
|
||||
// Fresh rect: layout may have shifted during the open delay.
|
||||
setTooltip({
|
||||
text,
|
||||
anchorRect: target.getBoundingClientRect(),
|
||||
preferBottom: target.getAttribute('data-tooltip-pos') === 'bottom',
|
||||
wrap: target.hasAttribute('data-tooltip-wrap'),
|
||||
anchorRect: anchor.getBoundingClientRect(),
|
||||
preferBottom: anchor.getAttribute('data-tooltip-pos') === 'bottom',
|
||||
wrap: anchor.hasAttribute('data-tooltip-wrap'),
|
||||
});
|
||||
};
|
||||
const onOut = () => setTooltip(null);
|
||||
|
||||
const onOver = (e: MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
|
||||
if (!anchor) return;
|
||||
// Already visible or already counting down for this anchor — leave it running.
|
||||
if (anchor === shownAnchorRef.current || anchor === pendingAnchorRef.current) return;
|
||||
// Moved onto a different anchor: drop old state and re-arm the delay.
|
||||
clearPending();
|
||||
if (shownAnchorRef.current) {
|
||||
shownAnchorRef.current = null;
|
||||
setTooltip(null);
|
||||
}
|
||||
pendingAnchorRef.current = anchor;
|
||||
pendingTimerRef.current = window.setTimeout(() => {
|
||||
pendingTimerRef.current = null;
|
||||
pendingAnchorRef.current = null;
|
||||
showAnchor(anchor);
|
||||
}, TOOLTIP_OPEN_DELAY_MS);
|
||||
};
|
||||
const onOut = (e: MouseEvent) => {
|
||||
const anchor = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
|
||||
if (!anchor) return;
|
||||
if (anchor !== shownAnchorRef.current && anchor !== pendingAnchorRef.current) return;
|
||||
// Moving within the same anchor (e.g. onto a child icon) must not cancel the delay.
|
||||
const to = e.relatedTarget as Node | null;
|
||||
if (to && anchor.contains(to)) return;
|
||||
hide();
|
||||
};
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!tooltipRef.current) return;
|
||||
const target = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (!target) setTooltip(null);
|
||||
if (!pendingAnchorRef.current && !shownAnchorRef.current) return;
|
||||
const anchor = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (!anchor) hide();
|
||||
};
|
||||
/** Clicking a tooltip anchor (e.g. opening a dropdown) keeps the cursor inside the element, so mouseout never runs — hide immediately. */
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (t) setTooltip(null);
|
||||
if ((e.target as HTMLElement).closest('[data-tooltip]')) hide();
|
||||
};
|
||||
/** Wheel interactions (e.g. volume on overflow button) should suppress tooltip immediately. */
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
const t = (e.target as HTMLElement).closest('[data-tooltip]');
|
||||
if (t) setTooltip(null);
|
||||
if ((e.target as HTMLElement).closest('[data-tooltip]')) hide();
|
||||
};
|
||||
document.addEventListener('mouseover', onOver);
|
||||
document.addEventListener('mouseout', onOut);
|
||||
@@ -51,6 +96,7 @@ export default function TooltipPortal() {
|
||||
document.addEventListener('mousedown', onDown, true);
|
||||
document.addEventListener('wheel', onWheel, { capture: true, passive: true });
|
||||
return () => {
|
||||
clearPending();
|
||||
document.removeEventListener('mouseover', onOver);
|
||||
document.removeEventListener('mouseout', onOut);
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { CalendarRange, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FilterQuickClear from './FilterQuickClear';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
import {
|
||||
ALBUM_YEAR_MAX,
|
||||
ALBUM_YEAR_MIN,
|
||||
@@ -136,6 +137,7 @@ export default function YearFilterButton({
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
{...tooltipAttrs(t('albums.yearFilterTooltip'), { pos: 'bottom' })}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '0.4rem',
|
||||
...(active ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useCoverLightboxSrc } from '../../cover/lightbox';
|
||||
import type { CoverArtRef } from '../../cover/types';
|
||||
import LastfmIcon from '../LastfmIcon';
|
||||
import StarRating from '../StarRating';
|
||||
import { tooltipAttrs } from '../tooltipAttrs';
|
||||
|
||||
interface Props {
|
||||
artist: SubsonicArtist;
|
||||
@@ -142,12 +143,20 @@ export default function ArtistDetailHero({
|
||||
{(info?.lastFmUrl || artist.name) && (
|
||||
<div className="artist-detail-links">
|
||||
{info?.lastFmUrl && (
|
||||
<button className="artist-ext-link" onClick={() => openLink(info.lastFmUrl!, 'lastfm')}>
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={() => openLink(info.lastFmUrl!, 'lastfm')}
|
||||
{...tooltipAttrs(t('artistDetail.lastfmTooltip'))}
|
||||
>
|
||||
<LastfmIcon size={14} />
|
||||
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
|
||||
</button>
|
||||
)}
|
||||
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
|
||||
<button
|
||||
className="artist-ext-link"
|
||||
onClick={() => openLink(wikiUrl, 'wiki')}
|
||||
{...tooltipAttrs(t('artistDetail.wikipediaTooltip'))}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
|
||||
</button>
|
||||
@@ -168,7 +177,12 @@ export default function ArtistDetailHero({
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={playAllLoading}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePlayAll}
|
||||
disabled={playAllLoading}
|
||||
{...tooltipAttrs(t('artistDetail.playAllTooltip'))}
|
||||
>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
|
||||
{t('artistDetail.playAll')}
|
||||
</button>
|
||||
@@ -176,7 +190,7 @@ export default function ArtistDetailHero({
|
||||
className="btn btn-surface"
|
||||
onClick={handleShuffle}
|
||||
disabled={playAllLoading}
|
||||
data-tooltip={isMobile ? t('artistDetail.shuffle') : undefined}
|
||||
{...tooltipAttrs(t('artistDetail.shuffleTooltip'))}
|
||||
>
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{!isMobile && t('artistDetail.shuffle')}
|
||||
@@ -187,7 +201,7 @@ export default function ArtistDetailHero({
|
||||
className="btn btn-surface"
|
||||
onClick={handleStartRadio}
|
||||
disabled={radioLoading}
|
||||
data-tooltip={isMobile ? t('artistDetail.radio') : undefined}
|
||||
{...tooltipAttrs(t('artistDetail.radioTooltip'))}
|
||||
>
|
||||
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
|
||||
{!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tooltipAttrs } from './tooltipAttrs';
|
||||
|
||||
describe('tooltipAttrs', () => {
|
||||
it('pairs data-tooltip with a matching aria-label', () => {
|
||||
expect(tooltipAttrs('Play this album')).toEqual({
|
||||
'data-tooltip': 'Play this album',
|
||||
'aria-label': 'Play this album',
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the forced position only when requested', () => {
|
||||
expect(tooltipAttrs('x', { pos: 'bottom' })['data-tooltip-pos']).toBe('bottom');
|
||||
expect('data-tooltip-pos' in tooltipAttrs('x')).toBe(false);
|
||||
});
|
||||
|
||||
it('adds the wrap marker only when requested', () => {
|
||||
expect('data-tooltip-wrap' in tooltipAttrs('x', { wrap: true })).toBe(true);
|
||||
expect('data-tooltip-wrap' in tooltipAttrs('x')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Pairs a tooltip with its accessible label so the two never drift apart.
|
||||
*
|
||||
* Spread onto any element that should show a hover tooltip rendered by
|
||||
* `TooltipPortal`. The same already-translated string becomes both the
|
||||
* `data-tooltip` (visual hover label) and the `aria-label` (screen readers).
|
||||
*
|
||||
* <button {...tooltipAttrs(t('albums.sortTooltip'))} onClick={…}>
|
||||
*
|
||||
* `pos: 'bottom'` is a viewport-edge escape hatch only (forces the tooltip
|
||||
* below the anchor instead of the default auto-flip) — do not use it on
|
||||
* ordinary toolbar buttons. `wrap` enables multi-line wrapping.
|
||||
*/
|
||||
export function tooltipAttrs(
|
||||
text: string,
|
||||
opts?: { pos?: 'bottom'; wrap?: boolean },
|
||||
): Record<string, string> {
|
||||
return {
|
||||
'data-tooltip': text,
|
||||
'aria-label': text,
|
||||
...(opts?.pos ? { 'data-tooltip-pos': opts.pos } : {}),
|
||||
...(opts?.wrap ? { 'data-tooltip-wrap': '' } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user