@sentropic/design-system-svelte 0.34.32 → 0.34.33

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/Badge.svelte CHANGED
@@ -4,13 +4,50 @@
4
4
 
5
5
  type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, "class"> & {
6
6
  tone?: "neutral" | "success" | "warning" | "error" | "info";
7
+ /**
8
+ * Badge shape — `"pill"` (default) is the current render (radius 999px, width
9
+ * grows with content). `"circle"` renders an equal-sided round bubble
10
+ * (`min-width === min-height`, equal inline/block padding, tabular-nums) — best
11
+ * for ≤2-digit counts. 3+ digit content degrades GRACEFULLY to a rounded-rect
12
+ * (never clipped), so consumer counts reaching the 1000s stay legible.
13
+ * Mirrors `Avatar`'s `shape`. Additive: with `shape` unset the badge renders
14
+ * byte-identically to before.
15
+ */
16
+ shape?: "pill" | "circle";
17
+ /**
18
+ * Density — `"md"` (default) is the current render. `"sm"` shrinks the
19
+ * font-size (the rail-bubble scale). Additive: with `size` unset the badge
20
+ * renders byte-identically to before.
21
+ */
22
+ size?: "sm" | "md";
7
23
  class?: string;
24
+ /**
25
+ * The number / text. Stays content-driven (no `value` prop). For SR users a
26
+ * bare count is ambiguous — pass an `aria-label` via `...rest` describing what
27
+ * is counted (e.g. `aria-label="128 entities"`).
28
+ */
8
29
  children?: Snippet;
9
30
  };
10
31
 
11
- let { tone = "neutral", class: className, children, ...rest }: BadgeProps = $props();
32
+ let {
33
+ tone = "neutral",
34
+ shape = "pill",
35
+ size = "md",
36
+ class: className,
37
+ children,
38
+ ...rest
39
+ }: BadgeProps = $props();
12
40
 
13
- const classes = () => ["st-badge", `st-badge--${tone}`, className].filter(Boolean).join(" ");
41
+ const classes = () =>
42
+ [
43
+ "st-badge",
44
+ `st-badge--${tone}`,
45
+ `st-badge--${shape}`,
46
+ `st-badge--${size}`,
47
+ className
48
+ ]
49
+ .filter(Boolean)
50
+ .join(" ");
14
51
  </script>
15
52
 
16
53
  <span {...rest} class={classes()}>
@@ -34,6 +71,33 @@
34
71
  text-transform: var(--st-component-badge-textTransform, none);
35
72
  }
36
73
 
