@supersoniks/concorde 5.0.3 → 5.0.5

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.
Files changed (151) hide show
  1. package/.agents/skills/concorde-component-docs/SKILL.md +194 -0
  2. package/.agents/skills/concorde-component-docs/template.md +45 -0
  3. package/ai/cursor/rules/concorde-theme.mdc +1 -1
  4. package/ai/cursor/rules/concorde.mdc +1 -1
  5. package/ai/skills/concorde/SKILL.md +1 -1
  6. package/ai/skills/concorde-theme/SKILL.md +21 -10
  7. package/build-infos.json +1 -1
  8. package/concorde-core.bundle.js +515 -521
  9. package/concorde-core.es.js +5151 -5056
  10. package/dist/concorde-core.bundle.js +515 -521
  11. package/dist/concorde-core.es.js +5151 -5056
  12. package/dist/docs-mock-api-sw.js +2 -0
  13. package/dist/docs-mock-api-sw.js.map +2 -2
  14. package/docs/assets/index-CD4WyL6o.css +1 -0
  15. package/docs/assets/{index-BnK6OS7w.js → index-wVZpLVYW.js} +815 -808
  16. package/docs/crawl/core/components/ui/alert/alert.html +76 -5
  17. package/docs/crawl/core/components/ui/alert-messages/alert-messages.html +113 -5
  18. package/docs/crawl/core/components/ui/badge/badge.html +57 -8
  19. package/docs/crawl/core/components/ui/captcha/captcha.html +90 -8
  20. package/docs/crawl/core/components/ui/card/card.html +122 -12
  21. package/docs/crawl/core/components/ui/divider/divider.html +117 -31
  22. package/docs/crawl/core/components/ui/form/fieldset/fieldset.html +232 -101
  23. package/docs/crawl/core/components/ui/form/form-actions/form-actions.html +95 -52
  24. package/docs/crawl/core/components/ui/form/form-layout/form-layout.html +97 -38
  25. package/docs/crawl/core/components/ui/group/group.html +60 -38
  26. package/docs/crawl/core/components/ui/icon/icon.html +136 -47
  27. package/docs/crawl/core/components/ui/image/image.html +118 -6
  28. package/docs/crawl/core/components/ui/link/link.html +65 -7
  29. package/docs/crawl/core/components/ui/loader/loader.html +97 -23
  30. package/docs/crawl/core/components/ui/menu/menu.html +162 -90
  31. package/docs/crawl/core/components/ui/modal/modal.html +275 -50
  32. package/docs/crawl/core/components/ui/pop/pop.html +242 -124
  33. package/docs/crawl/core/components/ui/progress/progress.html +103 -30
  34. package/docs/crawl/core/components/ui/table/table.html +303 -171
  35. package/docs/crawl/core/components/ui/theme/theme.html +131 -0
  36. package/docs/crawl/core/components/ui/toast/toast.html +170 -59
  37. package/docs/crawl/core/components/ui/tooltip/tooltip.html +90 -29
  38. package/docs/crawl/docs/_decorators/ancestor-attribute.html +3 -2
  39. package/docs/crawl/docs/_getting-started/start.html +1 -1
  40. package/docs/crawl/docs/_getting-started/theming.html +311 -63
  41. package/docs/crawl/hubs/form-components.html +71 -49
  42. package/docs/crawl/index.html +7 -6
  43. package/docs/docs-mock-api-sw.js +2 -0
  44. package/docs/docs-mock-api-sw.js.map +2 -2
  45. package/docs/index.html +6 -4
  46. package/docs/llms-full.txt +1468 -405
  47. package/docs/llms.txt +23 -30
  48. package/docs/sitemap.xml +3 -0
  49. package/docs/src/core/components/ui/alert/alert.md +21 -2
  50. package/docs/src/core/components/ui/alert-messages/alert-messages.md +85 -0
  51. package/docs/src/core/components/ui/badge/badge.md +19 -6
  52. package/docs/src/core/components/ui/captcha/captcha.md +65 -6
  53. package/docs/src/core/components/ui/card/card.md +73 -10
  54. package/docs/src/core/components/ui/divider/divider.md +107 -13
  55. package/docs/src/core/components/ui/form/fieldset/fieldset.md +175 -99
  56. package/docs/src/core/components/ui/form/form-actions/form-actions.md +75 -52
  57. package/docs/src/core/components/ui/form/form-layout/form-layout.md +109 -34
  58. package/docs/src/core/components/ui/group/group.md +36 -36
  59. package/docs/src/core/components/ui/icon/icon.md +132 -43
  60. package/docs/src/core/components/ui/image/image.md +35 -3
  61. package/docs/src/core/components/ui/link/link.md +43 -5
  62. package/docs/src/core/components/ui/loader/loader.md +79 -22
  63. package/docs/src/core/components/ui/menu/menu.md +133 -92
  64. package/docs/src/core/components/ui/modal/modal.md +163 -47
  65. package/docs/src/core/components/ui/pop/pop.md +217 -123
  66. package/docs/src/core/components/ui/progress/progress.md +76 -30
  67. package/docs/src/core/components/ui/table/table.md +147 -171
  68. package/docs/src/core/components/ui/theme/theme.md +122 -0
  69. package/docs/src/core/components/ui/toast/toast.md +108 -58
  70. package/docs/src/core/components/ui/tooltip/tooltip.md +68 -26
  71. package/docs/src/docs/_decorators/ancestor-attribute.md +3 -2
  72. package/docs/src/docs/_getting-started/start.md +1 -1
  73. package/docs/src/docs/_getting-started/theming.md +223 -61
  74. package/docs/src/docs/search/docs-search.json +2504 -399
  75. package/docs/src/tsconfig.json +18 -0
  76. package/index.html +4 -2
  77. package/package.json +7 -1
  78. package/public/docs-mock-api-sw.js +2 -0
  79. package/public/docs-mock-api-sw.js.map +2 -2
  80. package/src/core/components/ui/_css/align.ts +1 -0
  81. package/src/core/components/ui/_css/type.ts +86 -8
  82. package/src/core/components/ui/_i18n/ui-aria-labels.ts +8 -0
  83. package/src/core/components/ui/alert/alert.md +21 -2
  84. package/src/core/components/ui/alert-messages/alert-messages.md +85 -0
  85. package/src/core/components/ui/badge/badge.md +19 -6
  86. package/src/core/components/ui/badge/badge.ts +8 -9
  87. package/src/core/components/ui/button/button.ts +5 -15
  88. package/src/core/components/ui/captcha/captcha.md +65 -6
  89. package/src/core/components/ui/card/card.md +73 -10
  90. package/src/core/components/ui/card/card.ts +24 -22
  91. package/src/core/components/ui/divider/divider.md +107 -13
  92. package/src/core/components/ui/divider/divider.ts +40 -33
  93. package/src/core/components/ui/form/fieldset/fieldset.md +175 -99
  94. package/src/core/components/ui/form/fieldset/fieldset.ts +15 -28
  95. package/src/core/components/ui/form/fieldset/legend.ts +13 -13
  96. package/src/core/components/ui/form/form-actions/form-actions.md +75 -52
  97. package/src/core/components/ui/form/form-actions/form-actions.ts +7 -5
  98. package/src/core/components/ui/form/form-layout/form-layout.md +109 -34
  99. package/src/core/components/ui/form/form-layout/form-layout.ts +27 -24
  100. package/src/core/components/ui/form/textarea/textarea.ts +4 -3
  101. package/src/core/components/ui/group/group.md +36 -36
  102. package/src/core/components/ui/group/group.ts +5 -10
  103. package/src/core/components/ui/icon/icon.md +132 -43
  104. package/src/core/components/ui/image/image.md +35 -3
  105. package/src/core/components/ui/link/link.md +43 -5
  106. package/src/core/components/ui/loader/loader.md +79 -22
  107. package/src/core/components/ui/loader/loader.ts +67 -27
  108. package/src/core/components/ui/loader/styles/fixed.ts +6 -11
  109. package/src/core/components/ui/menu/menu.md +133 -92
  110. package/src/core/components/ui/menu/menu.ts +53 -58
  111. package/src/core/components/ui/modal/modal.md +163 -47
  112. package/src/core/components/ui/modal/modal.ts +2 -4
  113. package/src/core/components/ui/pop/pop.md +217 -123
  114. package/src/core/components/ui/pop/pop.ts +0 -1
  115. package/src/core/components/ui/progress/progress.md +76 -30
  116. package/src/core/components/ui/progress/progress.ts +25 -56
  117. package/src/core/components/ui/table/table-caption.ts +2 -2
  118. package/src/core/components/ui/table/table-tbody.ts +3 -3
  119. package/src/core/components/ui/table/table-td.ts +23 -18
  120. package/src/core/components/ui/table/table-th.ts +8 -8
  121. package/src/core/components/ui/table/table-tr.ts +4 -4
  122. package/src/core/components/ui/table/table.md +147 -171
  123. package/src/core/components/ui/table/table.ts +20 -19
  124. package/src/core/components/ui/theme/theme-collection/auto.ts +15 -0
  125. package/src/core/components/ui/theme/theme-collection/core-variables.ts +8 -31
  126. package/src/core/components/ui/theme/theme-collection/dark.ts +1 -7
  127. package/src/core/components/ui/theme/theme-collection/light.ts +30 -26
  128. package/src/core/components/ui/theme/theme.md +122 -0
  129. package/src/core/components/ui/theme/theme.ts +30 -7
  130. package/src/core/components/ui/toast/toast-item.ts +6 -9
  131. package/src/core/components/ui/toast/toast.md +108 -58
  132. package/src/core/components/ui/toast/types.ts +1 -1
  133. package/src/core/components/ui/tooltip/tooltip.md +68 -26
  134. package/src/core/decorators/subscriber/ancestorAttribute.spec.ts +26 -0
  135. package/src/core/decorators/subscriber/ancestorAttribute.ts +3 -4
  136. package/src/core/utils/AncestorAttributeObserver.ts +183 -70
  137. package/src/docs/_decorators/ancestor-attribute.md +3 -2
  138. package/src/docs/_getting-started/start.md +1 -1
  139. package/src/docs/_getting-started/theming.md +223 -61
  140. package/src/docs/code.ts +125 -58
  141. package/src/docs/header/header.ts +62 -54
  142. package/src/docs/mock-api/router.ts +2 -0
  143. package/src/docs/navigation/navigation.ts +6 -3
  144. package/src/docs/search/docs-search.json +2504 -399
  145. package/src/docs/search/markdown-renderer.ts +6 -6
  146. package/src/docs/search/search.ts +4 -4
  147. package/src/docs/tailwind/css/tailwind.css +6 -5
  148. package/src/tsconfig.json +18 -0
  149. package/src/tsconfig.tsbuildinfo +1 -1
  150. package/tailwind.config.js +19 -0
  151. package/docs/assets/index--GpD7R7a.css +0 -1
