@supersoniks/concorde 4.5.2 → 4.7.0

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 (185) hide show
  1. package/.gitlab-ci.yml +23 -0
  2. package/README.md +106 -55
  3. package/ai/AGENTS.md +52 -0
  4. package/ai/README.md +30 -0
  5. package/ai/cursor/rules/concorde-menu.mdc +15 -0
  6. package/ai/cursor/rules/concorde-scope.mdc +14 -0
  7. package/ai/cursor/rules/concorde-theme.mdc +13 -0
  8. package/ai/cursor/rules/concorde.mdc +49 -0
  9. package/ai/jetbrains/rules/concorde.md +39 -0
  10. package/ai/skills/concorde/SKILL.md +220 -0
  11. package/ai/skills/concorde-get-set-dp/SKILL.md +194 -0
  12. package/ai/skills/concorde-imports/SKILL.md +78 -0
  13. package/ai/skills/concorde-menu/SKILL.md +74 -0
  14. package/ai/skills/concorde-scope/SKILL.md +70 -0
  15. package/ai/skills/concorde-theme/SKILL.md +46 -0
  16. package/build-infos.json +1 -1
  17. package/concorde-core.bundle.js +159 -159
  18. package/concorde-core.es.js +1915 -1809
  19. package/dist/altcha-widget.js +2662 -0
  20. package/dist/concorde-core.bundle.js +159 -159
  21. package/dist/concorde-core.es.js +1915 -1809
  22. package/dist/docs-mock-api-sw.js +589 -0
  23. package/dist/docs-mock-api-sw.js.map +7 -0
  24. package/docs/altcha-widget.js +2662 -0
  25. package/docs/assets/index-D9pxaQYK.js +7508 -0
  26. package/docs/assets/index-t0-i22oI.css +1 -0
  27. package/docs/docs-mock-api-sw.js +589 -0
  28. package/docs/docs-mock-api-sw.js.map +7 -0
  29. package/docs/index.html +2 -2
  30. package/docs/src/core/components/functional/fetch/fetch.md +13 -11
  31. package/docs/src/core/components/functional/if/if.md +4 -11
  32. package/docs/src/core/components/functional/list/list.md +60 -194
  33. package/docs/src/core/components/functional/queue/queue.md +70 -85
  34. package/docs/src/core/components/functional/router/router.md +62 -97
  35. package/docs/src/core/components/functional/states/states.md +2 -2
  36. package/docs/src/core/components/functional/submit/submit.md +86 -55
  37. package/docs/src/core/components/ui/captcha/captcha.md +2 -2
  38. package/docs/src/core/components/ui/card/card.md +1 -1
  39. package/docs/src/core/components/ui/form/checkbox/checkbox.md +5 -32
  40. package/docs/src/core/components/ui/form/input/input.md +5 -30
  41. package/docs/src/core/components/ui/form/input-autocomplete/input-autocomplete.md +6 -4
  42. package/docs/src/core/components/ui/form/radio/radio.md +5 -32
  43. package/docs/src/core/components/ui/form/select/select.md +5 -31
  44. package/docs/src/core/components/ui/form/switch/switch.md +5 -32
  45. package/docs/src/core/components/ui/loader/loader.md +1 -13
  46. package/docs/src/core/components/ui/table/table.md +3 -3
  47. package/docs/src/docs/_core-concept/dataFlow.md +73 -0
  48. package/docs/src/docs/_core-concept/subscriber.md +9 -10
  49. package/docs/src/docs/_decorators/ancestor-attribute.md +4 -3
  50. package/docs/src/docs/_decorators/auto-subscribe.md +19 -16
  51. package/docs/src/docs/_decorators/bind.md +20 -17
  52. package/docs/src/docs/_decorators/get.md +7 -4
  53. package/docs/src/docs/_decorators/handle.md +171 -0
  54. package/docs/src/docs/_decorators/on-assign.md +99 -73
  55. package/docs/src/docs/_decorators/publish.md +2 -1
  56. package/docs/src/docs/_decorators/subscribe.md +70 -9
  57. package/docs/src/docs/_decorators/wait-for-ancestors.md +13 -10
  58. package/docs/src/docs/_directives/sub.md +91 -0
  59. package/docs/src/docs/_getting-started/ai-agents.md +56 -0
  60. package/docs/src/docs/_getting-started/concorde-manual-install.md +133 -0
  61. package/docs/src/docs/_getting-started/concorde-outside.md +13 -123
  62. package/docs/src/docs/_getting-started/create-a-component.md +2 -0
  63. package/docs/src/docs/_getting-started/my-first-component.md +236 -0
  64. package/docs/src/docs/_getting-started/my-first-subscriber.md +29 -83
  65. package/docs/src/docs/_getting-started/pubsub.md +21 -134
  66. package/docs/src/docs/_getting-started/start.md +26 -18
  67. package/docs/src/docs/_misc/api-configuration.md +79 -0
  68. package/docs/src/docs/_misc/dataProviderKey.md +38 -5
  69. package/docs/src/docs/_misc/docs-mock-api.md +60 -0
  70. package/docs/src/docs/_misc/endpoint.md +2 -1
  71. package/docs/src/docs/_misc/html-integration.md +13 -0
  72. package/docs/src/docs/search/docs-search.json +4163 -873
  73. package/docs/src/tsconfig.json +380 -317
  74. package/gitlab/job_tests.sh +55 -0
  75. package/package.json +37 -3
  76. package/public/altcha-widget.js +2662 -0
  77. package/public/docs-mock-api-sw.js +589 -0
  78. package/public/docs-mock-api-sw.js.map +7 -0
  79. package/scripts/ai-init.mjs +167 -0
  80. package/scripts/docs-mock-api-vite-plugin.ts +116 -0
  81. package/scripts/docs-open-in-editor-plugin.ts +130 -0
  82. package/scripts/pre-publish.mjs +2 -1
  83. package/src/core/components/functional/example/example.ts +1 -1
  84. package/src/core/components/functional/fetch/fetch.md +13 -11
  85. package/src/core/components/functional/if/if.md +4 -11
  86. package/src/core/components/functional/list/list.demo.ts +4 -4
  87. package/src/core/components/functional/list/list.md +60 -194
  88. package/src/core/components/functional/list/list.ts +8 -7
  89. package/src/core/components/functional/queue/queue.demo.ts +1 -1
  90. package/src/core/components/functional/queue/queue.md +70 -85
  91. package/src/core/components/functional/queue/queue.ts +4 -4
  92. package/src/core/components/functional/router/router.md +62 -97
  93. package/src/core/components/functional/router/router.ts +1 -1
  94. package/src/core/components/functional/states/states.md +2 -2
  95. package/src/core/components/functional/submit/submit.md +86 -55
  96. package/src/core/components/functional/submit/submit.ts +10 -3
  97. package/src/core/components/ui/captcha/captcha.md +2 -2
  98. package/src/core/components/ui/card/card.md +1 -1
  99. package/src/core/components/ui/form/checkbox/checkbox.md +5 -32
  100. package/src/core/components/ui/form/input/input.md +5 -30
  101. package/src/core/components/ui/form/input-autocomplete/input-autocomplete.md +6 -4
  102. package/src/core/components/ui/form/radio/radio.md +5 -32
  103. package/src/core/components/ui/form/select/select.md +5 -31
  104. package/src/core/components/ui/form/switch/switch.md +5 -32
  105. package/src/core/components/ui/loader/loader.md +1 -13
  106. package/src/core/components/ui/table/table.md +3 -3
  107. package/src/core/decorators/Subscriber.ts +2 -0
  108. package/src/core/decorators/subscriber/handle.disambig.spec.ts +20 -0
  109. package/src/core/decorators/subscriber/handle.skip.spec.ts +37 -0
  110. package/src/core/decorators/subscriber/handle.ts +128 -0
  111. package/src/core/decorators/subscriber/onAssign.ts +94 -4
  112. package/src/core/directives/DataProvider.sub.spec.ts +96 -0
  113. package/src/core/directives/DataProvider.ts +109 -40
  114. package/src/core/utils/PublisherProxy.ts +33 -18
  115. package/src/core/utils/dataProviderKey.ts +23 -0
  116. package/src/core/utils/publisherPathKey.spec.ts +58 -0
  117. package/src/decorators.ts +6 -0
  118. package/src/docs/_core-concept/dataFlow.md +73 -0
  119. package/src/docs/_core-concept/subscriber.md +9 -10
  120. package/src/docs/_decorators/ancestor-attribute.md +4 -3
  121. package/src/docs/_decorators/auto-subscribe.md +19 -16
  122. package/src/docs/_decorators/bind.md +20 -17
  123. package/src/docs/_decorators/get.md +7 -4
  124. package/src/docs/_decorators/handle.md +171 -0
  125. package/src/docs/_decorators/on-assign.md +99 -47
  126. package/src/docs/_decorators/publish.md +2 -1
  127. package/src/docs/_decorators/subscribe.md +70 -9
  128. package/src/docs/_decorators/wait-for-ancestors.md +13 -10
  129. package/src/docs/_directives/sub.md +91 -0
  130. package/src/docs/_getting-started/ai-agents.md +56 -0
  131. package/src/docs/_getting-started/concorde-manual-install.md +133 -0
  132. package/src/docs/_getting-started/concorde-outside.md +13 -123
  133. package/src/docs/_getting-started/create-a-component.md +2 -0
  134. package/src/docs/_getting-started/my-first-component.md +236 -0
  135. package/src/docs/_getting-started/my-first-subscriber.md +29 -83
  136. package/src/docs/_getting-started/pubsub.md +21 -134
  137. package/src/docs/_getting-started/start.md +26 -18
  138. package/src/docs/_misc/api-configuration.md +79 -0
  139. package/src/docs/_misc/dataProviderKey.md +38 -5
  140. package/src/docs/_misc/docs-mock-api.md +60 -0
  141. package/src/docs/_misc/endpoint.md +2 -1
  142. package/src/docs/_misc/html-integration.md +13 -0
  143. package/src/docs/code.ts +58 -12
  144. package/src/docs/components/docs-demo-sources.ts +397 -0
  145. package/src/docs/components/docs-lit-demo-raw.ts +28 -0
  146. package/src/docs/components/docs-lit-demo.ts +166 -0
  147. package/src/docs/components/docs-source-link.ts +72 -0
  148. package/src/docs/docs-location.ts +54 -0
  149. package/src/docs/docs.ts +12 -0
  150. package/src/docs/example/decorators-demo-bind-demos.ts +41 -46
  151. package/src/docs/example/decorators-demo-geo.ts +16 -11
  152. package/src/docs/example/decorators-demo-init.ts +2 -228
  153. package/src/docs/example/decorators-demo-subscribe-publish-get-demos.ts +142 -12
  154. package/src/docs/example/decorators-demo.ts +71 -70
  155. package/src/docs/example/docs-api-config-demos.ts +234 -0
  156. package/src/docs/example/docs-joke-demos.ts +297 -0
  157. package/src/docs/example/docs-list-demos.ts +179 -0
  158. package/src/docs/example/docs-provider-keys.ts +315 -0
  159. package/src/docs/example/docs-queue-demos.ts +114 -0
  160. package/src/docs/example/docs-router-demos.ts +89 -0
  161. package/src/docs/example/docs-submit-demos.ts +455 -0
  162. package/src/docs/example/docs-toggle-demos.ts +73 -0
  163. package/src/docs/example/docs-user-two-scopes.ts +37 -0
  164. package/src/docs/example/docs-users-list.ts +71 -0
  165. package/src/docs/example/users.ts +41 -24
  166. package/src/docs/mock-api/api-config-mock.ts +152 -0
  167. package/src/docs/mock-api/fixtures.ts +377 -0
  168. package/src/docs/mock-api/register.ts +25 -0
  169. package/src/docs/mock-api/router.ts +234 -0
  170. package/src/docs/mock-api/service-worker.ts +23 -0
  171. package/src/docs/mock-api/urls.ts +11 -0
  172. package/src/docs/navigation/navigation.ts +43 -7
  173. package/src/docs/search/docs-search.json +4193 -858
  174. package/src/docs/search/markdown-renderer.ts +7 -3
  175. package/src/docs/search/page.ts +11 -14
  176. package/src/docs/search/sonic-code-markdown.spec.ts +29 -0
  177. package/src/docs/search/sonic-code-markdown.ts +28 -0
  178. package/src/docs.ts +4 -0
  179. package/src/tsconfig.json +96 -0
  180. package/src/tsconfig.tsbuildinfo +1 -1
  181. package/vite.config.mts +8 -0
  182. package/docs/assets/index-CaysOMFz.js +0 -5046
  183. package/docs/assets/index-D8mGoXzF.css +0 -1
  184. package/docs/src/docs/_misc/templates-demo.md +0 -19
  185. package/src/docs/_misc/templates-demo.md +0 -19
