From c119a32277f98b6d91eca398a6fed4ab4802c344 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:53:22 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 8 ++ src/components/AlbumHeader.tsx | 22 ++++- src/components/GenreFilterBar.tsx | 2 + src/components/SongRow.tsx | 5 +- src/components/SortDropdown.tsx | 6 +- src/components/StarFilterButton.tsx | 1 - src/components/TooltipPortal.test.tsx | 69 ++++++++++++++++ src/components/TooltipPortal.tsx | 80 +++++++++++++++---- src/components/YearFilterButton.tsx | 2 + .../artistDetail/ArtistDetailHero.tsx | 24 ++++-- src/components/tooltipAttrs.test.ts | 21 +++++ src/components/tooltipAttrs.ts | 24 ++++++ src/locales/de/albumDetail.ts | 3 + src/locales/de/albums.ts | 2 + src/locales/de/artistDetail.ts | 5 ++ src/locales/de/common.ts | 2 + src/locales/de/search.ts | 5 ++ src/locales/en/albumDetail.ts | 3 + src/locales/en/albums.ts | 2 + src/locales/en/artistDetail.ts | 5 ++ src/locales/en/common.ts | 2 + src/locales/en/search.ts | 5 ++ src/locales/es/albumDetail.ts | 3 + src/locales/es/albums.ts | 2 + src/locales/es/artistDetail.ts | 5 ++ src/locales/es/common.ts | 2 + src/locales/es/search.ts | 5 ++ src/locales/fr/albumDetail.ts | 3 + src/locales/fr/albums.ts | 2 + src/locales/fr/artistDetail.ts | 5 ++ src/locales/fr/common.ts | 2 + src/locales/fr/search.ts | 5 ++ src/locales/nb/albumDetail.ts | 3 + src/locales/nb/albums.ts | 2 + src/locales/nb/artistDetail.ts | 5 ++ src/locales/nb/common.ts | 2 + src/locales/nb/search.ts | 5 ++ src/locales/nl/albumDetail.ts | 3 + src/locales/nl/albums.ts | 2 + src/locales/nl/artistDetail.ts | 5 ++ src/locales/nl/common.ts | 2 + src/locales/nl/search.ts | 5 ++ src/locales/ro/albumDetail.ts | 3 + src/locales/ro/albums.ts | 2 + src/locales/ro/artistDetail.ts | 5 ++ src/locales/ro/common.ts | 2 + src/locales/ro/search.ts | 5 ++ src/locales/ru/albumDetail.ts | 3 + src/locales/ru/albums.ts | 2 + src/locales/ru/artistDetail.ts | 5 ++ src/locales/ru/common.ts | 2 + src/locales/ru/search.ts | 5 ++ src/locales/zh/albumDetail.ts | 3 + src/locales/zh/albums.ts | 2 + src/locales/zh/artistDetail.ts | 5 ++ src/locales/zh/common.ts | 2 + src/locales/zh/search.ts | 5 ++ src/pages/Albums.tsx | 1 + src/pages/Artists.tsx | 3 + src/pages/SearchBrowsePage.tsx | 17 ++-- 60 files changed, 404 insertions(+), 34 deletions(-) create mode 100644 src/components/TooltipPortal.test.tsx create mode 100644 src/components/tooltipAttrs.test.ts create mode 100644 src/components/tooltipAttrs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f1a458..85bd3f90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -401,6 +401,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Single tracks in the discovery rails now show a round, vinyl-style cover so they read as songs rather than albums — clicking one still plays it instantly. * A new **To album** badge under the artist jumps to the track's album, available in all 9 languages. +### Tooltips — consistent hints on every button + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#972](https://github.com/Psychotoxical/psysonic/pull/972)** + +* Buttons across the app now show a short tooltip describing what they do, appearing after a 1-second hover so they never flash during quick mouse passes. +* Tooltips that were missing — on the artist, album, All Albums, track-list and playlist actions — are filled in, and tooltips that used to point different directions within the same toolbar now line up consistently. +* Advanced Search gains a **Search in:** label so the All / Artists / Albums / Songs row reads clearly as a scope limiter. Available in all 9 languages. + diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 145e54d9..a3281cc1 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -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({ ) : (
- {onShuffleAll && ( @@ -361,7 +367,12 @@ export default function AlbumHeader({
{showBioButton && ( - )} @@ -375,7 +386,12 @@ export default function AlbumHeader({ {downloadProgress}%
) : ( - )} diff --git a/src/components/GenreFilterBar.tsx b/src/components/GenreFilterBar.tsx index ef45294a..309e3eb6 100644 --- a/src/components/GenreFilterBar.tsx +++ b/src/components/GenreFilterBar.tsx @@ -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' }} > diff --git a/src/components/SongRow.tsx b/src/components/SongRow.tsx index 5a972e54..311306c9 100644 --- a/src/components/SongRow.tsx +++ b/src/components/SongRow.tsx @@ -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) { diff --git a/src/components/SortDropdown.tsx b/src/components/SortDropdown.tsx index 53f12708..0a0790eb 100644 --- a/src/components/SortDropdown.tsx +++ b/src/components/SortDropdown.tsx @@ -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 { value: V; @@ -12,9 +13,11 @@ interface Props { options: SortOption[]; onChange: (value: V) => void; ariaLabel?: string; + /** Hover tooltip describing the action (shown below the trigger). */ + tooltip?: string; } -export default function SortDropdown({ value, options, onChange, ariaLabel }: Props) { +export default function SortDropdown({ value, options, onChange, ariaLabel, tooltip }: Props) { const [open, setOpen] = useState(false); const [popStyle, setPopStyle] = useState({}); @@ -91,6 +94,7 @@ export default function SortDropdown({ 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' }} > diff --git a/src/components/StarFilterButton.tsx b/src/components/StarFilterButton.tsx index c8044a32..f4c2e3e6 100644 --- a/src/components/StarFilterButton.tsx +++ b/src/components/StarFilterButton.tsx @@ -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' }} > diff --git a/src/components/TooltipPortal.test.tsx b/src/components/TooltipPortal.test.tsx new file mode 100644 index 00000000..f452a47d --- /dev/null +++ b/src/components/TooltipPortal.test.tsx @@ -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 ( + <> + + + + ); +} + +describe('TooltipPortal open delay', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it('shows the tooltip only after the 1s open delay', () => { + renderWithProviders(); + 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(); + 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(); + 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(); + }); +}); diff --git a/src/components/TooltipPortal.tsx b/src/components/TooltipPortal.tsx index c205afde..cc47996a 100644 --- a/src/components/TooltipPortal.tsx +++ b/src/components/TooltipPortal.tsx @@ -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(null); const boxRef = useRef(null); const [style, setStyle] = useState({ opacity: 0 }); - const tooltipRef = useRef(null); - tooltipRef.current = tooltip; + // Anchor counting down to show, plus its pending timer. + const pendingAnchorRef = useRef(null); + const pendingTimerRef = useRef(null); + // Anchor whose tooltip is currently visible. + const shownAnchorRef = useRef(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); diff --git a/src/components/YearFilterButton.tsx b/src/components/YearFilterButton.tsx index 7fc7a3a8..3209d719 100644 --- a/src/components/YearFilterButton.tsx +++ b/src/components/YearFilterButton.tsx @@ -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)' } : {}), diff --git a/src/components/artistDetail/ArtistDetailHero.tsx b/src/components/artistDetail/ArtistDetailHero.tsx index 14d627e6..4afbb520 100644 --- a/src/components/artistDetail/ArtistDetailHero.tsx +++ b/src/components/artistDetail/ArtistDetailHero.tsx @@ -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) && (
{info?.lastFmUrl && ( - )} - @@ -168,7 +177,12 @@ export default function ArtistDetailHero({
{albums.length > 0 && ( <> - @@ -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 ?
: } {!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 ?
: } {!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))} diff --git a/src/components/tooltipAttrs.test.ts b/src/components/tooltipAttrs.test.ts new file mode 100644 index 00000000..b3768a86 --- /dev/null +++ b/src/components/tooltipAttrs.test.ts @@ -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); + }); +}); diff --git a/src/components/tooltipAttrs.ts b/src/components/tooltipAttrs.ts new file mode 100644 index 00000000..28e704b2 --- /dev/null +++ b/src/components/tooltipAttrs.ts @@ -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). + * + * @@ -339,6 +340,7 @@ export default function Artists() { onClick={() => setViewMode('grid')} style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }} data-tooltip={t('artists.gridView')} + data-tooltip-pos="bottom" > @@ -347,6 +349,7 @@ export default function Artists() { onClick={() => setViewMode('list')} style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }} data-tooltip={t('artists.listView')} + data-tooltip-pos="bottom" > diff --git a/src/pages/SearchBrowsePage.tsx b/src/pages/SearchBrowsePage.tsx index 68122f96..993a834f 100644 --- a/src/pages/SearchBrowsePage.tsx +++ b/src/pages/SearchBrowsePage.tsx @@ -11,6 +11,7 @@ import ArtistRow from '../components/ArtistRow'; import PagedSongList from '../components/PagedSongList'; import CustomSelect from '../components/CustomSelect'; import StarFilterButton from '../components/StarFilterButton'; +import { tooltipAttrs } from '../components/tooltipAttrs'; import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; @@ -821,11 +822,11 @@ export default function SearchBrowsePage() { }); }; - const typeOptions: { id: ResultType; label: string }[] = [ - { id: 'all', label: t('search.advancedAll') }, - { id: 'artists', label: t('search.artists') }, - { id: 'albums', label: t('search.albums') }, - { id: 'songs', label: t('search.songs') }, + const typeOptions: { id: ResultType; label: string; tooltip: string }[] = [ + { id: 'all', label: t('search.advancedAll'), tooltip: t('search.scopeAllTooltip') }, + { id: 'artists', label: t('search.artists'), tooltip: t('search.scopeArtistsChipTooltip') }, + { id: 'albums', label: t('search.albums'), tooltip: t('search.scopeAlbumsChipTooltip') }, + { id: 'songs', label: t('search.songs'), tooltip: t('search.scopeSongsChipTooltip') }, ]; const genreSelectOptions = [ @@ -1059,6 +1060,11 @@ export default function SearchBrowsePage() { {/* Row 4: Result type + Search button */}
+ {!trackFilterActive && ( + + {t('search.scopeRowLabel')} + + )} {!trackFilterActive && typeOptions.map(opt => (