@@ -1,71 +1,97 @@
1
1
  # Tooltip
2
2
 
3
+ Tooltip shows a short **`label`** on hover (and on focus when the trigger can receive focus). Wrap the trigger in the default slot—buttons, icons, or plain text. Default **`placement`** is **`top`** (center-aligned). An empty **`label`** hides the bubble the same way as **`disabled`**.
4
+
5
+ ## Basic example
6
+
3
7
  <sonic-code>
4
8
  <template>
5
- <sonic-tooltip label="Tada : Text appears">hover me</sonic-tooltip>
9
+ <sonic-tooltip label="Additional context on hover">
10
+ <sonic-button variant="outline">Hover me</sonic-button>
11
+ </sonic-tooltip>
6
12
  </template>
7
13
  </sonic-code>
8
14
 
9
15
  ## Placement
10
16
 
11
- By default, tooltip is centered next to its content
17
+ Use **`top`** · **`bottom`** · **`left`** · **`right`** for the main axis. Add **`-start`** or **`-end`** to align the bubble along the anchor edge.
12
18
 
13
19
  <sonic-code>
14
20
  <template>
15
- <sonic-tooltip placement="top" label="Top text">
16
- <sonic-button>Top</sonic-button>
17
- </sonic-tooltip>
18
- <sonic-tooltip placement="bottom" label="Bottom text">
19
- <sonic-button>Bottom</sonic-button>
20
- </sonic-tooltip>
21
- <sonic-tooltip placement="right" label="Right text">
22
- <sonic-button>Right</sonic-button>
23
- </sonic-tooltip>
24
- <sonic-tooltip placement="left" label="Left text">
25
- <sonic-button>Left</sonic-button>
26
- </sonic-tooltip>
21
+ <div class="flex flex-wrap gap-2">
22
+ <sonic-tooltip placement="top" label="Top">
23
+ <sonic-button>Top</sonic-button>
24
+ </sonic-tooltip>
25
+ <sonic-tooltip placement="bottom" label="Bottom">
26
+ <sonic-button>Bottom</sonic-button>
27
+ </sonic-tooltip>
28
+ <sonic-tooltip placement="right" label="Right">
29
+ <sonic-button>Right</sonic-button>
30
+ </sonic-tooltip>
31
+ <sonic-tooltip placement="left" label="Left">
32
+ <sonic-button>Left</sonic-button>
33
+ </sonic-tooltip>
34
+ </div>
27
35
  </template>
