ux-toolkit 0.1.0 → 0.4.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 +113 -7
- package/agents/card-reviewer.md +173 -0
- package/agents/comparison-reviewer.md +143 -0
- package/agents/density-reviewer.md +207 -0
- package/agents/detail-page-reviewer.md +143 -0
- package/agents/editor-reviewer.md +165 -0
- package/agents/form-reviewer.md +156 -0
- package/agents/game-ui-reviewer.md +181 -0
- package/agents/list-page-reviewer.md +132 -0
- package/agents/navigation-reviewer.md +145 -0
- package/agents/panel-reviewer.md +182 -0
- package/agents/replay-reviewer.md +174 -0
- package/agents/settings-reviewer.md +166 -0
- package/agents/ux-auditor.md +145 -45
- package/agents/ux-engineer.md +211 -38
- package/dist/cli.js +172 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +172 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +128 -4
- package/dist/index.d.ts +128 -4
- package/dist/index.js +172 -5
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/skills/canvas-grid-patterns/SKILL.md +367 -0
- package/skills/comparison-patterns/SKILL.md +354 -0
- package/skills/data-density-patterns/SKILL.md +493 -0
- package/skills/detail-page-patterns/SKILL.md +522 -0
- package/skills/drag-drop-patterns/SKILL.md +406 -0
- package/skills/editor-workspace-patterns/SKILL.md +552 -0
- package/skills/event-timeline-patterns/SKILL.md +542 -0
- package/skills/form-patterns/SKILL.md +608 -0
- package/skills/info-card-patterns/SKILL.md +531 -0
- package/skills/keyboard-shortcuts-patterns/SKILL.md +365 -0
- package/skills/list-page-patterns/SKILL.md +351 -0
- package/skills/modal-patterns/SKILL.md +750 -0
- package/skills/navigation-patterns/SKILL.md +476 -0
- package/skills/page-structure-patterns/SKILL.md +271 -0
- package/skills/playback-replay-patterns/SKILL.md +695 -0
- package/skills/react-ux-patterns/SKILL.md +434 -0
- package/skills/split-panel-patterns/SKILL.md +609 -0
- package/skills/status-visualization-patterns/SKILL.md +635 -0
- package/skills/toast-notification-patterns/SKILL.md +207 -0
- package/skills/turn-based-ui-patterns/SKILL.md +506 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: keyboard-shortcuts-patterns
|
|
3
|
+
description: Keyboard shortcuts, command palette (Cmd+K), and power user navigation patterns
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Keyboard Shortcuts & Command Palette Patterns
|
|
8
|
+
|
|
9
|
+
## 1. Command Palette (Cmd+K)
|
|
10
|
+
|
|
11
|
+
### Search UI Pattern
|
|
12
|
+
```tsx
|
|
13
|
+
interface CommandPaletteProps {
|
|
14
|
+
items: CommandItem[];
|
|
15
|
+
onSelect: (item: CommandItem) => void;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
recentItems?: CommandItem[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CommandItem {
|
|
21
|
+
id: string;
|
|
22
|
+
label: string;
|
|
23
|
+
category?: string;
|
|
24
|
+
keywords?: string[];
|
|
25
|
+
icon?: React.ReactNode;
|
|
26
|
+
shortcut?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fuzzy search with ranking
|
|
30
|
+
const fuzzyMatch = (search: string, item: CommandItem): number => {
|
|
31
|
+
const text = `${item.label} ${item.keywords?.join(' ')}`.toLowerCase();
|
|
32
|
+
const query = search.toLowerCase();
|
|
33
|
+
let score = 0;
|
|
34
|
+
let lastIndex = -1;
|
|
35
|
+
|
|
36
|
+
for (const char of query) {
|
|
37
|
+
const index = text.indexOf(char, lastIndex + 1);
|
|
38
|
+
if (index === -1) return 0;
|
|
39
|
+
score += 1 / (index - lastIndex);
|
|
40
|
+
lastIndex = index;
|
|
41
|
+
}
|
|
42
|
+
return score;
|
|
43
|
+
};
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Categories & Recent Items
|
|
47
|
+
```tsx
|
|
48
|
+
const CommandPalette = ({ items, recentItems }: CommandPaletteProps) => {
|
|
49
|
+
const [search, setSearch] = useState('');
|
|
50
|
+
const filtered = search ? items.filter(i => fuzzyMatch(search, i) > 0) : items;
|
|
51
|
+
const grouped = groupBy(filtered, 'category');
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="fixed inset-0 z-50 bg-black/50" role="dialog">
|
|
55
|
+
<div className="mx-auto mt-20 max-w-xl rounded-lg bg-white shadow-2xl">
|
|
56
|
+
<input
|
|
57
|
+
type="text"
|
|
58
|
+
value={search}
|
|
59
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
60
|
+
placeholder="Search commands..."
|
|
61
|
+
className="w-full border-b px-4 py-3 text-lg outline-none"
|
|
62
|
+
/>
|
|
63
|
+
<div className="max-h-96 overflow-y-auto">
|
|
64
|
+
{!search && recentItems && (
|
|
65
|
+
<CommandGroup title="Recent" items={recentItems} />
|
|
66
|
+
)}
|
|
67
|
+
{Object.entries(grouped).map(([category, items]) => (
|
|
68
|
+
<CommandGroup key={category} title={category} items={items} />
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 2. Global Shortcuts
|
|
78
|
+
|
|
79
|
+
### Common Pattern Registry
|
|
80
|
+
```tsx
|
|
81
|
+
const GLOBAL_SHORTCUTS = {
|
|
82
|
+
save: { key: 's', modifiers: ['meta'] },
|
|
83
|
+
undo: { key: 'z', modifiers: ['meta'] },
|
|
84
|
+
redo: { key: 'z', modifiers: ['meta', 'shift'] },
|
|
85
|
+
find: { key: 'f', modifiers: ['meta'] },
|
|
86
|
+
close: { key: 'Escape', modifiers: [] },
|
|
87
|
+
commandPalette: { key: 'k', modifiers: ['meta'] },
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
90
|
+
// Platform-aware modifier
|
|
91
|
+
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
|
92
|
+
const metaKey = isMac ? 'Cmd' : 'Ctrl';
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### useKeyboardShortcut Hook
|
|
96
|
+
```tsx
|
|
97
|
+
interface ShortcutConfig {
|
|
98
|
+
key: string;
|
|
99
|
+
modifiers?: ('meta' | 'ctrl' | 'shift' | 'alt')[];
|
|
100
|
+
enabled?: boolean;
|
|
101
|
+
preventDefault?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const useKeyboardShortcut = (
|
|
105
|
+
config: ShortcutConfig,
|
|
106
|
+
callback: () => void
|
|
107
|
+
) => {
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!config.enabled ?? true) return;
|
|
110
|
+
|
|
111
|
+
const handler = (e: KeyboardEvent) => {
|
|
112
|
+
const modMatch =
|
|
113
|
+
(!config.modifiers?.includes('meta') || e.metaKey) &&
|
|
114
|
+
(!config.modifiers?.includes('ctrl') || e.ctrlKey) &&
|
|
115
|
+
(!config.modifiers?.includes('shift') || e.shiftKey) &&
|
|
116
|
+
(!config.modifiers?.includes('alt') || e.altKey);
|
|
117
|
+
|
|
118
|
+
if (e.key === config.key && modMatch) {
|
|
119
|
+
if (config.preventDefault) e.preventDefault();
|
|
120
|
+
callback();
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
document.addEventListener('keydown', handler);
|
|
125
|
+
return () => document.removeEventListener('keydown', handler);
|
|
126
|
+
}, [config, callback]);
|
|
127
|
+
};
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## 3. Contextual Shortcuts
|
|
131
|
+
|
|
132
|
+
### Focus-Based Shortcuts
|
|
133
|
+
```tsx
|
|
134
|
+
const useContextualShortcuts = (context: 'editor' | 'sidebar' | 'modal') => {
|
|
135
|
+
const shortcuts = {
|
|
136
|
+
editor: {
|
|
137
|
+
'Cmd+B': 'Toggle bold',
|
|
138
|
+
'Cmd+I': 'Toggle italic',
|
|
139
|
+
'Cmd+/': 'Toggle comment',
|
|
140
|
+
},
|
|
141
|
+
sidebar: {
|
|
142
|
+
'ArrowUp/Down': 'Navigate items',
|
|
143
|
+
'Enter': 'Open item',
|
|
144
|
+
'/': 'Focus search',
|
|
145
|
+
},
|
|
146
|
+
modal: {
|
|
147
|
+
'Escape': 'Close modal',
|
|
148
|
+
'Tab': 'Next field',
|
|
149
|
+
},
|
|
150
|
+
}[context];
|
|
151
|
+
|
|
152
|
+
return shortcuts;
|
|
153
|
+
};
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## 4. Shortcut Discovery
|
|
157
|
+
|
|
158
|
+
### Tooltip Hints
|
|
159
|
+
```tsx
|
|
160
|
+
const Button = ({ shortcut, children }: { shortcut?: string }) => (
|
|
161
|
+
<button className="group relative px-4 py-2">
|
|
162
|
+
{children}
|
|
163
|
+
{shortcut && (
|
|
164
|
+
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-gray-500 opacity-0 group-hover:opacity-100">
|
|
165
|
+
{shortcut}
|
|
166
|
+
</span>
|
|
167
|
+
)}
|
|
168
|
+
</button>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Usage: <Button shortcut="⌘S">Save</Button>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Keyboard Shortcut Modal
|
|
175
|
+
```tsx
|
|
176
|
+
const ShortcutHelpModal = ({ shortcuts }: { shortcuts: Record<string, string> }) => (
|
|
177
|
+
<div className="rounded-lg bg-white p-6 shadow-xl">
|
|
178
|
+
<h2 className="mb-4 text-xl font-bold">Keyboard Shortcuts</h2>
|
|
179
|
+
<div className="grid gap-2">
|
|
180
|
+
{Object.entries(shortcuts).map(([key, description]) => (
|
|
181
|
+
<div key={key} className="flex justify-between border-b pb-2">
|
|
182
|
+
<span className="text-gray-700">{description}</span>
|
|
183
|
+
<kbd className="rounded bg-gray-100 px-2 py-1 font-mono text-sm">{key}</kbd>
|
|
184
|
+
</div>
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## 5. Shortcut Conflicts
|
|
192
|
+
|
|
193
|
+
### Browser Defaults to Avoid [CRITICAL]
|
|
194
|
+
- **Cmd+W** (close tab), **Cmd+T** (new tab), **Cmd+R** (reload)
|
|
195
|
+
- **Cmd+L** (address bar), **Cmd+D** (bookmark)
|
|
196
|
+
- Use `e.preventDefault()` cautiously
|
|
197
|
+
|
|
198
|
+
### Platform Differences
|
|
199
|
+
```tsx
|
|
200
|
+
const formatShortcut = (shortcut: string): string => {
|
|
201
|
+
const isMac = navigator.platform.includes('Mac');
|
|
202
|
+
return shortcut
|
|
203
|
+
.replace('Cmd', isMac ? '⌘' : 'Ctrl')
|
|
204
|
+
.replace('Alt', isMac ? '⌥' : 'Alt')
|
|
205
|
+
.replace('Shift', '⇧');
|
|
206
|
+
};
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## 6. Focus Management
|
|
210
|
+
|
|
211
|
+
### Focus Trap in Modals [CRITICAL]
|
|
212
|
+
```tsx
|
|
213
|
+
const useFocusTrap = (containerRef: RefObject<HTMLElement>) => {
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
const container = containerRef.current;
|
|
216
|
+
if (!container) return;
|
|
217
|
+
|
|
218
|
+
const focusable = container.querySelectorAll(
|
|
219
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
220
|
+
);
|
|
221
|
+
const first = focusable[0] as HTMLElement;
|
|
222
|
+
const last = focusable[focusable.length - 1] as HTMLElement;
|
|
223
|
+
|
|
224
|
+
const handler = (e: KeyboardEvent) => {
|
|
225
|
+
if (e.key === 'Tab') {
|
|
226
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
last.focus();
|
|
229
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
first.focus();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
container.addEventListener('keydown', handler);
|
|
237
|
+
first.focus();
|
|
238
|
+
return () => container.removeEventListener('keydown', handler);
|
|
239
|
+
}, [containerRef]);
|
|
240
|
+
};
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Focus Restoration [MAJOR]
|
|
244
|
+
```tsx
|
|
245
|
+
const Modal = ({ onClose }: { onClose: () => void }) => {
|
|
246
|
+
const previousFocus = useRef<HTMLElement | null>(null);
|
|
247
|
+
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
previousFocus.current = document.activeElement as HTMLElement;
|
|
250
|
+
return () => previousFocus.current?.focus();
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
return <div role="dialog">...</div>;
|
|
254
|
+
};
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## 7. Accessibility
|
|
258
|
+
|
|
259
|
+
### Screen Reader Announcements [CRITICAL]
|
|
260
|
+
```tsx
|
|
261
|
+
const useLiveRegion = () => {
|
|
262
|
+
const announce = (message: string, priority: 'polite' | 'assertive' = 'polite') => {
|
|
263
|
+
const region = document.getElementById('live-region');
|
|
264
|
+
if (region) {
|
|
265
|
+
region.setAttribute('aria-live', priority);
|
|
266
|
+
region.textContent = message;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
return announce;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// In root: <div id="live-region" className="sr-only" aria-live="polite" />
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Visible Focus Indicators
|
|
276
|
+
```tsx
|
|
277
|
+
// globals.css
|
|
278
|
+
.focus-visible:focus {
|
|
279
|
+
@apply outline-none ring-2 ring-blue-500 ring-offset-2;
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## 8. Shortcut Registry
|
|
284
|
+
|
|
285
|
+
### Centralized Management
|
|
286
|
+
```tsx
|
|
287
|
+
class ShortcutRegistry {
|
|
288
|
+
private shortcuts = new Map<string, () => void>();
|
|
289
|
+
|
|
290
|
+
register(key: string, callback: () => void, context?: string) {
|
|
291
|
+
const id = context ? `${context}:${key}` : key;
|
|
292
|
+
this.shortcuts.set(id, callback);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
unregister(key: string, context?: string) {
|
|
296
|
+
const id = context ? `${context}:${key}` : key;
|
|
297
|
+
this.shortcuts.delete(id);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
handle(e: KeyboardEvent, context?: string) {
|
|
301
|
+
const key = this.formatKey(e);
|
|
302
|
+
const contextKey = context ? `${context}:${key}` : key;
|
|
303
|
+
const handler = this.shortcuts.get(contextKey) || this.shortcuts.get(key);
|
|
304
|
+
if (handler) {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
handler();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private formatKey(e: KeyboardEvent): string {
|
|
311
|
+
const mods = [
|
|
312
|
+
e.metaKey && 'Cmd',
|
|
313
|
+
e.ctrlKey && 'Ctrl',
|
|
314
|
+
e.shiftKey && 'Shift',
|
|
315
|
+
e.altKey && 'Alt',
|
|
316
|
+
].filter(Boolean);
|
|
317
|
+
return [...mods, e.key].join('+');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export const shortcuts = new ShortcutRegistry();
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## 9. Audit Checklist
|
|
325
|
+
|
|
326
|
+
### Command Palette [CRITICAL]
|
|
327
|
+
- [ ] Cmd+K opens palette
|
|
328
|
+
- [ ] Fuzzy search works
|
|
329
|
+
- [ ] Recent items shown when empty
|
|
330
|
+
- [ ] Categories clearly separated
|
|
331
|
+
- [ ] Keyboard navigation (arrows, Enter, Escape)
|
|
332
|
+
|
|
333
|
+
### Global Shortcuts [MAJOR]
|
|
334
|
+
- [ ] Platform detection (Mac vs Windows)
|
|
335
|
+
- [ ] Shortcuts displayed in UI (tooltips, help modal)
|
|
336
|
+
- [ ] No conflicts with browser defaults
|
|
337
|
+
- [ ] preventDefault used appropriately
|
|
338
|
+
|
|
339
|
+
### Focus Management [CRITICAL]
|
|
340
|
+
- [ ] Focus trapped in modals
|
|
341
|
+
- [ ] Focus restored after modal close
|
|
342
|
+
- [ ] Visible focus indicators on all interactive elements
|
|
343
|
+
- [ ] Tab order is logical
|
|
344
|
+
|
|
345
|
+
### Accessibility [CRITICAL]
|
|
346
|
+
- [ ] Screen reader announces shortcut actions
|
|
347
|
+
- [ ] Skip to content link available
|
|
348
|
+
- [ ] All shortcuts documented in help
|
|
349
|
+
- [ ] Alternative non-keyboard methods available
|
|
350
|
+
|
|
351
|
+
### Contextual Shortcuts [MAJOR]
|
|
352
|
+
- [ ] Shortcuts change based on focus
|
|
353
|
+
- [ ] Conflicts resolved between contexts
|
|
354
|
+
- [ ] Help shows context-specific shortcuts
|
|
355
|
+
|
|
356
|
+
### Discovery [MINOR]
|
|
357
|
+
- [ ] Shortcut hints in tooltips
|
|
358
|
+
- [ ] Help modal accessible (Cmd+? or ?)
|
|
359
|
+
- [ ] Shortcuts shown in command palette
|
|
360
|
+
- [ ] Onboarding highlights key shortcuts
|
|
361
|
+
|
|
362
|
+
### Performance [MINOR]
|
|
363
|
+
- [ ] Debounced search in command palette
|
|
364
|
+
- [ ] Keyboard handlers cleaned up properly
|
|
365
|
+
- [ ] No memory leaks from event listeners
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: list-page-patterns
|
|
3
|
+
description: UX patterns specific to list/browse pages including filters, sorting, pagination, and grid/table displays
|
|
4
|
+
license: MIT
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# List Page UX Patterns
|
|
8
|
+
|
|
9
|
+
List pages display collections of items with filtering, sorting, and navigation capabilities.
|
|
10
|
+
|
|
11
|
+
## Required Components
|
|
12
|
+
|
|
13
|
+
### 1. Filter Card
|
|
14
|
+
Every list page MUST have a filter section.
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
<Card className="mb-6">
|
|
18
|
+
<div className="flex flex-col sm:flex-row gap-4">
|
|
19
|
+
{/* Search - always first */}
|
|
20
|
+
<div className="flex-1">
|
|
21
|
+
<Input
|
|
22
|
+
type="text"
|
|
23
|
+
placeholder="Search by name..."
|
|
24
|
+
value={filters.search}
|
|
25
|
+
onChange={(e) => handleFilterChange('search', e.target.value)}
|
|
26
|
+
aria-label="Search items"
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
{/* Filter dropdowns */}
|
|
31
|
+
<Select
|
|
32
|
+
value={filters.status}
|
|
33
|
+
onChange={(e) => handleFilterChange('status', e.target.value)}
|
|
34
|
+
options={statusOptions}
|
|
35
|
+
aria-label="Filter by status"
|
|
36
|
+
/>
|
|
37
|
+
|
|
38
|
+
{/* Clear filters button */}
|
|
39
|
+
<Button variant="secondary" onClick={clearFilters} className="text-xs">
|
|
40
|
+
Clear
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{/* Results count - REQUIRED */}
|
|
45
|
+
<div className="mt-4 text-sm text-text-secondary">
|
|
46
|
+
Showing {filteredItems.length} of {items.length} results
|
|
47
|
+
{hasActiveFilters && (
|
|
48
|
+
<span className="text-accent ml-1">(filtered)</span>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
</Card>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Results Display
|
|
55
|
+
|
|
56
|
+
#### Grid Layout (Cards)
|
|
57
|
+
```tsx
|
|
58
|
+
// For rich items with multiple data points
|
|
59
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
60
|
+
{items.map(item => (
|
|
61
|
+
<ItemCard key={item.id} item={item} onClick={() => handleClick(item)} />
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
#### Table Layout (Data-dense)
|
|
67
|
+
```tsx
|
|
68
|
+
// For data-heavy lists needing sorting
|
|
69
|
+
<Card variant="dark" className="overflow-hidden">
|
|
70
|
+
<div className="overflow-x-auto">
|
|
71
|
+
<table className="w-full min-w-[800px]">
|
|
72
|
+
<thead className="bg-surface-base">
|
|
73
|
+
<tr className="text-left text-text-secondary text-xs uppercase">
|
|
74
|
+
<SortableHeader column="name" />
|
|
75
|
+
<SortableHeader column="status" />
|
|
76
|
+
<SortableHeader column="date" />
|
|
77
|
+
</tr>
|
|
78
|
+
</thead>
|
|
79
|
+
<tbody className="divide-y divide-border/50">
|
|
80
|
+
{items.map(item => (
|
|
81
|
+
<tr key={item.id} className="hover:bg-surface-raised/20">
|
|
82
|
+
<td className="px-4 py-3">{item.name}</td>
|
|
83
|
+
</tr>
|
|
84
|
+
))}
|
|
85
|
+
</tbody>
|
|
86
|
+
</table>
|
|
87
|
+
</div>
|
|
88
|
+
</Card>
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 3. Sortable Table Header
|
|
92
|
+
```tsx
|
|
93
|
+
interface SortableHeaderProps {
|
|
94
|
+
label: string;
|
|
95
|
+
column: string;
|
|
96
|
+
currentColumn: string;
|
|
97
|
+
direction: 'asc' | 'desc';
|
|
98
|
+
onSort: (column: string) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function SortableHeader({ label, column, currentColumn, direction, onSort }: SortableHeaderProps) {
|
|
102
|
+
const isActive = column === currentColumn;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<th
|
|
106
|
+
className="px-4 py-3 font-medium cursor-pointer hover:text-white select-none"
|
|
107
|
+
onClick={() => onSort(column)}
|
|
108
|
+
>
|
|
109
|
+
<span className="flex items-center gap-1">
|
|
110
|
+
{label}
|
|
111
|
+
{isActive && (
|
|
112
|
+
<span className="text-accent text-xs">
|
|
113
|
+
{direction === 'asc' ? '▲' : '▼'}
|
|
114
|
+
</span>
|
|
115
|
+
)}
|
|
116
|
+
</span>
|
|
117
|
+
</th>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 4. Pagination
|
|
123
|
+
```tsx
|
|
124
|
+
// Only show if totalPages > 1
|
|
125
|
+
{totalPages > 1 && (
|
|
126
|
+
<div className="flex justify-center mt-6">
|
|
127
|
+
<PaginationButtons
|
|
128
|
+
currentPage={currentPage}
|
|
129
|
+
totalPages={totalPages}
|
|
130
|
+
onPageChange={setCurrentPage}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
function PaginationButtons({ currentPage, totalPages, onPageChange }) {
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex items-center gap-2">
|
|
138
|
+
<button
|
|
139
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
140
|
+
disabled={currentPage === 1}
|
|
141
|
+
className="px-3 py-1.5 rounded bg-surface-raised disabled:opacity-50"
|
|
142
|
+
>
|
|
143
|
+
Previous
|
|
144
|
+
</button>
|
|
145
|
+
|
|
146
|
+
<span className="text-sm text-text-secondary">
|
|
147
|
+
Page {currentPage} of {totalPages}
|
|
148
|
+
</span>
|
|
149
|
+
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
152
|
+
disabled={currentPage === totalPages}
|
|
153
|
+
className="px-3 py-1.5 rounded bg-surface-raised disabled:opacity-50"
|
|
154
|
+
>
|
|
155
|
+
Next
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### 5. Empty State
|
|
163
|
+
```tsx
|
|
164
|
+
{filteredItems.length === 0 && (
|
|
165
|
+
<EmptyState
|
|
166
|
+
icon={<EmptyIcon />}
|
|
167
|
+
title={hasFilters ? 'No items match your filters' : 'No items yet'}
|
|
168
|
+
message={hasFilters ? 'Try adjusting your search or filters' : 'Create your first item to get started'}
|
|
169
|
+
action={!hasFilters && (
|
|
170
|
+
<Button variant="primary" onClick={handleCreate}>
|
|
171
|
+
Create First Item
|
|
172
|
+
</Button>
|
|
173
|
+
)}
|
|
174
|
+
/>
|
|
175
|
+
)}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Item Card Pattern
|
|
179
|
+
|
|
180
|
+
### Standard Card Structure
|
|
181
|
+
```tsx
|
|
182
|
+
function ItemCard({ item, onClick }) {
|
|
183
|
+
return (
|
|
184
|
+
<div
|
|
185
|
+
onClick={onClick}
|
|
186
|
+
className="
|
|
187
|
+
group relative p-5 rounded-xl border-2 transition-all cursor-pointer
|
|
188
|
+
bg-gradient-to-br from-surface-raised/60 to-surface-raised/30
|
|
189
|
+
border-border hover:border-accent/50 hover:shadow-lg
|
|
190
|
+
hover:scale-[1.02] hover:-translate-y-0.5
|
|
191
|
+
"
|
|
192
|
+
>
|
|
193
|
+
{/* Accent line at top */}
|
|
194
|
+
<div className="absolute top-0 left-4 right-4 h-0.5 bg-accent/30 group-hover:bg-accent rounded-full" />
|
|
195
|
+
|
|
196
|
+
{/* Header with title and badge */}
|
|
197
|
+
<div className="flex items-start justify-between gap-3 mb-4">
|
|
198
|
+
<div className="flex-1 min-w-0">
|
|
199
|
+
<h3 className="text-lg font-bold text-white truncate group-hover:text-accent">
|
|
200
|
+
{item.name}
|
|
201
|
+
</h3>
|
|
202
|
+
{item.subtitle && (
|
|
203
|
+
<p className="text-accent/80 text-sm truncate">{item.subtitle}</p>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
<StatusBadge status={item.status} />
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{/* Stats or metadata */}
|
|
210
|
+
<div className="flex items-center justify-between text-sm text-text-secondary">
|
|
211
|
+
<span>{item.count} items</span>
|
|
212
|
+
<span>{item.date}</span>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{/* Hover arrow indicator */}
|
|
216
|
+
<div className="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
217
|
+
<ChevronRightIcon className="w-5 h-5 text-accent" />
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Filter State Management
|
|
225
|
+
|
|
226
|
+
### Standard Filter Pattern
|
|
227
|
+
```tsx
|
|
228
|
+
interface FilterState {
|
|
229
|
+
search: string;
|
|
230
|
+
status: string;
|
|
231
|
+
category: string;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function useFilters<T>(items: T[], filterFn: (item: T, filters: FilterState) => boolean) {
|
|
235
|
+
const [filters, setFilters] = useState<FilterState>({
|
|
236
|
+
search: '',
|
|
237
|
+
status: '',
|
|
238
|
+
category: '',
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const filteredItems = useMemo(() => {
|
|
242
|
+
return items.filter(item => filterFn(item, filters));
|
|
243
|
+
}, [items, filters, filterFn]);
|
|
244
|
+
|
|
245
|
+
const hasActiveFilters = Object.values(filters).some(v => v !== '');
|
|
246
|
+
|
|
247
|
+
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
|
248
|
+
setFilters(prev => ({ ...prev, [key]: value }));
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const clearFilters = () => {
|
|
252
|
+
setFilters({ search: '', status: '', category: '' });
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return { filters, filteredItems, hasActiveFilters, handleFilterChange, clearFilters };
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Sort State Management
|
|
260
|
+
|
|
261
|
+
### Standard Sort Pattern
|
|
262
|
+
```tsx
|
|
263
|
+
type SortDirection = 'asc' | 'desc';
|
|
264
|
+
|
|
265
|
+
interface SortState<T extends string> {
|
|
266
|
+
column: T;
|
|
267
|
+
direction: SortDirection;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function useSort<T, C extends string>(items: T[], sortFn: (a: T, b: T, column: C) => number) {
|
|
271
|
+
const [sort, setSort] = useState<SortState<C>>({
|
|
272
|
+
column: 'name' as C,
|
|
273
|
+
direction: 'asc',
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const sortedItems = useMemo(() => {
|
|
277
|
+
const sorted = [...items];
|
|
278
|
+
sorted.sort((a, b) => {
|
|
279
|
+
const result = sortFn(a, b, sort.column);
|
|
280
|
+
return sort.direction === 'asc' ? result : -result;
|
|
281
|
+
});
|
|
282
|
+
return sorted;
|
|
283
|
+
}, [items, sort, sortFn]);
|
|
284
|
+
|
|
285
|
+
const handleSort = (column: C) => {
|
|
286
|
+
setSort(prev => ({
|
|
287
|
+
column,
|
|
288
|
+
direction: prev.column === column && prev.direction === 'asc' ? 'desc' : 'asc',
|
|
289
|
+
}));
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
return { sort, sortedItems, handleSort };
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
## Advanced Filters Panel
|
|
297
|
+
|
|
298
|
+
### Expandable Advanced Filters
|
|
299
|
+
```tsx
|
|
300
|
+
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
301
|
+
const hasAdvancedFilters = filters.dateMin || filters.dateMax || filters.valueMin;
|
|
302
|
+
|
|
303
|
+
<Button
|
|
304
|
+
variant="ghost"
|
|
305
|
+
onClick={() => setShowAdvanced(!showAdvanced)}
|
|
306
|
+
className={hasAdvancedFilters ? 'text-accent' : ''}
|
|
307
|
+
>
|
|
308
|
+
{showAdvanced ? '▼' : '▶'} Advanced
|
|
309
|
+
{hasAdvancedFilters && ' •'}
|
|
310
|
+
</Button>
|
|
311
|
+
|
|
312
|
+
{showAdvanced && (
|
|
313
|
+
<div className="mt-4 pt-4 border-t border-border">
|
|
314
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
315
|
+
{/* Range filters */}
|
|
316
|
+
<div>
|
|
317
|
+
<label className="block text-xs text-text-secondary mb-1.5 uppercase">
|
|
318
|
+
Date Range
|
|
319
|
+
</label>
|
|
320
|
+
<div className="flex items-center gap-2">
|
|
321
|
+
<Input type="date" value={filters.dateMin} onChange={...} />
|
|
322
|
+
<span className="text-text-muted">–</span>
|
|
323
|
+
<Input type="date" value={filters.dateMax} onChange={...} />
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
## Audit Checklist for List Pages
|
|
332
|
+
|
|
333
|
+
### Critical (Must Fix)
|
|
334
|
+
- [ ] Has search input with aria-label - accessibility violation
|
|
335
|
+
- [ ] Handles empty initial state - users see nothing, think app is broken
|
|
336
|
+
- [ ] Loading state while fetching - users think app froze
|
|
337
|
+
- [ ] Cards link to detail pages - dead end without navigation
|
|
338
|
+
|
|
339
|
+
### Major (Should Fix)
|
|
340
|
+
- [ ] Has relevant filter dropdowns - can't find items efficiently
|
|
341
|
+
- [ ] Shows results count - users don't know if search worked
|
|
342
|
+
- [ ] Handles empty filtered results - confusing when no matches
|
|
343
|
+
- [ ] Grid is responsive (1-4 columns) - mobile users blocked
|
|
344
|
+
- [ ] Pagination works correctly - can't access all data
|
|
345
|
+
- [ ] Create button in header - no clear path to add items
|
|
346
|
+
|
|
347
|
+
### Minor (Nice to Have)
|
|
348
|
+
- [ ] Indicates when filters are active - visual clarity
|
|
349
|
+
- [ ] Has clear filters button - convenience
|
|
350
|
+
- [ ] Cards have hover states - interaction feedback
|
|
351
|
+
- [ ] Table has sortable headers (if using table) - power user feature
|