@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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=AppShell.panelCollapse.test.d.ts.map
@@ -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
+ });
@@ -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
- {@render primaryRail()}
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
- {@render navigationPanel()}
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
- {@render contextPanel()}
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
- {@render utilityPanel()}
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;AAE9D,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,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAiNJ,QAAA,MAAM,QAAQ,mDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,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"}
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=Icon.test.d.ts.map
@@ -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
+ });