28
36
  </sonic-code>
29
37
 
30
- ## More placement
38
+ ### Aligned placements
39
+
31
40
  <sonic-code>
32
41
  <template>
33
42
  <div class="flex gap-3 flex-wrap">
34
- <sonic-tooltip placement="top-start" label="My tooltip">
43
+ <sonic-tooltip placement="top-start" label="Top start">
35
44
  <sonic-button class="size-40 bg-neutral-200 rounded-lg">Top start</sonic-button>
36
45
  </sonic-tooltip>
37
- <sonic-tooltip placement="top-end" label="My tooltip">
46
+ <sonic-tooltip placement="top-end" label="Top end">
38
47
  <sonic-button class="size-40 bg-neutral-200 rounded-lg">Top end</sonic-button>
39
48
  </sonic-tooltip>
40
- <sonic-tooltip placement="bottom-start" label="My tooltip">
49
+ <sonic-tooltip placement="bottom-start" label="Bottom start">
41
50
  <sonic-button class="size-40 bg-neutral-200 rounded-lg">Bottom start</sonic-button>
42
51
  </sonic-tooltip>
43
- <sonic-tooltip placement="bottom-end" label="My tooltip">
52
+ <sonic-tooltip placement="bottom-end" label="Bottom end">
44
53
  <sonic-button class="size-40 bg-neutral-200 rounded-lg">Bottom end</sonic-button>
