tekivex-ui 2.2.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/TkxForm-BWK4LqY3.js +1461 -0
  2. package/dist/TkxForm-ljQjX7KD.cjs +15 -0
  3. package/dist/charts.cjs +1 -0
  4. package/dist/charts.js +480 -0
  5. package/dist/headless.cjs +1 -0
  6. package/dist/headless.js +258 -0
  7. package/dist/index-BINBzXuY.cjs +2 -0
  8. package/dist/index-eT_U4qB2.js +512 -0
  9. package/dist/index.cjs +63 -14
  10. package/dist/index.d.ts +33 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +11396 -6336
  13. package/dist/src/charts/TkxAreaChart.d.ts +29 -0
  14. package/dist/src/charts/TkxAreaChart.d.ts.map +1 -0
  15. package/dist/src/charts/TkxBarChart.d.ts +28 -0
  16. package/dist/src/charts/TkxBarChart.d.ts.map +1 -0
  17. package/dist/src/charts/TkxDonutChart.d.ts +23 -0
  18. package/dist/src/charts/TkxDonutChart.d.ts.map +1 -0
  19. package/dist/src/charts/TkxLineChart.d.ts +35 -0
  20. package/dist/src/charts/TkxLineChart.d.ts.map +1 -0
  21. package/dist/src/charts/TkxPieChart.d.ts +19 -0
  22. package/dist/src/charts/TkxPieChart.d.ts.map +1 -0
  23. package/dist/src/charts/TkxRadarChart.d.ts +20 -0
  24. package/dist/src/charts/TkxRadarChart.d.ts.map +1 -0
  25. package/dist/src/charts/TkxScatterChart.d.ts +28 -0
  26. package/dist/src/charts/TkxScatterChart.d.ts.map +1 -0
  27. package/dist/src/charts/index.d.ts +15 -0
  28. package/dist/src/charts/index.d.ts.map +1 -0
  29. package/dist/src/charts/shared.d.ts +33 -0
  30. package/dist/src/charts/shared.d.ts.map +1 -0
  31. package/dist/src/components/TkxAffix.d.ts +10 -0
  32. package/dist/src/components/TkxAffix.d.ts.map +1 -0
  33. package/dist/src/components/TkxAnchor.d.ts +14 -0
  34. package/dist/src/components/TkxAnchor.d.ts.map +1 -0
  35. package/dist/src/components/TkxCascader.d.ts +16 -0
  36. package/dist/src/components/TkxCascader.d.ts.map +1 -0
  37. package/dist/src/components/TkxDataGrid.d.ts +19 -1
  38. package/dist/src/components/TkxDataGrid.d.ts.map +1 -1
  39. package/dist/src/components/TkxForm.d.ts +33 -3
  40. package/dist/src/components/TkxForm.d.ts.map +1 -1
  41. package/dist/src/components/TkxImage.d.ts.map +1 -1
  42. package/dist/src/components/TkxList.d.ts +24 -0
  43. package/dist/src/components/TkxList.d.ts.map +1 -0
  44. package/dist/src/components/TkxMentions.d.ts +15 -0
  45. package/dist/src/components/TkxMentions.d.ts.map +1 -0
  46. package/dist/src/components/TkxQRCode.d.ts +11 -0
  47. package/dist/src/components/TkxQRCode.d.ts.map +1 -0
  48. package/dist/src/components/TkxResult.d.ts +11 -0
  49. package/dist/src/components/TkxResult.d.ts.map +1 -0
  50. package/dist/src/components/TkxSegmented.d.ts +16 -0
  51. package/dist/src/components/TkxSegmented.d.ts.map +1 -0
  52. package/dist/src/components/TkxSelect.d.ts +5 -1
  53. package/dist/src/components/TkxSelect.d.ts.map +1 -1
  54. package/dist/src/components/TkxTour.d.ts +15 -0
  55. package/dist/src/components/TkxTour.d.ts.map +1 -0
  56. package/dist/src/components/TkxTypography.d.ts +1 -1
  57. package/dist/src/components/TkxTypography.d.ts.map +1 -1
  58. package/dist/src/components/TkxWatermark.d.ts +12 -0
  59. package/dist/src/components/TkxWatermark.d.ts.map +1 -0
  60. package/dist/src/components/index.d.ts +10 -0
  61. package/dist/src/components/index.d.ts.map +1 -1
  62. package/dist/src/engine/tkx.d.ts +38 -0
  63. package/dist/src/engine/tkx.d.ts.map +1 -1
  64. package/dist/src/headless/index.d.ts +18 -0
  65. package/dist/src/headless/index.d.ts.map +1 -0
  66. package/dist/src/headless/useControllable.d.ts +20 -0
  67. package/dist/src/headless/useControllable.d.ts.map +1 -0
  68. package/dist/src/headless/useDebounce.d.ts +14 -0
  69. package/dist/src/headless/useDebounce.d.ts.map +1 -0
  70. package/dist/src/headless/useDisclosure.d.ts +20 -0
  71. package/dist/src/headless/useDisclosure.d.ts.map +1 -0
  72. package/dist/src/headless/useFormState.d.ts +44 -0
  73. package/dist/src/headless/useFormState.d.ts.map +1 -0
  74. package/dist/src/headless/useIntersectionObserver.d.ts +23 -0
  75. package/dist/src/headless/useIntersectionObserver.d.ts.map +1 -0
  76. package/dist/src/headless/useListSelection.d.ts +29 -0
  77. package/dist/src/headless/useListSelection.d.ts.map +1 -0
  78. package/dist/src/headless/useLocalStorage.d.ts +9 -0
  79. package/dist/src/headless/useLocalStorage.d.ts.map +1 -0
  80. package/dist/src/headless/useMediaQuery.d.ts +21 -0
  81. package/dist/src/headless/useMediaQuery.d.ts.map +1 -0
  82. package/dist/src/headless/useRovingTabIndex.d.ts +33 -0
  83. package/dist/src/headless/useRovingTabIndex.d.ts.map +1 -0
  84. package/dist/src/headless/useThrottle.d.ts +9 -0
  85. package/dist/src/headless/useThrottle.d.ts.map +1 -0
  86. package/dist/src/i18n/index.d.ts +47 -2
  87. package/dist/src/i18n/index.d.ts.map +1 -1
  88. package/dist/src/themes/index.d.ts.map +1 -1
  89. package/package.json +61 -7
  90. package/src/charts/TkxAreaChart.tsx +119 -0
  91. package/src/charts/TkxBarChart.tsx +115 -0
  92. package/src/charts/TkxDonutChart.tsx +145 -0
  93. package/src/charts/TkxLineChart.tsx +135 -0
  94. package/src/charts/TkxPieChart.tsx +82 -0
  95. package/src/charts/TkxRadarChart.tsx +90 -0
  96. package/src/charts/TkxScatterChart.tsx +102 -0
  97. package/src/charts/index.ts +20 -0
  98. package/src/charts/shared.ts +45 -0
  99. package/src/components/TkxAffix.tsx +138 -0
  100. package/src/components/TkxAnchor.tsx +193 -0
  101. package/src/components/TkxCascader.tsx +276 -0
  102. package/src/components/TkxDataGrid.tsx +557 -230
  103. package/src/components/TkxForm.tsx +67 -15
  104. package/src/components/TkxImage.tsx +1 -1
  105. package/src/components/TkxList.tsx +242 -0
  106. package/src/components/TkxMentions.tsx +210 -0
  107. package/src/components/TkxQRCode.tsx +174 -0
  108. package/src/components/TkxResult.tsx +128 -0
  109. package/src/components/TkxSegmented.tsx +169 -0
  110. package/src/components/TkxSelect.tsx +209 -6
  111. package/src/components/TkxTour.tsx +239 -0
  112. package/src/components/TkxTypography.tsx +4 -2
  113. package/src/components/TkxWatermark.tsx +143 -0
  114. package/src/components/index.ts +10 -0
  115. package/src/engine/tkx.ts +122 -3
  116. package/src/headless/index.ts +44 -0
  117. package/src/headless/useControllable.ts +40 -0
  118. package/src/headless/useDebounce.ts +35 -0
  119. package/src/headless/useDisclosure.ts +28 -0
  120. package/src/headless/useFormState.ts +111 -0
  121. package/src/headless/useIntersectionObserver.ts +51 -0
  122. package/src/headless/useListSelection.ts +101 -0
  123. package/src/headless/useLocalStorage.ts +46 -0
  124. package/src/headless/useMediaQuery.ts +37 -0
  125. package/src/headless/useRovingTabIndex.ts +84 -0
  126. package/src/headless/useThrottle.ts +46 -0
  127. package/src/i18n/I18nProvider.tsx +1 -1
  128. package/src/i18n/index.ts +678 -38
  129. package/src/themes/index.ts +31 -4
