@sentropic/design-system-svelte 0.34.69 → 0.34.71
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/AppShell.panelCollapse.test.d.ts +2 -0
- package/dist/AppShell.panelCollapse.test.d.ts.map +1 -0
- package/dist/AppShell.panelCollapse.test.js +129 -0
- package/dist/AppShell.svelte +216 -4
- package/dist/AppShell.svelte.d.ts +20 -0
- package/dist/AppShell.svelte.d.ts.map +1 -1
- package/dist/Icon.svelte +44 -0
- package/dist/Icon.svelte.d.ts +20 -0
- package/dist/Icon.svelte.d.ts.map +1 -0
- package/dist/Icon.test.d.ts +2 -0
- package/dist/Icon.test.d.ts.map +1 -0
- package/dist/Icon.test.js +38 -0
- package/dist/TimeRangePicker.svelte +618 -0
- package/dist/TimeRangePicker.svelte.d.ts +36 -0
- package/dist/TimeRangePicker.svelte.d.ts.map +1 -0
- package/dist/TimeRangePicker.test.d.ts +2 -0
- package/dist/TimeRangePicker.test.d.ts.map +1 -0
- package/dist/TimeRangePicker.test.js +482 -0
- package/dist/icons.d.ts +40 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +27 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/timeRange.d.ts +75 -0
- package/dist/timeRange.d.ts.map +1 -0
- package/dist/timeRange.js +198 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"AppShell.panelCollapse.test.d.ts","sourceRoot":"","sources":["../src/lib/AppShell.panelCollapse.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { fireEvent, render } from "@testing-library/svelte";
|
|
2
|
+
import { createRawSnippet } from "svelte";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import AppShell from "./AppShell.svelte";
|
|
5
|
+
const snippet = (html) => createRawSnippet(() => ({ render: () => `<div>${html}</div>` }));
|
|
6
|
+
const panelProps = {
|
|
7
|
+
primaryRail: snippet("rail-content"),
|
|
8
|
+
navigationPanel: snippet("nav-content"),
|
|
9
|
+
main: snippet("main-content"),
|
|
10
|
+
contextPanel: snippet("context-content"),
|
|
11
|
+
utilityPanel: snippet("utility-content"),
|
|
12
|
+
};
|
|
13
|
+
describe("AppShell — panelCollapse default (stack)", () => {
|
|
14
|
+
it("renders no disclosure buttons and no panel-region wrappers when panelCollapse is omitted", () => {
|
|
15
|
+
const { container } = render(AppShell, { props: { variant: "workspace", ...panelProps } });
|
|
16
|
+
expect(container.querySelectorAll(".st-appShell__panelDisclosure").length).toBe(0);
|
|
17
|
+
expect(container.querySelectorAll(".st-appShell__panelRegion").length).toBe(0);
|
|
18
|
+
});
|
|
19
|
+
it("renders no disclosure buttons when panelCollapse is explicitly 'stack'", () => {
|
|
20
|
+
const { container } = render(AppShell, { props: { variant: "workspace", panelCollapse: "stack", ...panelProps } });
|
|
21
|
+
expect(container.querySelectorAll(".st-appShell__panelDisclosure").length).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
it("still renders every panel's content directly inside its aside (unchanged desktop/stack markup)", () => {
|
|
24
|
+
const { container } = render(AppShell, { props: { variant: "workspace", ...panelProps } });
|
|
25
|
+
expect(container.querySelector(".st-appShell__primaryRail")?.textContent).toContain("rail-content");
|
|
26
|
+
expect(container.querySelector(".st-appShell__navigationPanel")?.textContent).toContain("nav-content");
|
|
27
|
+
expect(container.querySelector(".st-appShell__main")?.textContent).toContain("main-content");
|
|
28
|
+
expect(container.querySelector(".st-appShell__contextPanel")?.textContent).toContain("context-content");
|
|
29
|
+
expect(container.querySelector(".st-appShell__utilityPanel")?.textContent).toContain("utility-content");
|
|
30
|
+
});
|
|
31
|
+
it('sets data-panel-collapse="stack" on the root by default', () => {
|
|
32
|
+
const { container } = render(AppShell, { props: { variant: "workspace", ...panelProps } });
|
|
33
|
+
expect(container.querySelector(".st-appShell")?.getAttribute("data-panel-collapse")).toBe("stack");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
describe("AppShell — panelCollapse='accordion'", () => {
|
|
37
|
+
it("renders a disclosure button for each present panel, all initially collapsed", () => {
|
|
38
|
+
const { container } = render(AppShell, { props: { variant: "workspace", panelCollapse: "accordion", ...panelProps } });
|
|
39
|
+
const buttons = container.querySelectorAll(".st-appShell__panelDisclosure");
|
|
40
|
+
expect(buttons.length).toBe(4);
|
|
41
|
+
buttons.forEach((btn) => {
|
|
42
|
+
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
it("wires aria-controls on each trigger to the id of its region, and the region is labelled by the trigger", () => {
|
|
46
|
+
const { container } = render(AppShell, { props: { variant: "workspace", panelCollapse: "accordion", ...panelProps } });
|
|
47
|
+
const buttons = Array.from(container.querySelectorAll(".st-appShell__panelDisclosure"));
|
|
48
|
+
expect(buttons.length).toBeGreaterThan(0);
|
|
49
|
+
for (const button of buttons) {
|
|
50
|
+
const controlsId = button.getAttribute("aria-controls");
|
|
51
|
+
expect(controlsId).toBeTruthy();
|
|
52
|
+
const region = container.querySelector(`#${controlsId}`);
|
|
53
|
+
expect(region).toBeTruthy();
|
|
54
|
+
expect(region?.getAttribute("role")).toBe("region");
|
|
55
|
+
expect(region?.getAttribute("aria-labelledby")).toBe(button.id);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
it("toggles aria-expanded on click and reflects the collapsed state on the region", async () => {
|
|
59
|
+
const { container } = render(AppShell, { props: { variant: "workspace", panelCollapse: "accordion", ...panelProps } });
|
|
60
|
+
const trigger = container.querySelector("#st-appShell-navigationPanel-trigger");
|
|
61
|
+
const region = container.querySelector("#st-appShell-navigationPanel-region");
|
|
62
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
63
|
+
expect(region.classList.contains("st-appShell__panelRegion--collapsed")).toBe(true);
|
|
64
|
+
await fireEvent.click(trigger);
|
|
65
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
66
|
+
expect(region.classList.contains("st-appShell__panelRegion--collapsed")).toBe(false);
|
|
67
|
+
await fireEvent.click(trigger);
|
|
68
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
69
|
+
expect(region.classList.contains("st-appShell__panelRegion--collapsed")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it("toggles via the keyboard (Enter/Space activate a native button)", async () => {
|
|
72
|
+
const { container } = render(AppShell, { props: { variant: "workspace", panelCollapse: "accordion", ...panelProps } });
|
|
73
|
+
const trigger = container.querySelector("#st-appShell-contextPanel-trigger");
|
|
74
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
75
|
+
trigger.focus();
|
|
76
|
+
expect(document.activeElement).toBe(trigger);
|
|
77
|
+
// Native <button> activates on Enter/Space via its click event; assert the
|
|
78
|
+
// handler is reachable through a synthesized click (jsdom does not
|
|
79
|
+
// synthesize the keydown->click default action for us).
|
|
80
|
+
await fireEvent.click(trigger);
|
|
81
|
+
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
|
82
|
+
});
|
|
83
|
+
it("renders no disclosure for a panel that is not provided", () => {
|
|
84
|
+
const { container } = render(AppShell, {
|
|
85
|
+
props: { variant: "workspace", panelCollapse: "accordion", navigationPanel: snippet("nav-only"), main: panelProps.main },
|
|
86
|
+
});
|
|
87
|
+
const buttons = container.querySelectorAll(".st-appShell__panelDisclosure");
|
|
88
|
+
expect(buttons.length).toBe(1);
|
|
89
|
+
expect(container.querySelector(".st-appShell__primaryRail")).toBeNull();
|
|
90
|
+
expect(container.querySelector(".st-appShell__contextPanel")).toBeNull();
|
|
91
|
+
expect(container.querySelector(".st-appShell__utilityPanel")).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
it("keeps the panel content mounted in the DOM even while collapsed (no destroy/remount)", () => {
|
|
94
|
+
const { container } = render(AppShell, { props: { variant: "workspace", panelCollapse: "accordion", ...panelProps } });
|
|
95
|
+
// Collapsed by default, yet content is present — collapse is a CSS
|
|
96
|
+
// concern (sizing/hiding the region), never a conditional {#if}.
|
|
97
|
+
expect(container.querySelector("#st-appShell-utilityPanel-region")?.textContent).toContain("utility-content");
|
|
98
|
+
});
|
|
99
|
+
it("desktop markup still contains the panel content (accordion mode never removes it)", () => {
|
|
100
|
+
const { container } = render(AppShell, { props: { variant: "workspace", panelCollapse: "accordion", ...panelProps } });
|
|
101
|
+
// jsdom does not evaluate @media queries for layout, but the component
|
|
102
|
+
// must not conditionally destroy content based on breakpoint — assert
|
|
103
|
+
// every panel's content is present regardless.
|
|
104
|
+
expect(container.querySelector(".st-appShell__primaryRail")?.textContent).toContain("rail-content");
|
|
105
|
+
expect(container.querySelector(".st-appShell__navigationPanel")?.textContent).toContain("nav-content");
|
|
106
|
+
expect(container.querySelector(".st-appShell__contextPanel")?.textContent).toContain("context-content");
|
|
107
|
+
expect(container.querySelector(".st-appShell__utilityPanel")?.textContent).toContain("utility-content");
|
|
108
|
+
});
|
|
109
|
+
it("uses custom *Label props as the disclosure text, falling back to the existing aria-label props", async () => {
|
|
110
|
+
const { container } = render(AppShell, {
|
|
111
|
+
props: {
|
|
112
|
+
variant: "workspace",
|
|
113
|
+
panelCollapse: "accordion",
|
|
114
|
+
...panelProps,
|
|
115
|
+
primaryRailLabel: "Rail perso",
|
|
116
|
+
contextLabel: "Contexte",
|
|
117
|
+
utilityPanelLabel: "Outils",
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
expect(container.querySelector("#st-appShell-primaryRail-trigger")?.textContent).toContain("Rail perso");
|
|
121
|
+
// contextPanelLabel not set -> falls back to contextLabel.
|
|
122
|
+
expect(container.querySelector("#st-appShell-contextPanel-trigger")?.textContent).toContain("Contexte");
|
|
123
|
+
expect(container.querySelector("#st-appShell-utilityPanel-trigger")?.textContent).toContain("Outils");
|
|
124
|
+
});
|
|
125
|
+
it('sets data-panel-collapse="accordion" on the root', () => {
|
|
126
|
+
const { container } = render(AppShell, { props: { variant: "workspace", panelCollapse: "accordion", ...panelProps } });
|
|
127
|
+
expect(container.querySelector(".st-appShell")?.getAttribute("data-panel-collapse")).toBe("accordion");
|
|
128
|
+
});
|
|
129
|
+
});
|
package/dist/AppShell.svelte
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
export type AppShellVariant = "site" | "workspace";
|
|
6
6
|
export type AppShellUtilityMode = "reserve" | "overlay" | "floating";
|
|
7
7
|
export type AppShellUtilitySide = "left" | "right" | "bottom";
|
|
8
|
+
export type AppShellPanelCollapse = "stack" | "accordion";
|
|
8
9
|
|
|
9
10
|
export type AppShellProps = {
|
|
10
11
|
variant?: AppShellVariant;
|
|
@@ -23,6 +24,25 @@
|
|
|
23
24
|
utilityLabel?: string;
|
|
24
25
|
utilityMode?: AppShellUtilityMode;
|
|
25
26
|
utilitySide?: AppShellUtilitySide;
|
|
27
|
+
/**
|
|
28
|
+
* Below the 48rem breakpoint, `"stack"` (default) keeps today's behaviour —
|
|
29
|
+
* panels stack full-width in document order, always expanded. `"accordion"`
|
|
30
|
+
* degrades each present panel to a keyboard-accessible disclosure (collapsed
|
|
31
|
+
* by default) instead of squeezing the main content. Desktop (>48rem)
|
|
32
|
+
* rendering is IDENTICAL in both modes — side panels stay side-by-side
|
|
33
|
+
* columns. Panel content is mounted once regardless of mode/breakpoint;
|
|
34
|
+
* collapsing hides/sizes the region rather than destroying it, so stateful
|
|
35
|
+
* widgets (maps, live panels…) mounted in a panel never remount.
|
|
36
|
+
*/
|
|
37
|
+
panelCollapse?: AppShellPanelCollapse;
|
|
38
|
+
/** Disclosure label for `primaryRail` when `panelCollapse="accordion"`. */
|
|
39
|
+
primaryRailLabel?: string;
|
|
40
|
+
/** Disclosure label for `navigationPanel` when `panelCollapse="accordion"`. Defaults to `navigationLabel`. */
|
|
41
|
+
navigationPanelLabel?: string;
|
|
42
|
+
/** Disclosure label for `contextPanel` when `panelCollapse="accordion"`. Defaults to `contextLabel`. */
|
|
43
|
+
contextPanelLabel?: string;
|
|
44
|
+
/** Disclosure label for `utilityPanel` when `panelCollapse="accordion"`. Defaults to `utilityLabel`. */
|
|
45
|
+
utilityPanelLabel?: string;
|
|
26
46
|
class?: string;
|
|
27
47
|
};
|
|
28
48
|
</script>
|
|
@@ -58,9 +78,29 @@
|
|
|
58
78
|
utilityLabel = "Utility panel",
|
|
59
79
|
utilityMode = "reserve",
|
|
60
80
|
utilitySide = "right",
|
|
81
|
+
panelCollapse = "stack",
|
|
82
|
+
primaryRailLabel = "Primary rail",
|
|
83
|
+
navigationPanelLabel,
|
|
84
|
+
contextPanelLabel,
|
|
85
|
+
utilityPanelLabel,
|
|
61
86
|
class: className
|
|
62
87
|
}: AppShellProps = $props();
|
|
63
88
|
|
|
89
|
+
// Resolved disclosure labels: default to the existing aria-label props so a
|
|
90
|
+
// consumer who already customized navigationLabel/contextLabel/utilityLabel
|
|
91
|
+
// doesn't need to duplicate the translation for the accordion trigger text.
|
|
92
|
+
const navigationPanelLabelResolved = $derived(navigationPanelLabel ?? navigationLabel);
|
|
93
|
+
const contextPanelLabelResolved = $derived(contextPanelLabel ?? contextLabel);
|
|
94
|
+
const utilityPanelLabelResolved = $derived(utilityPanelLabel ?? utilityLabel);
|
|
95
|
+
|
|
96
|
+
// Uncontrolled per-panel disclosure state (v1) — each accordion panel starts
|
|
97
|
+
// collapsed. Desktop rendering never reads these (CSS scopes the collapse to
|
|
98
|
+
// `@media (max-width: 48rem)`), so they have zero effect above the breakpoint.
|
|
99
|
+
let primaryRailPanelOpen = $state(false);
|
|
100
|
+
let navigationPanelOpen = $state(false);
|
|
101
|
+
let contextPanelOpen = $state(false);
|
|
102
|
+
let utilityPanelOpen = $state(false);
|
|
103
|
+
|
|
64
104
|
const mode = $derived(variant ?? (config ? "site" : "workspace"));
|
|
65
105
|
const siteConfig = $derived(config ?? ({ brand: { name: "Sentropic" }, nav: [], theming: { themes: [], theme: "" } } as SiteConfig));
|
|
66
106
|
const brand = $derived(siteConfig.brand ?? { name: "Sentropic" });
|
|
@@ -182,6 +222,7 @@
|
|
|
182
222
|
data-st-app-shell-variant="workspace"
|
|
183
223
|
data-utility-mode={utilityMode}
|
|
184
224
|
data-utility-side={utilitySide}
|
|
225
|
+
data-panel-collapse={panelCollapse}
|
|
185
226
|
>
|
|
186
227
|
{#if topChrome}
|
|
187
228
|
<div class="st-appShell__topChrome">
|
|
@@ -191,12 +232,62 @@
|
|
|
191
232
|
<div class="st-appShell__body">
|
|
192
233
|
{#if primaryRail}
|
|
193
234
|
<aside class="st-appShell__primaryRail" aria-label="Primary rail">
|
|
194
|
-
{
|
|
235
|
+
{#if panelCollapse === "accordion"}
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
class="st-appShell__panelDisclosure"
|
|
239
|
+
aria-expanded={primaryRailPanelOpen}
|
|
240
|
+
aria-controls="st-appShell-primaryRail-region"
|
|
241
|
+
id="st-appShell-primaryRail-trigger"
|
|
242
|
+
onclick={() => (primaryRailPanelOpen = !primaryRailPanelOpen)}
|
|
243
|
+
>
|
|
244
|
+
<span class="st-appShell__panelDisclosureLabel">{primaryRailLabel}</span>
|
|
245
|
+
<span class="st-appShell__panelDisclosureIcon" class:st-appShell__panelDisclosureIcon--expanded={primaryRailPanelOpen}>
|
|
246
|
+
<ChevronDown size={16} aria-hidden="true" />
|
|
247
|
+
</span>
|
|
248
|
+
</button>
|
|
249
|
+
<div
|
|
250
|
+
class="st-appShell__panelRegion"
|
|
251
|
+
class:st-appShell__panelRegion--collapsed={!primaryRailPanelOpen}
|
|
252
|
+
id="st-appShell-primaryRail-region"
|
|
253
|
+
role="region"
|
|
254
|
+
aria-labelledby="st-appShell-primaryRail-trigger"
|
|
255
|
+
>
|
|
256
|
+
{@render primaryRail()}
|
|
257
|
+
</div>
|
|
258
|
+
{:else}
|
|
259
|
+
{@render primaryRail()}
|
|
260
|
+
{/if}
|
|
195
261
|
</aside>
|
|
196
262
|
{/if}
|
|
197
263
|
{#if navigationPanel}
|
|
198
264
|
<aside class="st-appShell__navigationPanel" aria-label={navigationLabel}>
|
|
199
|
-
{
|
|
265
|
+
{#if panelCollapse === "accordion"}
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
class="st-appShell__panelDisclosure"
|
|
269
|
+
aria-expanded={navigationPanelOpen}
|
|
270
|
+
aria-controls="st-appShell-navigationPanel-region"
|
|
271
|
+
id="st-appShell-navigationPanel-trigger"
|
|
272
|
+
onclick={() => (navigationPanelOpen = !navigationPanelOpen)}
|
|
273
|
+
>
|
|
274
|
+
<span class="st-appShell__panelDisclosureLabel">{navigationPanelLabelResolved}</span>
|
|
275
|
+
<span class="st-appShell__panelDisclosureIcon" class:st-appShell__panelDisclosureIcon--expanded={navigationPanelOpen}>
|
|
276
|
+
<ChevronDown size={16} aria-hidden="true" />
|
|
277
|
+
</span>
|
|
278
|
+
</button>
|
|
279
|
+
<div
|
|
280
|
+
class="st-appShell__panelRegion"
|
|
281
|
+
class:st-appShell__panelRegion--collapsed={!navigationPanelOpen}
|
|
282
|
+
id="st-appShell-navigationPanel-region"
|
|
283
|
+
role="region"
|
|
284
|
+
aria-labelledby="st-appShell-navigationPanel-trigger"
|
|
285
|
+
>
|
|
286
|
+
{@render navigationPanel()}
|
|
287
|
+
</div>
|
|
288
|
+
{:else}
|
|
289
|
+
{@render navigationPanel()}
|
|
290
|
+
{/if}
|
|
200
291
|
</aside>
|
|
201
292
|
{/if}
|
|
202
293
|
<main class="st-appShell__main" id={mainId}>
|
|
@@ -208,12 +299,62 @@
|
|
|
208
299
|
</main>
|
|
209
300
|
{#if contextPanel}
|
|
210
301
|
<aside class="st-appShell__contextPanel" aria-label={contextLabel}>
|
|
211
|
-
{
|
|
302
|
+
{#if panelCollapse === "accordion"}
|
|
303
|
+
<button
|
|
304
|
+
type="button"
|
|
305
|
+
class="st-appShell__panelDisclosure"
|
|
306
|
+
aria-expanded={contextPanelOpen}
|
|
307
|
+
aria-controls="st-appShell-contextPanel-region"
|
|
308
|
+
id="st-appShell-contextPanel-trigger"
|
|
309
|
+
onclick={() => (contextPanelOpen = !contextPanelOpen)}
|
|
310
|
+
>
|
|
311
|
+
<span class="st-appShell__panelDisclosureLabel">{contextPanelLabelResolved}</span>
|
|
312
|
+
<span class="st-appShell__panelDisclosureIcon" class:st-appShell__panelDisclosureIcon--expanded={contextPanelOpen}>
|
|
313
|
+
<ChevronDown size={16} aria-hidden="true" />
|
|
314
|
+
</span>
|
|
315
|
+
</button>
|
|
316
|
+
<div
|
|
317
|
+
class="st-appShell__panelRegion"
|
|
318
|
+
class:st-appShell__panelRegion--collapsed={!contextPanelOpen}
|
|
319
|
+
id="st-appShell-contextPanel-region"
|
|
320
|
+
role="region"
|
|
321
|
+
aria-labelledby="st-appShell-contextPanel-trigger"
|
|
322
|
+
>
|
|
323
|
+
{@render contextPanel()}
|
|
324
|
+
</div>
|
|
325
|
+
{:else}
|
|
326
|
+
{@render contextPanel()}
|
|
327
|
+
{/if}
|
|
212
328
|
</aside>
|
|
213
329
|
{/if}
|
|
214
330
|
{#if utilityPanel}
|
|
215
331
|
<aside class="st-appShell__utilityPanel" aria-label={utilityLabel}>
|
|
216
|
-
{
|
|
332
|
+
{#if panelCollapse === "accordion"}
|
|
333
|
+
<button
|
|
334
|
+
type="button"
|
|
335
|
+
class="st-appShell__panelDisclosure"
|
|
336
|
+
aria-expanded={utilityPanelOpen}
|
|
337
|
+
aria-controls="st-appShell-utilityPanel-region"
|
|
338
|
+
id="st-appShell-utilityPanel-trigger"
|
|
339
|
+
onclick={() => (utilityPanelOpen = !utilityPanelOpen)}
|
|
340
|
+
>
|
|
341
|
+
<span class="st-appShell__panelDisclosureLabel">{utilityPanelLabelResolved}</span>
|
|
342
|
+
<span class="st-appShell__panelDisclosureIcon" class:st-appShell__panelDisclosureIcon--expanded={utilityPanelOpen}>
|
|
343
|
+
<ChevronDown size={16} aria-hidden="true" />
|
|
344
|
+
</span>
|
|
345
|
+
</button>
|
|
346
|
+
<div
|
|
347
|
+
class="st-appShell__panelRegion"
|
|
348
|
+
class:st-appShell__panelRegion--collapsed={!utilityPanelOpen}
|
|
349
|
+
id="st-appShell-utilityPanel-region"
|
|
350
|
+
role="region"
|
|
351
|
+
aria-labelledby="st-appShell-utilityPanel-trigger"
|
|
352
|
+
>
|
|
353
|
+
{@render utilityPanel()}
|
|
354
|
+
</div>
|
|
355
|
+
{:else}
|
|
356
|
+
{@render utilityPanel()}
|
|
357
|
+
{/if}
|
|
217
358
|
</aside>
|
|
218
359
|
{/if}
|
|
219
360
|
</div>
|
|
@@ -419,6 +560,50 @@
|
|
|
419
560
|
margin: var(--st-spacing-4, 1rem);
|
|
420
561
|
}
|
|
421
562
|
|
|
563
|
+
/* Disclosure trigger for panelCollapse="accordion". Hidden by default (this
|
|
564
|
+
rule has no `@media` guard, so it also hides the button on desktop where
|
|
565
|
+
accordion mode is not active) — only shown by the max-width:48rem rule
|
|
566
|
+
below, and only when `data-panel-collapse="accordion"`. This keeps desktop
|
|
567
|
+
rendering byte-identical in both modes. */
|
|
568
|
+
.st-appShell__panelDisclosure {
|
|
569
|
+
align-items: center;
|
|
570
|
+
background: transparent;
|
|
571
|
+
border: 0;
|
|
572
|
+
color: inherit;
|
|
573
|
+
cursor: pointer;
|
|
574
|
+
display: none;
|
|
575
|
+
font: inherit;
|
|
576
|
+
font-weight: 650;
|
|
577
|
+
gap: var(--st-spacing-2, 0.5rem);
|
|
578
|
+
justify-content: space-between;
|
|
579
|
+
padding: var(--st-spacing-3, 0.75rem) var(--st-spacing-4, 1rem);
|
|
580
|
+
text-align: start;
|
|
581
|
+
width: 100%;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.st-appShell__panelDisclosure:focus-visible {
|
|
585
|
+
outline: 2px solid var(--st-semantic-border-focus, var(--st-semantic-brand-default, #2563eb));
|
|
586
|
+
outline-offset: -2px;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
.st-appShell__panelDisclosureIcon {
|
|
590
|
+
display: inline-flex;
|
|
591
|
+
flex-shrink: 0;
|
|
592
|
+
transition: transform var(--st-motion-duration-fast, 0.15s) ease;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.st-appShell__panelDisclosureIcon--expanded {
|
|
596
|
+
transform: rotate(180deg);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* The region stays mounted at all times — it is only ever resized/hidden via
|
|
600
|
+
CSS below, never removed from the DOM, so panel content (maps, live
|
|
601
|
+
widgets…) never remounts when a panel collapses or expands. */
|
|
602
|
+
.st-appShell__panelRegion {
|
|
603
|
+
min-block-size: 0;
|
|
604
|
+
min-inline-size: 0;
|
|
605
|
+
}
|
|
606
|
+
|
|
422
607
|
@media (max-width: 48rem) {
|
|
423
608
|
.st-appShell__body {
|
|
424
609
|
flex-flow: column nowrap;
|
|
@@ -433,5 +618,32 @@
|
|
|
433
618
|
inline-size: auto;
|
|
434
619
|
max-block-size: min(60vh, 28rem);
|
|
435
620
|
}
|
|
621
|
+
|
|
622
|
+
/* Accordion mode: the panel's own max-block-size cap is superseded by the
|
|
623
|
+
region's (below) so a collapsed panel can shrink to 0 and an expanded
|
|
624
|
+
one keeps the same 60vh/28rem ceiling the stack mode always had. */
|
|
625
|
+
.st-appShell[data-panel-collapse="accordion"] .st-appShell__primaryRail,
|
|
626
|
+
.st-appShell[data-panel-collapse="accordion"] .st-appShell__navigationPanel,
|
|
627
|
+
.st-appShell[data-panel-collapse="accordion"] .st-appShell__contextPanel,
|
|
628
|
+
.st-appShell[data-panel-collapse="accordion"] .st-appShell__utilityPanel {
|
|
629
|
+
max-block-size: none;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.st-appShell[data-panel-collapse="accordion"] .st-appShell__panelDisclosure {
|
|
633
|
+
display: flex;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.st-appShell[data-panel-collapse="accordion"] .st-appShell__panelRegion {
|
|
637
|
+
max-block-size: min(60vh, 28rem);
|
|
638
|
+
overflow: auto;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.st-appShell[data-panel-collapse="accordion"] .st-appShell__panelRegion--collapsed {
|
|
642
|
+
block-size: 0;
|
|
643
|
+
max-block-size: 0;
|
|
644
|
+
overflow: hidden;
|
|
645
|
+
padding-block: 0;
|
|
646
|
+
visibility: hidden;
|
|
647
|
+
}
|
|
436
648
|
}
|
|
437
649
|
</style>
|
|
@@ -3,6 +3,7 @@ import type { SiteConfig } from "./site-config";
|
|
|
3
3
|
export type AppShellVariant = "site" | "workspace";
|
|
4
4
|
export type AppShellUtilityMode = "reserve" | "overlay" | "floating";
|
|
5
5
|
export type AppShellUtilitySide = "left" | "right" | "bottom";
|
|
6
|
+
export type AppShellPanelCollapse = "stack" | "accordion";
|
|
6
7
|
export type AppShellProps = {
|
|
7
8
|
variant?: AppShellVariant;
|
|
8
9
|
config?: SiteConfig;
|
|
@@ -20,6 +21,25 @@ export type AppShellProps = {
|
|
|
20
21
|
utilityLabel?: string;
|
|
21
22
|
utilityMode?: AppShellUtilityMode;
|
|
22
23
|
utilitySide?: AppShellUtilitySide;
|
|
24
|
+
/**
|
|
25
|
+
* Below the 48rem breakpoint, `"stack"` (default) keeps today's behaviour —
|
|
26
|
+
* panels stack full-width in document order, always expanded. `"accordion"`
|
|
27
|
+
* degrades each present panel to a keyboard-accessible disclosure (collapsed
|
|
28
|
+
* by default) instead of squeezing the main content. Desktop (>48rem)
|
|
29
|
+
* rendering is IDENTICAL in both modes — side panels stay side-by-side
|
|
30
|
+
* columns. Panel content is mounted once regardless of mode/breakpoint;
|
|
31
|
+
* collapsing hides/sizes the region rather than destroying it, so stateful
|
|
32
|
+
* widgets (maps, live panels…) mounted in a panel never remount.
|
|
33
|
+
*/
|
|
34
|
+
panelCollapse?: AppShellPanelCollapse;
|
|
35
|
+
/** Disclosure label for `primaryRail` when `panelCollapse="accordion"`. */
|
|
36
|
+
primaryRailLabel?: string;
|
|
37
|
+
/** Disclosure label for `navigationPanel` when `panelCollapse="accordion"`. Defaults to `navigationLabel`. */
|
|
38
|
+
navigationPanelLabel?: string;
|
|
39
|
+
/** Disclosure label for `contextPanel` when `panelCollapse="accordion"`. Defaults to `contextLabel`. */
|
|
40
|
+
contextPanelLabel?: string;
|
|
41
|
+
/** Disclosure label for `utilityPanel` when `panelCollapse="accordion"`. Defaults to `utilityLabel`. */
|
|
42
|
+
utilityPanelLabel?: string;
|
|
23
43
|
class?: string;
|
|
24
44
|
};
|
|
25
45
|
declare const AppShell: import("svelte").Component<AppShellProps, {}, "">;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppShell.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AppShell.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,WAAW,CAAC;AACnD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;AACrE,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;
|
|
1
|
+
{"version":3,"file":"AppShell.svelte.d.ts","sourceRoot":"","sources":["../src/lib/AppShell.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,WAAW,CAAC;AACnD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,SAAS,GAAG,UAAU,CAAC;AACrE,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAC9D,MAAM,MAAM,qBAAqB,GAAG,OAAO,GAAG,WAAW,CAAC;AAE1D,MAAM,MAAM,aAAa,GAAG;IAC1B,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC,WAAW,CAAC,EAAE,mBAAmB,CAAC;IAClC;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,qBAAqB,CAAC;IACtC,2EAA2E;IAC3E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,8GAA8G;IAC9G,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,wGAAwG;IACxG,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,wGAAwG;IACxG,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAqRJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
|
package/dist/Icon.svelte
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { SVGAttributes } from "svelte/elements";
|
|
3
|
+
import { ICONS, type IconName } from "./icons.js";
|
|
4
|
+
|
|
5
|
+
type IconProps = Omit<SVGAttributes<SVGElement>, "class"> & {
|
|
6
|
+
/** Canonical DS icon name (see {@link IconName}). */
|
|
7
|
+
name: IconName;
|
|
8
|
+
/** Square size in px. Default 18 — the DS-standard inline glyph size. */
|
|
9
|
+
size?: number;
|
|
10
|
+
/** Stroke width. Default 2.25 — matches the DS's existing lucide usage. */
|
|
11
|
+
strokeWidth?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Accessible name. When set, the icon is exposed as an image with this
|
|
14
|
+
* label; when omitted, the icon is decorative (`aria-hidden`).
|
|
15
|
+
*/
|
|
16
|
+
title?: string;
|
|
17
|
+
class?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
name,
|
|
22
|
+
size = 18,
|
|
23
|
+
strokeWidth = 2.25,
|
|
24
|
+
title,
|
|
25
|
+
class: className,
|
|
26
|
+
...rest
|
|
27
|
+
}: IconProps = $props();
|
|
28
|
+
|
|
29
|
+
const Glyph = $derived(ICONS[name]);
|
|
30
|
+
const classes = $derived(["st-icon", className].filter(Boolean).join(" "));
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
{#if Glyph}
|
|
34
|
+
<Glyph
|
|
35
|
+
{size}
|
|
36
|
+
{strokeWidth}
|
|
37
|
+
class={classes}
|
|
38
|
+
role={title ? "img" : undefined}
|
|
39
|
+
aria-label={title}
|
|
40
|
+
aria-hidden={title ? undefined : "true"}
|
|
41
|
+
focusable="false"
|
|
42
|
+
{...rest}
|
|
43
|
+
/>
|
|
44
|
+
{/if}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SVGAttributes } from "svelte/elements";
|
|
2
|
+
import { type IconName } from "./icons.js";
|
|
3
|
+
type IconProps = Omit<SVGAttributes<SVGElement>, "class"> & {
|
|
4
|
+
/** Canonical DS icon name (see {@link IconName}). */
|
|
5
|
+
name: IconName;
|
|
6
|
+
/** Square size in px. Default 18 — the DS-standard inline glyph size. */
|
|
7
|
+
size?: number;
|
|
8
|
+
/** Stroke width. Default 2.25 — matches the DS's existing lucide usage. */
|
|
9
|
+
strokeWidth?: number;
|
|
10
|
+
/**
|
|
11
|
+
* Accessible name. When set, the icon is exposed as an image with this
|
|
12
|
+
* label; when omitted, the icon is decorative (`aria-hidden`).
|
|
13
|
+
*/
|
|
14
|
+
title?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
};
|
|
17
|
+
declare const Icon: import("svelte").Component<IconProps, {}, "">;
|
|
18
|
+
type Icon = ReturnType<typeof Icon>;
|
|
19
|
+
export default Icon;
|
|
20
|
+
//# sourceMappingURL=Icon.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Icon.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Icon.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAS,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAGhD,KAAK,SAAS,GAAG,IAAI,CAAC,aAAa,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC,GAAG;IAC1D,qDAAqD;IACrD,IAAI,EAAE,QAAQ,CAAC;IACf,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAyBJ,QAAA,MAAM,IAAI,+CAAwC,CAAC;AACnD,KAAK,IAAI,GAAG,UAAU,CAAC,OAAO,IAAI,CAAC,CAAC;AACpC,eAAe,IAAI,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Icon.test.d.ts","sourceRoot":"","sources":["../src/lib/Icon.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { render } from "@testing-library/svelte";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import Icon from "./Icon.svelte";
|
|
4
|
+
import { ICON_NAMES } from "./icons.js";
|
|
5
|
+
describe("Icon — canonical DS icon set", () => {
|
|
6
|
+
it("renders an svg for every canonical name", () => {
|
|
7
|
+
for (const name of ICON_NAMES) {
|
|
8
|
+
const { container, unmount } = render(Icon, { props: { name } });
|
|
9
|
+
const svg = container.querySelector("svg");
|
|
10
|
+
expect(svg, `expected an <svg> for icon "${name}"`).toBeTruthy();
|
|
11
|
+
unmount();
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
it("defaults to size 18 and stroke-width 2.25 (DS-standard)", () => {
|
|
15
|
+
const { container } = render(Icon, { props: { name: "settings" } });
|
|
16
|
+
const svg = container.querySelector("svg");
|
|
17
|
+
expect(svg.getAttribute("width")).toBe("18");
|
|
18
|
+
expect(svg.getAttribute("height")).toBe("18");
|
|
19
|
+
expect(svg.getAttribute("stroke-width")).toBe("2.25");
|
|
20
|
+
});
|
|
21
|
+
it("is decorative (aria-hidden) with no title", () => {
|
|
22
|
+
const { container } = render(Icon, { props: { name: "close" } });
|
|
23
|
+
const svg = container.querySelector("svg");
|
|
24
|
+
expect(svg.getAttribute("aria-hidden")).toBe("true");
|
|
25
|
+
});
|
|
26
|
+
it("exposes an accessible name when title is set", () => {
|
|
27
|
+
const { container } = render(Icon, { props: { name: "eye", title: "Afficher" } });
|
|
28
|
+
const svg = container.querySelector("svg");
|
|
29
|
+
expect(svg.getAttribute("aria-hidden")).toBeNull();
|
|
30
|
+
expect(svg.getAttribute("role")).toBe("img");
|
|
31
|
+
expect(svg.getAttribute("aria-label")).toBe("Afficher");
|
|
32
|
+
});
|
|
33
|
+
it("honours a custom size", () => {
|
|
34
|
+
const { container } = render(Icon, { props: { name: "layers", size: 24 } });
|
|
35
|
+
const svg = container.querySelector("svg");
|
|
36
|
+
expect(svg.getAttribute("width")).toBe("24");
|
|
37
|
+
});
|
|
38
|
+
});
|