45
54
  </sonic-tooltip>
46
- <sonic-tooltip placement="right-start" label="My tooltip">
55
+ <sonic-tooltip placement="right-start" label="Right start">
47
56
  <sonic-button class="size-40 bg-neutral-200 rounded-lg">Right start</sonic-button>
48
57
  </sonic-tooltip>
49
- <sonic-tooltip placement="right-end" label="My tooltip">
58
+ <sonic-tooltip placement="right-end" label="Right end">
50
59
  <sonic-button class="size-40 bg-neutral-200 rounded-lg">Right end</sonic-button>
51
60
  </sonic-tooltip>
52
- <sonic-tooltip placement="left-start" label="My tooltip">
61
+ <sonic-tooltip placement="left-start" label="Left start">
53
62
  <sonic-button class="size-40 bg-neutral-200 rounded-lg">Left start</sonic-button>
54
63
  </sonic-tooltip>
55
- <sonic-tooltip placement="left-end" label="My tooltip">
64
+ <sonic-tooltip placement="left-end" label="Left end">
56
65
  <sonic-button class="size-40 bg-neutral-200 rounded-lg">Left end</sonic-button>
57
66
  </sonic-tooltip>
58
67
  </div>
59
68
  </template>
60
69
  </sonic-code>
61
70
 
71
+ ## Label formatting
72
+
73
+ **`label`** supports line breaks (`\n` in markup or multiline text). Lines are trimmed; **`&nbsp;`** is normalized to spaces. **`white-space: pre-line`** preserves breaks in the bubble (max width **18rem**).
74
+
75
+ <sonic-code>
76
+ <template>
77
+ <sonic-tooltip label="First line&#10;Second line">
78
+ <sonic-button variant="outline">Multiline label</sonic-button>
79
+ </sonic-tooltip>
80
+ </template>
81
+ </sonic-code>
62
82
 
63
83
  ## Focusable
64
84
 
85
+ On non-interactive triggers, set **`focusable`** so the host gets **`tabindex="0"`** and the tooltip opens on keyboard focus as well as hover.
86
+
65
87
  <sonic-code>
66
88
  <template>
67
- <sonic-tooltip focusable label="The Concorde made its first transatlantic crossing on September 26, 1973">
68
- <sonic-icon library="iconoir" name="chat-bubble-question"></sonic-icon> Focus or hover non interactive element
89
+ <sonic-tooltip
90
+ focusable
91
+ label="The Concorde made its first transatlantic crossing on September 26, 1973"
92
+ >
93
+ <sonic-icon library="iconoir" name="chat-bubble-question"></sonic-icon>
94
+ Focus or hover this text
69
95
  </sonic-tooltip>
70
96
  </template>
71
97
  </sonic-code>
@@ -74,9 +100,25 @@ By default, tooltip is centered next to its content
74
100
 
75
101
  <sonic-code>
76
102
  <template>
