@supersoniks/concorde 4.5.1 → 4.6.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 (154) hide show
  1. package/build-infos.json +1 -1
  2. package/concorde-core.bundle.js +250 -246
  3. package/concorde-core.es.js +2109 -2064
  4. package/dist/concorde-core.bundle.js +250 -246
  5. package/dist/concorde-core.es.js +2109 -2064
  6. package/docs/assets/index-CaysOMFz.js +5046 -0
  7. package/docs/assets/index-D8mGoXzF.css +1 -0
  8. package/docs/css/docs.css +0 -0
  9. package/docs/fonts/ClashGrotesk-Bold.eot +0 -0
  10. package/docs/fonts/ClashGrotesk-Bold.ttf +0 -0
  11. package/docs/fonts/ClashGrotesk-Bold.woff +0 -0
  12. package/docs/fonts/ClashGrotesk-Bold.woff2 +0 -0
  13. package/docs/fonts/ClashGrotesk-Extralight.eot +0 -0
  14. package/docs/fonts/ClashGrotesk-Extralight.ttf +0 -0
  15. package/docs/fonts/ClashGrotesk-Extralight.woff +0 -0
  16. package/docs/fonts/ClashGrotesk-Extralight.woff2 +0 -0
  17. package/docs/fonts/ClashGrotesk-Light.eot +0 -0
  18. package/docs/fonts/ClashGrotesk-Light.ttf +0 -0
  19. package/docs/fonts/ClashGrotesk-Light.woff +0 -0
  20. package/docs/fonts/ClashGrotesk-Light.woff2 +0 -0
  21. package/docs/fonts/ClashGrotesk-Medium.eot +0 -0
  22. package/docs/fonts/ClashGrotesk-Medium.ttf +0 -0
  23. package/docs/fonts/ClashGrotesk-Medium.woff +0 -0
  24. package/docs/fonts/ClashGrotesk-Medium.woff2 +0 -0
  25. package/docs/fonts/ClashGrotesk-Regular.eot +0 -0
  26. package/docs/fonts/ClashGrotesk-Regular.ttf +0 -0
  27. package/docs/fonts/ClashGrotesk-Regular.woff +0 -0
  28. package/docs/fonts/ClashGrotesk-Regular.woff2 +0 -0
  29. package/docs/fonts/ClashGrotesk-Semibold.eot +0 -0
  30. package/docs/fonts/ClashGrotesk-Semibold.ttf +0 -0
  31. package/docs/fonts/ClashGrotesk-Semibold.woff +0 -0
  32. package/docs/fonts/ClashGrotesk-Semibold.woff2 +0 -0
  33. package/docs/fonts/ClashGrotesk-Variable.eot +0 -0
  34. package/docs/fonts/ClashGrotesk-Variable.ttf +0 -0
  35. package/docs/fonts/ClashGrotesk-Variable.woff +0 -0
  36. package/docs/fonts/ClashGrotesk-Variable.woff2 +0 -0
  37. package/docs/img/concorde-icon.svg +5 -0
  38. package/docs/img/concorde-logo.svg +1 -0
  39. package/docs/img/concorde.png +0 -0
  40. package/docs/img/concorde_def.png +0 -0
  41. package/docs/img/concorde_seuil.png.webp +0 -0
  42. package/docs/img/concorde_seuil_invert.png +0 -0
  43. package/docs/img/paul_metrand.jpg +0 -0
  44. package/docs/img/paul_metrand_xs.jpg +0 -0
  45. package/docs/index.html +93 -0
  46. package/docs/src/core/components/functional/date/date.md +290 -0
  47. package/docs/src/core/components/functional/fetch/fetch.md +123 -0
  48. package/docs/src/core/components/functional/if/if.md +16 -0
  49. package/docs/src/core/components/functional/list/list.md +199 -0
  50. package/docs/src/core/components/functional/mix/mix.md +41 -0
  51. package/docs/src/core/components/functional/queue/queue.md +87 -0
  52. package/docs/src/core/components/functional/router/router.md +129 -0
  53. package/docs/src/core/components/functional/sdui/default-library.json +108 -0
  54. package/docs/src/core/components/functional/sdui/example.json +99 -0
  55. package/docs/src/core/components/functional/sdui/sdui.md +356 -0
  56. package/docs/src/core/components/functional/states/states.md +87 -0
  57. package/docs/src/core/components/functional/submit/submit.md +83 -0
  58. package/docs/src/core/components/functional/subscriber/subscriber.md +91 -0
  59. package/docs/src/core/components/functional/value/value.md +35 -0
  60. package/docs/src/core/components/ui/alert/alert.md +121 -0
  61. package/docs/src/core/components/ui/alert-messages/alert-messages.md +0 -0
  62. package/docs/src/core/components/ui/badge/badge.md +127 -0
  63. package/docs/src/core/components/ui/button/button.md +182 -0
  64. package/docs/src/core/components/ui/captcha/captcha.md +24 -0
  65. package/docs/src/core/components/ui/card/card.md +97 -0
  66. package/docs/src/core/components/ui/divider/divider.md +35 -0
  67. package/docs/src/core/components/ui/form/checkbox/checkbox.md +104 -0
  68. package/docs/src/core/components/ui/form/fieldset/fieldset.md +129 -0
  69. package/docs/src/core/components/ui/form/form-actions/form-actions.md +77 -0
  70. package/docs/src/core/components/ui/form/form-layout/form-layout.md +44 -0
  71. package/docs/src/core/components/ui/form/input/input.md +167 -0
  72. package/docs/src/core/components/ui/form/input-autocomplete/input-autocomplete.md +131 -0
  73. package/docs/src/core/components/ui/form/radio/radio.md +84 -0
  74. package/docs/src/core/components/ui/form/select/select.md +97 -0
  75. package/docs/src/core/components/ui/form/switch/switch.md +84 -0
  76. package/docs/src/core/components/ui/form/textarea/textarea.md +65 -0
  77. package/docs/src/core/components/ui/group/group.md +75 -0
  78. package/docs/src/core/components/ui/icon/icon.md +125 -0
  79. package/docs/src/core/components/ui/icon/icons.json +1 -0
  80. package/docs/src/core/components/ui/image/image.md +107 -0
  81. package/docs/src/core/components/ui/link/link.md +43 -0
  82. package/docs/src/core/components/ui/loader/loader.md +67 -0
  83. package/docs/src/core/components/ui/menu/menu.md +329 -0
  84. package/docs/src/core/components/ui/modal/modal.md +119 -0
  85. package/docs/src/core/components/ui/pop/pop.md +96 -0
  86. package/docs/src/core/components/ui/progress/progress.md +63 -0
  87. package/docs/src/core/components/ui/table/table.md +455 -0
  88. package/docs/src/core/components/ui/toast/toast.md +166 -0
  89. package/docs/src/core/components/ui/tooltip/tooltip.md +82 -0
  90. package/docs/src/docs/_core-concept/overview.md +57 -0
  91. package/docs/src/docs/_core-concept/subscriber.md +76 -0
  92. package/docs/src/docs/_decorators/ancestor-attribute.md +78 -0
  93. package/docs/src/docs/_decorators/auto-subscribe.md +199 -0
  94. package/docs/src/docs/_decorators/bind.md +164 -0
  95. package/docs/src/docs/_decorators/get.md +65 -0
  96. package/docs/src/docs/_decorators/on-assign.md +362 -0
  97. package/docs/src/docs/_decorators/publish.md +54 -0
  98. package/docs/src/docs/_decorators/subscribe.md +36 -0
  99. package/docs/src/docs/_decorators/wait-for-ancestors.md +160 -0
  100. package/docs/src/docs/_getting-started/concorde-outside.md +143 -0
  101. package/docs/src/docs/_getting-started/create-a-component.md +137 -0
  102. package/docs/src/docs/_getting-started/my-first-subscriber.md +174 -0
  103. package/docs/src/docs/_getting-started/pubsub.md +150 -0
  104. package/docs/src/docs/_getting-started/start.md +39 -0
  105. package/docs/src/docs/_getting-started/theming.md +91 -0
  106. package/docs/src/docs/_misc/dataProviderKey.md +135 -0
  107. package/docs/src/docs/_misc/endpoint.md +42 -0
  108. package/docs/src/docs/_misc/templates-demo.md +19 -0
  109. package/docs/src/docs/search/docs-search.json +5242 -0
  110. package/docs/src/tag-list.json +1 -0
  111. package/docs/src/tsconfig-model.json +23 -0
  112. package/docs/src/tsconfig.json +987 -0
  113. package/docs/svg/regular/plane.svg +1 -0
  114. package/docs/svg/solid/plane.svg +1 -0
  115. package/package.json +5 -1
  116. package/src/core/components/functional/fetch/fetch.md +0 -0
  117. package/src/core/components/ui/_css/scroll.ts +0 -0
  118. package/src/core/components/ui/_css/size.ts +0 -0
  119. package/src/core/components/ui/alert/alert.ts +0 -0
  120. package/src/core/components/ui/button/button.ts +0 -0
  121. package/src/core/components/ui/captcha/altchaStyles.ts +0 -0
  122. package/src/core/components/ui/divider/divider.ts +0 -0
  123. package/src/core/components/ui/icon/icon.ts +0 -0
  124. package/src/core/components/ui/menu/menu.md +0 -0
  125. package/src/core/components/ui/modal/modal-close.ts +0 -0
  126. package/src/core/components/ui/modal/modal.md +0 -0
  127. package/src/core/components/ui/table/table-caption.ts +0 -0
  128. package/src/core/decorators/Subscriber.ts +2 -0
  129. package/src/core/decorators/api.ts +3 -3
  130. package/src/core/decorators/subscriber/ancestorAttribute.ts +5 -4
  131. package/src/core/decorators/subscriber/bind.ts +9 -7
  132. package/src/core/decorators/subscriber/common.ts +22 -2
  133. package/src/core/decorators/subscriber/dynamicPropertyWatch.spec.ts +125 -0
  134. package/src/core/decorators/subscriber/dynamicPropertyWatch.ts +157 -72
  135. package/src/core/decorators/subscriber/handle.disambig.spec.ts +20 -0
  136. package/src/core/decorators/subscriber/handle.skip.spec.ts +37 -0
  137. package/src/core/decorators/subscriber/handle.ts +128 -0
  138. package/src/core/decorators/subscriber/onAssign.ts +96 -6
  139. package/src/core/decorators/subscriber/publish.ts +2 -2
  140. package/src/core/utils/route.ts +0 -0
  141. package/src/decorators.ts +6 -0
  142. package/src/docs/_decorators/bind.md +1 -1
  143. package/src/docs/_decorators/handle.md +169 -0
  144. package/src/docs/_decorators/on-assign.md +52 -0
  145. package/src/docs/_misc/dataProviderKey.md +4 -4
  146. package/src/docs/code.ts +0 -0
  147. package/src/docs/example/decorators-demo-bind-demos.ts +6 -2
  148. package/src/docs/example/decorators-demo-geo.ts +10 -9
  149. package/src/docs/example/decorators-demo-subscribe-publish-get-demos.ts +91 -5
  150. package/src/docs/example/decorators-demo.ts +2 -2
  151. package/src/docs/navigation/navigation.ts +4 -0
  152. package/src/docs/search/docs-search.json +272 -22
  153. package/src/tsconfig.json +12 -0
  154. package/src/tsconfig.tsbuildinfo +1 -1
