@classic-homes/theme-svelte 1.1.2 → 1.2.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__/a11y/Tabs.a11y.test.js +22 -3
- package/dist/lib/components/Button.svelte +1 -1
- package/dist/lib/components/DataPanel.svelte +16 -0
- package/dist/lib/components/DataPanel.svelte.d.ts +11 -0
- package/dist/lib/components/DataPanelTab.svelte +53 -36
- package/dist/lib/components/DataPanelTab.svelte.d.ts +12 -0
- package/dist/lib/components/LogoMain.svelte +2 -2
- package/dist/lib/components/Switch.svelte +13 -0
- package/dist/lib/components/Tabs.svelte +11 -2
- package/dist/lib/components/Tabs.svelte.d.ts +10 -1
- package/dist/lib/components/layout/AuthLayout.svelte +3 -3
- package/dist/lib/components/layout/ErrorLayout.svelte +34 -9
- package/dist/lib/components/layout/ErrorLayout.svelte.d.ts +8 -0
- package/dist/lib/components/layout/Footer.svelte +6 -3
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import { render, screen } from '@testing-library/svelte';
|
|
3
3
|
import userEvent from '@testing-library/user-event';
|
|
4
4
|
import { axe } from 'vitest-axe';
|
|
5
|
+
import { createRawSnippet } from 'svelte';
|
|
5
6
|
import Tabs from '../../lib/components/Tabs.svelte';
|
|
6
7
|
describe('Tabs accessibility', () => {
|
|
7
8
|
const defaultTabs = [
|
|
@@ -85,13 +86,31 @@ describe('Tabs accessibility', () => {
|
|
|
85
86
|
await user.click(tabs[1]);
|
|
86
87
|
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
|
|
87
88
|
});
|
|
88
|
-
it('should
|
|
89
|
+
it('should omit aria-controls when no panel exists', () => {
|
|
90
|
+
// Without children there is no panel in the DOM; an aria-controls pointing
|
|
91
|
+
// at a missing element is an ARIA validity failure (axe: aria-valid-attr-value)
|
|
89
92
|
render(Tabs, {
|
|
90
93
|
props: { tabs: defaultTabs, label: 'Test tabs' },
|
|
91
94
|
});
|
|
95
|
+
for (const tab of screen.getAllByRole('tab')) {
|
|
96
|
+
expect(tab).not.toHaveAttribute('aria-controls');
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
it('should have aria-controls referencing a real panel when children exist', () => {
|
|
100
|
+
const children = createRawSnippet(() => ({
|
|
101
|
+
render: () => '<div id="test-tabs-panel-tab1" role="tabpanel">Panel 1</div>',
|
|
102
|
+
}));
|
|
103
|
+
render(Tabs, {
|
|
104
|
+
props: { tabs: defaultTabs, value: 'tab1', id: 'test-tabs', label: 'Test tabs', children },
|
|
105
|
+
});
|
|
92
106
|
const tabs = screen.getAllByRole('tab');
|
|
93
|
-
// Selected tab should have aria-controls
|
|
94
107
|
const selectedTab = tabs.find((tab) => tab.getAttribute('aria-selected') === 'true');
|
|
95
|
-
expect(selectedTab).toHaveAttribute('aria-controls');
|
|
108
|
+
expect(selectedTab).toHaveAttribute('aria-controls', 'test-tabs-panel-tab1');
|
|
109
|
+
// The reference must resolve to an element that exists
|
|
110
|
+
expect(document.getElementById('test-tabs-panel-tab1')).not.toBeNull();
|
|
111
|
+
// Unselected tabs have no panel mounted, so no aria-controls
|
|
112
|
+
for (const tab of tabs.filter((t) => t !== selectedTab)) {
|
|
113
|
+
expect(tab).not.toHaveAttribute('aria-controls');
|
|
114
|
+
}
|
|
96
115
|
});
|
|
97
116
|
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
5
5
|
|
|
6
6
|
const buttonVariants = tv({
|
|
7
|
-
base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
7
|
+
base: 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50',
|
|
8
8
|
variants: {
|
|
9
9
|
variant: {
|
|
10
10
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary-hover',
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
DEFAULT_PANEL_STATE,
|
|
18
18
|
DEFAULT_CONSTRAINTS,
|
|
19
19
|
DEFAULT_CARD_CONFIG,
|
|
20
|
+
DEFAULT_TAB_WIDTH,
|
|
20
21
|
CARD_HEADER_HEIGHT,
|
|
21
22
|
CARD_EXPANDED_RATIO,
|
|
22
23
|
CARD_MIN_WIDTH,
|
|
@@ -84,6 +85,17 @@
|
|
|
84
85
|
constraints?: PanelConstraints;
|
|
85
86
|
/** Card mode configuration */
|
|
86
87
|
cardConfig?: CardConfig;
|
|
88
|
+
/**
|
|
89
|
+
* Length (px) of the collapsed tab along its edge. Fixed rather than
|
|
90
|
+
* content-sized; raise it for longer titles. Defaults to 220.
|
|
91
|
+
*/
|
|
92
|
+
tabWidth?: number;
|
|
93
|
+
/**
|
|
94
|
+
* Distance (px) of the collapsed tab from its pinned edge. Use it to clear
|
|
95
|
+
* fixed app chrome — a sticky header, a toolbar — that would otherwise
|
|
96
|
+
* cover the tab. Defaults to 0.
|
|
97
|
+
*/
|
|
98
|
+
tabInset?: number;
|
|
87
99
|
/** Disable close button */
|
|
88
100
|
disableClose?: boolean;
|
|
89
101
|
/** Disable resize functionality */
|
|
@@ -127,6 +139,8 @@
|
|
|
127
139
|
actions = [],
|
|
128
140
|
constraints = {},
|
|
129
141
|
cardConfig = {},
|
|
142
|
+
tabWidth = DEFAULT_TAB_WIDTH,
|
|
143
|
+
tabInset = 0,
|
|
130
144
|
disableClose = false,
|
|
131
145
|
disableResize = false,
|
|
132
146
|
disableDrag = false,
|
|
@@ -1153,6 +1167,8 @@
|
|
|
1153
1167
|
{subtitle}
|
|
1154
1168
|
{edge}
|
|
1155
1169
|
{disableClose}
|
|
1170
|
+
{tabWidth}
|
|
1171
|
+
{tabInset}
|
|
1156
1172
|
onclick={handleTabClick}
|
|
1157
1173
|
onClose={handleClose}
|
|
1158
1174
|
offset={internalTabOffset}
|
|
@@ -33,6 +33,17 @@ interface Props {
|
|
|
33
33
|
constraints?: PanelConstraints;
|
|
34
34
|
/** Card mode configuration */
|
|
35
35
|
cardConfig?: CardConfig;
|
|
36
|
+
/**
|
|
37
|
+
* Length (px) of the collapsed tab along its edge. Fixed rather than
|
|
38
|
+
* content-sized; raise it for longer titles. Defaults to 220.
|
|
39
|
+
*/
|
|
40
|
+
tabWidth?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Distance (px) of the collapsed tab from its pinned edge. Use it to clear
|
|
43
|
+
* fixed app chrome — a sticky header, a toolbar — that would otherwise
|
|
44
|
+
* cover the tab. Defaults to 0.
|
|
45
|
+
*/
|
|
46
|
+
tabInset?: number;
|
|
36
47
|
/** Disable close button */
|
|
37
48
|
disableClose?: boolean;
|
|
38
49
|
/** Disable resize functionality */
|
|
@@ -1,7 +1,13 @@
|
|
|
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 {
|
|
4
|
+
import {
|
|
5
|
+
constrainTabOffset,
|
|
6
|
+
getTabTransform,
|
|
7
|
+
getTabBounds,
|
|
8
|
+
TAB_THICKNESS,
|
|
9
|
+
DEFAULT_TAB_WIDTH,
|
|
10
|
+
} from '@classic-homes/data-panel-core';
|
|
5
11
|
|
|
6
12
|
interface Props {
|
|
7
13
|
/** Tab label/title */
|
|
@@ -19,6 +25,18 @@
|
|
|
19
25
|
offset?: number;
|
|
20
26
|
/** Called with the new offset after the tab is dragged along its edge */
|
|
21
27
|
onOffsetChange?: (offset: number) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Length (px) of the tab along its edge. Fixed rather than content-sized so
|
|
30
|
+
* the tab's geometry is pure pixel math in every browser; raise it for
|
|
31
|
+
* longer labels. Defaults to 220.
|
|
32
|
+
*/
|
|
33
|
+
tabWidth?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Distance (px) from the pinned edge. Use it to clear fixed app chrome — a
|
|
36
|
+
* sticky header, a toolbar — that would otherwise cover the tab. Defaults
|
|
37
|
+
* to 0 (flush with the edge).
|
|
38
|
+
*/
|
|
39
|
+
tabInset?: number;
|
|
22
40
|
/** Additional class */
|
|
23
41
|
class?: string;
|
|
24
42
|
}
|
|
@@ -32,6 +50,8 @@
|
|
|
32
50
|
disableClose = false,
|
|
33
51
|
offset = 0,
|
|
34
52
|
onOffsetChange,
|
|
53
|
+
tabWidth = DEFAULT_TAB_WIDTH,
|
|
54
|
+
tabInset = 0,
|
|
35
55
|
class: className,
|
|
36
56
|
}: Props = $props();
|
|
37
57
|
|
|
@@ -39,35 +59,32 @@
|
|
|
39
59
|
|
|
40
60
|
const positionClasses = $derived<string>(
|
|
41
61
|
{
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
bottom: 'bottom-0 left-1/2 rounded-t-lg border-b-0',
|
|
62
|
+
// Border classes are in the chip's pre-rotation frame: its bottom rotates
|
|
63
|
+
// to the attached (edge) side and its top corners to the viewport-facing
|
|
64
|
+
// corners, so both side edges drop the bottom border and round the top.
|
|
65
|
+
// The anchored axis is set inline (see insetStyle) so tabInset can move it.
|
|
66
|
+
left: 'top-1/2 rounded-t-lg border-b-0',
|
|
67
|
+
right: 'top-1/2 rounded-t-lg border-b-0',
|
|
68
|
+
top: 'left-1/2 rounded-b-lg border-t-0',
|
|
69
|
+
bottom: 'left-1/2 rounded-t-lg border-b-0',
|
|
51
70
|
}[edge]
|
|
52
71
|
);
|
|
53
72
|
|
|
73
|
+
// Distance from the anchored edge. Pushes the tab clear of fixed app chrome
|
|
74
|
+
// (header, toolbar) that would otherwise cover it. The transform is relative
|
|
75
|
+
// to the tab's own box, so shifting the anchor leaves the geometry intact.
|
|
76
|
+
const insetStyle = $derived<string>(`${edge}: ${tabInset}px;`);
|
|
77
|
+
|
|
54
78
|
const draggable = $derived(!!onOffsetChange);
|
|
55
79
|
|
|
56
|
-
let btn = $state<HTMLDivElement | null>(null);
|
|
57
80
|
let dragOffset = $state<number | null>(null);
|
|
58
81
|
const effectiveOffset = $derived(dragOffset ?? offset);
|
|
59
82
|
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
const
|
|
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
|
-
);
|
|
83
|
+
// Geometry is entirely pixel-based (see getTabTransform): the tab carries an
|
|
84
|
+
// explicit width so nothing has to resolve a percentage against a
|
|
85
|
+
// content-sized box while a rotation is composed onto it.
|
|
86
|
+
const tabTransform = $derived<string>(getTabTransform(edge, tabWidth, effectiveOffset));
|
|
87
|
+
const tabBounds = $derived(getTabBounds(edge, tabWidth));
|
|
71
88
|
|
|
72
89
|
// Non-reactive drag bookkeeping.
|
|
73
90
|
let drag = { startX: 0, startY: 0, startOffset: 0, moved: false, pointerId: -1 };
|
|
@@ -93,12 +110,10 @@
|
|
|
93
110
|
if (drag.pointerId !== e.pointerId || !el.hasPointerCapture(e.pointerId)) return;
|
|
94
111
|
const delta = isVertical ? e.clientY - drag.startY : e.clientX - drag.startX;
|
|
95
112
|
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
113
|
dragOffset = constrainTabOffset(
|
|
99
114
|
drag.startOffset + delta,
|
|
100
115
|
edge,
|
|
101
|
-
|
|
116
|
+
tabBounds,
|
|
102
117
|
window.innerWidth,
|
|
103
118
|
window.innerHeight
|
|
104
119
|
);
|
|
@@ -148,27 +163,24 @@
|
|
|
148
163
|
|
|
149
164
|
function clampOffset() {
|
|
150
165
|
if (!onOffsetChange) return;
|
|
151
|
-
const rect = btn?.getBoundingClientRect();
|
|
152
|
-
if (!rect) return;
|
|
153
166
|
const clamped = constrainTabOffset(
|
|
154
167
|
offset,
|
|
155
168
|
edge,
|
|
156
|
-
|
|
169
|
+
tabBounds,
|
|
157
170
|
window.innerWidth,
|
|
158
171
|
window.innerHeight
|
|
159
172
|
);
|
|
160
173
|
if (clamped !== offset) onOffsetChange(clamped);
|
|
161
174
|
}
|
|
162
175
|
|
|
163
|
-
// Re-clamp the persisted offset to the current
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
// land the tab off-screen. Runs on mount and whenever edge/offset/size change.
|
|
176
|
+
// Re-clamp the persisted offset to the current geometry: an offset saved under
|
|
177
|
+
// a different edge, tabWidth, or viewport can land the tab off-screen. The
|
|
178
|
+
// tab's bounds are known from its props, so this never measures the DOM.
|
|
167
179
|
$effect(() => {
|
|
168
180
|
// Track the reactive inputs so this re-runs when they change.
|
|
169
181
|
void edge;
|
|
170
182
|
void offset;
|
|
171
|
-
void
|
|
183
|
+
void tabBounds;
|
|
172
184
|
if (dragOffset !== null) return;
|
|
173
185
|
clampOffset();
|
|
174
186
|
});
|
|
@@ -187,14 +199,19 @@
|
|
|
187
199
|
container carries the position/transform and the pointer-drag handlers. -->
|
|
188
200
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
189
201
|
<div
|
|
190
|
-
bind:this={btn}
|
|
191
202
|
class={cn(
|
|
192
|
-
|
|
203
|
+
// Same layer as the expanded panel: the tab is the only way to bring the
|
|
204
|
+
// panel back, so it must never sit under chrome the panel covers.
|
|
205
|
+
'fixed z-50 flex 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
206
|
draggable && 'cursor-grab touch-none active:cursor-grabbing',
|
|
194
207
|
positionClasses,
|
|
195
208
|
className
|
|
196
209
|
)}
|
|
197
|
-
style={
|
|
210
|
+
style={`${insetStyle} width: ${tabWidth}px; ${
|
|
211
|
+
/* Side tabs rotate, so they need a fixed thickness for a deterministic edge
|
|
212
|
+
hug; top/bottom stay content-height. */
|
|
213
|
+
isVertical ? `height: ${TAB_THICKNESS}px; ` : ''
|
|
214
|
+
}transform-origin: 0 0; transform: ${tabTransform};`}
|
|
198
215
|
onpointerdown={handlePointerDown}
|
|
199
216
|
onpointermove={draggable ? handlePointerMove : undefined}
|
|
200
217
|
onpointerup={draggable ? handlePointerUp : undefined}
|
|
@@ -15,6 +15,18 @@ interface Props {
|
|
|
15
15
|
offset?: number;
|
|
16
16
|
/** Called with the new offset after the tab is dragged along its edge */
|
|
17
17
|
onOffsetChange?: (offset: number) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Length (px) of the tab along its edge. Fixed rather than content-sized so
|
|
20
|
+
* the tab's geometry is pure pixel math in every browser; raise it for
|
|
21
|
+
* longer labels. Defaults to 220.
|
|
22
|
+
*/
|
|
23
|
+
tabWidth?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Distance (px) from the pinned edge. Use it to clear fixed app chrome — a
|
|
26
|
+
* sticky header, a toolbar — that would otherwise cover the tab. Defaults
|
|
27
|
+
* to 0 (flush with the edge).
|
|
28
|
+
*/
|
|
29
|
+
tabInset?: number;
|
|
18
30
|
/** Additional class */
|
|
19
31
|
class?: string;
|
|
20
32
|
}
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
dark: {
|
|
55
55
|
logo: '#C50F22', // Brand red
|
|
56
56
|
title: '#000000', // Black for light backgrounds
|
|
57
|
-
subtitle: '#
|
|
57
|
+
subtitle: '#666666', // Gray - #7a7a7a was 4.29:1 on white (WCAG 1.4.3 fail)
|
|
58
58
|
},
|
|
59
59
|
light: {
|
|
60
60
|
logo: '#ffffff',
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
'mono-dark': {
|
|
65
65
|
logo: '#002e3f', // Navy
|
|
66
66
|
title: '#002e3f',
|
|
67
|
-
subtitle: '#
|
|
67
|
+
subtitle: '#666666', // see dark variant note
|
|
68
68
|
},
|
|
69
69
|
'mono-light': {
|
|
70
70
|
logo: '#ffffff',
|
|
@@ -31,6 +31,19 @@
|
|
|
31
31
|
...restProps
|
|
32
32
|
}: Props = $props();
|
|
33
33
|
|
|
34
|
+
// Dev-mode a11y warning, matching Input/Select/Button: a Switch with no
|
|
35
|
+
// aria-label, aria-labelledby, or id (for an external <label for>) announces
|
|
36
|
+
// as a bare "switch" with no name.
|
|
37
|
+
if (typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production') {
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (!ariaLabel && !ariaLabelledby && !id) {
|
|
40
|
+
console.warn(
|
|
41
|
+
'Switch: provide aria-label, aria-labelledby, or an id paired with a <label for> so the switch has an accessible name.'
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
// Handle checked state changes from user interaction
|
|
35
48
|
// The primitive handles the actual state management via bind:checked
|
|
36
49
|
function handleCheckedChange(newChecked: boolean) {
|
|
@@ -19,7 +19,16 @@
|
|
|
19
19
|
label: string;
|
|
20
20
|
/** Additional classes for container */
|
|
21
21
|
class?: string;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Tab panel content - compose with `TabPanel` so each panel gets the
|
|
24
|
+
* matching `role="tabpanel"` / id / aria-labelledby wiring.
|
|
25
|
+
*
|
|
26
|
+
* aria-controls is only emitted while a panel actually exists in the DOM
|
|
27
|
+
* (children provided + tab active): a reference to a missing element is an
|
|
28
|
+
* ARIA validity failure (axe: aria-valid-attr-value). Consumers rendering
|
|
29
|
+
* panels OUTSIDE this component should give them
|
|
30
|
+
* `id="{id}-panel-{tabId}"` + `aria-labelledby="{id}-tab-{tabId}"`.
|
|
31
|
+
*/
|
|
23
32
|
children?: Snippet;
|
|
24
33
|
/** Callback when active tab changes */
|
|
25
34
|
onValueChange?: (value: string) => void;
|
|
@@ -111,7 +120,7 @@
|
|
|
111
120
|
role="tab"
|
|
112
121
|
type="button"
|
|
113
122
|
aria-selected={value === tab.id}
|
|
114
|
-
aria-controls={`${id}-panel-${tab.id}`}
|
|
123
|
+
aria-controls={children && value === tab.id ? `${id}-panel-${tab.id}` : undefined}
|
|
115
124
|
tabindex={value === tab.id ? 0 : -1}
|
|
116
125
|
disabled={tab.disabled}
|
|
117
126
|
class={cn(
|
|
@@ -15,7 +15,16 @@ interface Props {
|
|
|
15
15
|
label: string;
|
|
16
16
|
/** Additional classes for container */
|
|
17
17
|
class?: string;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Tab panel content - compose with `TabPanel` so each panel gets the
|
|
20
|
+
* matching `role="tabpanel"` / id / aria-labelledby wiring.
|
|
21
|
+
*
|
|
22
|
+
* aria-controls is only emitted while a panel actually exists in the DOM
|
|
23
|
+
* (children provided + tab active): a reference to a missing element is an
|
|
24
|
+
* ARIA validity failure (axe: aria-valid-attr-value). Consumers rendering
|
|
25
|
+
* panels OUTSIDE this component should give them
|
|
26
|
+
* `id="{id}-panel-{tabId}"` + `aria-labelledby="{id}-tab-{tabId}"`.
|
|
27
|
+
*/
|
|
19
28
|
children?: Snippet;
|
|
20
29
|
/** Callback when active tab changes */
|
|
21
30
|
onValueChange?: (value: string) => void;
|
|
@@ -85,8 +85,8 @@
|
|
|
85
85
|
className
|
|
86
86
|
)}
|
|
87
87
|
>
|
|
88
|
-
<!-- Logo -->
|
|
89
|
-
<
|
|
88
|
+
<!-- Logo. <header> so it isn't content outside every landmark (axe: region) -->
|
|
89
|
+
<header class="mb-6 flex flex-col items-center">
|
|
90
90
|
{#if logo}
|
|
91
91
|
{@render logo()}
|
|
92
92
|
{:else}
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
environment={logoEnvironment}
|
|
99
99
|
/>
|
|
100
100
|
{/if}
|
|
101
|
-
</
|
|
101
|
+
</header>
|
|
102
102
|
|
|
103
103
|
<!-- Main Content Card -->
|
|
104
104
|
<main
|
|
@@ -52,6 +52,14 @@
|
|
|
52
52
|
footer?: Snippet;
|
|
53
53
|
/** Additional classes for the container */
|
|
54
54
|
class?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Whether this layout owns the whole page (default). Set false when
|
|
57
|
+
* rendering inside an app layout that already provides the page chrome -
|
|
58
|
+
* e.g. a SvelteKit +error.svelte inside a layout with its own
|
|
59
|
+
* <main id="main-content"> - so AppShell and the main landmark aren't
|
|
60
|
+
* duplicated (axe: landmark-no-duplicate-main / landmark-main-is-top-level).
|
|
61
|
+
*/
|
|
62
|
+
standalone?: boolean;
|
|
55
63
|
/** Main content (custom error content, replaces default display) */
|
|
56
64
|
children?: Snippet;
|
|
57
65
|
}
|
|
@@ -72,6 +80,7 @@
|
|
|
72
80
|
actions,
|
|
73
81
|
footer,
|
|
74
82
|
class: className,
|
|
83
|
+
standalone = true,
|
|
75
84
|
children,
|
|
76
85
|
}: Props = $props();
|
|
77
86
|
|
|
@@ -128,16 +137,19 @@
|
|
|
128
137
|
}
|
|
129
138
|
</script>
|
|
130
139
|
|
|
131
|
-
|
|
140
|
+
{#snippet layoutBody()}
|
|
132
141
|
<div
|
|
133
142
|
class={cn(
|
|
134
|
-
'flex
|
|
143
|
+
'flex flex-col items-center justify-center overflow-auto bg-content-bg px-4 py-8 sm:px-6 lg:px-8',
|
|
144
|
+
standalone ? 'h-screen' : 'min-h-[60vh]',
|
|
135
145
|
className
|
|
136
146
|
)}
|
|
137
147
|
>
|
|
138
|
-
<!-- Logo
|
|
148
|
+
<!-- Logo. <header> so the logo isn't content outside every landmark
|
|
149
|
+
(axe: region); nested under a host <main> it simply loses the banner
|
|
150
|
+
role, which is correct. -->
|
|
139
151
|
{#if showLogo}
|
|
140
|
-
<
|
|
152
|
+
<header class="mb-6">
|
|
141
153
|
{#if logo}
|
|
142
154
|
{@render logo()}
|
|
143
155
|
{:else}
|
|
@@ -145,11 +157,16 @@
|
|
|
145
157
|
<LogoMain variant="horizontal" color="dark" size="md" />
|
|
146
158
|
</a>
|
|
147
159
|
{/if}
|
|
148
|
-
</
|
|
160
|
+
</header>
|
|
149
161
|
{/if}
|
|
150
162
|
|
|
151
|
-
<!-- Main Content
|
|
152
|
-
|
|
163
|
+
<!-- Main Content. A second main landmark is invalid when the host layout
|
|
164
|
+
already renders one, so embedded mode uses a plain div. -->
|
|
165
|
+
<svelte:element
|
|
166
|
+
this={standalone ? 'main' : 'div'}
|
|
167
|
+
id={standalone ? 'main-content' : undefined}
|
|
168
|
+
class="w-full max-w-md text-center"
|
|
169
|
+
>
|
|
153
170
|
{#if children}
|
|
154
171
|
{@render children()}
|
|
155
172
|
{:else}
|
|
@@ -197,7 +214,7 @@
|
|
|
197
214
|
</div>
|
|
198
215
|
{/if}
|
|
199
216
|
{/if}
|
|
200
|
-
</
|
|
217
|
+
</svelte:element>
|
|
201
218
|
|
|
202
219
|
<!-- Footer -->
|
|
203
220
|
{#if footer}
|
|
@@ -206,4 +223,12 @@
|
|
|
206
223
|
</footer>
|
|
207
224
|
{/if}
|
|
208
225
|
</div>
|
|
209
|
-
|
|
226
|
+
{/snippet}
|
|
227
|
+
|
|
228
|
+
{#if standalone}
|
|
229
|
+
<AppShell>
|
|
230
|
+
{@render layoutBody()}
|
|
231
|
+
</AppShell>
|
|
232
|
+
{:else}
|
|
233
|
+
{@render layoutBody()}
|
|
234
|
+
{/if}
|
|
@@ -46,6 +46,14 @@ interface Props {
|
|
|
46
46
|
footer?: Snippet;
|
|
47
47
|
/** Additional classes for the container */
|
|
48
48
|
class?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Whether this layout owns the whole page (default). Set false when
|
|
51
|
+
* rendering inside an app layout that already provides the page chrome -
|
|
52
|
+
* e.g. a SvelteKit +error.svelte inside a layout with its own
|
|
53
|
+
* <main id="main-content"> - so AppShell and the main landmark aren't
|
|
54
|
+
* duplicated (axe: landmark-no-duplicate-main / landmark-main-is-top-level).
|
|
55
|
+
*/
|
|
56
|
+
standalone?: boolean;
|
|
49
57
|
/** Main content (custom error content, replaces default display) */
|
|
50
58
|
children?: Snippet;
|
|
51
59
|
}
|
|
@@ -65,9 +65,11 @@
|
|
|
65
65
|
{#each links as section}
|
|
66
66
|
<div>
|
|
67
67
|
{#if section.title}
|
|
68
|
-
|
|
68
|
+
<!-- h2, not h3: most pages reach the footer with only an h1 in
|
|
69
|
+
scope, so h3 skipped a level (axe: heading-order) -->
|
|
70
|
+
<h2 class={cn('text-lg font-bold mb-4', isDark ? 'text-white' : 'text-foreground')}>
|
|
69
71
|
{section.title}
|
|
70
|
-
</
|
|
72
|
+
</h2>
|
|
71
73
|
{/if}
|
|
72
74
|
<ul class="mt-4 space-y-2">
|
|
73
75
|
{#each section.items as item}
|
|
@@ -127,7 +129,8 @@
|
|
|
127
129
|
<LogoMain variant="horizontal" color={isDark ? 'light' : 'dark'} size="sm" />
|
|
128
130
|
{/if}
|
|
129
131
|
|
|
130
|
-
|
|
132
|
+
<!-- gray-300, not gray-400: #9ca3af is 3.22:1 on the gray-600 footer -->
|
|
133
|
+
<p class={cn('text-center text-sm', isDark ? 'text-gray-300' : 'text-muted-foreground')}>
|
|
131
134
|
{displayCopyright}
|
|
132
135
|
</p>
|
|
133
136
|
</div>
|