77
- <sonic-tooltip disabled label="Disabled text">
103
+ <sonic-tooltip disabled label="Not shown">
78
104
  <sonic-button>Tooltip disabled</sonic-button>
79
105
  </sonic-tooltip>
80
106
  </template>
81
107
  </sonic-code>
82
108
 
109
+ ## Accessibility
110
+
111
+ The bubble uses **`popover="hint"`** (non-modal, no pointer capture). Prefer **`focusable`** when the slotted content is not already focusable. When the Popover API is unavailable, the host falls back to the native **`title`** attribute with a single-line label. Opacity transitions respect **`prefers-reduced-motion`**.
112
+
113
+ ## API reference
114
+
115
+ | Attribute | Values | Description |
116
+ |----------|---------|-------------|
117
+ | `label` | string | Tooltip copy; whitespace-only values suppress the bubble |
118
+ | `placement` | `top` (default) · `bottom` · `left` · `right` · `top-start` · `top-end` · `bottom-start` · `bottom-end` · `left-start` · `left-end` · `right-start` · `right-end` | Position relative to the trigger; repositions while open |
119
+ | `disabled` | boolean | Suppress show/hide; sets **`tabindex="-1"`** on the host |
120
+ | `focusable` | boolean | Add **`tabindex="0"`** on the host for keyboard access |
121
+
122
+ | Slot | Description |
123
+ |------|-------------|
124
+ | *(default)* | Trigger content (hover target) |
@@ -236,4 +236,30 @@ describe("ancestorAttribute", () => {
236
236
 
237
237
  expect(el.dataProvider).toBe("scope-a");
238
238
  });
239
+
240
+ it("suit un wrapper plus proche après reparent (sans opt-in)", async () => {
241
+ const tag = "test-ancestor-attr-dynamic-reparent";
242
+ class C extends HTMLElement {
243
+ @ancestorAttribute("dataProvider", { dynamic: true })
244
+ dataProvider: string | null = null;
245
+ }
246
+ if (!customElements.get(tag)) {
247
+ customElements.define(tag, C);
248
+ }
249
+
250
+ const outer = document.createElement("div");
251
+ outer.setAttribute("dataProvider", "from-outer");
252
+ const el = document.createElement(tag) as InstanceType<typeof C>;
253
+ outer.appendChild(el);
254
+ document.body.appendChild(outer);
255
+ expect(el.dataProvider).toBe("from-outer");
256
+
257
+ const closer = document.createElement("div");
258
+ closer.setAttribute("dataProvider", "from-closer");
259
+ closer.appendChild(el);
260
+ outer.appendChild(closer);
261
+ await flushFrames();
262
+
263
+ expect(el.dataProvider).toBe("from-closer");
264
+ });
239
265
  });
@@ -5,10 +5,9 @@ import { ConnectedComponent, setSubscribable } from "./common";
5
5
 
6
6
  export type AncestorAttributeOptions = {
7
7
  /**
8
- * Si true, le composant reste synchronisé avec les attributs ancêtres via
9
- * `AncestorAttributeObserver` (mutations + reparenting). Requis pour que
10
- * `@bind` / `@subscribe` avec `${dataProvider}` se re-lient quand le scope change.
11
- * Défaut : false (lecture unique au connect, rétrocompat).
8
+ * Si true, reste synchronisé quand un attribut ancêtre change **et** au
9
+ * reparenting (sans flag séparé). MO attributs partagés ; childList seulement
10
+ * sur le parent direct (ignoré pour les siblings). Défaut : false.
12
11
  */
13
12
  dynamic?: boolean;
14
13
  };
@@ -3,13 +3,14 @@ import HTML, { SearchableDomElement } from "./HTML";
3
3
  /**
4
4
  * Observateur pour `@ancestorAttribute({ dynamic: true })`.
5
5
  *
6
- * Un MutationObserver sur `document` ne voit pas les mutations dans les shadow
7
- * roots (Lit par défaut). Chaque abonnement observe donc directement les nœuds
8
- * de sa chaîne d'ancêtres (composant → parent → … → host shadow), comme
9
- * `watchScopedConfiguration` dans `api.ts`.
6
+ * ## Perf (FF / listes denses d'icônes)
7
+ * Ancien modèle : 1 MO/abonnement avec `childList` sur **toute** la chaîne +
8
+ * `reconnect()` à chaque mutation → O(n²) quand une list insert des siblings.
10
9
  *
11
- * Après un `childList` (wrapper plus proche, reparenting), la chaîne est
12
- * recalculée pour écouter les nouveaux ancêtres.
10
+ * Modèle actuel :
11
+ * 1. **MO attributs partagés** — 1 observer par `(ancêtre, attributeName)`.
12
+ * 2. **Reparent** — 1 MO `childList` partagé sur le parent direct ; on ne
13
+ * réagit que si *notre* nœud est dans added/removed (insert sibling → no-op).
13
14
  */