74
+ /* Shape variants (additive). `pill` is the UNTOUCHED base `.st-badge` above — no
75
+ `--pill` rule exists, so a `shape="pill"` (or unset) badge renders
76
+ byte-identically. `--circle` overlays an equal-sided round bubble ON TOP of
77
+ the base rules: equal min-width/min-height, equal inline/block padding,
78
+ centered, tabular-nums for stable digit width. 3+ digit content degrades to a
79
+ rounded-rect (the box grows past the diameter, never clips), so large counts
80
+ stay legible. Every leaf falls back to a base literal so a theme that emits no
81
+ `--st-component-badge-circle-*` renders the variant identically. */
82
+ .st-badge--circle {
83
+ border-radius: var(--st-component-badge-circle-radius, 50%);
84
+ box-sizing: border-box;
85
+ font-variant-numeric: tabular-nums;
86
+ justify-content: center;
87
+ min-width: var(--st-component-badge-circle-size, 1.25rem);
88
+ min-height: var(--st-component-badge-circle-size, 1.25rem);
89
+ /* Equal inline/block padding keeps 1–2 digits round; the inline padding lets
90
+ 3+ digits grow the box into a rounded-rect instead of clipping. */
91
+ padding: var(--st-component-badge-circle-padding, 0.125rem);
92
+ text-align: center;
93
+ }
94
+
95
+ /* Density variant (additive). `md` is the UNTOUCHED base font-size; `--sm`
96
+ reuses the Tag `sm` scale for cross-house consistency. */
97
+ .st-badge--sm {
98
+ font-size: var(--st-component-badge-sm-fontSize, 0.6875rem);
99
+ }
100
+
37
101
  .st-badge--neutral {
38
102
  background: var(--st-semantic-surface-subtle);
39
103
  color: var(--st-semantic-text-secondary);
@@ -2,7 +2,28 @@ import type { Snippet } from "svelte";
2
2
  import type { HTMLAttributes } from "svelte/elements";
3
3
  type BadgeProps = Omit<HTMLAttributes<HTMLSpanElement>, "class"> & {
4
4
  tone?: "neutral" | "success" | "warning" | "error" | "info";
5
+ /**
6
+ * Badge shape — `"pill"` (default) is the current render (radius 999px, width
7
+ * grows with content). `"circle"` renders an equal-sided round bubble
8
+ * (`min-width === min-height`, equal inline/block padding, tabular-nums) — best
9
+ * for ≤2-digit counts. 3+ digit content degrades GRACEFULLY to a rounded-rect
10
+ * (never clipped), so consumer counts reaching the 1000s stay legible.
11
+ * Mirrors `Avatar`'s `shape`. Additive: with `shape` unset the badge renders
12
+ * byte-identically to before.
13
+ */
14
+ shape?: "pill" | "circle";
15
+ /**
16
+ * Density — `"md"` (default) is the current render. `"sm"` shrinks the
17
+ * font-size (the rail-bubble scale). Additive: with `size` unset the badge
18
+ * renders byte-identically to before.
19
+ */
20
+ size?: "sm" | "md";
5
21
  class?: string;
22
+ /**
23
+ * The number / text. Stays content-driven (no `value` prop). For SR users a
24
+ * bare count is ambiguous — pass an `aria-label` via `...rest` describing what
25
+ * is counted (e.g. `aria-label="128 entities"`).
26
+ */
6
27
  children?: Snippet;
7
28
  };
8
29
  declare const Badge: import("svelte").Component<BadgeProps, {}, "">;
@@ -1 +1 @@
1
- {"version":3,"file":"Badge.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Badge.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,UAAU,GAAG,IAAI,CAAC,cAAc,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC,GAAG;IACjE,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAmBJ,QAAA,MAAM,KAAK,gDAAwC,CAAC;AACpD,KAAK,KAAK,GAAG,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC;AACtC,eAAe,KAAK,CAAC"}
1
+ {"version":3,"file":"Badge.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Badge.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,UAAU,GAAG,IAAI,CAAC,cAAc,CAAC,eAAe,CAAC,EAAE,OAAO,CAAC,GAAG;IACjE,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAC;IAC5D;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC1B;;;;OAIG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAmCJ,QAAA,MAAM,KAAK,gDAAwC,CAAC;AACpD,KAAK,KAAK,GAAG,UAAU,CAAC,OAAO,KAAK,CAAC,CAAC;AACtC,eAAe,KAAK,CAAC"}
@@ -7,7 +7,22 @@
7
7
  /** État ouvert (bindable). */
8
8
  open?: boolean;
9
9
  title: string;
10
+ /**
11
+ * Density of the trigger — `"md"` (default) is the current render. `"sm"`
12
+ * de-emphasizes the trigger (smaller font/weight/padding) for NESTED /
13
+ * level-2 collapsibles; `"lg"` enlarges it. Additive: with `size` unset the
14
+ * trigger renders byte-identically to before.
15
+ */
16
+ size?: "sm" | "md" | "lg";
10
17
  disabled?: boolean;
18
+ /**
19
+ * Trailing content rendered inside the trigger, BETWEEN the title and the
20
+ * chevron (e.g. a count Badge, a status Tag, a glyph). The chevron stays the
21
+ * rightmost affordance. If the trailing content carries information SR users
22
+ * need as part of the trigger name, set `aria-label` on the Collapsible via
23
+ * `...rest` (e.g. `aria-label="Entities, 128 items"`).
24
+ */
25
+ trailing?: Snippet;
11
26
  onToggle?: (open: boolean) => void;
12
27
  class?: string;
13
28
  children?: Snippet;
@@ -16,7 +31,9 @@
16
31
  let {
17
32
  open = $bindable(false),
18
33
  title,
34
+ size = "md",
19
35
  disabled = false,
36
+ trailing,
20
37
  onToggle,
21
38
  class: className,
22
39
  children,
@@ -26,7 +43,14 @@
26
43
  const uid = `st-collapsible-${Math.random().toString(36).slice(2, 9)}`;
27
44
 
28
45
  const classes = $derived(
29
- ["st-collapsible", open ? "st-collapsible--open" : null, className].filter(Boolean).join(" ")
46
+ [
47
+ "st-collapsible",
48
+ `st-collapsible--${size}`,
49
+ open ? "st-collapsible--open" : null,
50
+ className
51
+ ]
52
+ .filter(Boolean)
53
+ .join(" ")
30
54
  );
31
55
 
32
56
  function toggle() {
@@ -47,6 +71,9 @@
47
71
  onclick={toggle}
48
72
  >
49
73
  <span class="st-collapsible__title">{title}</span>
74
+ {#if trailing}
75
+ <span class="st-collapsible__trailing">{@render trailing()}</span>
76
+ {/if}
50
77
  <span class="st-collapsible__icon" aria-hidden="true">
51
78
  <ChevronDown size={18} strokeWidth={2.25} />
52
79
  </span>
@@ -103,10 +130,37 @@
103
130
  cursor: not-allowed;
104
131
  }
105
132
 
133
+ /* Density variants (additive). `md` is the UNTOUCHED base `.st-collapsible__trigger`
134
+ above — no `--md` rule exists, so a `size="md"` (or unset) trigger renders
135
+ byte-identically. `--sm` de-emphasizes for nesting; `--lg` enlarges. Every
136
+ leaf falls back to a base literal so a theme that emits no
137
+ `--st-component-collapsible-*` renders these variants identically. */
138
+ .st-collapsible--sm .st-collapsible__trigger {
139
+ font-size: var(--st-component-collapsible-sm-fontSize, 0.875rem);
140
+ font-weight: var(--st-component-collapsible-sm-fontWeight, 500);
141
+ padding: var(--st-component-collapsible-sm-paddingBlock, 0.4rem)
142
+ var(--st-component-collapsible-sm-paddingInline, 0.25rem);
143
+ }
144
+
145
+ .st-collapsible--lg .st-collapsible__trigger {
146
+ font-size: var(--st-component-collapsible-lg-fontSize, 1rem);
147
+ padding: var(--st-component-collapsible-lg-paddingBlock, 0.875rem)
148
+ var(--st-component-collapsible-lg-paddingInline, 0.25rem);
149
+ }
150
+
106
151
  .st-collapsible__title {
107
152
  flex: 1 1 auto;
108
153
  }
109
154
 
155
+ /* Trigger trailing slot (additive). Holds a count badge / status / glyph
156
+ between the title and the chevron; never grows, so the chevron stays the
157
+ rightmost affordance and the title keeps `flex: 1 1 auto`. */
158
+ .st-collapsible__trailing {
159
+ align-items: center;
160
+ display: inline-flex;
161
+ flex: 0 0 auto;
162
+ }
163
+
110
164
  .st-collapsible__icon {
111
165
  align-items: center;
112
166
  color: var(--st-semantic-text-secondary);
@@ -4,7 +4,22 @@ type CollapsibleProps = Omit<HTMLAttributes<HTMLDivElement>, "class" | "title">
4
4
  /** État ouvert (bindable). */
5
5
  open?: boolean;
6
6
  title: string;
7
+ /**
8
+ * Density of the trigger — `"md"` (default) is the current render. `"sm"`
9
+ * de-emphasizes the trigger (smaller font/weight/padding) for NESTED /
10
+ * level-2 collapsibles; `"lg"` enlarges it. Additive: with `size` unset the
11
+ * trigger renders byte-identically to before.
12
+ */
13
+ size?: "sm" | "md" | "lg";
7
14
  disabled?: boolean;
15
+ /**
16
+ * Trailing content rendered inside the trigger, BETWEEN the title and the
17
+ * chevron (e.g. a count Badge, a status Tag, a glyph). The chevron stays the
18
+ * rightmost affordance. If the trailing content carries information SR users
19
+ * need as part of the trigger name, set `aria-label` on the Collapsible via
20
+ * `...rest` (e.g. `aria-label="Entities, 128 items"`).
21
+ */
22
+ trailing?: Snippet;
8
23
  onToggle?: (open: boolean) => void;
9
24
  class?: string;
10
25
  children?: Snippet;
@@ -1 +1 @@
1
- {"version":3,"file":"Collapsible.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Collapsible.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,gBAAgB,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG;IAChF,8BAA8B;IAC9B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AAgDJ,QAAA,MAAM,WAAW,0DAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"Collapsible.svelte.d.ts","sourceRoot":"","sources":["../src/lib/Collapsible.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGpD,KAAK,gBAAgB,GAAG,IAAI,CAAC,cAAc,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG;IAChF,8BAA8B;IAC9B,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,IAAI,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA4DJ,QAAA,MAAM,WAAW,0DAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=Collapsible.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Collapsible.test.d.ts","sourceRoot":"","sources":["../src/lib/Collapsible.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,68 @@
1
+ import { fireEvent, render } from "@testing-library/svelte";
2
+ import { createRawSnippet } from "svelte";
3
+ import { describe, expect, it } from "vitest";
4
+ import Collapsible from "./Collapsible.svelte";
5
+ const snippet = (html) => createRawSnippet(() => ({ render: () => `<span>${html}</span>` }));
6
+ // Svelte scopes component styles by appending a per-component hash class (e.g.
7
+ // "svelte-pav3x3") to every styled element's class list. Compare against the
8
+ // SEMANTIC class only (the first token) so the assertions are stable across
9
+ // builds and read the structural class, not the scope hash.
10
+ const structuralClass = (el) => el.className.split(/\s+/)[0];
11
+ describe("Collapsible — base (byte-identity)", () => {
12
+ it("renders the default trigger with no trailing span and the md density class", () => {
13
+ const { container } = render(Collapsible, { props: { title: "Entities" } });
14
+ const root = container.querySelector(".st-collapsible");
15
+ expect(root).toBeTruthy();
16
+ // Default density is md (additive class), and there is NO trailing span when
17
+ // the slot is absent — the trigger keeps its original [title][chevron] shape.
18
+ expect(root.classList.contains("st-collapsible--md")).toBe(true);
19
+ expect(root.classList.contains("st-collapsible--sm")).toBe(false);
20
+ expect(container.querySelector(".st-collapsible__trailing")).toBeNull();
21
+ expect(container.querySelector(".st-collapsible__title")?.textContent).toBe("Entities");
22
+ // Trigger order is preserved: only the title span then the chevron.
23
+ const trigger = container.querySelector(".st-collapsible__trigger");
24
+ const spans = Array.from(trigger.children).map(structuralClass);
25
+ expect(spans).toEqual(["st-collapsible__title", "st-collapsible__icon"]);
26
+ });
27
+ });
28
+ describe("Collapsible — size", () => {
29
+ it("size=\"sm\" toggles st-collapsible--sm", () => {
30
+ const { container } = render(Collapsible, { props: { title: "Type", size: "sm" } });
31
+ const root = container.querySelector(".st-collapsible");
32
+ expect(root.classList.contains("st-collapsible--sm")).toBe(true);
33
+ expect(root.classList.contains("st-collapsible--md")).toBe(false);
34
+ });
35
+ it("size=\"lg\" toggles st-collapsible--lg", () => {
36
+ const { container } = render(Collapsible, { props: { title: "Section", size: "lg" } });
37
+ expect(container.querySelector(".st-collapsible").classList.contains("st-collapsible--lg")).toBe(true);
38
+ });
39
+ });
40
+ describe("Collapsible — trailing slot", () => {
41
+ it("renders trailing content between the title and the chevron", () => {
42
+ const { container } = render(Collapsible, {
43
+ props: { title: "Entities", trailing: snippet("128") },
44
+ });
45
+ const trailing = container.querySelector(".st-collapsible__trailing");
46
+ expect(trailing).toBeTruthy();
47
+ expect(trailing?.textContent).toContain("128");
48
+ // Order inside the trigger: title, trailing, chevron (chevron stays last).
49
+ const trigger = container.querySelector(".st-collapsible__trigger");
50
+ const order = Array.from(trigger.children).map(structuralClass);
51
+ expect(order).toEqual([
52
+ "st-collapsible__title",
53
+ "st-collapsible__trailing",
54
+ "st-collapsible__icon",
55
+ ]);
56
+ });
57
+ });
58
+ describe("Collapsible — a11y unchanged", () => {
59
+ it("toggles aria-expanded on click with size + trailing set", async () => {
60
+ const { container } = render(Collapsible, {
61
+ props: { title: "Entities", size: "sm", trailing: snippet("7") },
62
+ });
63
+ const trigger = container.querySelector(".st-collapsible__trigger");
64
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
65
+ await fireEvent.click(trigger);
66
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
67
+ });
68
+ });
@@ -58,8 +58,11 @@
58
58
  let internal = $state<Set<string>>(new Set());
59
59
  const selectedValues = $derived(controlled ? toSet(value) : internal);
60
60
 
61
- // --- Row registry: ordered by DOM position so arrow nav matches the visual
62
- // order regardless of registration timing. -------------------------------
61
+ // --- Row registry. Rows are stored in INSERTION order (registration is O(1));
62
+ // the DOM-ordered view is computed LAZILY (see `orderedEntries`) and only
63
+ // when a consumer actually needs visual order (arrow nav / roving tab stop).
64
+ // Sorting eagerly on every register() was O(n) per mount → O(n²) for the
65
+ // whole list (issue #26); deferring it makes a large list mount in O(n).
63
66
  type Entry = { el: HTMLElement; value: string | undefined; disabled?: boolean };
64
67
  let entries = $state<Entry[]>([]);
65
68
 
@@ -76,17 +79,28 @@
76
79
  });
77
80
  }
78
81
 
82
+ // DOM-ordered view of the registry. Memoised by `$derived.by` so the O(n log n)
83
+ // `compareDocumentPosition` sort runs at most ONCE per registry change (a batch
84
+ // of mounts in the same tick collapses into a single recompute), not once per
85
+ // register() call. ORDER-dependent readers (`navigate`, `effectiveTabStop`'s
86
+ // "first enabled row") read THIS so visual order is correct regardless of
87
+ // registration timing; order-INDEPENDENT lookups (`valueOf`/`isSelected`,
88
+ // disabled-membership) read the raw `entries`. register() itself stays O(1).
89
+ const orderedEntries = $derived.by(() => sortByDom(entries));
90
+
79
91
  // register/unregister are called from each row's $effect. They read AND write
80
92
  // `entries`, so the read must be untracked — otherwise the calling effect would
81
93
  // subscribe to `entries`, and writing it would re-run the effect forever.
82
- // Disabled rows are registered with disabled:true so navigate() can skip them
83
- // explicitly, making the skip correct even when disabled state changes mid-session.
94
+ // register() APPENDS in insertion order (O(1)); the DOM sort is deferred to the
95
+ // lazy `orderedEntries`. Disabled rows are registered with disabled:true so
96
+ // navigate() can skip them explicitly, making the skip correct even when the
97
+ // disabled state changes mid-session.
84
98
  function register(el: HTMLElement, rowValue: string | undefined, rowDisabled = false): () => void {
85
99
  untrack(() => {
86
- entries = sortByDom([
100
+ entries = [
87
101
  ...entries.filter((e) => e.el !== el),
88
102
  { el, value: rowValue, disabled: rowDisabled }
89
- ]);
103
+ ];
90
104
  });
91
105
  return () => {
92
106
  untrack(() => {
@@ -103,7 +117,8 @@
103
117
  const entry = entries.find((e) => e.el === tabStopEl);
104
118
  if (entry && !entry.disabled) return tabStopEl;
105
119
  }
106
- return entries.find((e) => !e.disabled)?.el ?? null;
120
+ // "First enabled row" must be in DOM order, so read the ordered view.
121
+ return orderedEntries.find((e) => !e.disabled)?.el ?? null;
107
122
  });
108
123
 
109
124
  // Si la row qui détient le focus DOM devient disabled (in-place, sans unmount),
@@ -161,38 +176,42 @@
161
176
  }
162
177
 
163
178
  function navigate(el: HTMLElement, key: string) {
164
- if (entries.length === 0) return;
165
- const idx = entries.findIndex((e) => e.el === el);
179
+ // Keyboard navigation walks rows in VISUAL (DOM) order, so read the lazily
180
+ // sorted view here. This is the first point the deferred sort is forced — the
181
+ // O(n²) register-time sort storm is gone, the sort runs once on demand.
182
+ const ordered = orderedEntries;
183
+ if (ordered.length === 0) return;
184
+ const idx = ordered.findIndex((e) => e.el === el);
166
185
  if (idx === -1) return;
167
186
 
168
187
  let targetIdx: number | null = null;
169
188
 
170
189
  if (key === "ArrowDown" || key === "ArrowRight") {
171
190
  // Walk forward from current position, find the next non-disabled entry.
172
- for (let i = idx + 1; i < entries.length; i++) {
173
- if (!entries[i].disabled) { targetIdx = i; break; }
191
+ for (let i = idx + 1; i < ordered.length; i++) {
192
+ if (!ordered[i].disabled) { targetIdx = i; break; }
174
193
  }
175
194
  } else if (key === "ArrowUp" || key === "ArrowLeft") {
176
195
  // Walk backward from current position, find the previous non-disabled entry.
177
196
  for (let i = idx - 1; i >= 0; i--) {
178
- if (!entries[i].disabled) { targetIdx = i; break; }
197
+ if (!ordered[i].disabled) { targetIdx = i; break; }
179
198
  }
180
199
  } else if (key === "Home") {
181
200
  // First non-disabled entry.
182
- for (let i = 0; i < entries.length; i++) {
183
- if (!entries[i].disabled) { targetIdx = i; break; }
201
+ for (let i = 0; i < ordered.length; i++) {
202
+ if (!ordered[i].disabled) { targetIdx = i; break; }
184
203
  }
185
204
  } else if (key === "End") {
186
205
  // Last non-disabled entry.
187
- for (let i = entries.length - 1; i >= 0; i--) {
188
- if (!entries[i].disabled) { targetIdx = i; break; }
206
+ for (let i = ordered.length - 1; i >= 0; i--) {
207
+ if (!ordered[i].disabled) { targetIdx = i; break; }
189
208
  }
190
209
  }
191
210
 
192
211
  // If no target found (all remaining are disabled, or already at boundary), stay put.
193
212
  if (targetIdx === null) return;
194
213
 
195
- const target = entries[targetIdx]?.el;
214
+ const target = ordered[targetIdx]?.el;
196
215
  if (target) {
197
216
  tabStopEl = target;
198
217
  target.focus();
@@ -1 +1 @@
1
- {"version":3,"file":"SelectableList.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableList.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,MAAM,MAAM,mBAAmB,GAAG;IAChC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,KAAK,IAAI,CAAC;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA0MJ,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"SelectableList.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableList.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC,MAAM,MAAM,mBAAmB,GAAG;IAChC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,CAAC;IACjC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,IAAI,KAAK,IAAI,CAAC;IACrD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC;AA6NJ,QAAA,MAAM,cAAc,yDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -60,6 +60,16 @@
60
60
  trailing?: Snippet;
61
61
  /** Main content. */
62
62
  children?: Snippet;
63
+ /**
64
+ * Optional secondary line (the "legend") rendered MUTED and smaller UNDER
65
+ * `children`. When present the content column stacks vertically (a
66
+ * `--hasCaption` modifier); when absent the row stays single-line and
67
+ * byte-identical. The caption joins the row's accessible name by default (the
68
+ * SR reads "label, caption"); wrap it `aria-hidden` if it is purely
69
+ * decorative. MUST NOT contain interactive controls — a row is a single tab
70
+ * stop.
71
+ */
72
+ caption?: Snippet;
63
73
  class?: string;
64
74
  };
65
75
  </script>
@@ -77,6 +87,7 @@
77
87
  leading,
78
88
  trailing,
79
89
  children,
90
+ caption,
80
91
  class: className
81
92
  }: SelectableRowProps = $props();
82
93
 
@@ -125,6 +136,7 @@
125
136
  isSelected ? "st-selectableRow--selected" : null,
126
137
  disabled ? "st-selectableRow--disabled" : null,
127
138
  accentBar ? "st-selectableRow--accentBar" : null,
139
+ caption ? "st-selectableRow--hasCaption" : null,
128
140
  className
129
141
  ]
130
142
  .filter(Boolean)
@@ -190,7 +202,17 @@
190
202
  {#if leading}
191
203
  <span class="st-selectableRow__leading">{@render leading()}</span>
192
204
  {/if}
193
- <span class="st-selectableRow__content">{@render children?.()}</span>
205
+ {#if caption}
206
+ <!-- Caption present: the content column stacks the primary label over a muted
207
+ second line. Both lines truncate independently (each min-width:0 + ellipsis)
208
+ so a long caption never pushes the row width. -->
209
+ <span class="st-selectableRow__content st-selectableRow__content--stacked">
210
+ <span class="st-selectableRow__label">{@render children?.()}</span>
211
+ <span class="st-selectableRow__caption">{@render caption()}</span>
212
+ </span>
213
+ {:else}
214
+ <span class="st-selectableRow__content">{@render children?.()}</span>
215
+ {/if}
194
216
  {#if trailing}
195
217
  <span class="st-selectableRow__trailing">{@render trailing()}</span>
196
218
  {/if}
@@ -300,6 +322,36 @@
300
322
  white-space: nowrap;
301
323
  }
302
324
 
325
+ /* Caption variant (additive). Rows WITHOUT a caption keep the single-line
326
+ `.st-selectableRow__content` above byte-identically (no `--stacked` rule
327
+ applies). `--stacked` overlays a vertical column: the primary `__label` keeps
328
+ its own single-line ellipsis, and the muted `__caption` truncates
329
+ independently so a long legend never pushes the row width. Every leaf falls
330
+ back to a base literal so a theme that emits no
331
+ `--st-component-selectableRow-caption*` renders the caption identically. */
332
+ .st-selectableRow__content--stacked {
333
+ display: flex;
334
+ flex-direction: column;
335
+ /* The column stack drops the inline ellipsis/nowrap (each line truncates on
336
+ its own child); keep min-width:0 so the column can shrink and ellipsize. */
337
+ overflow: visible;
338
+ white-space: normal;
339
+ gap: var(--st-component-selectableRow-captionGap, 0.125rem);
340
+ }
341
+
342
+ .st-selectableRow__label,
343
+ .st-selectableRow__caption {
344
+ min-width: 0;
345
+ overflow: hidden;
346
+ text-overflow: ellipsis;
347
+ white-space: nowrap;
348
+ }
349
+
350
+ .st-selectableRow__caption {
351
+ color: var(--st-component-selectableRow-captionColor, var(--st-semantic-text-muted));
352
+ font-size: var(--st-component-selectableRow-captionFontSize, 0.75rem);
353
+ }
354
+
303
355
  @media (prefers-reduced-motion: reduce) {
304
356
  .st-selectableRow { transition: none; }
305
357
  }
@@ -56,6 +56,16 @@ export type SelectableRowProps = {
56
56
  trailing?: Snippet;
57
57
  /** Main content. */
58
58
  children?: Snippet;
59
+ /**
60
+ * Optional secondary line (the "legend") rendered MUTED and smaller UNDER
61
+ * `children`. When present the content column stacks vertically (a
62
+ * `--hasCaption` modifier); when absent the row stays single-line and
63
+ * byte-identical. The caption joins the row's accessible name by default (the
64
+ * SR reads "label, caption"); wrap it `aria-hidden` if it is purely
65
+ * decorative. MUST NOT contain interactive controls — a row is a single tab
66
+ * stop.
67
+ */
68
+ caption?: Snippet;
59
69
  class?: string;
60
70
  };
61
71
  declare const SelectableRow: import("svelte").Component<SelectableRowProps, {}, "selected">;
@@ -1 +1 @@
1
- {"version":3,"file":"SelectableRow.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableRow.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,eAA+B,CAAC;AAEhE,MAAM,MAAM,qBAAqB,GAAG;IAClC,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;IACvB,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,wIAAwI;IACxI,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,CAAC,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;IACzF,uDAAuD;IACvD,UAAU,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC;IACzC,iFAAiF;IACjF,SAAS,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC;IACxC,6EAA6E;IAC7E,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;IACpC,wDAAwD;IACxD,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;IACpC,gDAAgD;IAChD,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,mCAAmC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oBAAoB;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAiIJ,QAAA,MAAM,aAAa,gEAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"SelectableRow.svelte.d.ts","sourceRoot":"","sources":["../src/lib/SelectableRow.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEtC;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,eAA+B,CAAC;AAEhE,MAAM,MAAM,qBAAqB,GAAG;IAClC,gEAAgE;IAChE,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC;IACvB,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC;IAC5B,wIAAwI;IACxI,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,CAAC,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;IACzF,uDAAuD;IACvD,UAAU,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC;IACzC,iFAAiF;IACjF,SAAS,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,OAAO,CAAC;IACxC,6EAA6E;IAC7E,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;IACpC,wDAAwD;IACxD,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,KAAK,IAAI,CAAC;IACpC,gDAAgD;IAChD,QAAQ,EAAE,CAAC,EAAE,EAAE,WAAW,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,iCAAiC;IACjC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oCAAoC;IACpC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,mCAAmC;IACnC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,oBAAoB;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;;;OAQG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA2IJ,QAAA,MAAM,aAAa,gEAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentropic/design-system-svelte",
3
- "version": "0.34.32",
3
+ "version": "0.34.33",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -42,10 +42,10 @@
42
42
  "@sentropic/design-system-theme-carbon": "0.2.2",
43
43
  "@sentropic/design-system-theme-dsfr": "0.2.2",
44
44
  "@sveltejs/package": "^2.5.0",
45
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
45
+ "@sveltejs/vite-plugin-svelte": "^7.1.2",
46
46
  "@testing-library/svelte": "^5.2.8",
47
47
  "svelte": "^5.53.2",
48
- "vite": "^7.3.1",
48
+ "vite": "^8.0.16",
49
49
  "vitest": "^4.0.15",
50
50
  "@sentropic/design-system-themes": "0.11.0"
51
51
  }