mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
125649ce1b
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
181 lines
6.2 KiB
TypeScript
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
|