@tuturuuu/ui 0.30.2 → 0.31.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.
Files changed (91) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/biome.json +1 -1
  3. package/package.json +95 -49
  4. package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
  5. package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
  6. package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
  7. package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
  8. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
  9. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
  10. package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
  11. package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
  12. package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
  13. package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
  14. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
  15. package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
  16. package/src/components/ui/custom/nav-link.tsx +7 -7
  17. package/src/components/ui/custom/nav.tsx +15 -63
  18. package/src/components/ui/custom/notification-card.tsx +361 -0
  19. package/src/components/ui/custom/notification-list.tsx +178 -0
  20. package/src/components/ui/custom/notification-popover-client.tsx +12 -780
  21. package/src/components/ui/custom/notification-runtime.tsx +23 -0
  22. package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
  23. package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
  24. package/src/components/ui/custom/satellite-brand.tsx +122 -0
  25. package/src/components/ui/custom/satellite-content.tsx +148 -0
  26. package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
  27. package/src/components/ui/custom/satellite-language-item.tsx +26 -0
  28. package/src/components/ui/custom/satellite-navigation.tsx +86 -0
  29. package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
  30. package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
  31. package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
  32. package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
  33. package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
  34. package/src/components/ui/custom/satellite-shell.tsx +50 -0
  35. package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
  36. package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
  37. package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
  38. package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
  39. package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
  40. package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
  41. package/src/components/ui/custom/sidebar-context.tsx +5 -14
  42. package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
  43. package/src/components/ui/custom/structure.tsx +14 -0
  44. package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
  45. package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
  46. package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
  47. package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
  48. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
  49. package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
  50. package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
  51. package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
  52. package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
  53. package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
  54. package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
  55. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
  56. package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
  57. package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
  58. package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
  59. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
  60. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
  61. package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
  62. package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
  63. package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
  64. package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
  65. package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
  66. package/src/components/ui/pwa-status.test.tsx +53 -0
  67. package/src/components/ui/pwa-status.tsx +113 -0
  68. package/src/components/ui/report-problem-dialog-content.tsx +350 -0
  69. package/src/components/ui/report-problem-dialog.tsx +6 -975
  70. package/src/components/ui/report-problem-model.ts +133 -0
  71. package/src/components/ui/report-problem-mutation.ts +122 -0
  72. package/src/components/ui/save-notification.test.tsx +36 -0
  73. package/src/components/ui/save-notification.tsx +17 -0
  74. package/src/components/ui/use-report-problem.ts +488 -0
  75. package/src/declarations.d.ts +2 -0
  76. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
  77. package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
  78. package/src/hooks/calendar-date-utils.ts +11 -0
  79. package/src/hooks/calendar-event-lookup.test.ts +77 -0
  80. package/src/hooks/calendar-event-lookup.ts +47 -0
  81. package/src/hooks/calendar-range-cache.ts +52 -0
  82. package/src/hooks/pwa-install-prompt.ts +44 -0
  83. package/src/hooks/use-calendar-sync.tsx +23 -100
  84. package/src/hooks/use-calendar.tsx +21 -77
  85. package/src/hooks/use-notifications.ts +9 -24
  86. package/src/hooks/use-open-initial-calendar-event.ts +27 -0
  87. package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
  88. package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
  89. package/src/hooks/use-workspace-presence.ts +0 -18
  90. package/src/lib/tasks-app-url.test.ts +25 -0
  91. package/src/lib/tasks-app-url.ts +8 -0
