@tuturuuu/ui 0.37.0 → 0.37.2

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 (63) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +7 -5
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +181 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  6. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  7. package/src/components/ui/custom/structure.tsx +37 -5
  8. package/src/components/ui/custom/user-filters.tsx +2 -1
  9. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  10. package/src/components/ui/custom/workspace-picker-content.tsx +293 -0
  11. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  12. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  13. package/src/components/ui/custom/workspace-select.tsx +87 -359
  14. package/src/components/ui/date-time-picker-timezone.test.tsx +258 -0
  15. package/src/components/ui/date-time-picker-values.ts +205 -0
  16. package/src/components/ui/date-time-picker.tsx +70 -167
  17. package/src/components/ui/legacy/calendar/agenda-view.tsx +29 -94
  18. package/src/components/ui/legacy/calendar/all-day-color.test.tsx +48 -0
  19. package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +29 -41
  20. package/src/components/ui/legacy/calendar/calendar-cell-color.test.tsx +70 -0
  21. package/src/components/ui/legacy/calendar/calendar-cell.tsx +4 -5
  22. package/src/components/ui/legacy/calendar/calendar-content.test.tsx +82 -3
  23. package/src/components/ui/legacy/calendar/calendar-content.tsx +37 -34
  24. package/src/components/ui/legacy/calendar/calendar-day-bridge.test.tsx +94 -0
  25. package/src/components/ui/legacy/calendar/calendar-day-display.test.tsx +106 -0
  26. package/src/components/ui/legacy/calendar/calendar-header.tsx +14 -5
  27. package/src/components/ui/legacy/calendar/calendar-matrix.tsx +4 -9
  28. package/src/components/ui/legacy/calendar/calendar-views.test.tsx +120 -6
  29. package/src/components/ui/legacy/calendar/day-title.tsx +6 -6
  30. package/src/components/ui/legacy/calendar/dynamic-island.test.tsx +140 -0
  31. package/src/components/ui/legacy/calendar/dynamic-island.tsx +27 -24
  32. package/src/components/ui/legacy/calendar/event-card.test.tsx +206 -7
  33. package/src/components/ui/legacy/calendar/event-card.tsx +27 -49
  34. package/src/components/ui/legacy/calendar/event-modal.tsx +6 -26
  35. package/src/components/ui/legacy/calendar/event-picker-bounds.ts +16 -0
  36. package/src/components/ui/legacy/calendar/event-preview-popover.test.tsx +139 -0
  37. package/src/components/ui/legacy/calendar/event-preview-popover.tsx +13 -17
  38. package/src/components/ui/legacy/calendar/event-preview-time.ts +41 -0
  39. package/src/components/ui/legacy/calendar/location-timeline.tsx +9 -17
  40. package/src/components/ui/legacy/calendar/month-calendar.tsx +20 -10
  41. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  42. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  43. package/src/components/ui/legacy/calendar/time-indicator-line.tsx +3 -20
  44. package/src/components/ui/legacy/calendar/time-indicator.tsx +11 -3
  45. package/src/components/ui/legacy/calendar/weekday-bar.tsx +2 -8
  46. package/src/components/ui/legacy/calendar/year-calendar.tsx +27 -39
  47. package/src/components/ui/resizable.tsx +2 -1
  48. package/src/hooks/__tests__/use-calendar-sync.test.tsx +45 -0
  49. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  52. package/src/hooks/calendar-event-lookup.test.ts +39 -0
  53. package/src/hooks/calendar-event-lookup.ts +22 -8
  54. package/src/hooks/calendar-sync-range.test.ts +92 -0
  55. package/src/hooks/calendar-sync-range.ts +40 -0
  56. package/src/hooks/use-calendar-clock.ts +49 -0
  57. package/src/hooks/use-calendar-day-zone.tsx +12 -0
  58. package/src/hooks/use-calendar-sync.tsx +42 -80
  59. package/src/hooks/use-calendar.tsx +3 -3
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-user.ts +15 -3
  62. package/src/hooks/use-workspace-visibility.ts +234 -0
  63. package/src/lib/calendar-day.ts +67 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.37.2](https://github.com/tutur3u/platform/compare/ui-v0.37.1...ui-v0.37.2) (2026-10-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **calendar:** bind source color hydration to persisted identity ([00269c3](https://github.com/tutur3u/platform/commit/00269c315cd2ac8e1c9607f450100db8537cada2))
9
+ * **calendar:** preserve month hover color contrast ([6fd591c](https://github.com/tutur3u/platform/commit/6fd591c46f4c90d6f4c685c63b140728dd0339fe))
10
+ * **calendar:** preserve opaque rendering cues and hydrate source colors ([33847dd](https://github.com/tutur3u/platform/commit/33847ddcd8a3cf2b5cef8ebf50ca4c1700424a27))
11
+ * **calendar:** preserve resolved contrast during event hover ([79001b3](https://github.com/tutur3u/platform/commit/79001b32ad1c813119f8f7df934e7142bbbd7585))
12
+ * **calendar:** render opaque readable event colors ([#5694](https://github.com/tutur3u/platform/issues/5694)) ([6e3d81d](https://github.com/tutur3u/platform/commit/6e3d81d6fdd6f8155f12e36ede58d31fde0ac362))
13
+ * **calendar:** restore timer restart and metadata color refresh ([58c17ff](https://github.com/tutur3u/platform/commit/58c17ff6a89624678cc207cd9551bc54f15f53d2)) ([#5722](https://github.com/tutur3u/platform/issues/5722)) ([8954e54](https://github.com/tutur3u/platform/commit/8954e5476fd04ada4cb846e909fa26b8a669cda3))
14
+
15
+ ## [0.37.1](https://github.com/tutur3u/platform/compare/ui-v0.37.0...ui-v0.37.1) (2026-10-01)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **calendar:** align calendar-day navigation and zoned query ranges ([e81a8be](https://github.com/tutur3u/platform/commit/e81a8be8edf2e46b2653bfcf30bc118d12984e88))
21
+ * **calendar:** align date navigation and zoned query ranges ([#5654](https://github.com/tutur3u/platform/issues/5654)) ([f40288c](https://github.com/tutur3u/platform/commit/f40288c7cc817f71ca30f6ee5331efbf726c4ada))
22
+ * **calendar:** format clock options without browser dates ([757709e](https://github.com/tutur3u/platform/commit/757709ecbf1070cfa3248ece54f9b9c66cd000c7))
23
+ * **calendar:** honor resolved timezone in event picker ([eae3a25](https://github.com/tutur3u/platform/commit/eae3a25ec443fbb1fe51acf1c642a6b44e6c80f8)) ([#5648](https://github.com/tutur3u/platform/issues/5648)) ([7ccc4b3](https://github.com/tutur3u/platform/commit/7ccc4b356411744f5b90e55c35c1e6f759981d42))
24
+ * **calendar:** preserve explicit time bounds and guard DST rollover ([56d746b](https://github.com/tutur3u/platform/commit/56d746b91e8c4d48c6c97ad88a32cab74f8457cc))
25
+ * **calendar:** respect timezone in picker bounds and previews ([47720c7](https://github.com/tutur3u/platform/commit/47720c72d09545cd86d3d3814d4fb83f53848890))
26
+ * **calendar:** type-check picker regression import ([60aef11](https://github.com/tutur3u/platform/commit/60aef114c7ad9b3b327befaee37798db1a98b9b6))
27
+ * **calendar:** use calendar timezone for picker bounds and previews ([#5653](https://github.com/tutur3u/platform/issues/5653)) ([ea912c0](https://github.com/tutur3u/platform/commit/ea912c0fd89bf54ca073ae9c8b219a3696db4d19))
28
+
3
29
  ## [0.37.0](https://github.com/tutur3u/platform/compare/ui-v0.36.1...ui-v0.37.0) (2026-09-29)
4
30
 
5
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -91,9 +91,9 @@
91
91
  "@tuturuuu/apis": "0.13.0",
92
92
  "@tuturuuu/hooks": "0.1.2",
93
93
  "@tuturuuu/icons": "0.1.1",
94
- "@tuturuuu/internal-api": "0.47.0",
94
+ "@tuturuuu/internal-api": "0.48.0",
95
95
  "@tuturuuu/supabase": "0.5.2",
96
- "@tuturuuu/utils": "0.33.0",
96
+ "@tuturuuu/utils": "0.33.3",
97
97
  "@types/debug": "^4.1.13",
98
98
  "browser-image-compression": "^2.0.2",
99
99
  "class-variance-authority": "^0.7.1",
@@ -144,7 +144,7 @@
144
144
  "use-debounce": "^10.1.1",
145
145
  "vaul": "^1.1.2",
146
146
  "y-protocols": "^1.0.7",
147
- "yjs": "^13.6.32",
147
+ "yjs": "^13.6.33",
148
148
  "zod": "^4.6.2"
149
149
  },
150
150
  "devDependencies": {
@@ -154,7 +154,7 @@
154
154
  "@tanstack/react-table": "^9.2.4",
155
155
  "@testing-library/jest-dom": "^7.0.1",
156
156
  "@testing-library/react": "^16.3.3",
157
- "@tuturuuu/types": "0.33.0",
157
+ "@tuturuuu/types": "0.33.1",
158
158
  "@tuturuuu/typescript-config": "0.1.2",
159
159
  "@types/html2canvas": "^1.0.0",
160
160
  "@types/lodash": "^4.17.25",
@@ -226,6 +226,8 @@
226
226
  "./hooks/*": "./src/hooks/*.ts",
227
227
  "./hooks/time-blocking-provider": "./src/hooks/time-blocking-provider.tsx",
228
228
  "./hooks/use-at-bottom": "./src/hooks/use-at-bottom.tsx",
229
+ "./hooks/use-calendar-day-zone": "./src/hooks/use-calendar-day-zone.tsx",
230
+ "./lib/calendar-day": "./src/lib/calendar-day.ts",
229
231
  "./hooks/use-calendar-preferences": "./src/hooks/use-calendar-preferences.tsx",
230
232
  "./hooks/use-calendar-sync": "./src/hooks/use-calendar-sync.tsx",
231
233
  "./hooks/use-calendar": "./src/hooks/use-calendar.tsx",
@@ -0,0 +1,181 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import type { useWorkspaceVisibility } from '../../../../hooks/use-workspace-visibility';
5
+ import { Dialog } from '../../dialog';
6
+ import { WorkspacePickerContent } from '../workspace-picker-content';
7
+
8
+ vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
9
+ vi.mock('../workspace-select-icon', () => ({
10
+ WorkspaceIcon: () => <span aria-hidden="true" />,
11
+ }));
12
+ const one: InternalApiWorkspaceSummary = {
13
+ id: 'a',
14
+ name: 'Repeated name',
15
+ personal: false,
16
+ avatar_url: null,
17
+ logo_url: null,
18
+ tier: 'PRO',
19
+ };
20
+ const two: InternalApiWorkspaceSummary = { ...one, id: 'b' };
21
+ const other: InternalApiWorkspaceSummary = {
22
+ ...one,
23
+ id: 'z',
24
+ name: 'Another workspace',
25
+ };
26
+ function visibility(overrides = {}) {
27
+ return {
28
+ known: true,
29
+ hiddenIds: [],
30
+ pending: new Set(),
31
+ isError: false,
32
+ updateError: null,
33
+ setHidden: vi.fn().mockResolvedValue(undefined),
34
+ refetch: vi.fn(),
35
+ ...overrides,
36
+ } as unknown as ReturnType<typeof useWorkspaceVisibility>;
37
+ }
38
+ describe('workspace sheet rendered identities', () => {
39
+ it('keeps floating create and search actions separate from workspace selection', () => {
40
+ const onCreate = vi.fn();
41
+ const onJoin = vi.fn();
42
+ const onSelect = vi.fn();
43
+ render(
44
+ <Dialog open>
45
+ <WorkspacePickerContent
46
+ workspaces={[one]}
47
+ visibility={visibility()}
48
+ onSelect={onSelect}
49
+ onCreate={onCreate}
50
+ onJoin={onJoin}
51
+ />
52
+ </Dialog>
53
+ );
54
+ const create = screen.getByRole('button', {
55
+ name: 'create_workspace_action',
56
+ });
57
+ const search = screen.getByRole('button', { name: 'search_workspace' });
58
+ expect(create.parentElement).toBe(search.parentElement);
59
+ expect(create.parentElement).toHaveClass('absolute');
60
+ fireEvent.click(create);
61
+ fireEvent.click(
62
+ screen.getByRole('button', { name: 'join_workspace_action' })
63
+ );
64
+ expect(onCreate).toHaveBeenCalledOnce();
65
+ expect(onJoin).toHaveBeenCalledOnce();
66
+ expect(onSelect).not.toHaveBeenCalled();
67
+ });
68
+ it('focuses search when opened and clears the current filtered query', async () => {
69
+ render(
70
+ <Dialog open>
71
+ <WorkspacePickerContent
72
+ workspaces={[one, other]}
73
+ visibility={visibility()}
74
+ onSelect={vi.fn()}
75
+ />
76
+ </Dialog>
77
+ );
78
+ fireEvent.click(screen.getByRole('button', { name: 'search_workspace' }));
79
+ const input = screen.getByRole('textbox', { name: 'search_workspace' });
80
+ await waitFor(() => expect(document.activeElement).toBe(input));
81
+ fireEvent.change(input, { target: { value: 'Repeated' } });
82
+ expect(screen.queryByText('Another workspace')).toBeNull();
83
+ fireEvent.click(screen.getByRole('button', { name: 'clear_search' }));
84
+ expect(input).toHaveValue('');
85
+ expect(document.activeElement).toBe(input);
86
+ expect(screen.getByText('Another workspace')).toBeInTheDocument();
87
+ });
88
+ it('provides fullscreen scrollable modal content with trapped focus and Escape close', async () => {
89
+ const onOpenChange = vi.fn();
90
+ render(
91
+ <>
92
+ <input aria-label="Outside picker" />
93
+ <Dialog open onOpenChange={onOpenChange}>
94
+ <WorkspacePickerContent
95
+ workspaces={[one]}
96
+ visibility={visibility()}
97
+ onSelect={vi.fn()}
98
+ />
99
+ </Dialog>
100
+ </>
101
+ );
102
+ const dialog = screen.getByRole('dialog');
103
+ expect(dialog).toHaveClass('inset-0', 'h-dvh', 'overflow-hidden');
104
+ const row = screen.getByRole('button', { name: 'Repeated name PRO' });
105
+ expect(row.closest('.overflow-y-auto')).not.toBeNull();
106
+ const outside = screen.getByLabelText('Outside picker');
107
+ outside.focus();
108
+ await waitFor(() =>
109
+ expect(dialog.contains(document.activeElement)).toBe(true)
110
+ );
111
+ fireEvent.keyDown(document.activeElement ?? dialog, { key: 'Escape' });
112
+ await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
113
+ });
114
+ it('selects refreshed filtered objects with duplicate names and reordered input', () => {
115
+ const onSelect = vi.fn();
116
+ const state = visibility();
117
+ const { rerender } = render(
118
+ <Dialog open>
119
+ <WorkspacePickerContent
120
+ workspaces={[other, two, one]}
121
+ visibility={state}
122
+ onSelect={onSelect}
123
+ />
124
+ </Dialog>
125
+ );
126
+ fireEvent.click(screen.getByRole('button', { name: 'search_workspace' }));
127
+ fireEvent.change(screen.getByRole('textbox'), {
128
+ target: { value: 'Repeated' },
129
+ });
130
+ expect(screen.queryByText('Another workspace')).toBeNull();
131
+ const updated = { ...two, tier: 'ENTERPRISE' as const };
132
+ rerender(
133
+ <Dialog open>
134
+ <WorkspacePickerContent
135
+ workspaces={[one, other, updated]}
136
+ visibility={state}
137
+ onSelect={onSelect}
138
+ />
139
+ </Dialog>
140
+ );
141
+ fireEvent.click(
142
+ screen.getByRole('button', { name: 'Repeated name ENTERPRISE' })
143
+ );
144
+ expect(onSelect).toHaveBeenCalledWith(updated);
145
+ });
146
+ it('restores only the hidden ID without selecting a current/default scope', () => {
147
+ const state = visibility({ hiddenIds: ['b'] });
148
+ const onSelect = vi.fn();
149
+ render(
150
+ <Dialog open>
151
+ <WorkspacePickerContent
152
+ restoreOnly
153
+ workspaces={[one, two, other]}
154
+ visibility={state}
155
+ onSelect={onSelect}
156
+ />
157
+ </Dialog>
158
+ );
159
+ fireEvent.click(
160
+ screen.getByRole('button', { name: 'restore_workspace: Repeated name' })
161
+ );
162
+ expect(state.setHidden).toHaveBeenCalledWith('b', false);
163
+ expect(onSelect).not.toHaveBeenCalled();
164
+ expect(screen.queryByText('Another workspace')).toBeNull();
165
+ });
166
+ it('does not expose unfiltered choices while the private list is unknown', () => {
167
+ render(
168
+ <Dialog open>
169
+ <WorkspacePickerContent
170
+ workspaces={[one, two]}
171
+ visibility={visibility({ known: false, isError: true })}
172
+ onSelect={vi.fn()}
173
+ />
174
+ </Dialog>
175
+ );
176
+ expect(screen.queryByText('Repeated name')).toBeNull();
177
+ expect(screen.getByRole('status').textContent).toBe(
178
+ 'hidden_workspaces_load_error'
179
+ );
180
+ });
181
+ });
@@ -6,6 +6,7 @@ import {
6
6
  buildWorkspaceSetupHandoffUrl,
7
7
  mergeWorkspaceSelectWorkspaces,
8
8
  normalizeWorkspaceSwitchPath,
9
+ resolveGuestWorkspaceLanding,
9
10
  resolveWorkspaceAvatarUrl,
10
11
  } from '../workspace-select-helpers';
11
12
 
@@ -126,44 +127,6 @@ describe('mergeWorkspaceSelectWorkspaces', () => {
126
127
  /<AvatarFallback[\s\S]*<AvatarImage/u
127
128
  );
128
129
  });
129
-
130
- it('lays out the short create and join actions in the command item grid', () => {
131
- const workspaceSelectSource = readFileSync(
132
- join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
133
- 'utf8'
134
- );
135
-
136
- expect(workspaceSelectSource).toContain(
137
- '[&_[cmdk-group-items]]:grid-cols-2'
138
- );
139
- expect(workspaceSelectSource).toContain(
140
- "t('common.create_workspace_action')"
141
- );
142
- expect(workspaceSelectSource).toContain(
143
- "t('common.join_workspace_action')"
144
- );
145
- });
146
-
147
- it('supports a modal popover so nested settings pickers remain scrollable', () => {
148
- const workspaceSelectSource = readFileSync(
149
- join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
150
- 'utf8'
151
- );
152
-
153
- expect(workspaceSelectSource).toContain('popoverModal = false');
154
- expect(workspaceSelectSource).toContain('<Popover modal={popoverModal}');
155
- });
156
-
157
- it('focuses workspace search as soon as the picker opens', () => {
158
- const workspaceSelectSource = readFileSync(
159
- join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
160
- 'utf8'
161
- );
162
-
163
- expect(workspaceSelectSource).toContain(
164
- '<CommandInput autoFocus placeholder="Search workspace..." />'
165
- );
166
- });
167
130
  });
168
131
 
169
132
  describe('normalizeWorkspaceSwitchPath', () => {
@@ -200,3 +163,26 @@ describe('normalizeWorkspaceSwitchPath', () => {
200
163
  ).toBe('/workspace-1/users');
201
164
  });
202
165
  });
166
+
167
+ it.each([null, ''])(
168
+ 'preserves the boards fallback for a guest without a landing path (%s)',
169
+ (guest_landing_path) => {
170
+ expect(
171
+ resolveGuestWorkspaceLanding({ access_type: 'guest', guest_landing_path })
172
+ ).toBe('/tasks/boards');
173
+ }
174
+ );
175
+ it('preserves explicit guest landing while members retain normal path resolution', () => {
176
+ expect(
177
+ resolveGuestWorkspaceLanding({
178
+ access_type: 'guest',
179
+ guest_landing_path: '/tasks/boards/shared-board',
180
+ })
181
+ ).toBe('/tasks/boards/shared-board');
182
+ expect(
183
+ resolveGuestWorkspaceLanding({
184
+ access_type: 'member',
185
+ guest_landing_path: null,
186
+ })
187
+ ).toBeNull();
188
+ });
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import { useQuery } from '@tanstack/react-query';
4
+ import { listWorkspaces } from '@tuturuuu/internal-api/workspaces';
5
+ import { useTranslations } from 'next-intl';
6
+ import {
7
+ useWorkspaceActor,
8
+ useWorkspaceVisibility,
9
+ } from '../../../hooks/use-workspace-visibility';
10
+ import { Button } from '../button';
11
+ import { Dialog, DialogTrigger } from '../dialog';
12
+ import { WorkspacePickerContent } from './workspace-picker-content';
13
+
14
+ /** Private Settings restore editor; restoring never changes current/default scope. */
15
+ export function HiddenWorkspacesSettings() {
16
+ const t = useTranslations('common');
17
+ const actor = useWorkspaceActor();
18
+ const visibility = useWorkspaceVisibility();
19
+ const list = useQuery({
20
+ queryKey: ['workspace-ui-list', actor?.actorId],
21
+ enabled: Boolean(actor),
22
+ queryFn: async () => {
23
+ actor!.assertActive();
24
+ const result = await listWorkspaces();
25
+ actor!.assertActive();
26
+ return result;
27
+ },
28
+ });
29
+ return (
30
+ <Dialog>
31
+ <DialogTrigger asChild>
32
+ <Button variant="outline" disabled={!actor}>
33
+ {t('hidden_workspaces')}
34
+ </Button>
35
+ </DialogTrigger>
36
+ <WorkspacePickerContent
37
+ listLoading={list.isLoading}
38
+ listError={list.error}
39
+ onRetryList={() => {
40
+ void list.refetch();
41
+ }}
42
+ restoreOnly
43
+ workspaces={list.data ?? []}
44
+ visibility={visibility}
45
+ onSelect={() => {}}
46
+ />
47
+ </Dialog>
48
+ );
49
+ }
@@ -0,0 +1,16 @@
1
+ /** Measure the fixed mobile header including safe-area padding and wrapped rows. */
2
+ export function observeMobileHeaderHeight(nav: HTMLElement, main: HTMLElement) {
3
+ const update = () => {
4
+ main.style.setProperty(
5
+ '--mobile-nav-height',
6
+ `${nav.getBoundingClientRect().height}px`
7
+ );
8
+ };
9
+ update();
10
+ const observer = new ResizeObserver(update);
11
+ observer.observe(nav);
12
+ return () => {
13
+ observer.disconnect();
14
+ main.style.removeProperty('--mobile-nav-height');
15
+ };
16
+ }
@@ -4,7 +4,14 @@ import { ChevronLeft, ChevronRight, Menu, X } from '@tuturuuu/icons';
4
4
  import { Button } from '@tuturuuu/ui/button';
5
5
  import { TooltipProvider } from '@tuturuuu/ui/tooltip';
6
6
  import { cn } from '@tuturuuu/utils/format';
7
- import { type CSSProperties, type ReactNode, useEffect } from 'react';
7
+ import {
8
+ type CSSProperties,
9
+ type ReactNode,
10
+ useEffect,
11
+ useLayoutEffect,
12
+ useRef,
13
+ } from 'react';
14
+ import { observeMobileHeaderHeight } from './mobile-header-height';
8
15
 
9
16
  interface StructureProps {
10
17
  sidebarLabels?: { open: string; close: string };
@@ -22,6 +29,7 @@ interface StructureProps {
22
29
  sidebarUtility?: ReactNode;
23
30
  feedbackButton?: ReactNode;
24
31
  children: ReactNode;
32
+ contentFullBleed?: boolean;
25
33
  onMouseEnter?: () => void;
26
34
  onMouseLeave?: () => void;
27
35
  sidebarHidden?: boolean;
@@ -47,6 +55,7 @@ export function Structure({
47
55
  sidebarUtility,
48
56
  feedbackButton,
49
57
  children,
58
+ contentFullBleed = false,
50
59
  onMouseEnter,
51
60
  onMouseLeave,
52
61
  sidebarHidden = false,
@@ -55,6 +64,15 @@ export function Structure({
55
64
  sidebarHeaderClassName,
56
65
  sidebarHeaderHeight,
57
66
  }: StructureProps) {
67
+ const mobileNavRef = useRef<HTMLElement>(null);
68
+ const mainRef = useRef<HTMLElement>(null);
69
+ useLayoutEffect(() => {
70
+ if (!contentFullBleed || sidebarHidden) return;
71
+ const nav = mobileNavRef.current;
72
+ const main = mainRef.current;
73
+ if (nav && main) return observeMobileHeaderHeight(nav, main);
74
+ }, [contentFullBleed, sidebarHidden]);
75
+
58
76
  const sidebarStyle = {
59
77
  '--sidebar-collapsed-width': sidebarCollapsedWidth,
60
78
  '--sidebar-expanded-width': sidebarExpandedWidth,
@@ -86,7 +104,10 @@ export function Structure({
86
104
  return (
87
105
  <>
88
106
  {!sidebarHidden && (
89
- <nav className="safe-top safe-x fixed inset-x-0 top-0 z-30 max-sm:border-b md:hidden">
107
+ <nav
108
+ ref={mobileNavRef}
109
+ className="safe-top safe-x fixed inset-x-0 top-0 z-30 max-sm:border-b md:hidden"
110
+ >
90
111
  <div className="bg-background/50 p-2 font-semibold backdrop-blur-md md:px-8 lg:px-16 xl:px-32">
91
112
  <div className="relative flex items-center justify-between gap-2 md:gap-4">
92
113
  <div className="flex w-full items-center gap-2">
@@ -236,8 +257,12 @@ export function Structure({
236
257
  {/* Main content area - overflow-y-auto removed to prevent double scrollbars */}
237
258
  {/* Body element now handles page-level scrolling */}
238
259
  <main
260
+ ref={mainRef}
239
261
  className={cn(
240
- 'relative flex h-full min-h-screen flex-col overflow-x-clip overflow-y-visible transition-all duration-300 ease-in-out',
262
+ 'relative flex flex-col overflow-x-clip transition-all duration-300 ease-in-out',
263
+ contentFullBleed
264
+ ? 'h-dvh min-h-0 overflow-y-hidden'
265
+ : 'h-full min-h-screen overflow-y-visible',
241
266
  sidebarHidden
242
267
  ? 'md:pl-0'
243
268
  : isCollapsed || overlayOnExpand
@@ -249,8 +274,15 @@ export function Structure({
249
274
  {header && <div className="mb-4 hidden md:block">{header}</div>}
250
275
  <div
251
276
  className={cn(
252
- 'safe-bottom relative h-full w-full p-2 md:p-4 md:pt-4',
253
- sidebarHidden ? 'pt-2' : 'pt-17 pl-2'
277
+ 'safe-bottom relative w-full',
278
+ contentFullBleed
279
+ ? sidebarHidden
280
+ ? 'flex min-h-0 flex-1 flex-col'
281
+ : 'flex min-h-0 flex-1 flex-col pt-[var(--mobile-nav-height,calc(4.25rem+env(safe-area-inset-top,0px)))] md:pt-0'
282
+ : [
283
+ 'h-full p-2 md:p-4 md:pt-4',
284
+ sidebarHidden ? 'pt-2' : 'pt-17 pl-2',
285
+ ]
254
286
  )}
255
287
  >
256
288
  {children}
@@ -175,7 +175,8 @@ export function Filter({
175
175
  className={cn('h-8 border-dashed px-1', className)}
176
176
  disabled={disabled}
177
177
  >
178
- {selectedSize > 0 ? null : (
178
+ {selectedSize > 0 &&
179
+ options.some((option) => selectedValues.has(option.value)) ? null : (
179
180
  <>
180
181
  {icon} {title}
181
182
  </>
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useTranslations } from 'next-intl';
4
+ import type { ComponentProps } from 'react';
5
+ import { useWorkspaceVisibility } from '../../../hooks/use-workspace-visibility';
6
+ import { Button } from '../button';
7
+ import { Filter } from './user-filters';
8
+
9
+ /** Filter choices only; never change the underlying authorized report/data query. */
10
+ export function VisibleWorkspaceFilter(props: ComponentProps<typeof Filter>) {
11
+ const t = useTranslations('common');
12
+ const visibility = useWorkspaceVisibility();
13
+ return (
14
+ <>
15
+ <Filter
16
+ {...props}
17
+ options={
18
+ visibility.known
19
+ ? props.options?.filter(
20
+ (option) => !visibility.hiddenIds.includes(option.value)
21
+ )
22
+ : []
23
+ }
24
+ />
25
+ {!visibility.known && visibility.isError && (
26
+ <span role="alert">
27
+ {t('hidden_workspaces_load_error')}
28
+ <Button
29
+ size="sm"
30
+ variant="ghost"
31
+ onClick={() => {
32
+ void visibility.refetch();
33
+ }}
34
+ >
35
+ {t('retry')}
36
+ </Button>
37
+ </span>
38
+ )}
39
+ </>
40
+ );
41
+ }