14
15
 
15
16
  type SyncOptions = {
@@ -22,7 +23,18 @@ type Subscription = {
22
23
  attributeName: string;
23
24
  propertyKey: string;
24
25
  syncOptions: SyncOptions;
25
- disconnectObservers: (() => void) | null;
26
+ watchedAncestors: Element[];
27
+ parentForReparent: Element | null;
28
+ };
29
+
30
+ type AttrSlot = {
31
+ observer: MutationObserver;
32
+ subscriptions: Set<Subscription>;
33
+ };
34
+
35
+ type ParentSlot = {
36
+ observer: MutationObserver;
37
+ byComponent: Map<Element, Set<Subscription>>;
26
38
  };
27
39
 
28
40
  /** Chaîne composant → ancêtres, en traversant les shadow roots. */
@@ -40,58 +52,17 @@ function walkAncestorElements(node: Node): Element[] {
40
52
  return elements;
41
53
  }
42
54
 
43
- /**
44
- * Observe attributs + enfants directs de chaque ancêtre.
45
- * Retourne une fonction de nettoyage (disconnect).
46
- */
47
- function attachAncestorChainObservers(
48
- subscription: Subscription,
49
- onMutation: () => void,
50
- ): () => void {
51
- const observers: MutationObserver[] = [];
52
- let disconnected = false;
53
- const attributeFilter = HTML.scopeAttributeNames(subscription.attributeName);
54
-
55
- const reconnect = () => {
56
- for (const observer of observers) {
57
- observer.disconnect();
58
- }
59
- observers.length = 0;
60
- if (disconnected) return;
61
-
62
- for (const element of walkAncestorElements(subscription.component)) {
63
- const observer = new MutationObserver((records) => {
64
- if (disconnected) return;
65
- if (records.some((record) => record.type === "childList")) {
66
- reconnect();
67
- }
68
- onMutation();
69
- });
70
- observer.observe(element, {
71
- attributes: true,
72
- attributeFilter,
73
- childList: true,
74
- subtree: false,
75
- });
76
- observers.push(observer);
77
- }
78
- };
79
-
80
- reconnect();
81
-
82
- return () => {
83
- disconnected = true;
84
- for (const observer of observers) {
85
- observer.disconnect();
86
- }
87
- observers.length = 0;
88
- };
55
+ function directParentElement(component: HTMLElement): Element | null {
56
+ const parent =
57
+ component.parentNode ||
58
+ ((component.getRootNode() as ShadowRoot)?.host ?? null);
59
+ return parent instanceof Element ? parent : null;
89
60
  }
90
61
 
91
62
  /**
92
63
  * Point unique de synchronisation DOM → propriété du composant.
93
64
  * N'assigne que si la valeur change (`Object.is`) pour limiter les re-renders Lit
94
- * et éviter de reboucler inutilement `observeDynamicProperty` (@bind / @subscribe).
65
+ * et éviter de reboucler inutilement `observeDynamicProperty` (@bind / `@subscribe`).
95
66
  */
96
67
  export function syncAncestorAttributeValue(
97
68
  component: object,
@@ -115,8 +86,11 @@ export default class AncestorAttributeObserver {
115
86
  private static pendingRefresh = new Set<Subscription>();
116
87
  private static animationFrameId: number | null = null;
117
88
 
89
+ private static attrSlots = new WeakMap<Element, Map<string, AttrSlot>>();
90
+ private static parentSlots = new WeakMap<Element, ParentSlot>();
91
+
118
92
  /**
119
- * Enregistre un composant en mode `{ dynamic: true }`.
93
+ * Enregistre un composant en mode `{ dynamic: true }` (attributs + reparent).
120
94
  * Le `unsubscribe` retourné doit être appelé au `disconnectedCallback`.
121
95
  */
122
96
  static subscribe(
@@ -130,32 +104,172 @@ export default class AncestorAttributeObserver {
130
104
  attributeName,
131
105
  propertyKey,
132
106
  syncOptions,
133
- disconnectObservers: null,
107
+ watchedAncestors: [],
108
+ parentForReparent: null,
134
109
  };
135
110
 
136
- subscription.disconnectObservers = attachAncestorChainObservers(
137
- subscription,
138
- () => {
139
- if (!AncestorAttributeObserver.subscriptions.has(subscription)) return;
140
- AncestorAttributeObserver.pendingRefresh.add(subscription);
141
- AncestorAttributeObserver.scheduleRefresh();
142
- },
143
- );
144
-
145
111
  AncestorAttributeObserver.subscriptions.add(subscription);
112
+ AncestorAttributeObserver.bindSubscription(subscription);
146
113
 
147
114
  return () => {
148
115
  AncestorAttributeObserver.unsubscribe(subscription);
149
116
  };
150
117
  }
151
118
 
152
- /** Coupe les observateurs et retire l'abonnement (pas de fuite après disconnect). */
119
+ private static bindSubscription(subscription: Subscription): void {
120
+ AncestorAttributeObserver.unbindWatchers(subscription);
121
+
122
+ const ancestors = walkAncestorElements(subscription.component);
123
+ subscription.watchedAncestors = ancestors;
124
+
125
+ for (const ancestor of ancestors) {
126
+ AncestorAttributeObserver.addAttrSlot(
127
+ ancestor,
128
+ subscription.attributeName,
129
+ subscription,
130
+ );
131
+ }
132
+
133
+ const parent = directParentElement(subscription.component);
134
+ subscription.parentForReparent = parent;
135
+ if (parent) {
136
+ AncestorAttributeObserver.addParentSlot(parent, subscription);
137
+ }
138
+
139
+ AncestorAttributeObserver.pendingRefresh.add(subscription);
140
+ AncestorAttributeObserver.scheduleRefresh();
141
+ }
142
+
143
+ private static unbindWatchers(subscription: Subscription): void {
144
+ for (const ancestor of subscription.watchedAncestors) {
145
+ AncestorAttributeObserver.removeAttrSlot(
146
+ ancestor,
147
+ subscription.attributeName,
148
+ subscription,
149
+ );
150
+ }
151
+ subscription.watchedAncestors = [];
152
+
153
+ if (subscription.parentForReparent) {
154
+ AncestorAttributeObserver.removeParentSlot(
155
+ subscription.parentForReparent,
156
+ subscription,
157
+ );
158
+ subscription.parentForReparent = null;
159
+ }
160
+ }
161
+
162
+ private static addAttrSlot(
163
+ element: Element,
164
+ attributeName: string,
165
+ subscription: Subscription,
166
+ ): void {
167
+ let byAttr = AncestorAttributeObserver.attrSlots.get(element);
168
+ if (!byAttr) {
169
+ byAttr = new Map();
170
+ AncestorAttributeObserver.attrSlots.set(element, byAttr);
171
+ }
172
+
173
+ let slot = byAttr.get(attributeName);
174
+ if (!slot) {
175
+ const attributeFilter = HTML.scopeAttributeNames(attributeName);
176
+ const subscriptions = new Set<Subscription>();
177
+ const observer = new MutationObserver(() => {
178
+ for (const sub of subscriptions) {
179
+ if (!AncestorAttributeObserver.subscriptions.has(sub)) continue;
180
+ AncestorAttributeObserver.pendingRefresh.add(sub);
181
+ }
182
+ AncestorAttributeObserver.scheduleRefresh();
183
+ });
184
+ observer.observe(element, {
185
+ attributes: true,
186
+ attributeFilter,
187
+ });
188
+ slot = { observer, subscriptions };
189
+ byAttr.set(attributeName, slot);
190
+ }
191
+
192
+ slot.subscriptions.add(subscription);
193
+ }
194
+
195
+ private static removeAttrSlot(
196
+ element: Element,
197
+ attributeName: string,
198
+ subscription: Subscription,
199
+ ): void {
200
+ const byAttr = AncestorAttributeObserver.attrSlots.get(element);
201
+ if (!byAttr) return;
202
+ const slot = byAttr.get(attributeName);
203
+ if (!slot) return;
204
+
205
+ slot.subscriptions.delete(subscription);
206
+ if (slot.subscriptions.size > 0) return;
207
+
208
+ slot.observer.disconnect();
209
+ byAttr.delete(attributeName);
210
+ }
211
+
212
+ private static addParentSlot(
213
+ parent: Element,
214
+ subscription: Subscription,
215
+ ): void {
216
+ let slot = AncestorAttributeObserver.parentSlots.get(parent);
217
+ if (!slot) {
218
+ const byComponent = new Map<Element, Set<Subscription>>();
219
+ const observer = new MutationObserver((records) => {
220
+ const touched = new Set<Element>();
221
+ for (const record of records) {
222
+ record.removedNodes.forEach((n) => {
223
+ if (n instanceof Element) touched.add(n);
224
+ });
225
+ record.addedNodes.forEach((n) => {
226
+ if (n instanceof Element) touched.add(n);
227
+ });
228
+ }
229
+ for (const el of touched) {
230
+ const subs = byComponent.get(el);
231
+ if (!subs) continue;
232
+ for (const sub of [...subs]) {
233
+ if (!AncestorAttributeObserver.subscriptions.has(sub)) continue;
234
+ AncestorAttributeObserver.bindSubscription(sub);
235
+ }
236
+ }
237
+ });
238
+ observer.observe(parent, { childList: true });
239
+ slot = { observer, byComponent };
240
+ AncestorAttributeObserver.parentSlots.set(parent, slot);
241
+ }
242
+
243
+ let set = slot.byComponent.get(subscription.component);
244
+ if (!set) {
245
+ set = new Set();
246
+ slot.byComponent.set(subscription.component, set);
247
+ }
248
+ set.add(subscription);
249
+ }
250
+
251
+ private static removeParentSlot(
252
+ parent: Element,
253
+ subscription: Subscription,
254
+ ): void {
255
+ const slot = AncestorAttributeObserver.parentSlots.get(parent);
256
+ if (!slot) return;
257
+
258
+ const set = slot.byComponent.get(subscription.component);
259
+ if (set) {
260
+ set.delete(subscription);
261
+ if (set.size === 0) slot.byComponent.delete(subscription.component);
262
+ }
263
+
264
+ if (slot.byComponent.size > 0) return;
265
+ slot.observer.disconnect();
266
+ AncestorAttributeObserver.parentSlots.delete(parent);
267
+ }
268
+
153
269
  private static unsubscribe(subscription: Subscription): void {
154
270
  if (!AncestorAttributeObserver.subscriptions.has(subscription)) return;
155
271
 
156
- subscription.disconnectObservers?.();
157
- subscription.disconnectObservers = null;
158
-
272
+ AncestorAttributeObserver.unbindWatchers(subscription);
159
273
  AncestorAttributeObserver.subscriptions.delete(subscription);
160
274
  AncestorAttributeObserver.pendingRefresh.delete(subscription);
161
275
 
@@ -187,7 +301,6 @@ export default class AncestorAttributeObserver {
187
301
 
188
302
  for (const subscription of subscriptions) {
189
303
  if (!AncestorAttributeObserver.subscriptions.has(subscription)) continue;
190
- if (!subscription.component.isConnected) continue;
191
304
  syncAncestorAttributeValue(
192
305
  subscription.component,
193
306
  subscription.attributeName,
@@ -69,8 +69,9 @@ This decorator is particularly useful for:
69
69
  - By default (`dynamic: false`), the value is read once at connect time and is not updated afterward
70
70
  - With `{ dynamic: true }`, the property stays in sync when:
71
71
  - an ancestor **changes** the attribute value
72
- - a **closer** ancestor **gains** the attribute (that value wins)
73
- - the **nearest** ancestor **loses** the attribute (falls back to the next one up, or `null`)
72
+ - a **closer** ancestor **gains** / **loses** the attribute (fallback / `null`)
73
+ - the component is **reparented** (no extra opt-in) — parent `childList` only reacts if *this* node moves
74
+ - Observers are **shared** (one per ancestor attribute / one per parent) so large lists of `sonic-icon` stay cheap on Firefox.
74
75
  - When `dynamic` is enabled, updates also propagate to `@subscribe` / `@bind` paths that depend on the decorated property (e.g. `${dataProvider}`)
75
76
 
76
77
  ### Dynamic mode
@@ -3,7 +3,7 @@
3
3
  ## What is Concorde?
4
4
 
5
5
  Based on **[lit.dev](https://lit.dev)**, Concorde is a collection of web components for shared apps and websites.
6
- Build UIs without tying components to a specific host stack, while keeping visual consistency through a small set of CSS variables.
6
+ Build UIs without tying components to a specific host stack, while keeping visual consistency through the smallest set of CSS variables needed to define a theme.
7
7
 
8
8
  ## Why and use case
9
9