@classic-homes/theme-svelte 0.1.56 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/nav-access.test.js +145 -0
- package/dist/__tests__/sidebar-expansion.test.js +59 -0
- package/dist/lib/components/DataPanel.svelte +383 -114
- package/dist/lib/components/DataPanelHeader.svelte +4 -4
- package/dist/lib/components/DataPanelTab.svelte +212 -44
- package/dist/lib/components/DataPanelTab.svelte.d.ts +10 -1
- package/dist/lib/components/layout/DashboardLayout.svelte +1 -0
- package/dist/lib/components/layout/Sidebar.svelte +22 -17
- package/dist/lib/components/layout/Sidebar.svelte.d.ts +10 -1
- package/dist/lib/schemas/auth.d.ts +18 -8
- package/dist/lib/schemas/auth.js +4 -1
- package/dist/lib/schemas/index.d.ts +1 -1
- package/dist/lib/stores/dataPanel.svelte.d.ts +2 -0
- package/dist/lib/stores/dataPanel.svelte.js +14 -0
- package/dist/lib/stores/sidebar.svelte.d.ts +7 -2
- package/dist/lib/stores/sidebar.svelte.js +9 -3
- package/dist/lib/types/layout.d.ts +52 -4
- package/dist/lib/utils/nav-access.d.ts +61 -0
- package/dist/lib/utils/nav-access.js +74 -0
- package/package.json +1 -1
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
{#snippet trigger()}
|
|
90
90
|
<button
|
|
91
91
|
type="button"
|
|
92
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
92
|
+
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
93
93
|
aria-label="Panel options"
|
|
94
94
|
>
|
|
95
95
|
<svg
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
<!-- Collapse/Expand button -->
|
|
142
142
|
<button
|
|
143
143
|
type="button"
|
|
144
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
144
|
+
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
145
145
|
onclick={() => onExpandedChange?.(!isExpanded)}
|
|
146
146
|
aria-label={isExpanded ? 'Collapse panel' : 'Expand panel'}
|
|
147
147
|
aria-expanded={isExpanded}
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
{#snippet trigger()}
|
|
169
169
|
<button
|
|
170
170
|
type="button"
|
|
171
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
171
|
+
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
172
172
|
aria-label="Panel options"
|
|
173
173
|
>
|
|
174
174
|
<svg
|
|
@@ -195,7 +195,7 @@
|
|
|
195
195
|
{#if !disableClose}
|
|
196
196
|
<button
|
|
197
197
|
type="button"
|
|
198
|
-
class="flex h-8 w-8 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
198
|
+
class="relative flex h-8 w-8 items-center justify-center rounded-md before:absolute before:-inset-1.5 before:content-[''] bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
199
199
|
onclick={() => onClose?.()}
|
|
200
200
|
aria-label="Close panel"
|
|
201
201
|
>
|
|
@@ -1,72 +1,240 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { cn } from '../utils.js';
|
|
3
3
|
import type { PanelEdge } from '@classic-homes/data-panel-core';
|
|
4
|
+
import { constrainTabOffset } from '@classic-homes/data-panel-core';
|
|
4
5
|
|
|
5
6
|
interface Props {
|
|
6
7
|
/** Tab label/title */
|
|
7
8
|
title?: string;
|
|
9
|
+
/** Secondary line under the title */
|
|
10
|
+
subtitle?: string;
|
|
8
11
|
/** Current edge position */
|
|
9
12
|
edge: PanelEdge;
|
|
10
|
-
/**
|
|
13
|
+
/** Expand the panel (tap/click/keyboard on the chip body) */
|
|
11
14
|
onclick?: () => void;
|
|
15
|
+
/** Close the panel (chip close button) */
|
|
16
|
+
onClose?: () => void;
|
|
17
|
+
disableClose?: boolean;
|
|
18
|
+
/** Offset (px) of the tab along its edge from the centered position */
|
|
19
|
+
offset?: number;
|
|
20
|
+
/** Called with the new offset after the tab is dragged along its edge */
|
|
21
|
+
onOffsetChange?: (offset: number) => void;
|
|
12
22
|
/** Additional class */
|
|
13
23
|
class?: string;
|
|
14
24
|
}
|
|
15
25
|
|
|
16
|
-
let {
|
|
26
|
+
let {
|
|
27
|
+
title = 'Panel',
|
|
28
|
+
subtitle,
|
|
29
|
+
edge,
|
|
30
|
+
onclick,
|
|
31
|
+
onClose,
|
|
32
|
+
disableClose = false,
|
|
33
|
+
offset = 0,
|
|
34
|
+
onOffsetChange,
|
|
35
|
+
class: className,
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
const isVertical = $derived(edge === 'left' || edge === 'right');
|
|
17
39
|
|
|
18
|
-
// Position classes based on edge
|
|
19
40
|
const positionClasses = $derived<string>(
|
|
20
41
|
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
// Side tabs are the whole chip rotated 90°, so they need a fixed thickness
|
|
43
|
+
// (h-14 → 56px) for a deterministic edge hug; see tabTransform below. Border
|
|
44
|
+
// classes are in the chip's pre-rotation frame: its bottom rotates to the
|
|
45
|
+
// attached (edge) side and its top corners to the viewport-facing corners,
|
|
46
|
+
// so both edges drop the bottom border and round the top.
|
|
47
|
+
left: 'left-0 top-1/2 h-14 rounded-t-lg border-b-0',
|
|
48
|
+
right: 'right-0 top-1/2 h-14 rounded-t-lg border-b-0',
|
|
49
|
+
top: 'top-0 left-1/2 rounded-b-lg border-t-0',
|
|
50
|
+
bottom: 'bottom-0 left-1/2 rounded-t-lg border-b-0',
|
|
25
51
|
}[edge]
|
|
26
52
|
);
|
|
27
53
|
|
|
28
|
-
|
|
29
|
-
|
|
54
|
+
const draggable = $derived(!!onOffsetChange);
|
|
55
|
+
|
|
56
|
+
let btn = $state<HTMLDivElement | null>(null);
|
|
57
|
+
let dragOffset = $state<number | null>(null);
|
|
58
|
+
const effectiveOffset = $derived(dragOffset ?? offset);
|
|
59
|
+
|
|
60
|
+
// Half the side-tab thickness (h-14 → 56px). Left/right tabs are the whole chip
|
|
61
|
+
// rotated 90°; we center the rotated footprint on the edge, then shift it back
|
|
62
|
+
// by half its thickness so the outer long edge sits flush.
|
|
63
|
+
const SIDE_HALF = 28;
|
|
64
|
+
const tabTransform = $derived<string>(
|
|
65
|
+
edge === 'left'
|
|
66
|
+
? `translate(calc(-50% + ${SIDE_HALF}px), calc(-50% + ${effectiveOffset}px)) rotate(90deg)`
|
|
67
|
+
: edge === 'right'
|
|
68
|
+
? `translate(calc(50% - ${SIDE_HALF}px), calc(-50% + ${effectiveOffset}px)) rotate(-90deg)`
|
|
69
|
+
: `translateX(calc(-50% + ${effectiveOffset}px))`
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Non-reactive drag bookkeeping.
|
|
73
|
+
let drag = { startX: 0, startY: 0, startOffset: 0, moved: false, pointerId: -1 };
|
|
74
|
+
|
|
75
|
+
function handlePointerDown(e: PointerEvent) {
|
|
76
|
+
if (!onOffsetChange) return;
|
|
77
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
78
|
+
// Let action buttons (close) handle their own press without starting a drag.
|
|
79
|
+
if ((e.target as HTMLElement).closest('[data-no-drag]')) return;
|
|
80
|
+
drag = {
|
|
81
|
+
startX: e.clientX,
|
|
82
|
+
startY: e.clientY,
|
|
83
|
+
startOffset: offset,
|
|
84
|
+
moved: false,
|
|
85
|
+
pointerId: e.pointerId,
|
|
86
|
+
};
|
|
87
|
+
dragOffset = offset;
|
|
88
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function handlePointerMove(e: PointerEvent) {
|
|
92
|
+
const el = e.currentTarget as HTMLElement;
|
|
93
|
+
if (drag.pointerId !== e.pointerId || !el.hasPointerCapture(e.pointerId)) return;
|
|
94
|
+
const delta = isVertical ? e.clientY - drag.startY : e.clientX - drag.startX;
|
|
95
|
+
if (Math.abs(delta) > 3) drag.moved = true;
|
|
96
|
+
const rect = btn?.getBoundingClientRect();
|
|
97
|
+
const tabSize = { width: rect?.width ?? 160, height: rect?.height ?? 48 };
|
|
98
|
+
dragOffset = constrainTabOffset(
|
|
99
|
+
drag.startOffset + delta,
|
|
100
|
+
edge,
|
|
101
|
+
tabSize,
|
|
102
|
+
window.innerWidth,
|
|
103
|
+
window.innerHeight
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function handlePointerUp(e: PointerEvent) {
|
|
108
|
+
const el = e.currentTarget as HTMLElement;
|
|
109
|
+
if (drag.pointerId !== e.pointerId) return;
|
|
110
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
111
|
+
drag.pointerId = -1;
|
|
112
|
+
if (drag.moved) {
|
|
113
|
+
// A drag repositions.
|
|
114
|
+
onOffsetChange?.(dragOffset ?? offset);
|
|
115
|
+
} else {
|
|
116
|
+
// A tap anywhere on the chip expands. Handled here (not on the inner
|
|
117
|
+
// button's click) because the container holds pointer capture during a
|
|
118
|
+
// press, which would otherwise swallow the button click.
|
|
119
|
+
onclick?.();
|
|
120
|
+
}
|
|
121
|
+
dragOffset = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A canceled pointer (OS gesture, scroll) must not count as a tap-to-expand;
|
|
125
|
+
// just discard the in-flight drag and revert to the persisted offset.
|
|
126
|
+
function handlePointerCancel(e: PointerEvent) {
|
|
127
|
+
if (drag.pointerId !== e.pointerId) return;
|
|
128
|
+
const el = e.currentTarget as HTMLElement;
|
|
129
|
+
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
|
130
|
+
drag.pointerId = -1;
|
|
131
|
+
drag.moved = false;
|
|
132
|
+
dragOffset = null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Keyboard activation of the expand button (pointer taps expand via the
|
|
136
|
+
// container's pointerup above).
|
|
137
|
+
function handleExpandKeyDown(e: KeyboardEvent) {
|
|
138
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
onclick?.();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function handleClose(e: MouseEvent) {
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
onClose?.();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clampOffset() {
|
|
150
|
+
if (!onOffsetChange) return;
|
|
151
|
+
const rect = btn?.getBoundingClientRect();
|
|
152
|
+
if (!rect) return;
|
|
153
|
+
const clamped = constrainTabOffset(
|
|
154
|
+
offset,
|
|
155
|
+
edge,
|
|
156
|
+
{ width: rect.width, height: rect.height },
|
|
157
|
+
window.innerWidth,
|
|
158
|
+
window.innerHeight
|
|
159
|
+
);
|
|
160
|
+
if (clamped !== offset) onOffsetChange(clamped);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Re-clamp the persisted offset to the current tab geometry. The tab's
|
|
164
|
+
// along-edge extent depends on its rendered size (and, for side edges, on the
|
|
165
|
+
// 90° rotation), so an offset saved under a different size/edge/viewport can
|
|
166
|
+
// land the tab off-screen. Runs on mount and whenever edge/offset/size change.
|
|
167
|
+
$effect(() => {
|
|
168
|
+
// Track the reactive inputs so this re-runs when they change.
|
|
169
|
+
void edge;
|
|
170
|
+
void offset;
|
|
171
|
+
void btn;
|
|
172
|
+
if (dragOffset !== null) return;
|
|
173
|
+
clampOffset();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
$effect(() => {
|
|
177
|
+
const onResize = () => {
|
|
178
|
+
if (dragOffset === null) clampOffset();
|
|
179
|
+
};
|
|
180
|
+
window.addEventListener('resize', onResize);
|
|
181
|
+
return () => window.removeEventListener('resize', onResize);
|
|
182
|
+
});
|
|
30
183
|
</script>
|
|
31
184
|
|
|
32
|
-
|
|
33
|
-
|
|
185
|
+
<!-- Presentational drag container: holds the expand + close buttons as siblings
|
|
186
|
+
so neither interactive control is nested inside another (valid a11y). The
|
|
187
|
+
container carries the position/transform and the pointer-drag handlers. -->
|
|
188
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
189
|
+
<div
|
|
190
|
+
bind:this={btn}
|
|
34
191
|
class={cn(
|
|
35
|
-
'fixed z-40 flex
|
|
192
|
+
'fixed z-40 flex max-w-[220px] items-center gap-2 border border-border bg-background px-2.5 py-1.5 shadow-md focus-within:ring-2 focus-within:ring-ring',
|
|
193
|
+
draggable && 'cursor-grab touch-none active:cursor-grabbing',
|
|
36
194
|
positionClasses,
|
|
37
|
-
isVertical && 'writing-mode-vertical',
|
|
38
195
|
className
|
|
39
196
|
)}
|
|
40
|
-
{
|
|
41
|
-
|
|
197
|
+
style={`transform: ${tabTransform}`}
|
|
198
|
+
onpointerdown={handlePointerDown}
|
|
199
|
+
onpointermove={draggable ? handlePointerMove : undefined}
|
|
200
|
+
onpointerup={draggable ? handlePointerUp : undefined}
|
|
201
|
+
onpointercancel={draggable ? handlePointerCancel : undefined}
|
|
42
202
|
>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
fill="none"
|
|
50
|
-
stroke="currentColor"
|
|
51
|
-
stroke-width="2"
|
|
52
|
-
stroke-linecap="round"
|
|
53
|
-
stroke-linejoin="round"
|
|
54
|
-
class={cn(
|
|
55
|
-
'shrink-0',
|
|
56
|
-
edge === 'left' && 'rotate-90',
|
|
57
|
-
edge === 'right' && '-rotate-90',
|
|
58
|
-
edge === 'top' && 'rotate-180',
|
|
59
|
-
edge === 'bottom' && ''
|
|
60
|
-
)}
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
aria-label={`Expand ${title} panel`}
|
|
206
|
+
class="flex min-w-0 flex-1 flex-col text-left leading-tight hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
207
|
+
onclick={draggable ? undefined : onclick}
|
|
208
|
+
onkeydown={handleExpandKeyDown}
|
|
61
209
|
>
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
210
|
+
<span class="truncate text-sm font-semibold">{title}</span>
|
|
211
|
+
{#if subtitle}
|
|
212
|
+
<span class="truncate text-xs text-muted-foreground">{subtitle}</span>
|
|
213
|
+
{/if}
|
|
214
|
+
</button>
|
|
215
|
+
|
|
216
|
+
{#if !disableClose && onClose}
|
|
217
|
+
<button
|
|
218
|
+
data-no-drag
|
|
219
|
+
type="button"
|
|
220
|
+
class="relative ml-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground before:absolute before:-inset-2.5 before:content-[''] hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
221
|
+
aria-label="Close panel"
|
|
222
|
+
onclick={handleClose}
|
|
223
|
+
>
|
|
224
|
+
<svg
|
|
225
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
226
|
+
width="14"
|
|
227
|
+
height="14"
|
|
228
|
+
viewBox="0 0 24 24"
|
|
229
|
+
fill="none"
|
|
230
|
+
stroke="currentColor"
|
|
231
|
+
stroke-width="2"
|
|
232
|
+
stroke-linecap="round"
|
|
233
|
+
stroke-linejoin="round"
|
|
234
|
+
>
|
|
235
|
+
<path d="M18 6 6 18" />
|
|
236
|
+
<path d="m6 6 12 12" />
|
|
237
|
+
</svg>
|
|
238
|
+
</button>
|
|
239
|
+
{/if}
|
|
240
|
+
</div>
|
|
@@ -2,10 +2,19 @@ import type { PanelEdge } from '@classic-homes/data-panel-core';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
/** Tab label/title */
|
|
4
4
|
title?: string;
|
|
5
|
+
/** Secondary line under the title */
|
|
6
|
+
subtitle?: string;
|
|
5
7
|
/** Current edge position */
|
|
6
8
|
edge: PanelEdge;
|
|
7
|
-
/**
|
|
9
|
+
/** Expand the panel (tap/click/keyboard on the chip body) */
|
|
8
10
|
onclick?: () => void;
|
|
11
|
+
/** Close the panel (chip close button) */
|
|
12
|
+
onClose?: () => void;
|
|
13
|
+
disableClose?: boolean;
|
|
14
|
+
/** Offset (px) of the tab along its edge from the centered position */
|
|
15
|
+
offset?: number;
|
|
16
|
+
/** Called with the new offset after the tab is dragged along its edge */
|
|
17
|
+
onOffsetChange?: (offset: number) => void;
|
|
9
18
|
/** Additional class */
|
|
10
19
|
class?: string;
|
|
11
20
|
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { Snippet } from 'svelte';
|
|
16
16
|
import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
|
|
17
17
|
import { cn } from '../../utils.js';
|
|
18
|
+
import { filterNavItems } from '../../utils/nav-access.js';
|
|
18
19
|
import { sidebarStore } from '../../stores/sidebar.svelte.js';
|
|
19
20
|
import LogoMain from '../LogoMain.svelte';
|
|
20
21
|
import QuickLinks from './QuickLinks.svelte';
|
|
@@ -25,8 +26,17 @@
|
|
|
25
26
|
interface Props {
|
|
26
27
|
/** Navigation sections */
|
|
27
28
|
navigation: NavSection[];
|
|
28
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Every role the current user holds. Items are matched against the whole
|
|
31
|
+
* set, so a user with several roles sees the union of what they allow.
|
|
32
|
+
*/
|
|
29
33
|
userRoles?: string[];
|
|
34
|
+
/** Every permission the current user holds, for permission-gated items. */
|
|
35
|
+
userPermissions?: string[];
|
|
36
|
+
/** Require ALL of an item's `roles` rather than any (default: any). */
|
|
37
|
+
requireAllRoles?: boolean;
|
|
38
|
+
/** Require ALL of an item's `permissions` rather than any (default: any). */
|
|
39
|
+
requireAllPermissions?: boolean;
|
|
30
40
|
/** Whether sidebar is collapsed (desktop) */
|
|
31
41
|
collapsed?: boolean;
|
|
32
42
|
/** Whether currently on mobile */
|
|
@@ -80,6 +90,9 @@
|
|
|
80
90
|
let {
|
|
81
91
|
navigation,
|
|
82
92
|
userRoles,
|
|
93
|
+
userPermissions,
|
|
94
|
+
requireAllRoles = false,
|
|
95
|
+
requireAllPermissions = false,
|
|
83
96
|
collapsed = false,
|
|
84
97
|
isMobile = false,
|
|
85
98
|
mobileOpen = false,
|
|
@@ -106,27 +119,19 @@
|
|
|
106
119
|
class: className,
|
|
107
120
|
}: Props = $props();
|
|
108
121
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Recursively filter items based on roles
|
|
117
|
-
function filterItems(items: NavItem[]): NavItem[] {
|
|
118
|
-
return items.filter(isItemVisible).map((item) => ({
|
|
119
|
-
...item,
|
|
120
|
-
children: item.children ? filterItems(item.children) : undefined,
|
|
121
|
-
}));
|
|
122
|
-
}
|
|
122
|
+
const accessContext = $derived({
|
|
123
|
+
userRoles,
|
|
124
|
+
userPermissions,
|
|
125
|
+
requireAllRoles,
|
|
126
|
+
requireAllPermissions,
|
|
127
|
+
});
|
|
123
128
|
|
|
124
|
-
// Filter navigation
|
|
129
|
+
// Filter navigation by what the user holds, dropping sections left empty.
|
|
125
130
|
const roleFilteredNavigation = $derived(
|
|
126
131
|
navigation
|
|
127
132
|
.map((section) => ({
|
|
128
133
|
...section,
|
|
129
|
-
items:
|
|
134
|
+
items: filterNavItems(section.items, accessContext),
|
|
130
135
|
}))
|
|
131
136
|
.filter((section) => section.items.length > 0)
|
|
132
137
|
);
|
|
@@ -16,8 +16,17 @@ import type { NavSection, NavItem, QuickLink } from '../../types/layout.js';
|
|
|
16
16
|
interface Props {
|
|
17
17
|
/** Navigation sections */
|
|
18
18
|
navigation: NavSection[];
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Every role the current user holds. Items are matched against the whole
|
|
21
|
+
* set, so a user with several roles sees the union of what they allow.
|
|
22
|
+
*/
|
|
20
23
|
userRoles?: string[];
|
|
24
|
+
/** Every permission the current user holds, for permission-gated items. */
|
|
25
|
+
userPermissions?: string[];
|
|
26
|
+
/** Require ALL of an item's `roles` rather than any (default: any). */
|
|
27
|
+
requireAllRoles?: boolean;
|
|
28
|
+
/** Require ALL of an item's `permissions` rather than any (default: any). */
|
|
29
|
+
requireAllPermissions?: boolean;
|
|
21
30
|
/** Whether sidebar is collapsed (desktop) */
|
|
22
31
|
collapsed?: boolean;
|
|
23
32
|
/** Whether currently on mobile */
|
|
@@ -243,7 +243,10 @@ export declare const emailChangeSchema: z.ZodObject<{
|
|
|
243
243
|
}>;
|
|
244
244
|
export type EmailChangeFormData = z.infer<typeof emailChangeSchema>;
|
|
245
245
|
/**
|
|
246
|
-
* User schema
|
|
246
|
+
* User schema — for validating a user payload from an API.
|
|
247
|
+
*
|
|
248
|
+
* Distinct from the layout `User` in `types/layout.ts`, which is a display
|
|
249
|
+
* view-model for the sidebar and avatar. This one describes data on the wire.
|
|
247
250
|
*/
|
|
248
251
|
export declare const userSchema: z.ZodObject<{
|
|
249
252
|
id: z.ZodString;
|
|
@@ -262,9 +265,9 @@ export declare const userSchema: z.ZodObject<{
|
|
|
262
265
|
email: string;
|
|
263
266
|
username?: string | undefined;
|
|
264
267
|
roles?: string[] | undefined;
|
|
268
|
+
permissions?: string[] | undefined;
|
|
265
269
|
fullName?: string | undefined;
|
|
266
270
|
avatarUrl?: string | null | undefined;
|
|
267
|
-
permissions?: string[] | undefined;
|
|
268
271
|
mfaEnabled?: boolean | undefined;
|
|
269
272
|
emailVerified?: boolean | undefined;
|
|
270
273
|
createdAt?: string | undefined;
|
|
@@ -274,15 +277,22 @@ export declare const userSchema: z.ZodObject<{
|
|
|
274
277
|
email: string;
|
|
275
278
|
username?: string | undefined;
|
|
276
279
|
roles?: string[] | undefined;
|
|
280
|
+
permissions?: string[] | undefined;
|
|
277
281
|
fullName?: string | undefined;
|
|
278
282
|
avatarUrl?: string | null | undefined;
|
|
279
|
-
permissions?: string[] | undefined;
|
|
280
283
|
mfaEnabled?: boolean | undefined;
|
|
281
284
|
emailVerified?: boolean | undefined;
|
|
282
285
|
createdAt?: string | undefined;
|
|
283
286
|
updatedAt?: string | undefined;
|
|
284
287
|
}>;
|
|
285
|
-
|
|
288
|
+
/**
|
|
289
|
+
* A user payload as validated by {@link userSchema}.
|
|
290
|
+
*
|
|
291
|
+
* Named `AuthUser` to keep the package's `User` unambiguous — that name belongs
|
|
292
|
+
* to the layout view-model. Previously exported as `User`, where it collided
|
|
293
|
+
* with the layout type and lost, making it unreachable from the package root.
|
|
294
|
+
*/
|
|
295
|
+
export type AuthUser = z.infer<typeof userSchema>;
|
|
286
296
|
/**
|
|
287
297
|
* Auth tokens schema
|
|
288
298
|
*/
|
|
@@ -324,9 +334,9 @@ export declare const loginResponseSchema: z.ZodObject<{
|
|
|
324
334
|
email: string;
|
|
325
335
|
username?: string | undefined;
|
|
326
336
|
roles?: string[] | undefined;
|
|
337
|
+
permissions?: string[] | undefined;
|
|
327
338
|
fullName?: string | undefined;
|
|
328
339
|
avatarUrl?: string | null | undefined;
|
|
329
|
-
permissions?: string[] | undefined;
|
|
330
340
|
mfaEnabled?: boolean | undefined;
|
|
331
341
|
emailVerified?: boolean | undefined;
|
|
332
342
|
createdAt?: string | undefined;
|
|
@@ -336,9 +346,9 @@ export declare const loginResponseSchema: z.ZodObject<{
|
|
|
336
346
|
email: string;
|
|
337
347
|
username?: string | undefined;
|
|
338
348
|
roles?: string[] | undefined;
|
|
349
|
+
permissions?: string[] | undefined;
|
|
339
350
|
fullName?: string | undefined;
|
|
340
351
|
avatarUrl?: string | null | undefined;
|
|
341
|
-
permissions?: string[] | undefined;
|
|
342
352
|
mfaEnabled?: boolean | undefined;
|
|
343
353
|
emailVerified?: boolean | undefined;
|
|
344
354
|
createdAt?: string | undefined;
|
|
@@ -368,9 +378,9 @@ export declare const loginResponseSchema: z.ZodObject<{
|
|
|
368
378
|
email: string;
|
|
369
379
|
username?: string | undefined;
|
|
370
380
|
roles?: string[] | undefined;
|
|
381
|
+
permissions?: string[] | undefined;
|
|
371
382
|
fullName?: string | undefined;
|
|
372
383
|
avatarUrl?: string | null | undefined;
|
|
373
|
-
permissions?: string[] | undefined;
|
|
374
384
|
mfaEnabled?: boolean | undefined;
|
|
375
385
|
emailVerified?: boolean | undefined;
|
|
376
386
|
createdAt?: string | undefined;
|
|
@@ -390,9 +400,9 @@ export declare const loginResponseSchema: z.ZodObject<{
|
|
|
390
400
|
email: string;
|
|
391
401
|
username?: string | undefined;
|
|
392
402
|
roles?: string[] | undefined;
|
|
403
|
+
permissions?: string[] | undefined;
|
|
393
404
|
fullName?: string | undefined;
|
|
394
405
|
avatarUrl?: string | null | undefined;
|
|
395
|
-
permissions?: string[] | undefined;
|
|
396
406
|
mfaEnabled?: boolean | undefined;
|
|
397
407
|
emailVerified?: boolean | undefined;
|
|
398
408
|
createdAt?: string | undefined;
|
package/dist/lib/schemas/auth.js
CHANGED
|
@@ -181,7 +181,10 @@ export const emailChangeSchema = z.object({
|
|
|
181
181
|
// API Response Schemas (for validating API responses)
|
|
182
182
|
// ============================================================================
|
|
183
183
|
/**
|
|
184
|
-
* User schema
|
|
184
|
+
* User schema — for validating a user payload from an API.
|
|
185
|
+
*
|
|
186
|
+
* Distinct from the layout `User` in `types/layout.ts`, which is a display
|
|
187
|
+
* view-model for the sidebar and avatar. This one describes data on the wire.
|
|
185
188
|
*/
|
|
186
189
|
export const userSchema = z.object({
|
|
187
190
|
id: z.string(),
|
|
@@ -9,7 +9,7 @@ export { emailSchema, passwordSchema, loginPasswordSchema, usernameSchema, fullN
|
|
|
9
9
|
export { loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema, changePasswordSchema, mfaVerifySchema, mfaSetupSchema, profileUpdateSchema, emailChangeSchema, } from './auth.js';
|
|
10
10
|
export type { LoginFormData, RegisterFormData, ForgotPasswordFormData, ResetPasswordFormData, ChangePasswordFormData, MfaVerifyFormData, MfaSetupFormData, ProfileUpdateFormData, EmailChangeFormData, } from './auth.js';
|
|
11
11
|
export { userSchema, authTokensSchema, loginResponseSchema } from './auth.js';
|
|
12
|
-
export type {
|
|
12
|
+
export type { AuthUser, AuthTokens, LoginResponse } from './auth.js';
|
|
13
13
|
export { nonEmptyString, trimmedString, optionalString, urlSchema, optionalUrlSchema, slugSchema, } from './common.js';
|
|
14
14
|
export { positiveInt, nonNegativeInt, percentageSchema, priceSchema, numericString, } from './common.js';
|
|
15
15
|
export { isoDateSchema, dateStringSchema, timeStringSchema, futureDateSchema, pastDateSchema, } from './common.js';
|
|
@@ -22,6 +22,7 @@ export declare function createDataPanelStore(options?: DataPanelStoreOptions): {
|
|
|
22
22
|
readonly detachedSize: Size;
|
|
23
23
|
readonly pinnedSize: number;
|
|
24
24
|
readonly cardSnapIndex: number;
|
|
25
|
+
readonly tabOffset: number;
|
|
25
26
|
setMode: (newMode: PanelMode) => void;
|
|
26
27
|
setVariant: (newVariant: PanelVariant) => void;
|
|
27
28
|
setEdge: (newEdge: PanelEdge) => void;
|
|
@@ -31,6 +32,7 @@ export declare function createDataPanelStore(options?: DataPanelStoreOptions): {
|
|
|
31
32
|
setDetachedSize: (size: Size) => void;
|
|
32
33
|
setPinnedSize: (size: number) => void;
|
|
33
34
|
setCardSnapIndex: (index: number) => void;
|
|
35
|
+
setTabOffset: (offset: number) => void;
|
|
34
36
|
reset: () => void;
|
|
35
37
|
getState: () => DataPanelState;
|
|
36
38
|
};
|
|
@@ -22,6 +22,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
22
22
|
let detachedSize = $state(merged.detachedSize);
|
|
23
23
|
let pinnedSize = $state(merged.pinnedSize);
|
|
24
24
|
let cardSnapIndex = $state(merged.cardSnapIndex);
|
|
25
|
+
let tabOffset = $state(merged.tabOffset);
|
|
25
26
|
// Resolved constraints
|
|
26
27
|
const resolvedConstraints = { ...DEFAULT_CONSTRAINTS, ...constraints };
|
|
27
28
|
// Debounced save for persistence
|
|
@@ -42,6 +43,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
42
43
|
detachedSize,
|
|
43
44
|
pinnedSize,
|
|
44
45
|
cardSnapIndex,
|
|
46
|
+
tabOffset,
|
|
45
47
|
});
|
|
46
48
|
saveTimeout = null;
|
|
47
49
|
}, 300);
|
|
@@ -116,6 +118,12 @@ export function createDataPanelStore(options = {}) {
|
|
|
116
118
|
cardSnapIndex = index;
|
|
117
119
|
debouncedSave();
|
|
118
120
|
}
|
|
121
|
+
function setTabOffset(offset) {
|
|
122
|
+
if (tabOffset === offset)
|
|
123
|
+
return;
|
|
124
|
+
tabOffset = offset;
|
|
125
|
+
debouncedSave();
|
|
126
|
+
}
|
|
119
127
|
function reset() {
|
|
120
128
|
mode = DEFAULT_PANEL_STATE.mode;
|
|
121
129
|
variant = DEFAULT_PANEL_STATE.variant;
|
|
@@ -125,6 +133,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
125
133
|
detachedSize = DEFAULT_PANEL_STATE.detachedSize;
|
|
126
134
|
pinnedSize = DEFAULT_PANEL_STATE.pinnedSize;
|
|
127
135
|
cardSnapIndex = DEFAULT_PANEL_STATE.cardSnapIndex;
|
|
136
|
+
tabOffset = DEFAULT_PANEL_STATE.tabOffset;
|
|
128
137
|
debouncedSave();
|
|
129
138
|
}
|
|
130
139
|
// Getters for current state (readonly)
|
|
@@ -138,6 +147,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
138
147
|
detachedSize,
|
|
139
148
|
pinnedSize,
|
|
140
149
|
cardSnapIndex,
|
|
150
|
+
tabOffset,
|
|
141
151
|
};
|
|
142
152
|
}
|
|
143
153
|
return {
|
|
@@ -166,6 +176,9 @@ export function createDataPanelStore(options = {}) {
|
|
|
166
176
|
get cardSnapIndex() {
|
|
167
177
|
return cardSnapIndex;
|
|
168
178
|
},
|
|
179
|
+
get tabOffset() {
|
|
180
|
+
return tabOffset;
|
|
181
|
+
},
|
|
169
182
|
// Actions
|
|
170
183
|
setMode,
|
|
171
184
|
setVariant,
|
|
@@ -176,6 +189,7 @@ export function createDataPanelStore(options = {}) {
|
|
|
176
189
|
setDetachedSize,
|
|
177
190
|
setPinnedSize,
|
|
178
191
|
setCardSnapIndex,
|
|
192
|
+
setTabOffset,
|
|
179
193
|
reset,
|
|
180
194
|
getState,
|
|
181
195
|
};
|
|
@@ -122,8 +122,13 @@ declare class SidebarStore {
|
|
|
122
122
|
getPinnedIds(): string[];
|
|
123
123
|
/**
|
|
124
124
|
* Compute which sections should be initially expanded based on user roles.
|
|
125
|
-
*
|
|
126
|
-
*
|
|
125
|
+
*
|
|
126
|
+
* A section listing `expandForRoles` expands for a user holding ANY of them.
|
|
127
|
+
* Sections without it fall back to the default: expanded unless explicitly
|
|
128
|
+
* collapsed.
|
|
129
|
+
*
|
|
130
|
+
* This is a UX affordance, not access control — it decides what starts open,
|
|
131
|
+
* not what is visible. Filtering is `filterNavItems`.
|
|
127
132
|
*/
|
|
128
133
|
computeInitialExpansions(sections: NavSection[], userRoles?: string[]): string[];
|
|
129
134
|
/**
|