File size: 4,335 Bytes
cd99321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'

export interface SlidingTab<T extends string> {
  id: T
  label: React.ReactNode
  title?: 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 active = tabRefs.current.get(activeTab)
    const container = containerRef.current
    if (!active || !container) return
    const containerRect = container.getBoundingClientRect()
    const activeRect = active.getBoundingClientRect()
    setIndicator({
      left: activeRect.left - containerRect.left + container.scrollLeft,
      width: activeRect.width,
      ready: true,
    })
  }, [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
            key={tab.id}
            ref={el => { tabRefs.current.set(tab.id, el) }}
            onClick={() => onChange(tab.id)}
            style={btnStyle}
            title={tab.title ?? (typeof tab.label === 'string' ? tab.label : undefined)}
          >
            {Icon && <Icon size={size === 'sm' ? 13 : 15} />}
            {tab.label}
            {tab.count != null && (
              <span style={{
                fontSize: 10, 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>
  )
}

export default SlidingTabs