@@ -0,0 +1,174 @@
1
+ import { useRef, useEffect, useMemo } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { sanitizeString } from '../engine/security';
4
+ import { useReducedMotion } from '../hooks';
5
+ import { tkx } from '../engine/tkx';
6
+
7
+ // ── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export interface TkxQRCodeProps {
10
+ value: string;
11
+ size?: number;
12
+ color?: string;
13
+ bgColor?: string;
14
+ errorLevel?: 'L' | 'M' | 'Q' | 'H';
15
+ icon?: string;
16
+ bordered?: boolean;
17
+ }
18
+
19
+ // ── Hash Utility ─────────────────────────────────────────────────────────────
20
+
21
+ function simpleHash(str: string): number {
22
+ let hash = 5381;
23
+ for (let i = 0; i < str.length; i++) {
24
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
25
+ }
26
+ return Math.abs(hash);
27
+ }
28
+
29
+ // Deterministic PRNG from seed
30
+ function seededRandom(seed: number): () => number {
31
+ let s = seed;
32
+ return () => {
33
+ s = (s * 1664525 + 1013904223) | 0;
34
+ return ((s >>> 0) / 0xffffffff);
35
+ };
36
+ }
37
+
38
+ // ── Grid Generator ───────────────────────────────────────────────────────────
39
+
40
+ const ERROR_DENSITY: Record<string, number> = { L: 0.38, M: 0.42, Q: 0.48, H: 0.55 };
41
+
42
+ function generateGrid(value: string, gridSize: number, errorLevel: string): boolean[][] {
43
+ const hash = simpleHash(value);
44
+ const rand = seededRandom(hash);
45
+ const density = ERROR_DENSITY[errorLevel] ?? 0.42;
46
+ const grid: boolean[][] = Array.from({ length: gridSize }, () =>
47
+ Array.from({ length: gridSize }, () => false),
48
+ );
49
+
50
+ // Finder patterns (top-left, top-right, bottom-left)
51
+ const drawFinder = (startR: number, startC: number) => {
52
+ for (let r = 0; r < 7; r++) {
53
+ for (let c = 0; c < 7; c++) {
54
+ const isEdge = r === 0 || r === 6 || c === 0 || c === 6;
55
+ const isInner = r >= 2 && r <= 4 && c >= 2 && c <= 4;
56
+ grid[startR + r][startC + c] = isEdge || isInner;
57
+ }
58
+ }
59
+ };
60
+
61
+ drawFinder(0, 0);
62
+ drawFinder(0, gridSize - 7);
63
+ drawFinder(gridSize - 7, 0);
64
+
65
+ // Timing patterns
66
+ for (let i = 7; i < gridSize - 7; i++) {
67
+ grid[6][i] = i % 2 === 0;
68
+ grid[i][6] = i % 2 === 0;
69
+ }
70
+
71
+ // Data area: fill with deterministic pseudo-random pattern
72
+ for (let r = 0; r < gridSize; r++) {
73
+ for (let c = 0; c < gridSize; c++) {
74
+ const inFinder =
75
+ (r < 8 && c < 8) ||
76
+ (r < 8 && c >= gridSize - 8) ||
77
+ (r >= gridSize - 8 && c < 8);
78
+ const isTiming = r === 6 || c === 6;
79
+ if (inFinder || isTiming) continue;
80
+ grid[r][c] = rand() < density;
81
+ }
82
+ }
83
+
84
+ return grid;
85
+ }
86
+
87
+ // ── Component ────────────────────────────────────────────────────────────────
88
+
89
+ export function TkxQRCode({
90
+ value,
91
+ size = 160,
92
+ color,
93
+ bgColor,
94
+ errorLevel = 'M',
95
+ icon,
96
+ bordered = true,
97
+ }: TkxQRCodeProps) {
98
+ const theme = useTheme();
99
+ const reducedMotion = useReducedMotion();
100
+ const canvasRef = useRef<HTMLCanvasElement>(null);
101
+ const safeValue = sanitizeString(value);
102
+ const fg = color ?? theme.text;
103
+ const bg = bgColor ?? theme.surface;
104
+
105
+ const gridSize = 25;
106
+ const grid = useMemo(
107
+ () => generateGrid(safeValue, gridSize, errorLevel),
108
+ [safeValue, errorLevel],
109
+ );
110
+
111
+ useEffect(() => {
112
+ const canvas = canvasRef.current;
113
+ if (!canvas) return;
114
+ const ctx = canvas.getContext('2d');
115
+ if (!ctx) return;
116
+
117
+ const dpr = window.devicePixelRatio || 1;
118
+ canvas.width = size * dpr;
119
+ canvas.height = size * dpr;
120
+ ctx.scale(dpr, dpr);
121
+
122
+ const cellSize = size / gridSize;
123
+
124
+ // Background
125
+ ctx.fillStyle = bg;
126
+ ctx.fillRect(0, 0, size, size);
127
+
128
+ // Cells
129
+ ctx.fillStyle = fg;
130
+ for (let r = 0; r < gridSize; r++) {
131
+ for (let c = 0; c < gridSize; c++) {
132
+ if (grid[r][c]) {
133
+ ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize);
134
+ }
135
+ }
136
+ }
137
+
138
+ // Center icon area (clear a zone for icon)
139
+ if (icon) {
140
+ const iconSize = size * 0.22;
141
+ const offset = (size - iconSize) / 2;
142
+ ctx.fillStyle = bg;
143
+ ctx.fillRect(offset - 2, offset - 2, iconSize + 4, iconSize + 4);
144
+
145
+ const img = new Image();
146
+ img.crossOrigin = 'anonymous';
147
+ img.onload = () => {
148
+ ctx.drawImage(img, offset, offset, iconSize, iconSize);
149
+ };
150
+ img.src = sanitizeString(icon);
151
+ }
152
+ }, [grid, size, fg, bg, icon]);
153
+
154
+ return (
155
+ <div
156
+ role="img"
157
+ aria-label={`QR code for: ${safeValue}`}
158
+ className={tkx('inline-block rounded-lg')}
159
+ style={{
160
+ padding: bordered ? 12 : 0,
161
+ backgroundColor: bordered ? bg : 'transparent',
162
+ border: bordered ? `1px solid ${theme.border}` : 'none',
163
+ lineHeight: 0,
164
+ animation: reducedMotion ? 'none' : 'tkxFadeIn 0.2s ease',
165
+ }}
166
+ >
167
+ <canvas
168
+ ref={canvasRef}
169
+ aria-hidden="true"
170
+ style={{ width: size, height: size, display: 'block' }}
171
+ />
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,128 @@
1
+ import { type ReactNode } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { sanitizeString } from '../engine/security';
4
+ import { useReducedMotion } from '../hooks';
5
+ import { tkx } from '../engine/tkx';
6
+
7
+ // ── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export type ResultStatus = 'success' | 'error' | 'warning' | 'info' | '404' | '403' | '500';
10
+
11
+ export interface TkxResultProps {
12
+ status: ResultStatus;
13
+ title: string;
14
+ subTitle?: string;
15
+ icon?: ReactNode;
16
+ extra?: ReactNode;
17
+ }
18
+
19
+ // ── Status Icons (SVG) ──────────────────────────────────────────────────────
20
+
21
+ const STATUS_ICONS: Record<ResultStatus, (color: string) => ReactNode> = {
22
+ success: (c) => (
23
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
24
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
25
+ <path d="M22 37l9 9 19-19" stroke={c} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
26
+ </svg>
27
+ ),
28
+ error: (c) => (
29
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
30
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
31
+ <path d="M26 26l20 20M46 26L26 46" stroke={c} strokeWidth="4" strokeLinecap="round" />
32
+ </svg>
33
+ ),
34
+ warning: (c) => (
35
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
36
+ <path d="M36 6L4 64h64L36 6z" stroke={c} strokeWidth="3" fill={`${c}18`} />
37
+ <path d="M36 30v16" stroke={c} strokeWidth="4" strokeLinecap="round" />
38
+ <circle cx="36" cy="54" r="2.5" fill={c} />
39
+ </svg>
40
+ ),
41
+ info: (c) => (
42
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
43
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
44
+ <path d="M36 32v18" stroke={c} strokeWidth="4" strokeLinecap="round" />
45
+ <circle cx="36" cy="22" r="2.5" fill={c} />
46
+ </svg>
47
+ ),
48
+ '404': (c) => (
49
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
50
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
51
+ <text x="36" y="44" textAnchor="middle" fontSize="22" fontWeight="bold" fill={c}>404</text>
52
+ </svg>
53
+ ),
54
+ '403': (c) => (
55
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
56
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
57
+ <path d="M28 30h16v16H28z" stroke={c} strokeWidth="3" fill="none" />
58
+ <path d="M32 30v-4a4 4 0 018 0v4" stroke={c} strokeWidth="3" fill="none" />
59
+ </svg>
60
+ ),
61
+ '500': (c) => (
62
+ <svg width="72" height="72" viewBox="0 0 72 72" fill="none" aria-hidden="true">
63
+ <circle cx="36" cy="36" r="34" stroke={c} strokeWidth="3" fill={`${c}18`} />
64
+ <text x="36" y="44" textAnchor="middle" fontSize="22" fontWeight="bold" fill={c}>500</text>
65
+ </svg>
66
+ ),
67
+ };
68
+
69
+ // ── Color Mapping ────────────────────────────────────────────────────────────
70
+
71
+ function getStatusColor(status: ResultStatus, theme: ReturnType<typeof useTheme>): string {
72
+ switch (status) {
73
+ case 'success': return theme.success;
74
+ case 'error':
75
+ case '500': return theme.danger;
76
+ case 'warning': return theme.warning;
77
+ case 'info': return theme.info;
78
+ case '404':
79
+ case '403': return theme.textMuted;
80
+ }
81
+ }
82
+
83
+ // ── Component ────────────────────────────────────────────────────────────────
84
+
85
+ export function TkxResult({ status, title, subTitle, icon, extra }: TkxResultProps) {
86
+ const theme = useTheme();
87
+ const reducedMotion = useReducedMotion();
88
+ const accentColor = getStatusColor(status, theme);
89
+ const safeTitle = sanitizeString(title);
90
+ const safeSubTitle = subTitle ? sanitizeString(subTitle) : undefined;
91
+
92
+ return (
93
+ <div
94
+ role="status"
95
+ aria-live="polite"
96
+ className={tkx('flex flex-col items-center text-center px-6 py-12 font-sans')}
97
+ style={{
98
+ animation: reducedMotion ? 'none' : 'tkxFadeIn 0.3s ease',
99
+ }}
100
+ >
101
+ <div className={tkx('mb-6')}>
102
+ {icon ?? STATUS_ICONS[status](accentColor)}
103
+ </div>
104
+
105
+ <h2
106
+ className={tkx('text-xl font-semibold m-0 mb-2')}
107
+ style={{ color: theme.text }}
108
+ >
109
+ {safeTitle}
110
+ </h2>
111
+
112
+ {safeSubTitle && (
113
+ <p
114
+ className={tkx('text-sm m-0 mb-6 max-w-md leading-relaxed')}
115
+ style={{ color: theme.textMuted }}
116
+ >
117
+ {safeSubTitle}
118
+ </p>
119
+ )}
120
+
121
+ {extra && (
122
+ <div className={tkx('flex items-center gap-3 mt-4')}>
123
+ {extra}
124
+ </div>
125
+ )}
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,169 @@
1
+ import { type ReactNode, useState, useRef, useEffect, useCallback } from 'react';
2
+ import { useTheme } from '../themes';
3
+ import { sanitizeString } from '../engine/security';
4
+ import { useReducedMotion } from '../hooks';
5
+ import { tkx } from '../engine/tkx';
6
+
7
+ // ── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export interface SegmentedOption {
10
+ value: string;
11
+ label: string;
12
+ icon?: ReactNode;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ export interface TkxSegmentedProps {
17
+ options: SegmentedOption[];
18
+ value?: string;
19
+ onChange?: (value: string) => void;
20
+ size?: 'sm' | 'md' | 'lg';
21
+ block?: boolean;
22
+ }
23
+
24
+ // ── Size Map ─────────────────────────────────────────────────────────────────
25
+
26
+ const SIZE_MAP = {
27
+ sm: { height: 28, fontSize: 12, px: 10 },
28
+ md: { height: 36, fontSize: 14, px: 16 },
29
+ lg: { height: 44, fontSize: 16, px: 20 },
30
+ } as const;
31
+
32
+ // ── Component ────────────────────────────────────────────────────────────────
33
+
34
+ export function TkxSegmented({
35
+ options,
36
+ value,
37
+ onChange,
38
+ size = 'md',
39
+ block = false,
40
+ }: TkxSegmentedProps) {
41
+ const theme = useTheme();
42
+ const reducedMotion = useReducedMotion();
43
+ const containerRef = useRef<HTMLDivElement>(null);
44
+ const [activeIndex, setActiveIndex] = useState(() =>
45
+ Math.max(0, options.findIndex((o) => o.value === value)),
46
+ );
47
+ const [indicatorStyle, setIndicatorStyle] = useState<React.CSSProperties>({});
48
+ const dims = SIZE_MAP[size];
49
+
50
+ // Sync external value
51
+ useEffect(() => {
52
+ const idx = options.findIndex((o) => o.value === value);
53
+ if (idx >= 0) setActiveIndex(idx);
54
+ }, [value, options]);
55
+
56
+ // Sliding indicator position
57
+ useEffect(() => {
58
+ const container = containerRef.current;
59
+ if (!container) return;
60
+ const buttons = container.querySelectorAll<HTMLButtonElement>('[data-seg-btn]');
61
+ const btn = buttons[activeIndex];
62
+ if (!btn) return;
63
+ setIndicatorStyle({
64
+ left: btn.offsetLeft,
65
+ width: btn.offsetWidth,
66
+ height: btn.offsetHeight,
67
+ transition: reducedMotion ? 'none' : 'left 0.2s ease, width 0.2s ease',
68
+ });
69
+ }, [activeIndex, options, reducedMotion]);
70
+
71
+ const handleSelect = useCallback(
72
+ (idx: number) => {
73
+ const opt = options[idx];
74
+ if (!opt || opt.disabled) return;
75
+ setActiveIndex(idx);
76
+ onChange?.(opt.value);
77
+ },
78
+ [options, onChange],
79
+ );
80
+
81
+ const handleKeyDown = useCallback(
82
+ (e: React.KeyboardEvent) => {
83
+ let next = activeIndex;
84
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
85
+ e.preventDefault();
86
+ next = (activeIndex + 1) % options.length;
87
+ while (options[next]?.disabled && next !== activeIndex) {
88
+ next = (next + 1) % options.length;
89
+ }
90
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
91
+ e.preventDefault();
92
+ next = (activeIndex - 1 + options.length) % options.length;
93
+ while (options[next]?.disabled && next !== activeIndex) {
94
+ next = (next - 1 + options.length) % options.length;
95
+ }
96
+ }
97
+ if (next !== activeIndex) {
98
+ handleSelect(next);
99
+ const container = containerRef.current;
100
+ const buttons = container?.querySelectorAll<HTMLButtonElement>('[data-seg-btn]');
101
+ buttons?.[next]?.focus();
102
+ }
103
+ },
104
+ [activeIndex, options, handleSelect],
105
+ );
106
+
107
+ return (
108
+ <div
109
+ ref={containerRef}
110
+ role="radiogroup"
111
+ aria-label="Segmented control"
112
+ className={tkx('relative inline-flex items-center rounded-lg p-1')}
113
+ style={{
114
+ backgroundColor: theme.surfaceAlt,
115
+ border: `1px solid ${theme.border}`,
116
+ width: block ? '100%' : undefined,
117
+ }}
118
+ onKeyDown={handleKeyDown}
119
+ >
120
+ {/* Sliding indicator */}
121
+ <div
122
+ aria-hidden="true"
123
+ className={tkx('absolute top-1 rounded-md')}
124
+ style={{
125
+ ...indicatorStyle,
126
+ backgroundColor: theme.surface,
127
+ boxShadow: `0 1px 3px ${theme.border}`,
128
+ zIndex: 0,
129
+ }}
130
+ />
131
+
132
+ {options.map((opt, idx) => {
133
+ const isActive = idx === activeIndex;
134
+ const safeLabel = sanitizeString(opt.label);
135
+ return (
136
+ <button
137
+ key={opt.value}
138
+ data-seg-btn
139
+ type="button"
140
+ role="radio"
141
+ aria-checked={isActive}
142
+ aria-disabled={opt.disabled || undefined}
143
+ disabled={opt.disabled}
144
+ tabIndex={isActive ? 0 : -1}
145
+ className={tkx(
146
+ 'relative z-10 flex items-center justify-center gap-1 rounded-md border-0 cursor-pointer',
147
+ 'font-sans whitespace-nowrap select-none',
148
+ )}
149
+ style={{
150
+ height: dims.height,
151
+ fontSize: dims.fontSize,
152
+ padding: `0 ${dims.px}px`,
153
+ flex: block ? 1 : undefined,
154
+ color: isActive ? theme.text : theme.textMuted,
155
+ backgroundColor: 'transparent',
156
+ opacity: opt.disabled ? 0.4 : 1,
157
+ fontWeight: isActive ? 600 : 400,
158
+ transition: reducedMotion ? 'none' : 'color 0.15s ease',
159
+ }}
160
+ onClick={() => handleSelect(idx)}
161
+ >
162
+ {opt.icon && <span aria-hidden="true">{opt.icon}</span>}
163
+ {safeLabel}
164
+ </button>
165
+ );
166
+ })}
167
+ </div>
168
+ );
169
+ }