@@ -0,0 +1,621 @@
1
+ 'use client';
2
+
3
+ import {
4
+ ArrowLeft,
5
+ ChevronDown,
6
+ ChevronRight,
7
+ Compass,
8
+ Search,
9
+ X,
10
+ } from '@tuturuuu/icons';
11
+ import {
12
+ Breadcrumb,
13
+ BreadcrumbItem,
14
+ BreadcrumbLink,
15
+ BreadcrumbList,
16
+ BreadcrumbPage,
17
+ BreadcrumbSeparator,
18
+ } from '@tuturuuu/ui/breadcrumb';
19
+ import { Button } from '@tuturuuu/ui/button';
20
+ import {
21
+ Collapsible,
22
+ CollapsibleContent,
23
+ CollapsibleTrigger,
24
+ } from '@tuturuuu/ui/collapsible';
25
+ import {
26
+ Command,
27
+ CommandEmpty,
28
+ CommandGroup,
29
+ CommandInput,
30
+ CommandItem,
31
+ CommandList,
32
+ } from '@tuturuuu/ui/command';
33
+ import {
34
+ DialogClose,
35
+ DialogContent,
36
+ DialogDescription,
37
+ DialogTitle,
38
+ } from '@tuturuuu/ui/dialog';
39
+ import {
40
+ Drawer,
41
+ DrawerContent,
42
+ DrawerDescription,
43
+ DrawerHeader,
44
+ DrawerTitle,
45
+ DrawerTrigger,
46
+ } from '@tuturuuu/ui/drawer';
47
+ import { useIsMobile } from '@tuturuuu/ui/hooks/use-mobile';
48
+ import { Separator } from '@tuturuuu/ui/separator';
49
+ import {
50
+ Sidebar,
51
+ SidebarContent,
52
+ SidebarGroup,
53
+ SidebarGroupContent,
54
+ SidebarGroupLabel,
55
+ SidebarHeader,
56
+ SidebarInput,
57
+ SidebarMenu,
58
+ SidebarMenuButton,
59
+ SidebarMenuItem,
60
+ SidebarProvider,
61
+ } from '@tuturuuu/ui/sidebar';
62
+ import { cn } from '@tuturuuu/utils/format';
63
+ import type { ComponentType, KeyboardEvent, ReactNode } from 'react';
64
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
65
+ import type { createSettingsSearchEngine } from './settings-dialog-search';
66
+ import { loadSettingsSearchEngine } from './settings-dialog-search-loader';
67
+
68
+ export interface SettingsNavItem {
69
+ name: string;
70
+ label: string;
71
+ icon: ComponentType<{ className?: string }>;
72
+ description?: string;
73
+ hideContentHeader?: boolean;
74
+ disabled?: boolean;
75
+ keywords?: string[];
76
+ aliases?: string[];
77
+ searchLabels?: string[];
78
+ }
79
+
80
+ export interface SettingsNavGroup {
81
+ label: string;
82
+ items: SettingsNavItem[];
83
+ }
84
+
85
+ export interface SettingsDialogShellProps {
86
+ t: (
87
+ key:
88
+ | 'common.close'
89
+ | 'common.no_results_found'
90
+ | 'common.settings'
91
+ | 'onboarding_guide.description'
92
+ | 'onboarding_guide.replay_app'
93
+ | 'onboarding_guide.restart_journey'
94
+ | 'onboarding_guide.title'
95
+ | 'search.search'
96
+ | 'settings.back_to_app'
97
+ | 'settings.manage_settings'
98
+ | 'settings.search_settings_placeholder',
99
+ values?: Record<string, string | number>
100
+ ) => string;
101
+ persistedTab: string | null;
102
+ setPersistedTab: (value: string) => void;
103
+ onboardingPanel: ReactNode;
104
+ /** Ordered list of navigation groups */
105
+ navItems: SettingsNavGroup[];
106
+ /** Currently active tab name */
107
+ activeTab: string;
108
+ /** Callback when active tab changes */
109
+ onActiveTabChange: (tab: string) => void;
110
+ /**
111
+ * Group labels that should be expanded by default.
112
+ * Used when expandAllAccordions is false.
113
+ * If not provided in that mode, only the first group expands.
114
+ */
115
+ primaryGroupLabels?: string[];
116
+ /** Override to expand all accordions (user preference). Defaults to expanded. */
117
+ expandAllAccordions?: boolean;
118
+ /** Enable dialog-scoped keyboard shortcuts for search and tab navigation */
119
+ keyboardNavigation?: boolean;
120
+ /** Optional context control that replaces the active group breadcrumb */
121
+ activeGroupBreadcrumb?: ReactNode;
122
+ /** Content to render in the main area */
123
+ children: ReactNode;
124
+ }
125
+
126
+ type SettingsSearchEngine = ReturnType<typeof createSettingsSearchEngine>;
127
+ type SettingsSearchEngineFactory = typeof createSettingsSearchEngine;
128
+
129
+ function isEditableShortcutTarget(target: EventTarget | null) {
130
+ if (!(target instanceof HTMLElement)) return false;
131
+
132
+ const tagName = target.tagName.toLowerCase();
133
+
134
+ return (
135
+ target.isContentEditable ||
136
+ tagName === 'input' ||
137
+ tagName === 'textarea' ||
138
+ tagName === 'select'
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Shared settings dialog shell providing the layout framework.
144
+ *
145
+ * Each app provides its own `navItems` (ordered by priority) and
146
+ * renders tab-specific content via `children`. The `primaryGroupLabels`
147
+ * prop controls which groups expand when `expandAllAccordions` is false,
148
+ * enabling apps to highlight their domain while preserving a compact mode.
149
+ */
150
+ export function SatelliteSettingsDialogShell({
151
+ t,
152
+ persistedTab,
153
+ setPersistedTab,
154
+ onboardingPanel,
155
+ navItems,
156
+ activeTab,
157
+ onActiveTabChange,
158
+ primaryGroupLabels,
159
+ expandAllAccordions = true,
160
+ keyboardNavigation = false,
161
+ activeGroupBreadcrumb,
162
+ children,
163
+ }: SettingsDialogShellProps) {
164
+ const isMobile = useIsMobile();
165
+ const desktopSearchInputRef = useRef<HTMLInputElement>(null);
166
+ const mobileSearchInputRef = useRef<HTMLInputElement>(null);
167
+ const searchEngineLoadRef = useRef<Promise<void> | null>(null);
168
+ const [searchQuery, setSearchQuery] = useState('');
169
+ const [searchEngineFactory, setSearchEngineFactory] =
170
+ useState<SettingsSearchEngineFactory | null>(null);
171
+ const [mobileNavOpen, setMobileNavOpen] = useState(false);
172
+ const extendedNavItems = useMemo<SettingsNavGroup[]>(
173
+ () => [
174
+ ...navItems,
175
+ {
176
+ label: t('onboarding_guide.title'),
177
+ items: [
178
+ {
179
+ name: 'onboarding_guide',
180
+ label: t('onboarding_guide.title'),
181
+ description: t('onboarding_guide.description'),
182
+ icon: Compass,
183
+ keywords: [
184
+ t('onboarding_guide.replay_app'),
185
+ t('onboarding_guide.restart_journey'),
186
+ ],
187
+ },
188
+ ],
189
+ },
190
+ ],
191
+ [navItems, t]
192
+ );
193
+
194
+ const searchEngine = useMemo<SettingsSearchEngine | null>(
195
+ () => searchEngineFactory?.(extendedNavItems) ?? null,
196
+ [extendedNavItems, searchEngineFactory]
197
+ );
198
+
199
+ const allNavItems = useMemo(
200
+ () =>
201
+ searchEngine?.allItems ??
202
+ extendedNavItems.flatMap((group) => group.items),
203
+ [extendedNavItems, searchEngine]
204
+ );
205
+
206
+ const activeGroup = extendedNavItems.find((group) =>
207
+ group.items.some((item) => item.name === activeTab)
208
+ );
209
+
210
+ const activeItem =
211
+ allNavItems.find((item) => item.name === activeTab) ||
212
+ allNavItems.find((item) => !item.disabled) ||
213
+ allNavItems[0];
214
+ const showContentHeader = !activeItem?.hideContentHeader;
215
+ const persistedItem = allNavItems.find(
216
+ (item) => !item.disabled && item.name === persistedTab
217
+ );
218
+
219
+ useEffect(() => {
220
+ if (persistedItem && persistedItem.name !== activeTab) {
221
+ onActiveTabChange(persistedItem.name);
222
+ }
223
+ }, [activeTab, onActiveTabChange, persistedItem]);
224
+
225
+ const selectActiveTab = useCallback(
226
+ (tab: string) => {
227
+ onActiveTabChange(tab);
228
+ void setPersistedTab(tab);
229
+ },
230
+ [onActiveTabChange, setPersistedTab]
231
+ );
232
+
233
+ const ensureSearchEngine = useCallback(() => {
234
+ if (searchEngineFactory || searchEngineLoadRef.current) return;
235
+
236
+ searchEngineLoadRef.current = loadSettingsSearchEngine()
237
+ .then((module) => {
238
+ setSearchEngineFactory(() => module.createSettingsSearchEngine);
239
+ })
240
+ .finally(() => {
241
+ searchEngineLoadRef.current = null;
242
+ });
243
+ }, [searchEngineFactory]);
244
+
245
+ const filteredNavItems = useMemo(
246
+ () =>
247
+ searchQuery
248
+ ? (searchEngine?.search(searchQuery) ?? extendedNavItems)
249
+ : extendedNavItems,
250
+ [extendedNavItems, searchEngine, searchQuery]
251
+ );
252
+
253
+ const filteredEnabledItems = useMemo(
254
+ () =>
255
+ searchEngine?.getEnabledItems(searchQuery) ??
256
+ filteredNavItems.flatMap((group) =>
257
+ group.items.filter((item) => !item.disabled)
258
+ ),
259
+ [filteredNavItems, searchEngine, searchQuery]
260
+ );
261
+ const keyboardEnabledItems = useMemo(
262
+ () =>
263
+ filteredEnabledItems.filter((item) => item.name !== 'onboarding_guide'),
264
+ [filteredEnabledItems]
265
+ );
266
+
267
+ const updateSearchQuery = useCallback(
268
+ (value: string) => {
269
+ if (value) ensureSearchEngine();
270
+ setSearchQuery(value);
271
+ },
272
+ [ensureSearchEngine]
273
+ );
274
+
275
+ const isGroupExpandedByDefault = (groupLabel: string, index: number) => {
276
+ if (expandAllAccordions || searchQuery) return true;
277
+ if (primaryGroupLabels) return primaryGroupLabels.includes(groupLabel);
278
+ return index === 0;
279
+ };
280
+
281
+ const focusSearch = useCallback(() => {
282
+ if (isMobile) {
283
+ setMobileNavOpen(true);
284
+ ensureSearchEngine();
285
+ requestAnimationFrame(() => {
286
+ mobileSearchInputRef.current?.focus();
287
+ });
288
+ return;
289
+ }
290
+
291
+ ensureSearchEngine();
292
+ desktopSearchInputRef.current?.focus();
293
+ }, [ensureSearchEngine, isMobile]);
294
+
295
+ const changeActiveItem = useCallback(
296
+ (targetIndex: number) => {
297
+ const targetItem = keyboardEnabledItems[targetIndex];
298
+ if (targetItem) selectActiveTab(targetItem.name);
299
+ },
300
+ [keyboardEnabledItems, selectActiveTab]
301
+ );
302
+
303
+ const moveActiveItem = useCallback(
304
+ (direction: 1 | -1) => {
305
+ if (keyboardEnabledItems.length === 0) return;
306
+
307
+ const currentIndex = keyboardEnabledItems.findIndex(
308
+ (item) => item.name === activeTab
309
+ );
310
+ const nextIndex =
311
+ currentIndex === -1
312
+ ? direction === 1
313
+ ? 0
314
+ : keyboardEnabledItems.length - 1
315
+ : (currentIndex + direction + keyboardEnabledItems.length) %
316
+ keyboardEnabledItems.length;
317
+
318
+ changeActiveItem(nextIndex);
319
+ },
320
+ [activeTab, changeActiveItem, keyboardEnabledItems]
321
+ );
322
+
323
+ const handleKeyboardNavigation = useCallback(
324
+ (event: KeyboardEvent<HTMLDivElement>) => {
325
+ if (!keyboardNavigation || event.defaultPrevented) return;
326
+
327
+ const key = event.key.toLowerCase();
328
+ const isModifierSearch =
329
+ (event.metaKey || event.ctrlKey) && !event.altKey && key === 'f';
330
+ const isSlashSearch =
331
+ !event.metaKey && !event.ctrlKey && !event.altKey && event.key === '/';
332
+
333
+ if (isModifierSearch || isSlashSearch) {
334
+ if (isSlashSearch && isEditableShortcutTarget(event.target)) return;
335
+
336
+ event.preventDefault();
337
+ focusSearch();
338
+ return;
339
+ }
340
+
341
+ if (
342
+ !event.altKey ||
343
+ event.metaKey ||
344
+ event.ctrlKey ||
345
+ isEditableShortcutTarget(event.target)
346
+ ) {
347
+ return;
348
+ }
349
+
350
+ if (event.key === 'ArrowDown') {
351
+ event.preventDefault();
352
+ moveActiveItem(1);
353
+ return;
354
+ }
355
+
356
+ if (event.key === 'ArrowUp') {
357
+ event.preventDefault();
358
+ moveActiveItem(-1);
359
+ return;
360
+ }
361
+
362
+ if (event.key === 'Home') {
363
+ event.preventDefault();
364
+ changeActiveItem(0);
365
+ return;
366
+ }
367
+
368
+ if (event.key === 'End') {
369
+ event.preventDefault();
370
+ changeActiveItem(keyboardEnabledItems.length - 1);
371
+ }
372
+ },
373
+ [
374
+ changeActiveItem,
375
+ keyboardEnabledItems.length,
376
+ focusSearch,
377
+ keyboardNavigation,
378
+ moveActiveItem,
379
+ ]
380
+ );
381
+
382
+ return (
383
+ <DialogContent
384
+ presentation="fullscreen"
385
+ className="flex-col"
386
+ onKeyDown={handleKeyboardNavigation}
387
+ showCloseButton={false}
388
+ >
389
+ <DialogTitle className="sr-only">{t('common.settings')}</DialogTitle>
390
+ <DialogDescription className="sr-only">
391
+ {t('common.settings')}
392
+ </DialogDescription>
393
+ <SidebarProvider className="flex h-full min-h-0 items-start">
394
+ <Sidebar
395
+ collapsible="none"
396
+ className="hidden h-full w-72 flex-col border-r bg-muted/30 md:flex"
397
+ >
398
+ <SidebarHeader className="z-10 gap-3 p-4 pb-0">
399
+ <DialogClose asChild>
400
+ <Button
401
+ type="button"
402
+ variant="ghost"
403
+ className="h-9 justify-start px-2 text-muted-foreground"
404
+ >
405
+ <ArrowLeft className="h-4 w-4" />
406
+ {t('settings.back_to_app')}
407
+ </Button>
408
+ </DialogClose>
409
+ <div className="relative">
410
+ <Search className="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
411
+ <SidebarInput
412
+ ref={desktopSearchInputRef}
413
+ placeholder={t('settings.search_settings_placeholder')}
414
+ className="bg-background pl-8"
415
+ value={searchQuery}
416
+ onChange={(e) => updateSearchQuery(e.target.value)}
417
+ onFocus={ensureSearchEngine}
418
+ />
419
+ </div>
420
+ </SidebarHeader>
421
+ <SidebarContent className="overflow-y-auto p-4">
422
+ {filteredNavItems.map((group, index) => (
423
+ <Collapsible
424
+ key={`${group.label}-${searchQuery ? 'search' : 'browse'}-${expandAllAccordions ? 'expanded' : 'collapsed'}`}
425
+ defaultOpen={isGroupExpandedByDefault(group.label, index)}
426
+ open={expandAllAccordions ? true : undefined}
427
+ className="group/collapsible"
428
+ >
429
+ <SidebarGroup className="p-0">
430
+ <SidebarGroupLabel
431
+ asChild
432
+ className="group/label w-full cursor-pointer text-sidebar-foreground text-sm hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
433
+ >
434
+ <CollapsibleTrigger>
435
+ {group.label}
436
+ <ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
437
+ </CollapsibleTrigger>
438
+ </SidebarGroupLabel>
439
+ <CollapsibleContent>
440
+ <SidebarGroupContent>
441
+ <SidebarMenu>
442
+ {group.items.map((item) => (
443
+ <SidebarMenuItem key={item.name}>
444
+ <SidebarMenuButton
445
+ disabled={item.disabled}
446
+ isActive={activeTab === item.name}
447
+ onClick={() => {
448
+ if (!item.disabled) {
449
+ selectActiveTab(item.name);
450
+ }
451
+ }}
452
+ className={cn(
453
+ 'h-9 w-full justify-start px-2 transition-colors',
454
+ item.disabled &&
455
+ 'cursor-not-allowed opacity-50',
456
+ activeTab === item.name
457
+ ? 'bg-accent font-medium text-accent-foreground'
458
+ : 'text-muted-foreground hover:bg-accent/50 hover:text-accent-foreground'
459
+ )}
460
+ >
461
+ <item.icon className="mr-2 h-4 w-4" />
462
+ <span>{item.label}</span>
463
+ </SidebarMenuButton>
464
+ </SidebarMenuItem>
465
+ ))}
466
+ </SidebarMenu>
467
+ </SidebarGroupContent>
468
+ </CollapsibleContent>
469
+ </SidebarGroup>
470
+ </Collapsible>
471
+ ))}
472
+ </SidebarContent>
473
+ </Sidebar>
474
+ <main className="flex h-full flex-1 flex-col overflow-hidden bg-background">
475
+ <header className="flex h-14 shrink-0 items-center gap-2 border-b px-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-6">
476
+ <div className="flex flex-1 items-center gap-2 md:flex-initial">
477
+ {isMobile && (
478
+ <Drawer open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
479
+ <DrawerTrigger asChild>
480
+ <Button
481
+ variant="outline"
482
+ role="combobox"
483
+ aria-expanded={mobileNavOpen}
484
+ className="w-full flex-1 justify-between gap-2"
485
+ >
486
+ {activeItem && (
487
+ <>
488
+ <activeItem.icon className="h-4 w-4 shrink-0" />
489
+ <span className="flex-1 truncate text-left">
490
+ {activeItem.label}
491
+ </span>
492
+ </>
493
+ )}
494
+ <ChevronDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
495
+ </Button>
496
+ </DrawerTrigger>
497
+ <DrawerContent>
498
+ <DrawerHeader className="sr-only">
499
+ <DrawerTitle>{t('common.settings')}</DrawerTitle>
500
+ <DrawerDescription>
501
+ {t('search.search')}
502
+ </DrawerDescription>
503
+ </DrawerHeader>
504
+ <Command
505
+ className="rounded-none border-0"
506
+ shouldFilter={false}
507
+ >
508
+ <CommandInput
509
+ ref={mobileSearchInputRef}
510
+ value={searchQuery}
511
+ onFocus={ensureSearchEngine}
512
+ onValueChange={updateSearchQuery}
513
+ placeholder={t('settings.search_settings_placeholder')}
514
+ />
515
+ <CommandList className="max-h-[50vh]">
516
+ <CommandEmpty>
517
+ {t('common.no_results_found')}
518
+ </CommandEmpty>
519
+ {filteredNavItems.map((group) => (
520
+ <CommandGroup key={group.label} heading={group.label}>
521
+ {group.items.map((item) => (
522
+ <CommandItem
523
+ disabled={item.disabled}
524
+ key={item.name}
525
+ value={`${group.label} ${item.label} ${item.description || ''} ${item.keywords?.join(' ') || ''} ${item.aliases?.join(' ') || ''} ${item.searchLabels?.join(' ') || ''}`}
526
+ onSelect={() => {
527
+ if (item.disabled) return;
528
+ selectActiveTab(item.name);
529
+ setMobileNavOpen(false);
530
+ }}
531
+ className={cn(
532
+ 'flex items-center gap-2',
533
+ activeTab === item.name && 'bg-accent'
534
+ )}
535
+ >
536
+ <item.icon className="h-4 w-4" />
537
+ <div className="flex flex-col">
538
+ <span>{item.label}</span>
539
+ {item.description && (
540
+ <span className="line-clamp-1 text-muted-foreground text-xs">
541
+ {item.description}
542
+ </span>
543
+ )}
544
+ </div>
545
+ </CommandItem>
546
+ ))}
547
+ </CommandGroup>
548
+ ))}
549
+ </CommandList>
550
+ </Command>
551
+ </DrawerContent>
552
+ </Drawer>
553
+ )}
554
+
555
+ <Breadcrumb className="hidden md:flex">
556
+ <BreadcrumbList>
557
+ <BreadcrumbItem>
558
+ <BreadcrumbLink href="#" className="pointer-events-none">
559
+ {t('common.settings')}
560
+ </BreadcrumbLink>
561
+ </BreadcrumbItem>
562
+ <BreadcrumbSeparator />
563
+ {activeGroup && (
564
+ <>
565
+ <BreadcrumbItem data-testid="settings-active-group-breadcrumb">
566
+ {activeGroupBreadcrumb ?? (
567
+ <BreadcrumbPage className="text-muted-foreground">
568
+ {activeGroup.label}
569
+ </BreadcrumbPage>
570
+ )}
571
+ </BreadcrumbItem>
572
+ <BreadcrumbSeparator />
573
+ </>
574
+ )}
575
+ <BreadcrumbItem>
576
+ <BreadcrumbPage>{activeItem?.label}</BreadcrumbPage>
577
+ </BreadcrumbItem>
578
+ </BreadcrumbList>
579
+ </Breadcrumb>
580
+ </div>
581
+ <DialogClose asChild>
582
+ <Button
583
+ type="button"
584
+ variant="ghost"
585
+ size="icon"
586
+ className="shrink-0 text-muted-foreground md:hidden"
587
+ >
588
+ <X
589
+ className="h-4 w-4"
590
+ data-testid="settings-mobile-close-icon"
591
+ />
592
+ <span className="sr-only">{t('common.close')}</span>
593
+ </Button>
594
+ </DialogClose>
595
+ </header>
596
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-2 md:p-6">
597
+ <div className="mx-auto w-full max-w-4xl space-y-6">
598
+ {showContentHeader && (
599
+ <>
600
+ <div className="space-y-1">
601
+ <h2 className="font-semibold text-lg tracking-tight">
602
+ {activeItem?.label}
603
+ </h2>
604
+ <p className="text-muted-foreground text-sm">
605
+ {activeItem?.description ||
606
+ t('settings.manage_settings', {
607
+ label: activeItem?.label?.toLowerCase() ?? '',
608
+ })}
609
+ </p>
610
+ </div>
611
+ <Separator />
612
+ </>
613
+ )}
614
+ {activeTab === 'onboarding_guide' ? onboardingPanel : children}
615
+ </div>
616
+ </div>
617
+ </main>
618
+ </SidebarProvider>
619
+ </DialogContent>
620
+ );
621
+ }