@@ -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
+ }
@@ -3,7 +3,7 @@ import { ConnectedComponent, setSubscribable } from "./common";
3
3
  import { extractDynamicDependencies, resolveDynamicPath } from "./dynamicPath";
4
4
  import {
5
5
  onAssignDynamicWatchKeys,
6
- registerDynamicPropertyWatcher,
6
+ observeDynamicProperty,
7
7
  } from "./dynamicPropertyWatch";
8
8
  import { getPublisherFromPath } from "./publisherPath";
9
9
 
@@ -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
  };
@@ -141,7 +231,7 @@ export function onAssign(...values: Array<string>) {
141
231
  for (const conf of confs) {
142
232
  if (conf.pathConfig.isDynamic) {
143
233
  for (const dependency of conf.pathConfig.dynamicDependencies) {
144
- const unsubscribe = registerDynamicPropertyWatcher(
234
+ const unsubscribe = observeDynamicProperty(
145
235
  onAssignDynamicWatchKeys.watcherStore,
146
236
  onAssignDynamicWatchKeys.hooked,
147
237
  component,
@@ -7,7 +7,7 @@ import { ConnectedComponent, setSubscribable } from "./common";
7
7
  import { extractDynamicDependencies, resolveDynamicPath } from "./dynamicPath";
8
8
  import {
9
9
  publishDynamicWatchKeys,
10
- registerDynamicPropertyWatcher,
10
+ observeDynamicProperty,
11
11
  } from "./dynamicPropertyWatch";
12
12
  import { getPublisherFromPath } from "./publisherPath";
13
13
 
@@ -116,7 +116,7 @@ export function publish<T, U = any>(
116
116
  if (dynamicDependencies.length) {
117
117
  for (const dependency of dynamicDependencies) {
118
118
  state.cleanupWatchers.push(
119
- registerDynamicPropertyWatcher(
119
+ observeDynamicProperty(
120
120
  publishDynamicWatchKeys.watcherStore,
121
121
  publishDynamicWatchKeys.hooked,
122
122
  comp,
File without changes
package/src/decorators.ts CHANGED
@@ -7,6 +7,11 @@ export const bind = mySubscriber.bind;
7
7
  export const publish = mySubscriber.publish;
8
8
  export const subscribe = mySubscriber.subscribe;
9
9
  export const onAssign = mySubscriber.onAssign;
10
+ export const handle = mySubscriber.handle;
11
+ export {
12
+ Skip,
13
+ type HandleOptions,
14
+ } from "@supersoniks/concorde/core/decorators/Subscriber";
10
15
  export const ancestorAttribute = mySubscriber.ancestorAttribute;
11
16
  export const autoSubscribe = mySubscriber.autoSubscribe;
12
17
  export const autoFill = mySubscriber.autoFill;
@@ -31,6 +36,7 @@ window["concorde-decorator-subscriber"] = {
31
36
  publish: mySubscriber.publish,
32
37
  subscribe: mySubscriber.subscribe,
33
38
  onAssing: mySubscriber.onAssign,
39
+ handle: mySubscriber.handle,
34
40
  ancestorAttribute: mySubscriber.ancestorAttribute,
35
41
  autoSubscribe: mySubscriber.autoSubscribe,
36
42
  autoFill: mySubscriber.autoFill,
@@ -4,7 +4,7 @@ Binds a class property to a path in a publisher. The property updates when publi
4
4
 
5
5
  For Lit re-renders, also add `@state()` on the same property.
6
6
 
7
- **See also:** [@subscribe](#docs/_decorators/subscribe.md/subscribe), [@publish](#docs/_decorators/publish.md/publish), [@get](#docs/_decorators/get.md/get).
7
+ **See also:** [@subscribe](#docs/_decorators/subscribe.md/subscribe), [@handle](#docs/_decorators/handle.md/handle), [@publish](#docs/_decorators/publish.md/publish), [@get](#docs/_decorators/get.md/get).
8
8
 
9
9
  ## Principle
10
10
 
@@ -0,0 +1,169 @@
1
+ # @handle
2
+
3
+ Typed callback on one or more `DataProviderKey<T>` paths: invokes the decorated **method** when a publisher assigns a value (calculations, side effects, updating other `@state` properties, etc.).
4
+
5
+ Unlike [@subscribe](#docs/_decorators/subscribe.md/subscribe), nothing is bound to the decorated member — only your method runs. `@handle` is typed and accepts up to **3 keys** plus an optional trailing `HandleOptions` object. It supersedes the string-based [@onAssign](#docs/_decorators/on-assign.md/on-assign).
6
+
7
+ By default the method is called on **every** assignment, even when the value is `null` / `undefined`. Use the options below to restrict that.
8
+
9
+ ## Import
10
+
11
+ <sonic-code language="typescript">
12
+ <template>
13
+ import { handle, Skip } from "@supersoniks/concorde/decorators";
14
+ import { DataProviderKey } from "@supersoniks/concorde/dataProviderKey";
15
+ </template>
16
+ </sonic-code>
17
+
18
+ ## Basic example
19
+
20
+ <sonic-code language="typescript">
21
+ <template>
22
+ type DemoCounterData = { count: number };
23
+ const demoDataKey = new DataProviderKey&lt;DemoCounterData&gt;("demoData");
24
+ //
25
+ @customElement("demo-handle")
26
+ export class DemoHandle extends LitElement {
27
+ @state() doubled = 0;
28
+ @state() lastUpdate = "";
29
+ //
30
+ @handle(demoDataKey.count)
31
+ onCountChange(count: number) {
32
+ this.doubled = count * 2;
33
+ this.lastUpdate = new Date().toLocaleTimeString();
34
+ }
35
+ //
36
+ incrementCount() {
37
+ const publisher = PublisherManager.get("demoData");
38
+ const data = publisher.get() as DemoCounterData;
39
+ publisher.set({ ...data, count: data.count + 1 });
40
+ }
41
+ //
42
+ render() {
43
+ return html`
44
+ &lt;p&gt;Doubled count: ${this.doubled}&lt;/p&gt;
45
+ &lt;p&gt;&lt;small&gt;Last update: ${this.lastUpdate}&lt;/small&gt;&lt;/p&gt;
46
+ &lt;sonic-button @click=${this.incrementCount}&gt;Increment&lt;/sonic-button&gt;
47
+ `;
48
+ }
49
+ }
50
+ </template>
51
+ </sonic-code>
52
+
53
+ <sonic-code>
54
+ <template>
55
+ <demo-handle></demo-handle>
56
+ </template>
57
+ </sonic-code>
58
+
59
+ ## Dynamic path
60
+
61
+ Placeholders in `DataProviderKey` resolve from the host component’s properties (same rules as `@bind` / `@subscribe`).
62
+
63
+ <sonic-code language="typescript">
64
+ <template>
65
+ type User = { firstName: string; lastName: string; email: string };
66
+ //
67
+ @customElement("demo-handle-dynamic")
68
+ export class DemoHandleDynamic extends LitElement {
69
+ @property({ type: Number })
70
+ userIndex = 0;
71
+ //
72
+ @state() displayName = "";
73
+ @state() lastUpdate = "";
74
+ //
75
+ @handle(new DataProviderKey&lt;User, { userIndex: number }&gt;("demoUsers.${userIndex}"))
76
+ onUserAssigned(user: User) {
77
+ this.displayName = `${user.firstName} ${user.lastName}`;
78
+ this.lastUpdate = new Date().toLocaleTimeString();
79
+ }
80
+ //
81
+ render() {
82
+ return html`...`;
83
+ }
84
+ }
85
+ </template>
86
+ </sonic-code>
87
+
88
+ <sonic-code>
89
+ <template>
90
+ <demo-handle-dynamic></demo-handle-dynamic>
91
+ </template>
92
+ </sonic-code>
93
+
94
+ ## Multiple paths
95
+
96
+ `@handle` accepts up to **3 keys**; the method receives one strongly-typed argument per key, in order. Each assignment triggers the method, so make your method safe against partial values (or use `waitForAllDefined`, see below).
97
+
98
+ <sonic-code language="typescript">
99
+ <template>
100
+ type QueueConfig = { onInactivity: { stillHere: { show: boolean } } };
101
+ const config = new DataProviderKey&lt;QueueConfig&gt;("sessionQueueConfig");
102
+ const idle = new DataProviderKey&lt;{ isIdle: boolean }&gt;("idleStatus");
103
+ //
104
+ @customElement("demo-handle-multi")
105
+ export class DemoHandleMulti extends LitElement {
106
+ //
107
+ // show: boolean, isIdle: boolean — fully typed from the keys
108
+ @handle(config.onInactivity.stillHere.show, idle.isIdle)
109
+ onInactivity(show: boolean, isIdle: boolean) {
110
+ if (show === true && isIdle === true) this.openModal();
111
+ else this.closeModal();
112
+ }
113
+ }
114
+ </template>
115
+ </sonic-code>
116
+
117
+ ## Options (`HandleOptions`)
118
+
119
+ Pass an options object as the **last** argument. The names map to real situations seen in the apps.
120
+
121
+ ### `waitForAllDefined`
122
+
123
+ Only call the method once **all** watched keys are defined (non `null` / `undefined`). This reproduces the historical `@onAssign` semantics — use it when the logic only makes sense with every source ready (e.g. building a date from `date` + `timeZone` + `direction`).
124
+
125
+ <sonic-code language="typescript">
126
+ <template>
127
+ @handle(trip.departureDate, trip.event.timeZone, form.direction, {
128
+ waitForAllDefined: true,
129
+ })
130
+ updateDepartureDate(date: number, timeZone: string, direction: string) {
131
+ // called only when the three values are all available
132
+ this.formDate = formatDate(date, timeZone, direction);
133
+ }
134
+ </template>
135
+ </sonic-code>
136
+
137
+ ### `skip`
138
+
139
+ Do **not** call the method when a received value belongs to one of the listed **categories** (the `Skip` enum). Each entry is a named category — not a value — so there is no value/pattern ambiguity (e.g. `{}` is `Skip.EmptyObject`, an explicit "empty object" category, never a value comparison).
140
+
141
+ | Category | Matches |
142
+ | --- | --- |
143
+ | `Skip.Nullish` | `null` or `undefined` (a publisher always emits `null`, never `undefined`) |
144
+ | `Skip.EmptyString` | `""` |
145
+ | `Skip.EmptyObject` | object with no keys (`{}`), arrays excluded |
146
+ | `Skip.EmptyArray` | empty array (`[]`) |
147
+
148
+ Useful when a not-yet-initialized publisher emits `{}` as a "loading" state. For a **specific value** (e.g. a particular string), guard inside the method instead.
149
+
150
+ <sonic-code language="typescript">
151
+ <template>
152
+ @handle(user.profile, { skip: [Skip.Nullish, Skip.EmptyObject] })
153
+ onProfile(profile: Profile) {
154
+ // not called while the publisher still holds {} (not loaded yet)
155
+ this.displayName = profile.firstName;
156
+ }
157
+ </template>
158
+ </sonic-code>
159
+
160
+ > Options can be combined, e.g. `@handle(a, b, { waitForAllDefined: true, skip: [Skip.Nullish] })`. For any **arbitrary** validation on a specific value, just guard inside the method (`if (!isValid(v)) return;`) — that is exactly what an `accept`-style predicate would do, since `@handle` only runs your method.
161
+
162
+ ## Highlights
163
+
164
+ - Strict typing: the method receives one argument per key, in order.
165
+ - Up to 3 keys; for 4+ keys (rare), keep [@onAssign](#docs/_decorators/on-assign.md/on-assign) for now.
166
+ - By default the method runs on **every** assignment, even with `null` / `undefined` (unlike `@onAssign`, which waits for all values). Opt back into that behavior with `waitForAllDefined`.
167
+ - `skip` filters out values by **named category** (e.g. `[Skip.Nullish, Skip.EmptyObject]`); for arbitrary checks on a specific value, guard inside the method.
168
+
169
+ See also [DataProviderKey](#docs/_misc/dataProviderKey.md/dataProviderKey).
@@ -1,7 +1,12 @@
1
1
  # @onAssign
2
2
 
3
+ > **⚠️ Deprecated — use [@handle](#docs/_decorators/handle.md/handle) instead.**
4
+ > `@onAssign` takes untyped **string** paths; `@handle` does the same with full typing via `DataProviderKey`, supports up to 3 keys, and exposes explicit options. `@onAssign` stays functional and unchanged during the migration. See the **Migrating to @handle** section below.
5
+
3
6
  The `@onAssign` decorator allows you to execute a method when one or more publishers are updated. The method is called only when all specified publishers have been assigned values.
4
7
 
8
+ For a **typed** equivalent (recommended), use [@handle](#docs/_decorators/handle.md/handle).
9
+
5
10
  ## Principle
6
11
 
7
12
  This decorator subscribes to one or more publishers via the `PublisherManager`. When all specified publishers have been assigned values (via `set`), the decorated method is called with all the values as arguments.
@@ -327,6 +332,53 @@ shippingPub.set({ address: "123 Main St" });
327
332
  </template>
328
333
  </sonic-code>
329
334
 
335
+ ## Migrating to @handle
336
+
337
+ `@handle` is the typed successor of `@onAssign`. The key behavioral difference: `@onAssign` waits for **all** values to be defined before calling the method, whereas `@handle` calls it on **every** assignment by default. Use the `waitForAllDefined` option to keep the old semantics.
338
+
339
+ ### Why migrate
340
+
341
+ - **Typed paths**: keys are `DataProviderKey<T>`, so the method arguments are strongly typed (no more `any`).
342
+ - **Explicit intent**: `waitForAllDefined` and `skip` replace implicit behavior.
343
+ - **Single API**: `@handle` covers the mono- and multi-path cases (up to 3 keys).
344
+
345
+ ### Equivalent semantics (`waitForAllDefined`)
346
+
347
+ <sonic-code language="typescript">
348
+ <template>
349
+ // Before
350
+ @onAssign("demoUser", "demoUserSettings")
351
+ handleDataReady(user: any, settings: any) { /* ... */ }
352
+ //
353
+ // After — same "wait for everything" behavior, but typed
354
+ const user = new DataProviderKey&lt;User&gt;("demoUser");
355
+ const settings = new DataProviderKey&lt;Settings&gt;("demoUserSettings");
356
+ //
357
+ @handle(user, settings, { waitForAllDefined: true })
358
+ handleDataReady(user: User, settings: Settings) { /* ... */ }
359
+ </template>
360
+ </sonic-code>
361
+
362
+ ### Single path
363
+
364
+ <sonic-code language="typescript">
365
+ <template>
366
+ // Before
367
+ @onAssign("settings.modules.logs_route.enabled")
368
+ onLogRoute(value: boolean) { /* ... */ }
369
+ //
370
+ // After
371
+ const settings = new DataProviderKey&lt;AppSettings&gt;("settings");
372
+ //
373
+ @handle(settings.modules.logs_route.enabled)
374
+ onLogRoute(value: boolean) { /* ... */ }
375
+ </template>
376
+ </sonic-code>
377
+
378
+ ### 4+ paths
379
+
380
+ `@handle` is capped at 3 keys. For the rare case of 4 or more publishers, keep `@onAssign` for now, or split the logic into several `@handle` methods that each store their value and call a shared method (guarding against partial values).
381
+
330
382
  ## Notes
331
383
 
332
384
  - This decorator works with any component that has `connectedCallback` and `disconnectedCallback` methods (such as `LitElement` or components extending `Subscriber`)
@@ -97,13 +97,13 @@ const pathProp = key.path; // "data.count"
97
97
 
98
98
  ## Use cases
99
99
 
100
- - **Type-safe bindings**: paths for `@bind`, `@subscribe`, `@publish`
100
+ - **Type-safe bindings**: paths for `@bind`, `@subscribe`, `@publish`, `@handle`
101
101
  - **Dynamic paths**: reusable keys with `${...}` placeholders
102
102
  - **Form fields**: form data paths with compile-time checking
103
103
 
104
- ## Integration with @subscribe and @publish
104
+ ## Integration with @subscribe, @publish and @handle
105
105
 
106
- Use `DataProviderKey` with `@subscribe` (read-only) or `@publish` (write-only). The decorated property **must** match the key’s value type:
106
+ Use `DataProviderKey` with `@subscribe` (read-only), `@publish` (write-only), or `@handle` (method callback on assign). With `@subscribe` / `@publish`, the decorated property **must** match the key’s value type. With `@handle`, the method receives `(value: T)`.
107
107
 
108
108
  <sonic-code language="typescript">
109
109
  <template>
@@ -126,7 +126,7 @@ export class UserForm extends LitElement {
126
126
  </template>
127
127
  </sonic-code>
128
128
 
129
- Both decorators support dynamic paths: `"base.${prop}"` in the constructor. A wrong property type (e.g. `number` for `DataProviderKey<string>`) is a TypeScript error.
129
+ These decorators support dynamic paths: `"base.${prop}"` in the constructor. A wrong property type (e.g. `number` for `DataProviderKey<string>`) is a TypeScript error. See [@handle](#docs/_decorators/handle.md/handle) for method callbacks.
130
130
 
131
131
  ## Notes
132
132
 
package/src/docs/code.ts CHANGED
File without changes
@@ -92,7 +92,9 @@ export class DemoBindReflect extends LitElement {
92
92
  }
93
93
 
94
94
  type BindReflectDemoData = { count: number };
95
- const bindReflectDemoKey = new DataProviderKey<BindReflectDemoData>("bindReflectDemo");
95
+ const bindReflectDemoKey = new DataProviderKey<BindReflectDemoData>(
96
+ "bindReflectDemo",
97
+ );
96
98
 
97
99
  @customElement("demo-bind-key")
98
100
  export class DemoBindKey extends LitElement {
@@ -110,7 +112,9 @@ export class DemoBindKey extends LitElement {
110
112
  render() {
111
113
  return html`
112
114
  <div class="mb-3">
113
- <p>@bind with DataProviderKey&lt;number&gt; (type-safe): ${this.count}</p>
115
+ <p>
116
+ @bind with DataProviderKey&lt;number&gt; (type-safe): ${this.count}
117
+ </p>
114
118
  </div>
115
119
  <sonic-button @click=${() => this.count++}>Increment</sonic-button>
116
120
  `;
@@ -17,19 +17,19 @@ export const geoApiDemoConfiguration: APIConfiguration = {
17
17
  };
18
18
 
19
19
  const docsDemoGeoApiConfigurationId = "docsDemoGeoApiConfiguration";
20
- PublisherManager.get(docsDemoGeoApiConfigurationId).set(geoApiDemoConfiguration);
21
-
22
- export const docsDemoGeoApiConfigurationKey = new DataProviderKey<APIConfiguration>(
23
- docsDemoGeoApiConfigurationId,
20
+ PublisherManager.get(docsDemoGeoApiConfigurationId).set(
21
+ geoApiDemoConfiguration,
24
22
  );
25
23
 
24
+ export const docsDemoGeoApiConfigurationKey =
25
+ new DataProviderKey<APIConfiguration>(docsDemoGeoApiConfigurationId);
26
+
26
27
  export const geoCommunesApiGetEndpoint = new Endpoint<GeoCommuneRow[]>(
27
28
  "communes?limit=5&fields=nom,code",
28
29
  );
29
30
 
30
- export const docsDemoDynApiConfKeyTemplate = new DataProviderKey<APIConfiguration>(
31
- "docsDemoDynApiConf${configSlot}",
32
- );
31
+ export const docsDemoDynApiConfKeyTemplate =
32
+ new DataProviderKey<APIConfiguration>("docsDemoDynApiConf${configSlot}");
33
33
 
34
34
  PublisherManager.get("docsDemoDynApiConfA").set(geoApiDemoConfiguration);
35
35
  PublisherManager.get("docsDemoDynApiConfB").set({
@@ -41,5 +41,6 @@ export const geoCommunesApiGetEndpointDynamic = new Endpoint<GeoCommuneRow[]>(
41
41
  "communes?limit=${communeLimit}&fields=nom,code",
42
42
  );
43
43
 
44
- export const geoCommunesApiGetPublishKey =
45
- new DataProviderKey<ApiGetResult<GeoCommuneRow[]>>(geoCommunesApiGetEndpoint.path);
44
+ export const geoCommunesApiGetPublishKey = new DataProviderKey<
45
+ ApiGetResult<GeoCommuneRow[]>
46
+ >(geoCommunesApiGetEndpoint.path);