Files
TREK/client/src/components/shared/SlidingTabs.tsx
T
Maurice 125649ce1b fix(planner): cards over the map stay clear of the layer pill, and a wide tab bar shows full tab names
The base-layer pill sits in the map's bottom-left corner on purpose. The
place inspector, a tour's detail and the day panel are centred between the
sidebars and were assumed never to reach that corner, but with both
sidebars open in a 1280 window they do, and the pill covered "Remove from
Day" and Edit. These cards now keep their left edge clear of the pill: a
narrow map moves the card right of it, a wide map keeps it centred as
before. The pill itself does not move, and the phone sheets are unchanged.

The Bookings tab showed "Book" at every width, which read as cut off. The
short form now only shows where the bar is tight; from xl up the tab shows
its full name.

Refs #2604
2026-10-12 15:46:09 +02:00

181 lines
6.2 KiB
TypeScript

import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
export interface SlidingTab<T extends string> {
id: T
label: React.ReactNode
/**
* Native tooltip. Opt-in and never derived from the label: a tooltip that only
* repeats the text next to the cursor is noise, so a caller sets this solely
* when the tab shows something the label does not spell out.
*/
title?: string
/**
* Accessible name, for a tab whose visible label is an abbreviation. Silent on
* screen, unlike `title`, so it carries the full name without putting a
* tooltip under the cursor.
*/
ariaLabel?: string
icon?: React.ComponentType<{ size?: number; className?: string }>
count?: number
}
interface SlidingTabsProps<T extends string> {
tabs: readonly SlidingTab<T>[]
activeTab: T
onChange: (id: T) => void
size?: 'sm' | 'md'
fullWidth?: boolean
className?: string
indicatorColor?: string
indicatorTextColor?: string
}
// Stripe-style sliding indicator — der aktive Pill gleitet zwischen Tabs.
// Nutzt gemessene Offsets der Buttons + CSS transform.
export function SlidingTabs<T extends string>({
tabs, activeTab, onChange, size = 'md', fullWidth, className,
indicatorColor = 'var(--accent)', indicatorTextColor = 'var(--accent-text)',
}: SlidingTabsProps<T>): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null)
const tabRefs = useRef<Map<T, HTMLButtonElement | null>>(new Map())
const [indicator, setIndicator] = useState<{ left: number; width: number; ready: boolean }>({ left: 0, width: 0, ready: false })
useLayoutEffect(() => {
const measure = (): void => {
const active = tabRefs.current.get(activeTab)
const container = containerRef.current
if (!active || !container) return
const containerRect = container.getBoundingClientRect()
const activeRect = active.getBoundingClientRect()
const left = activeRect.left - containerRect.left + container.scrollLeft
const width = activeRect.width
setIndicator(prev =>
prev.ready && prev.left === left && prev.width === width
? prev
: { left, width, ready: true },
)
}
measure()
// Re-measure once web fonts settle — on a reload the first measure runs against
// fallback-font metrics, so the (bold) active label reflows and the pill drifts.
let cancelled = false
void document.fonts?.ready?.then(() => {
if (!cancelled) measure()
})
// Re-measure on layout/size changes: container (window resize) + active label reflow.
let ro: ResizeObserver | undefined
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(() => measure())
if (containerRef.current) ro.observe(containerRef.current)
const active = tabRefs.current.get(activeTab)
if (active) ro.observe(active)
}
return () => {
cancelled = true
ro?.disconnect()
}
}, [activeTab, tabs.length])
const padding = size === 'sm' ? '5px 12px' : '6px 14px'
const fontSize = size === 'sm' ? 12 : 13
const borderRadius = size === 'sm' ? 18 : 20
return (
<div
ref={containerRef}
className={className}
style={{
position: 'relative', display: 'flex', alignItems: 'center',
gap: 2, overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
width: fullWidth ? '100%' : undefined,
}}
>
{/* Sliding indicator */}
{indicator.ready && (
<div
aria-hidden
style={{
position: 'absolute',
top: '50%',
left: indicator.left,
width: indicator.width,
height: size === 'sm' ? 26 : 30,
background: indicatorColor,
borderRadius,
transform: 'translateY(-50%)',
transition: 'left 320ms cubic-bezier(0.77, 0, 0.175, 1), width 320ms cubic-bezier(0.77, 0, 0.175, 1)',
pointerEvents: 'none',
zIndex: 0,
willChange: 'left, width',
}}
/>
)}
{tabs.map(tab => {
const isActive = tab.id === activeTab
const Icon = tab.icon
const btnStyle: CSSProperties = {
position: 'relative', zIndex: 1,
flexShrink: 0,
padding,
borderRadius,
border: 'none',
cursor: 'pointer',
fontSize,
fontWeight: isActive ? 600 : 500,
background: 'transparent',
color: isActive ? indicatorTextColor : 'var(--text-muted)',
fontFamily: 'inherit',
transition: 'color 220ms cubic-bezier(0.23, 1, 0.32, 1)',
display: 'flex', alignItems: 'center', gap: 6,
flex: fullWidth ? 1 : undefined,
justifyContent: 'center',
whiteSpace: 'nowrap',
}
return (
<button type="button"
key={tab.id}
ref={el => { tabRefs.current.set(tab.id, el) }}
onClick={() => onChange(tab.id)}
style={btnStyle}
title={tab.title}
aria-label={tab.ariaLabel}
>
{Icon && <Icon size={size === 'sm' ? 13 : 15} />}
{tab.label}
{tab.count != null && (
<span style={{
fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600,
padding: '1px 6px', borderRadius: 99, minWidth: 16,
background: isActive ? 'rgba(255,255,255,0.22)' : 'var(--bg-tertiary)',
color: isActive ? 'inherit' : 'var(--text-faint)',
textAlign: 'center',
}}>{tab.count}</span>
)}
</button>
)
})}
</div>
)
}
/**
* A tab label that also has a short form: icon only on a phone, the short form
* where the bar is tight, the full name from xl up. A wide window has the room,
* and an abbreviation there ("Book" for Bookings) read as cut off (#2604).
*/
export function TabLabel({ label, short }: { label: string; short?: string }): React.ReactElement {
if (!short || short === label) return <span className="hidden sm:inline">{label}</span>
return (
<>
<span className="hidden sm:inline xl:hidden">{short}</span>
<span className="hidden xl:inline">{label}</span>
</>
)
}
export default SlidingTabs