tekivex-ui 2.0.1 → 2.1.0
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.
- package/README.md +1 -1
- package/dist/index.cjs +12 -12
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2207 -2018
- package/dist/src/components/TkxAppBar.d.ts +12 -0
- package/dist/src/components/TkxAppBar.d.ts.map +1 -0
- package/dist/src/components/TkxAutocomplete.d.ts +25 -0
- package/dist/src/components/TkxAutocomplete.d.ts.map +1 -0
- package/dist/src/components/TkxBottomNav.d.ts +15 -0
- package/dist/src/components/TkxBottomNav.d.ts.map +1 -0
- package/dist/src/components/TkxBreadcrumb.d.ts +17 -0
- package/dist/src/components/TkxBreadcrumb.d.ts.map +1 -0
- package/dist/src/components/TkxCard.d.ts.map +1 -1
- package/dist/src/components/TkxDataGrid.d.ts +33 -0
- package/dist/src/components/TkxDataGrid.d.ts.map +1 -0
- package/dist/src/components/TkxMasonry.d.ts +12 -0
- package/dist/src/components/TkxMasonry.d.ts.map +1 -0
- package/dist/src/components/TkxModal.d.ts.map +1 -1
- package/dist/src/components/TkxPopover.d.ts +15 -0
- package/dist/src/components/TkxPopover.d.ts.map +1 -0
- package/dist/src/components/TkxRichTextDisplay.d.ts +19 -0
- package/dist/src/components/TkxRichTextDisplay.d.ts.map +1 -0
- package/dist/src/components/TkxSelect.d.ts.map +1 -1
- package/dist/src/components/TkxSlider.d.ts.map +1 -1
- package/dist/src/components/TkxSnackbar.d.ts +16 -0
- package/dist/src/components/TkxSnackbar.d.ts.map +1 -0
- package/dist/src/components/TkxSpeedDial.d.ts +18 -0
- package/dist/src/components/TkxSpeedDial.d.ts.map +1 -0
- package/dist/src/components/TkxToolbar.d.ts +22 -0
- package/dist/src/components/TkxToolbar.d.ts.map +1 -0
- package/dist/src/components/TkxTooltip.d.ts.map +1 -1
- package/dist/src/components/TkxTransferList.d.ts +20 -0
- package/dist/src/components/TkxTransferList.d.ts.map +1 -0
- package/dist/src/components/TkxTreeView.d.ts +23 -0
- package/dist/src/components/TkxTreeView.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +13 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/i18n/I18nProvider.d.ts +10 -0
- package/dist/src/i18n/I18nProvider.d.ts.map +1 -0
- package/dist/src/i18n/index.d.ts +50 -0
- package/dist/src/i18n/index.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/components/TkxAppBar.tsx +260 -0
- package/src/components/TkxAutocomplete.tsx +453 -0
- package/src/components/TkxBottomNav.tsx +239 -0
- package/src/components/TkxBreadcrumb.tsx +322 -0
- package/src/components/TkxCard.tsx +3 -1
- package/src/components/TkxDataGrid.tsx +540 -0
- package/src/components/TkxMasonry.tsx +176 -0
- package/src/components/TkxModal.tsx +4 -3
- package/src/components/TkxPopover.tsx +295 -0
- package/src/components/TkxRichTextDisplay.tsx +394 -0
- package/src/components/TkxSelect.tsx +2 -0
- package/src/components/TkxSlider.tsx +1 -0
- package/src/components/TkxSnackbar.tsx +314 -0
- package/src/components/TkxSpeedDial.tsx +395 -0
- package/src/components/TkxToolbar.tsx +260 -0
- package/src/components/TkxTooltip.tsx +3 -2
- package/src/components/TkxTransferList.tsx +487 -0
- package/src/components/TkxTreeView.tsx +421 -0
- package/src/components/index.ts +13 -0
- package/src/hooks/index.ts +1 -1
- package/src/i18n/I18nProvider.tsx +36 -0
- package/src/i18n/index.ts +237 -0
- package/dist/tests/TkxButton.test.d.ts +0 -2
- package/dist/tests/TkxButton.test.d.ts.map +0 -1
- package/dist/tests/a11y/components.test.d.ts +0 -2
- package/dist/tests/a11y/components.test.d.ts.map +0 -1
- package/dist/tests/quantum.test.d.ts +0 -2
- package/dist/tests/quantum.test.d.ts.map +0 -1
- package/dist/tests/security.test.d.ts +0 -2
- package/dist/tests/security.test.d.ts.map +0 -1
- package/dist/tests/setup.d.ts +0 -2
- package/dist/tests/setup.d.ts.map +0 -1
- package/dist/tests/tkx.test.d.ts +0 -2
- package/dist/tests/tkx.test.d.ts.map +0 -1
- package/dist/tests/wcag.test.d.ts +0 -2
- package/dist/tests/wcag.test.d.ts.map +0 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
type KeyboardEvent,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useTheme } from '../themes';
|
|
9
|
+
import { sanitizeString } from '../engine/security';
|
|
10
|
+
import { useReducedMotion } from '../hooks';
|
|
11
|
+
import { tkx } from '../engine/tkx';
|
|
12
|
+
|
|
13
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface BottomNavItem {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
icon: ReactNode;
|
|
19
|
+
badge?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TkxBottomNavProps {
|
|
23
|
+
items: BottomNavItem[];
|
|
24
|
+
activeId?: string;
|
|
25
|
+
onChange?: (id: string) => void;
|
|
26
|
+
showLabels?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const MAX_ITEMS = 5;
|
|
32
|
+
const NAV_HEIGHT = 56;
|
|
33
|
+
|
|
34
|
+
// ── Keyframe injection ──────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
let injected = false;
|
|
37
|
+
function injectKeyframes() {
|
|
38
|
+
if (injected || typeof document === 'undefined') return;
|
|
39
|
+
injected = true;
|
|
40
|
+
const style = document.createElement('style');
|
|
41
|
+
style.id = 'tkx-bottom-nav-kf';
|
|
42
|
+
style.textContent = `
|
|
43
|
+
@keyframes tkx-bottom-nav-pulse {
|
|
44
|
+
0% { transform: scale(1); }
|
|
45
|
+
50% { transform: scale(1.15); }
|
|
46
|
+
100% { transform: scale(1); }
|
|
47
|
+
}
|
|
48
|
+
`;
|
|
49
|
+
document.head.appendChild(style);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export function TkxBottomNav({
|
|
55
|
+
items,
|
|
56
|
+
activeId,
|
|
57
|
+
onChange,
|
|
58
|
+
showLabels = true,
|
|
59
|
+
}: TkxBottomNavProps) {
|
|
60
|
+
const theme = useTheme();
|
|
61
|
+
const reduced = useReducedMotion();
|
|
62
|
+
const navRef = useRef<HTMLElement>(null);
|
|
63
|
+
|
|
64
|
+
// Inject keyframes once
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
injectKeyframes();
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
// Clamp to max 5 items
|
|
70
|
+
const visibleItems = items.slice(0, MAX_ITEMS);
|
|
71
|
+
|
|
72
|
+
const handleSelect = useCallback(
|
|
73
|
+
(id: string) => {
|
|
74
|
+
onChange?.(id);
|
|
75
|
+
},
|
|
76
|
+
[onChange],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// ── Keyboard navigation (left/right arrows) ────────────────────────────
|
|
80
|
+
|
|
81
|
+
const handleKeyDown = useCallback(
|
|
82
|
+
(e: KeyboardEvent<HTMLElement>) => {
|
|
83
|
+
const nav = navRef.current;
|
|
84
|
+
if (!nav) return;
|
|
85
|
+
|
|
86
|
+
const buttons = Array.from(
|
|
87
|
+
nav.querySelectorAll<HTMLButtonElement>('[role="tab"]'),
|
|
88
|
+
);
|
|
89
|
+
const currentIndex = buttons.findIndex(
|
|
90
|
+
(btn) => btn === document.activeElement,
|
|
91
|
+
);
|
|
92
|
+
if (currentIndex === -1) return;
|
|
93
|
+
|
|
94
|
+
let nextIndex = currentIndex;
|
|
95
|
+
|
|
96
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
nextIndex = (currentIndex + 1) % buttons.length;
|
|
99
|
+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
nextIndex = (currentIndex - 1 + buttons.length) % buttons.length;
|
|
102
|
+
} else if (e.key === 'Home') {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
nextIndex = 0;
|
|
105
|
+
} else if (e.key === 'End') {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
nextIndex = buttons.length - 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (nextIndex !== currentIndex) {
|
|
111
|
+
buttons[nextIndex].focus();
|
|
112
|
+
handleSelect(visibleItems[nextIndex].id);
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[handleSelect, visibleItems],
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// ── Transition ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const transition = reduced ? 'none' : 'color 200ms ease, transform 150ms ease';
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<nav
|
|
124
|
+
ref={navRef}
|
|
125
|
+
role="navigation"
|
|
126
|
+
aria-label="Bottom navigation"
|
|
127
|
+
className={tkx('fixed bottom-0 left-0 right-0 z-[1000] font-sans')}
|
|
128
|
+
style={{
|
|
129
|
+
height: NAV_HEIGHT,
|
|
130
|
+
backgroundColor: theme.surface,
|
|
131
|
+
borderTop: `1px solid ${theme.border}`,
|
|
132
|
+
boxShadow: `0 -2px 12px ${theme.bg}44`,
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
<div
|
|
136
|
+
role="tablist"
|
|
137
|
+
aria-label="Navigation tabs"
|
|
138
|
+
className={tkx('flex items-center justify-around h-full')}
|
|
139
|
+
onKeyDown={handleKeyDown}
|
|
140
|
+
>
|
|
141
|
+
{visibleItems.map((item) => {
|
|
142
|
+
const isActive = item.id === activeId;
|
|
143
|
+
const safeLabel = sanitizeString(item.label);
|
|
144
|
+
const color = isActive ? theme.primary : theme.textMuted;
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<button
|
|
148
|
+
key={item.id}
|
|
149
|
+
role="tab"
|
|
150
|
+
aria-selected={isActive}
|
|
151
|
+
aria-label={safeLabel}
|
|
152
|
+
tabIndex={isActive ? 0 : -1}
|
|
153
|
+
onClick={() => handleSelect(item.id)}
|
|
154
|
+
className={tkx(
|
|
155
|
+
'flex flex-col items-center justify-center gap-0.5',
|
|
156
|
+
'bg-transparent border-none cursor-pointer p-1',
|
|
157
|
+
'flex-1 h-full relative',
|
|
158
|
+
'focus-visible:focus-ring',
|
|
159
|
+
)}
|
|
160
|
+
style={{
|
|
161
|
+
color,
|
|
162
|
+
transition,
|
|
163
|
+
outline: 'none',
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
{/* Active indicator bar */}
|
|
167
|
+
{isActive && (
|
|
168
|
+
<div
|
|
169
|
+
aria-hidden="true"
|
|
170
|
+
style={{
|
|
171
|
+
position: 'absolute',
|
|
172
|
+
top: 0,
|
|
173
|
+
left: '25%',
|
|
174
|
+
right: '25%',
|
|
175
|
+
height: 3,
|
|
176
|
+
borderRadius: '0 0 3px 3px',
|
|
177
|
+
backgroundColor: theme.primary,
|
|
178
|
+
transition: reduced ? 'none' : 'width 200ms ease',
|
|
179
|
+
}}
|
|
180
|
+
/>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{/* Icon wrapper with badge */}
|
|
184
|
+
<div
|
|
185
|
+
className={tkx('relative flex items-center justify-center')}
|
|
186
|
+
style={{
|
|
187
|
+
animation:
|
|
188
|
+
isActive && !reduced
|
|
189
|
+
? 'tkx-bottom-nav-pulse 300ms ease-out'
|
|
190
|
+
: 'none',
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{item.icon}
|
|
194
|
+
|
|
195
|
+
{/* Badge */}
|
|
196
|
+
{item.badge !== undefined && item.badge > 0 && (
|
|
197
|
+
<span
|
|
198
|
+
aria-label={`${item.badge} notifications`}
|
|
199
|
+
className={tkx(
|
|
200
|
+
'absolute flex items-center justify-center',
|
|
201
|
+
'text-[10px] font-bold leading-none rounded-full',
|
|
202
|
+
)}
|
|
203
|
+
style={{
|
|
204
|
+
top: -4,
|
|
205
|
+
right: -8,
|
|
206
|
+
minWidth: 16,
|
|
207
|
+
height: 16,
|
|
208
|
+
padding: '0 4px',
|
|
209
|
+
backgroundColor: theme.danger,
|
|
210
|
+
color: theme.bg,
|
|
211
|
+
}}
|
|
212
|
+
>
|
|
213
|
+
{item.badge > 99 ? '99+' : item.badge}
|
|
214
|
+
</span>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{/* Label */}
|
|
219
|
+
{showLabels && (
|
|
220
|
+
<span
|
|
221
|
+
className={tkx('text-[10px] leading-tight mt-0.5')}
|
|
222
|
+
style={{
|
|
223
|
+
fontWeight: isActive ? 600 : 400,
|
|
224
|
+
maxWidth: '100%',
|
|
225
|
+
overflow: 'hidden',
|
|
226
|
+
textOverflow: 'ellipsis',
|
|
227
|
+
whiteSpace: 'nowrap',
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
{safeLabel}
|
|
231
|
+
</span>
|
|
232
|
+
)}
|
|
233
|
+
</button>
|
|
234
|
+
);
|
|
235
|
+
})}
|
|
236
|
+
</div>
|
|
237
|
+
</nav>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
type CSSProperties,
|
|
5
|
+
type KeyboardEvent,
|
|
6
|
+
} from 'react';
|
|
7
|
+
import { useTheme } from '../themes';
|
|
8
|
+
import { sanitizeString } from '../engine/security';
|
|
9
|
+
import { useReducedMotion } from '../hooks';
|
|
10
|
+
import { tkx } from '../engine/tkx';
|
|
11
|
+
|
|
12
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface BreadcrumbItem {
|
|
15
|
+
label: string;
|
|
16
|
+
href?: string;
|
|
17
|
+
icon?: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TkxBreadcrumbProps {
|
|
21
|
+
items: BreadcrumbItem[];
|
|
22
|
+
separator?: ReactNode;
|
|
23
|
+
maxItems?: number;
|
|
24
|
+
onNavigate?: (item: BreadcrumbItem, index: number) => void;
|
|
25
|
+
className?: string;
|
|
26
|
+
style?: CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Chevron separator icon ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function DefaultSeparator({ color }: { color: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<svg
|
|
34
|
+
width="16"
|
|
35
|
+
height="16"
|
|
36
|
+
viewBox="0 0 16 16"
|
|
37
|
+
fill="none"
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
style={{ flexShrink: 0 }}
|
|
40
|
+
>
|
|
41
|
+
<path
|
|
42
|
+
d="M6 3l5 5-5 5"
|
|
43
|
+
stroke={color}
|
|
44
|
+
strokeWidth="1.5"
|
|
45
|
+
strokeLinecap="round"
|
|
46
|
+
strokeLinejoin="round"
|
|
47
|
+
/>
|
|
48
|
+
</svg>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Ellipsis button ─────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function EllipsisButton({
|
|
55
|
+
onClick,
|
|
56
|
+
bgColor,
|
|
57
|
+
hoverColor,
|
|
58
|
+
textColor,
|
|
59
|
+
}: {
|
|
60
|
+
onClick: () => void;
|
|
61
|
+
bgColor: string;
|
|
62
|
+
hoverColor: string;
|
|
63
|
+
textColor: string;
|
|
64
|
+
}) {
|
|
65
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
66
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
onClick();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<button
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={onClick}
|
|
76
|
+
onKeyDown={handleKeyDown}
|
|
77
|
+
aria-label="Show hidden breadcrumb items"
|
|
78
|
+
className={tkx(
|
|
79
|
+
'inline-flex items-center justify-center rounded px-1.5 py-0.5',
|
|
80
|
+
'text-sm font-medium cursor-pointer border-none outline-none',
|
|
81
|
+
'focus-visible:ring-2 focus-visible:ring-offset-1',
|
|
82
|
+
)}
|
|
83
|
+
style={{
|
|
84
|
+
backgroundColor: bgColor,
|
|
85
|
+
color: textColor,
|
|
86
|
+
transition: 'background-color 150ms ease',
|
|
87
|
+
}}
|
|
88
|
+
onMouseEnter={(e) => {
|
|
89
|
+
(e.currentTarget as HTMLElement).style.backgroundColor = hoverColor;
|
|
90
|
+
}}
|
|
91
|
+
onMouseLeave={(e) => {
|
|
92
|
+
(e.currentTarget as HTMLElement).style.backgroundColor = bgColor;
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
…
|
|
96
|
+
</button>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Component ───────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
export function TkxBreadcrumb({
|
|
103
|
+
items,
|
|
104
|
+
separator,
|
|
105
|
+
maxItems,
|
|
106
|
+
onNavigate,
|
|
107
|
+
className,
|
|
108
|
+
style,
|
|
109
|
+
}: TkxBreadcrumbProps) {
|
|
110
|
+
const theme = useTheme();
|
|
111
|
+
const reducedMotion = useReducedMotion();
|
|
112
|
+
|
|
113
|
+
// Collapse logic: show first, last, and ellipsis when items exceed maxItems
|
|
114
|
+
const shouldCollapse = maxItems !== undefined && maxItems > 1 && items.length > maxItems;
|
|
115
|
+
|
|
116
|
+
const getVisibleItems = useCallback((): { item: BreadcrumbItem; originalIndex: number }[] => {
|
|
117
|
+
if (!shouldCollapse || !maxItems) {
|
|
118
|
+
return items.map((item, i) => ({ item, originalIndex: i }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const headCount = Math.ceil((maxItems - 1) / 2);
|
|
122
|
+
const tailCount = maxItems - 1 - headCount;
|
|
123
|
+
|
|
124
|
+
const head = items.slice(0, headCount).map((item, i) => ({
|
|
125
|
+
item,
|
|
126
|
+
originalIndex: i,
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
const tail = items.slice(items.length - tailCount).map((item, i) => ({
|
|
130
|
+
item,
|
|
131
|
+
originalIndex: items.length - tailCount + i,
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
return head.concat(tail);
|
|
135
|
+
}, [items, maxItems, shouldCollapse]);
|
|
136
|
+
|
|
137
|
+
const visibleItems = getVisibleItems();
|
|
138
|
+
|
|
139
|
+
// Where to insert the ellipsis
|
|
140
|
+
const headCount = shouldCollapse && maxItems ? Math.ceil((maxItems - 1) / 2) : -1;
|
|
141
|
+
|
|
142
|
+
const handleClick = (item: BreadcrumbItem, index: number) => {
|
|
143
|
+
onNavigate?.(item, index);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLElement>, item: BreadcrumbItem, index: number) => {
|
|
147
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
handleClick(item, index);
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const transitionStyle: CSSProperties = reducedMotion
|
|
154
|
+
? {}
|
|
155
|
+
: { transition: 'color 150ms ease, background-color 150ms ease' };
|
|
156
|
+
|
|
157
|
+
const renderSeparator = (key: string) => (
|
|
158
|
+
<li
|
|
159
|
+
key={key}
|
|
160
|
+
role="presentation"
|
|
161
|
+
aria-hidden="true"
|
|
162
|
+
className={tkx('flex items-center mx-1.5')}
|
|
163
|
+
>
|
|
164
|
+
{separator ?? <DefaultSeparator color={theme.textMuted} />}
|
|
165
|
+
</li>
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const renderItem = (
|
|
169
|
+
item: BreadcrumbItem,
|
|
170
|
+
originalIndex: number,
|
|
171
|
+
isLast: boolean,
|
|
172
|
+
) => {
|
|
173
|
+
const safeLabel = sanitizeString(item.label);
|
|
174
|
+
|
|
175
|
+
if (isLast) {
|
|
176
|
+
return (
|
|
177
|
+
<li key={`item-${originalIndex}`}>
|
|
178
|
+
<span
|
|
179
|
+
aria-current="page"
|
|
180
|
+
className={tkx('inline-flex items-center gap-1.5 text-sm font-medium')}
|
|
181
|
+
style={{ color: theme.text }}
|
|
182
|
+
>
|
|
183
|
+
{item.icon && (
|
|
184
|
+
<span aria-hidden="true" className={tkx('flex-shrink-0')}>
|
|
185
|
+
{item.icon}
|
|
186
|
+
</span>
|
|
187
|
+
)}
|
|
188
|
+
{safeLabel}
|
|
189
|
+
</span>
|
|
190
|
+
</li>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const isLink = !!item.href;
|
|
195
|
+
|
|
196
|
+
const commonStyle: CSSProperties = {
|
|
197
|
+
color: theme.textMuted,
|
|
198
|
+
textDecoration: 'none',
|
|
199
|
+
...transitionStyle,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const hoverHandlers = {
|
|
203
|
+
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => {
|
|
204
|
+
(e.currentTarget as HTMLElement).style.color = theme.primary;
|
|
205
|
+
},
|
|
206
|
+
onMouseLeave: (e: React.MouseEvent<HTMLElement>) => {
|
|
207
|
+
(e.currentTarget as HTMLElement).style.color = theme.textMuted;
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const content = (
|
|
212
|
+
<>
|
|
213
|
+
{item.icon && (
|
|
214
|
+
<span aria-hidden="true" className={tkx('flex-shrink-0')}>
|
|
215
|
+
{item.icon}
|
|
216
|
+
</span>
|
|
217
|
+
)}
|
|
218
|
+
{safeLabel}
|
|
219
|
+
</>
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (isLink) {
|
|
223
|
+
return (
|
|
224
|
+
<li key={`item-${originalIndex}`}>
|
|
225
|
+
<a
|
|
226
|
+
href={item.href}
|
|
227
|
+
onClick={(e) => {
|
|
228
|
+
if (onNavigate) {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
handleClick(item, originalIndex);
|
|
231
|
+
}
|
|
232
|
+
}}
|
|
233
|
+
className={tkx(
|
|
234
|
+
'inline-flex items-center gap-1.5 text-sm rounded',
|
|
235
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
|
|
236
|
+
)}
|
|
237
|
+
style={{
|
|
238
|
+
...commonStyle,
|
|
239
|
+
focusRingColor: theme.primary,
|
|
240
|
+
} as CSSProperties}
|
|
241
|
+
{...hoverHandlers}
|
|
242
|
+
>
|
|
243
|
+
{content}
|
|
244
|
+
</a>
|
|
245
|
+
</li>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<li key={`item-${originalIndex}`}>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => handleClick(item, originalIndex)}
|
|
254
|
+
onKeyDown={(e) => handleKeyDown(e, item, originalIndex)}
|
|
255
|
+
className={tkx(
|
|
256
|
+
'inline-flex items-center gap-1.5 text-sm rounded',
|
|
257
|
+
'border-none bg-transparent cursor-pointer p-0',
|
|
258
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-offset-1',
|
|
259
|
+
)}
|
|
260
|
+
style={commonStyle}
|
|
261
|
+
{...hoverHandlers}
|
|
262
|
+
>
|
|
263
|
+
{content}
|
|
264
|
+
</button>
|
|
265
|
+
</li>
|
|
266
|
+
);
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Build the rendered list
|
|
270
|
+
const rendered: ReactNode[] = [];
|
|
271
|
+
let ellipsisInserted = false;
|
|
272
|
+
|
|
273
|
+
for (let vi = 0; vi < visibleItems.length; vi++) {
|
|
274
|
+
const { item, originalIndex } = visibleItems[vi];
|
|
275
|
+
const isLast = originalIndex === items.length - 1;
|
|
276
|
+
|
|
277
|
+
// Insert ellipsis after headCount items
|
|
278
|
+
if (shouldCollapse && !ellipsisInserted && vi === headCount) {
|
|
279
|
+
if (rendered.length > 0) {
|
|
280
|
+
rendered.push(renderSeparator(`sep-ellipsis-before`));
|
|
281
|
+
}
|
|
282
|
+
rendered.push(
|
|
283
|
+
<li key="ellipsis" role="presentation">
|
|
284
|
+
<EllipsisButton
|
|
285
|
+
onClick={() => {
|
|
286
|
+
// Re-render without collapse -- parent can control maxItems
|
|
287
|
+
}}
|
|
288
|
+
bgColor={theme.surfaceAlt}
|
|
289
|
+
hoverColor={theme.surface}
|
|
290
|
+
textColor={theme.textMuted}
|
|
291
|
+
/>
|
|
292
|
+
</li>,
|
|
293
|
+
);
|
|
294
|
+
ellipsisInserted = true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (rendered.length > 0 && rendered[rendered.length - 1] !== null) {
|
|
298
|
+
rendered.push(renderSeparator(`sep-${originalIndex}`));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
rendered.push(renderItem(item, originalIndex, isLast));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<nav
|
|
306
|
+
aria-label="Breadcrumb"
|
|
307
|
+
className={tkx('font-sans', className ?? '')}
|
|
308
|
+
style={{
|
|
309
|
+
...style,
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
<ol
|
|
313
|
+
role="list"
|
|
314
|
+
className={tkx('flex items-center flex-wrap list-none m-0 p-0')}
|
|
315
|
+
>
|
|
316
|
+
{rendered}
|
|
317
|
+
</ol>
|
|
318
|
+
</nav>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export default TkxBreadcrumb;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
|
|
2
2
|
import { useTheme } from '../themes';
|
|
3
|
+
import { useReducedMotion } from '../hooks';
|
|
3
4
|
import { tkx, cx } from '../engine/tkx';
|
|
4
5
|
|
|
5
6
|
export type CardVariant = 'default' | 'glass' | 'quantum' | 'elevated' | 'outlined';
|
|
@@ -20,6 +21,7 @@ const PADDING_CLASS: Record<CardPadding, string> = {
|
|
|
20
21
|
export const TkxCard = forwardRef<HTMLElement, TkxCardProps>(
|
|
21
22
|
({ variant = 'default', isHoverable, isClickable, padding = 'md', as, children, className, style, ...rest }, ref) => {
|
|
22
23
|
const theme = useTheme();
|
|
24
|
+
const reducedMotion = useReducedMotion();
|
|
23
25
|
const Tag = (as ?? (isClickable ? 'button' : 'div')) as 'div';
|
|
24
26
|
|
|
25
27
|
const variantStyle: React.CSSProperties = {
|
|
@@ -33,7 +35,7 @@ export const TkxCard = forwardRef<HTMLElement, TkxCardProps>(
|
|
|
33
35
|
const base = tkx(
|
|
34
36
|
'rounded-xl w-full text-left block font-sans focus-visible:focus-ring',
|
|
35
37
|
PADDING_CLASS[padding],
|
|
36
|
-
(isHoverable || isClickable) && 'transition-transform duration-150 hover:scale-[1.01]',
|
|
38
|
+
(isHoverable || isClickable) && !reducedMotion && 'transition-transform duration-150 hover:scale-[1.01]',
|
|
37
39
|
isClickable ? 'cursor-pointer' : 'cursor-default',
|
|
38
40
|
);
|
|
39
41
|
|