@@ -64,34 +64,8 @@
64
64
  </template>
65
65
  </sonic-code>
66
66
 
67
- ## Example of use
68
- <sonic-code>
69
- <template>
70
- <sonic-select
71
- formDataProvider="select-filter"
72
- name="lang"
73
- value="fr"
74
- >
75
- <option value="fr">fr<option>
76
- <optionn value="en">en<option>
77
- </sonic-select>
78
- <sonic-subscriber dataProvider="select-filter" class="text-xl my-4 block font-bold">
79
- Blagues trouvées pour le code de langue"<span data-bind ::inner-html="$lang"></span>" :
80
- </sonic-subscriber>
81
- <sonic-queue
82
- lazyload
83
- dataProviderExpression="joke/Any?amount=10"
84
- dataFilterProvider="select-filter"
85
- serviceURL="https://v2.jokeapi.dev"
86
- key="jokes"
87
- >
88
- <template>
89
- <div class="border-0 border-b-[1px] border-b-neutral-300 border-dotted py-3">
90
- <div data-bind ::inner-html="$joke"></div>
91
- <div data-bind ::inner-html="$setup"></div>
92
- <div data-bind ::inner-html="$delivery"></div>
93
- </div>
94
- </template>
95
- </sonic-queue>
96
- </template>
97
- </sonic-code>
67
+ ## Example of use — language filter + queue
68
+
69
+ **`name="lang"`** is sent as `?lang=fr|en` to the [doc mock API](#docs/_misc/docs-mock-api.md/docs-mock-api) (`filterDocsJokes`). Same pattern as [Input](#core/components/ui/form/input/input.md/input) (`contains`):
70
+
71
+ <docs-lit-demo for="docs-joke-lang-demo"></docs-lit-demo>
@@ -50,35 +50,8 @@
50
50
  </sonic-code>
51
51
 
52
52
 
53
- ## Example of use
54
- <sonic-code>
55
- <template>
56
- <sonic-subscriber dataProvider="jokeFilterswitch" class="text-xl my-4 block font-bold">
57
- Remove following jokes :
58
- <sonic-value key="blacklistFlags" class="block text-sm"></sonic-value>
59
- </sonic-subscriber>
60
- <div formDataProvider="jokeFilterswitch" class="grid grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-2 mt-2 mb-3">
61
- <sonic-switch name="blacklistFlags" value="nsfw">nsfw</sonic-switch>
62
- <sonic-switch name="blacklistFlags" value="religious">religious</sonic-switch>
63
- <sonic-switch name="blacklistFlags" value="political">political</sonic-switch>
64
- <sonic-switch name="blacklistFlags" value="racist" checked >racist</sonic-switch>
65
- <sonic-switch name="blacklistFlags" value="sexist" >sexist</sonic-switch>
66
- <sonic-switch name="blacklistFlags" value="explicit">explicit</sonic-switch>
67
- </div>
68
- <sonic-queue
69
- lazyload
70
- dataProviderExpression="joke/Any?amount=10&lang=en"
71
- dataFilterProvider="jokeFilterswitch"
72
- serviceURL="https://v2.jokeapi.dev"
73
- key="jokes"
74
- >
75
- <template>
76
- <div class="border-0 border-b-[1px] border-b-neutral-300 py-3 leading-tight">
77
- <sonic-value key="joke"></sonic-value>
78
- <sonic-value key="setup" class="font-bold"></sonic-value><br>
79
- <sonic-value key="delivery"></sonic-value>
80
- </div>
81
- </template>
82
- </sonic-queue>
83
- </template>
84
- </sonic-code>
53
+ ## Example of use — blacklist + queue
54
+
55
+ Same mock filter as [Checkbox](#core/components/ui/form/checkbox/checkbox.md/checkbox); switches can enable several flags (comma-separated in the query).
56
+
57
+ <docs-lit-demo for="docs-joke-blacklist-switch-demo"></docs-lit-demo>
@@ -29,19 +29,7 @@
29
29
 
30
30
  ## Fixed mode
31
31
 
32
- <sonic-code >
33
- <template>
34
- <div dataProvider="toggleLoaderForm" formDataProvider="toggleLoaderForm">
35
- <sonic-checkbox label="Show fixed loader"
36
- name="toggleLoader"
37
- unique value="true">
38
- </sonic-checkbox>
39
- <sonic-if data-bind ::condition="$toggleLoader" >
40
- <sonic-loader></sonic-loader>
41
- </sonic-if>
42
- </div>
43
- </template>
44
- </sonic-code>
32
+ <docs-lit-demo for="docs-toggle-loader-demo"></docs-lit-demo>
45
33
 
46
34
 
47
35
  ## Loading button
@@ -42,7 +42,7 @@
42
42
  </sonic-tr>
43
43
  </sonic-thead>
44
44
  <sonic-tbody>
45
- <sonic-list debug fetch serviceURL="https://reqres.in" dataProvider="api/users" key="data" displayContents>
45
+ <sonic-list debug fetch serviceURL="/docs-mock-api" dataProvider="api/users" key="data" displayContents>
46
46
  <template>
47
47
  <sonic-tr>
48
48
  <sonic-td data-bind ::inner-html="$id"></sonic-td>
@@ -135,7 +135,7 @@
135
135
  </sonic-tr>
136
136
  </sonic-thead>
137
137
  <sonic-tbody>
138
- <sonic-list fetch serviceURL="https://reqres.in" dataProvider="api/users" key="data" displayContents>
138
+ <sonic-list fetch serviceURL="/docs-mock-api" dataProvider="api/users" key="data" displayContents>
139
139
  <template>
140
140
  <sonic-tr>
141
141
  <sonic-td data-bind ::inner-html="$id"></sonic-td>
@@ -256,7 +256,7 @@ Every table is responsive by default
256
256
  </sonic-tr>
257
257
  </sonic-thead>
258
258
  <sonic-tbody>
259
- <sonic-list fetch serviceURL="https://reqres.in" dataProvider="api/users" key="data" displayContents>
259
+ <sonic-list fetch serviceURL="/docs-mock-api" dataProvider="api/users" key="data" displayContents>
260
260
  <template>
261
261
  <sonic-tr>
262
262
  <sonic-td data-bind ::inner-html="$id"></sonic-td>
@@ -2,6 +2,8 @@ export { bind } from "./subscriber/bind";
2
2
  export { publish } from "./subscriber/publish";
3
3
  export { subscribe } from "./subscriber/subscribe";
4
4
  export { onAssign } from "./subscriber/onAssign";
5
+ export { handle, Skip } from "./subscriber/handle";
6
+ export type { HandleOptions } from "./subscriber/handle";
5
7
  export { autoSubscribe } from "./subscriber/autoSubscribe";
6
8
  export { autoFill } from "./subscriber/autoFill";
7
9
  export { ancestorAttribute } from "./subscriber/ancestorAttribute";
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DataProviderKey } from "../../utils/dataProviderKey";
3
+
4
+ function isDataProviderKey(value: unknown): boolean {
5
+ return Object.prototype.toString.call(value) === "[object DataProviderKey]";
6
+ }
7
+
8
+ describe("handle: désambiguïsation clé vs options", () => {
9
+ it("reconnaît une DataProviderKey (y compris après navigation)", () => {
10
+ const key = new DataProviderKey<{ total: number }>("cart");
11
+ expect(isDataProviderKey(key)).toBe(true);
12
+ expect(isDataProviderKey(key.total)).toBe(true);
13
+ });
14
+
15
+ it("ne confond pas un objet d'options avec une clé", () => {
16
+ expect(isDataProviderKey({ skip: ["emptyObject"] })).toBe(false);
17
+ expect(isDataProviderKey({ waitForAllDefined: true })).toBe(false);
18
+ expect(isDataProviderKey(undefined)).toBe(false);
19
+ });
20
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Skip, isSkipped } from "./onAssign";
3
+
4
+ describe("isSkipped (catégories Skip de @handle)", () => {
5
+ it("Nullish : null et undefined uniquement", () => {
6
+ expect(isSkipped(null, [Skip.Nullish])).toBe(true);
7
+ expect(isSkipped(undefined, [Skip.Nullish])).toBe(true);
8
+ expect(isSkipped(0, [Skip.Nullish])).toBe(false);
9
+ expect(isSkipped("", [Skip.Nullish])).toBe(false);
10
+ });
11
+
12
+ it("EmptyString : pas de coercition", () => {
13
+ expect(isSkipped("", [Skip.EmptyString])).toBe(true);
14
+ expect(isSkipped("a", [Skip.EmptyString])).toBe(false);
15
+ expect(isSkipped(0, [Skip.EmptyString])).toBe(false);
16
+ });
17
+
18
+ it("EmptyObject : objet sans clé, tableau exclu", () => {
19
+ expect(isSkipped({}, [Skip.EmptyObject])).toBe(true);
20
+ expect(isSkipped({ a: 1 }, [Skip.EmptyObject])).toBe(false);
21
+ expect(isSkipped([], [Skip.EmptyObject])).toBe(false);
22
+ expect(isSkipped(null, [Skip.EmptyObject])).toBe(false);
23
+ });
24
+
25
+ it("EmptyArray : tableau vide, objet exclu", () => {
26
+ expect(isSkipped([], [Skip.EmptyArray])).toBe(true);
27
+ expect(isSkipped([1], [Skip.EmptyArray])).toBe(false);
28
+ expect(isSkipped({}, [Skip.EmptyArray])).toBe(false);
29
+ });
30
+
31
+ it("combinaison de catégories", () => {
32
+ const kinds = [Skip.Nullish, Skip.EmptyObject];
33
+ expect(isSkipped(null, kinds)).toBe(true);
34
+ expect(isSkipped({}, kinds)).toBe(true);
35
+ expect(isSkipped({ a: 1 }, kinds)).toBe(false);
36
+ });
37
+ });
@@ -0,0 +1,128 @@
1
+ import type {
2
+ DataProviderKey,
3
+ DataProviderKeyHost,
4
+ } from "../../utils/dataProviderKey";
5
+ import { createOnAssign, Skip } from "./onAssign";
6
+
7
+ export { Skip } from "./onAssign";
8
+
9
+ /**
10
+ * Options de `@handle`, nommées d'après les cas d'usage réels rencontrés dans
11
+ * les projets (panier, file d'attente, formulaires multi-publishers, etc.).
12
+ *
13
+ * Par défaut (aucune option), `@handle` appelle la méthode **à chaque
14
+ * assignation**, même quand la valeur reçue est `null`/`undefined` (c'est la
15
+ * différence de comportement voulue par rapport à `@onAssign`).
16
+ */
17
+ export type HandleOptions = {
18
+ /**
19
+ * Attendre que **toutes** les clés surveillées soient définies (non
20
+ * `null`/`undefined`) avant d'appeler la méthode. Reproduit la sémantique
21
+ * historique de `@onAssign`.
22
+ *
23
+ * À utiliser quand la logique combine plusieurs sources et n'a de sens que
24
+ * lorsque toutes sont prêtes (ex. calcul de date à partir de
25
+ * `date` + `timeZone` + `direction`).
26
+ */
27
+ waitForAllDefined?: boolean;
28
+ /**
29
+ * Ne pas appeler la méthode si la valeur reçue appartient à l'une de ces
30
+ * catégories. Chaque entrée est une catégorie nommée (pas une valeur), donc
31
+ * aucune ambiguïté valeur/motif.
32
+ *
33
+ * - `Skip.Nullish` ignore `null`/`undefined` ;
34
+ * - `Skip.EmptyObject` / `Skip.EmptyArray` ignorent `{}` / `[]` ;
35
+ * - `Skip.EmptyString` ignore `""`.
36
+ *
37
+ * Pratique quand un publisher non initialisé émet `{}` comme état « pas encore
38
+ * chargé » : `skip: [Skip.EmptyObject]`. Pour une validation arbitraire sur une
39
+ * valeur précise, faire le test directement dans la méthode.
40
+ */
41
+ skip?: Skip[];
42
+ };
43
+
44
+ type HandleDecorator<Host, Fn> = (
45
+ target: Host,
46
+ propertyKey: string,
47
+ descriptor: TypedPropertyDescriptor<Fn>,
48
+ ) => void;
49
+
50
+ /**
51
+ * Détecte une `DataProviderKey` (Proxy marqué `Symbol.toStringTag`) afin de la
52
+ * distinguer d'un objet d'options passé en dernier argument.
53
+ */
54
+ function isDataProviderKey(value: unknown): value is DataProviderKey<unknown> {
55
+ return Object.prototype.toString.call(value) === "[object DataProviderKey]";
56
+ }
57
+
58
+ /**
59
+ * Callback typé déclenché lorsqu'un (ou plusieurs) chemin(s) publisher sont
60
+ * assignés via `DataProviderKey<T>`. Invoque la méthode décorée avec la/les
61
+ * valeur(s) assignée(s) (calculs, effets de bord, mise à jour d'autres `@state`…).
62
+ *
63
+ * Supporte les chemins dynamiques : placeholders type `"users.${userIndex}"`.
64
+ *
65
+ * Contrairement à `@onAssign`, la méthode est appelée à chaque assignation,
66
+ * même quand la valeur reçue est `null`/`undefined` — sauf si une option
67
+ * (`waitForAllDefined`, `skip`) restreint le déclenchement.
68
+ *
69
+ * @example
70
+ * // Mono-clé
71
+ * const cart = new DataProviderKey<Cart>("cart");
72
+ * @handle(cart.total)
73
+ * updateSummary(total: number) {
74
+ * this.summary = total * this.taxRate;
75
+ * }
76
+ *
77
+ * @example
78
+ * // Multi-clés (coordination de plusieurs publishers)
79
+ * @handle(config.show, idle.isIdle, { waitForAllDefined: true })
80
+ * onModal(show: boolean, isIdle: boolean) {
81
+ * if (show && isIdle) this.open(); else this.close();
82
+ * }
83
+ *
84
+ * @example
85
+ * // Ignorer un objet vide émis par un publisher non initialisé
86
+ * @handle(user.profile, { skip: [Skip.EmptyObject] })
87
+ * onProfile(profile: Profile) { this.name = profile.name; }
88
+ */
89
+ export function handle<A, U = any>(
90
+ key: DataProviderKey<A, U>,
91
+ options?: HandleOptions,
92
+ ): HandleDecorator<DataProviderKeyHost<U>, (a: A) => void>;
93
+ export function handle<A, B, UA = any, UB = any>(
94
+ keyA: DataProviderKey<A, UA>,
95
+ keyB: DataProviderKey<B, UB>,
96
+ options?: HandleOptions,
97
+ ): HandleDecorator<
98
+ DataProviderKeyHost<UA> & DataProviderKeyHost<UB>,
99
+ (a: A, b: B) => void
100
+ >;
101
+ export function handle<A, B, C, UA = any, UB = any, UC = any>(
102
+ keyA: DataProviderKey<A, UA>,
103
+ keyB: DataProviderKey<B, UB>,
104
+ keyC: DataProviderKey<C, UC>,
105
+ options?: HandleOptions,
106
+ ): HandleDecorator<
107
+ DataProviderKeyHost<UA> & DataProviderKeyHost<UB> & DataProviderKeyHost<UC>,
108
+ (a: A, b: B, c: C) => void
109
+ >;
110
+ export function handle(
111
+ ...args: Array<DataProviderKey<unknown> | HandleOptions | undefined>
112
+ ) {
113
+ const last = args[args.length - 1];
114
+ const hasOptions = last !== undefined && !isDataProviderKey(last);
115
+ const options = (hasOptions ? last : {}) as HandleOptions;
116
+ const keys = (hasOptions ? args.slice(0, -1) : args) as Array<
117
+ DataProviderKey<unknown>
118
+ >;
119
+ const paths = keys.map((key) => key.path);
120
+
121
+ return createOnAssign(
122
+ {
123
+ dispatchWhenUndefined: !options.waitForAllDefined,
124
+ skip: options.skip,
125
+ },
126
+ paths,
127
+ );
128
+ }
@@ -23,7 +23,100 @@ type Configuration = {
23
23
  index: number;
24
24
  };
25
25
 
26
+ /**
27
+ * Catégories de valeurs « ignorables » par le garde `skip`. Chaque catégorie est
28
+ * un prédicat nommé (et non une égalité de valeur), ce qui lève toute ambiguïté
29
+ * entre « valeur » et « motif » (notamment pour l'objet vide).
30
+ */
31
+ export enum Skip {
32
+ /**
33
+ * `null` ou `undefined`. En pratique un publisher renvoie toujours `null`
34
+ * (jamais `undefined`) car `get()` coerce `undefined → null`.
35
+ */
36
+ Nullish = "nullish",
37
+ /** Chaîne vide `""`. */
38
+ EmptyString = "emptyString",
39
+ /** Objet sans clé (`{}`), tableau exclu. */
40
+ EmptyObject = "emptyObject",
41
+ /** Tableau vide (`[]`). */
42
+ EmptyArray = "emptyArray",
43
+ }
44
+
45
+ const SKIP_PREDICATES: Record<Skip, (value: unknown) => boolean> = {
46
+ [Skip.Nullish]: (v) => v === null || v === undefined,
47
+ [Skip.EmptyString]: (v) => v === "",
48
+ [Skip.EmptyObject]: (v) =>
49
+ typeof v === "object" &&
50
+ v !== null &&
51
+ !Array.isArray(v) &&
52
+ Object.keys(v).length === 0,
53
+ [Skip.EmptyArray]: (v) => Array.isArray(v) && v.length === 0,
54
+ };
55
+
56
+ /**
57
+ * `true` si `value` appartient à au moins une des catégories `kinds`.
58
+ */
59
+ export function isSkipped(value: unknown, kinds: Skip[]): boolean {
60
+ return kinds.some((kind) => SKIP_PREDICATES[kind](value));
61
+ }
62
+
63
+ export type OnAssignOptions = {
64
+ /**
65
+ * Quand `true`, le callback est invoqué à chaque assignation, même si la
66
+ * valeur reçue est `null`/`undefined`.
67
+ * Quand `false` (défaut), le callback n'est invoqué que lorsque toutes les
68
+ * valeurs surveillées sont définies (non `null` et non `undefined`).
69
+ */
70
+ dispatchWhenUndefined?: boolean;
71
+ /**
72
+ * Ne pas invoquer le callback si une valeur reçue appartient à l'une de ces
73
+ * catégories (ex. `[Skip.Nullish, Skip.EmptyObject]`).
74
+ */
75
+ skip?: Skip[];
76
+ };
77
+
78
+ function shouldDispatch(
79
+ currentValues: unknown[],
80
+ expectedCount: number,
81
+ options: OnAssignOptions,
82
+ ): boolean {
83
+ // Garde structurelle : toutes les valeurs doivent être définies.
84
+ if (!options.dispatchWhenUndefined) {
85
+ const definedCount = currentValues
86
+ .slice(0, expectedCount)
87
+ .filter((v) => v !== null && v !== undefined).length;
88
+ if (definedCount !== expectedCount) return false;
89
+ }
90
+ // Garde de contenu : catégories skip.
91
+ if (options.skip && options.skip.length > 0) {
92
+ for (let i = 0; i < expectedCount; i++) {
93
+ if (isSkipped(currentValues[i], options.skip)) return false;
94
+ }
95
+ }
96
+ return true;
97
+ }
98
+
99
+ /**
100
+ * @deprecated Utiliser `@handle` à la place.
101
+ *
102
+ * `@onAssign` prend des chemins sous forme de **chaînes** non typées et n'appelle
103
+ * la méthode que lorsque **toutes** les valeurs sont définies. `@handle` offre la
104
+ * même chose en **typé** (via `DataProviderKey`), avec des options explicites :
105
+ *
106
+ * - équivalent direct : `@onAssign("a", "b")` → `@handle(keyA, keyB, { waitForAllDefined: true })`
107
+ * - comportement par défaut de `@handle` : appel à chaque assignation (même `null`/`undefined`)
108
+ * - `skip` pour ignorer des catégories de valeurs (ex. `[Skip.Nullish, Skip.EmptyObject]`)
109
+ *
110
+ * `@onAssign` reste fonctionnel et inchangé le temps de la migration.
111
+ */
26
112
  export function onAssign(...values: Array<string>) {
113
+ return createOnAssign({}, values);
114
+ }
115
+
116
+ export function createOnAssign(
117
+ options: OnAssignOptions,
118
+ values: Array<string>,
119
+ ) {
27
120
  const pathConfigs: PathConfiguration[] = values.map((path) => {
28
121
  const dynamicDependencies = extractDynamicDependencies(path);
29
122
  return {
@@ -69,10 +162,7 @@ export function onAssign(...values: Array<string>) {
69
162
  const callbacks: Set<Callback> = new Set();
70
163
  const onAssign = (assignedValue: unknown) => {
71
164
  onAssignValues[i] = assignedValue;
72
- if (
73
- onAssignValues.filter((v) => v !== null && v !== undefined)
74
- .length === values.length
75
- ) {
165
+ if (shouldDispatch(onAssignValues, values.length, options)) {
76
166
  callbacks.forEach((callback) => callback(...onAssignValues));
77
167
  }
78
168
  };
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { html, LitElement } from "lit";
3
+ import { customElement, property } from "lit/decorators.js";
4
+ import { DataProviderKey } from "../utils/dataProviderKey";
5
+ import { dp, set } from "../utils/PublisherProxy";
6
+ import { sub } from "./DataProvider";
7
+
8
+ type User = { name: string };
9
+
10
+ const dynamicUserKey = new DataProviderKey<User, { userIndex: number }>(
11
+ "subSpec.users.${userIndex}",
12
+ );
13
+
14
+ const staticCountKey = new DataProviderKey<{ count: number }>(
15
+ "subSpec.counter",
16
+ ).count;
17
+
18
+ @customElement("sub-spec-host")
19
+ class SubSpecHost extends LitElement {
20
+ @property({ type: Number }) userIndex = 0;
21
+
22
+ render() {
23
+ return html`
24
+ <span id="dynamic">${sub(dynamicUserKey.name)}</span>
25
+ <span id="static">${sub(staticCountKey)}</span>
26
+ <span id="string">${sub("subSpec.label")}</span>
27
+ `;
28
+ }
29
+ }
30
+
31
+ describe("sub() with DataProviderKey", () => {
32
+ let host: SubSpecHost;
33
+
34
+ beforeEach(async () => {
35
+ set("subSpec.users.0", { name: "Alice" });
36
+ set("subSpec.users.1", { name: "Bob" });
37
+ set("subSpec.counter", { count: 0 });
38
+ dpSetCount(7);
39
+ set("subSpec.label", "hello");
40
+
41
+ host = document.createElement("sub-spec-host") as SubSpecHost;
42
+ document.body.appendChild(host);
43
+ await host.updateComplete;
44
+ await flushMicrotasks();
45
+ });
46
+
47
+ afterEach(() => {
48
+ document.body.replaceChildren();
49
+ });
50
+
51
+ it("suit une clé dynamique quand userIndex change", async () => {
52
+ expect(text(host, "#dynamic")).toBe("Alice");
53
+
54
+ host.userIndex = 1;
55
+ await host.updateComplete;
56
+ await flushFrames();
57
+
58
+ expect(text(host, "#dynamic")).toBe("Bob");
59
+ });
60
+
61
+ it("suit une sous-clé statique", async () => {
62
+ expect(text(host, "#static")).toBe("7");
63
+
64
+ dpSetCount(42);
65
+ await host.updateComplete;
66
+
67
+ expect(text(host, "#static")).toBe("42");
68
+ });
69
+
70
+ it("accepte encore un chemin string", async () => {
71
+ expect(text(host, "#string")).toBe("hello");
72
+ set("subSpec.label", "world");
73
+ await host.updateComplete;
74
+ expect(text(host, "#string")).toBe("world");
75
+ });
76
+ });
77
+
78
+ function dpSetCount(count: number) {
79
+ dp(staticCountKey).set(count);
80
+ }
81
+
82
+ function text(el: SubSpecHost, selector: string): string {
83
+ return el.shadowRoot?.querySelector(selector)?.textContent ?? "";
84
+ }
85
+
86
+ async function flushMicrotasks() {
87
+ await Promise.resolve();
88
+ await Promise.resolve();
89
+ await Promise.resolve();
90
+ }
91
+
92
+ async function flushFrames() {
93
+ await flushMicrotasks();
94
+ await new Promise((r) => requestAnimationFrame(r));
95
+ await new Promise((r) => requestAnimationFrame(r));
96
+ }