face-up 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FaceUp.js CHANGED
@@ -17,6 +17,11 @@
17
17
  *
18
18
  * `static formAssociated = true` is set automatically via `static onAssigned`.
19
19
  *
20
+ * Integration is seamless — because the feature is a getter-only property,
21
+ * `assignGingerly` merges directly into the instance. The consumer simply
22
+ * sets properties on the feature (e.g., `el.faceUp.value = x`) and the
23
+ * setters handle syncing to ElementInternals automatically.
24
+ *
20
25
  * @implements {FaceUpProps}
21
26
  */
22
27
  class FaceUp {
@@ -25,29 +30,23 @@ class FaceUp {
25
30
  * Called once by assignFeatures after registration.
26
31
  * Sets `static formAssociated = true` on the host constructor so the
27
32
  * consumer doesn't need to declare it manually.
28
- * @param {Function} ctr - The custom element constructor
33
+ * @param {typeof HTMLElement} ctr - The custom element constructor
29
34
  * @param {object} _featureConfig - The feature config (unused)
30
35
  */
31
36
  static onAssigned(ctr, _featureConfig) {
37
+ // @ts-ignore — formAssociated is a custom element static property
32
38
  if (!ctr.formAssociated) {
39
+ // @ts-ignore
33
40
  ctr.formAssociated = true;
34
41
  }
35
42
  }
43
+
36
44
  /** @type {WeakRef<HTMLElement> | undefined} */
37
45
  #hostRef;
38
46
 
39
47
  /** @type {ElementInternals | undefined} */
40
48
  #internals;
41
49
 
42
- /** @type {AbortController | undefined} */
43
- #abortController;
44
-
45
- /** @type {EventTarget | undefined} */
46
- #hostPropagator;
47
-
48
- /** @type {boolean} */
49
- #hasDisconnected = false;
50
-
51
50
  /** @type {string | File | FormData | null} */
52
51
  #value = null;
53
52
 
@@ -72,7 +71,6 @@ class FaceUp {
72
71
  this.#hostRef = new WeakRef(hostElement);
73
72
  if (ctx.shared) {
74
73
  this.#internals = ctx.shared.internals;
75
- this.#hostPropagator = ctx.shared.hostPropagator;
76
74
  }
77
75
  if (initVals) {
78
76
  if (initVals.value !== undefined) this.#value = initVals.value;
@@ -80,9 +78,10 @@ class FaceUp {
80
78
  if (initVals.disabled !== undefined) this.#disabled = initVals.disabled;
81
79
  if (initVals.required !== undefined) this.#required = initVals.required;
82
80
  if (initVals.validationMessage !== undefined) this.#validationMessage = initVals.validationMessage;
83
- if (initVals.hostPropagator !== undefined) this.#hostPropagator = initVals.hostPropagator;
84
81
  }
85
- this.#connect();
82
+ // Sync initial state to internals
83
+ this.#syncFormValue();
84
+ this.#validateInternal();
86
85
  }
87
86
 
88
87
  // ─── Public Properties ───────────────────────────────────────────
@@ -117,12 +116,6 @@ class FaceUp {
117
116
  this.#validateInternal();
118
117
  }
119
118
 
120
- get hostPropagator() { return this.#hostPropagator ?? null; }
121
- set hostPropagator(v) {
122
- this.#hostPropagator = v ?? undefined;
123
- this.#reconnectListeners();
124
- }
125
-
126
119
  // ─── Read-only Form Control Accessors ────────────────────────────
127
120
 
128
121
  /** The form element the host is associated with */
@@ -200,68 +193,8 @@ class FaceUp {
200
193
  }
201
194
  }
202
195
 
203
- // ─── Lifecycle (callbackForwarding) ──────────────────────────────
204
-
205
- connectedCallback() {
206
- if (this.#hasDisconnected) {
207
- this.#hasDisconnected = false;
208
- this.#connect();
209
- }
210
- }
211
-
212
- disconnectedCallback() {
213
- this.#hasDisconnected = true;
214
- this.#cleanup();
215
- }
216
-
217
196
  // ─── Private Methods ─────────────────────────────────────────────
218
197
 
219
- #connect() {
220
- this.#abortController = new AbortController();
221
- const signal = this.#abortController.signal;
222
-
223
- // Listen for value/state/required changes from the host propagator
224
- if (this.#hostPropagator) {
225
- this.#hostPropagator.addEventListener('value', (/** @type {CustomEvent} */ e) => {
226
- this.#value = e.detail?.value ?? e.detail;
227
- this.#syncFormValue();
228
- this.#validateInternal();
229
- }, { signal });
230
-
231
- this.#hostPropagator.addEventListener('state', (/** @type {CustomEvent} */ e) => {
232
- this.#state = e.detail?.value ?? e.detail;
233
- this.#syncFormValue();
234
- }, { signal });
235
-
236
- this.#hostPropagator.addEventListener('required', (/** @type {CustomEvent} */ e) => {
237
- this.#required = !!(e.detail?.value ?? e.detail);
238
- this.#validateInternal();
239
- }, { signal });
240
-
241
- this.#hostPropagator.addEventListener('disabled', (/** @type {CustomEvent} */ e) => {
242
- this.#disabled = !!(e.detail?.value ?? e.detail);
243
- }, { signal });
244
- }
245
-
246
- // Sync initial value if already set
247
- this.#syncFormValue();
248
- this.#validateInternal();
249
- }
250
-
251
- #cleanup() {
252
- if (this.#abortController) {
253
- this.#abortController.abort();
254
- this.#abortController = undefined;
255
- }
256
- }
257
-
258
- #reconnectListeners() {
259
- this.#cleanup();
260
- if (!this.#hasDisconnected) {
261
- this.#connect();
262
- }
263
- }
264
-
265
198
  /**
266
199
  * Syncs the current value (and optional state) to ElementInternals.
267
200
  */
package/RAConfig.mjs ADDED
@@ -0,0 +1,88 @@
1
+ // @ts-check
2
+ /** @import {Merges} from './types/roundabout/types.d.ts' */
3
+
4
+ /**
5
+ * @typedef {object} FaceUpForwardingProps
6
+ * @property {string | File | FormData | null} value
7
+ * @property {string | File | FormData | null} state
8
+ * @property {boolean} disabled
9
+ * @property {boolean} required
10
+ * @property {string} validationMessage
11
+ */
12
+
13
+ /**
14
+ * Property key constants for FaceUp-relevant forwarding.
15
+ * @type {{ [K in keyof FaceUpForwardingProps]: K }}
16
+ */
17
+ export const props = {
18
+ value: 'value',
19
+ state: 'state',
20
+ disabled: 'disabled',
21
+ required: 'required',
22
+ validationMessage: 'validationMessage',
23
+ };
24
+
25
+ /**
26
+ * Creates merges configuration for forwarding properties from the host view model
27
+ * to the faceUp feature instance via roundabout's reactive system.
28
+ *
29
+ * When any of these properties change on the view model, the corresponding
30
+ * setter on the feature fires, which syncs to ElementInternals automatically.
31
+ *
32
+ * @param {string} [featureKey='faceUp'] - The feature property name on the host element.
33
+ * @returns {Merges<FaceUpForwardingProps>}
34
+ *
35
+ * @example
36
+ * ```js
37
+ * import { getFaceUpMerges } from 'face-up/RAConfig.mjs';
38
+ *
39
+ * export const raConfig = {
40
+ * merges: [
41
+ * ...getFaceUpMerges(), // uses default 'faceUp' key
42
+ * // ...or with a custom key:
43
+ * // ...getFaceUpMerges('formControl'),
44
+ * ]
45
+ * };
46
+ * ```
47
+ */
48
+ export function getFaceUpMerges(featureKey = 'faceUp') {
49
+ return [
50
+ {
51
+ ifKeyIn: [props.value],
52
+ assign: {
53
+ [`?.${featureKey}?.value`]: '?.value'
54
+ }
55
+ },
56
+ {
57
+ ifKeyIn: [props.state],
58
+ assign: {
59
+ [`?.${featureKey}?.state`]: '?.state'
60
+ }
61
+ },
62
+ {
63
+ ifKeyIn: [props.disabled],
64
+ assign: {
65
+ [`?.${featureKey}?.disabled`]: '?.disabled'
66
+ }
67
+ },
68
+ {
69
+ ifKeyIn: [props.required],
70
+ assign: {
71
+ [`?.${featureKey}?.required`]: '?.required'
72
+ }
73
+ },
74
+ {
75
+ ifKeyIn: [props.validationMessage],
76
+ assign: {
77
+ [`?.${featureKey}?.validationMessage`]: '?.validationMessage'
78
+ }
79
+ },
80
+ ];
81
+ }
82
+
83
+ /**
84
+ * Pre-built merges using the default 'faceUp' feature key.
85
+ * Convenience export for the common case.
86
+ * @type {Merges<FaceUpForwardingProps>}
87
+ */
88
+ export const faceUpMerges = getFaceUpMerges();
package/README.md CHANGED
@@ -20,17 +20,19 @@ import 'assign-gingerly/assignFeatures.js';
20
20
  import { FaceUp } from 'face-up/FaceUp.js';
21
21
 
22
22
  class MyInput extends HTMLElement {
23
- propagator = new EventTarget();
24
23
  #internals;
25
24
 
26
25
  static supportedFeatures = {
27
26
  faceUp: {
28
27
  fallbackSpawn: FaceUp,
29
- callbackForwarding: ['connectedCallback', 'disconnectedCallback'],
28
+ callbackForwarding: [
29
+ 'formDisabledCallback',
30
+ 'formResetCallback',
31
+ 'formStateRestoreCallback'
32
+ ],
30
33
  getSharedContext(instance) {
31
34
  return {
32
- internals: instance.#internals,
33
- hostPropagator: instance.propagator
35
+ internals: instance.#internals
34
36
  };
35
37
  }
36
38
  }
@@ -40,19 +42,6 @@ class MyInput extends HTMLElement {
40
42
  super();
41
43
  this.#internals = this.attachInternals();
42
44
  }
43
-
44
- // Forward form lifecycle callbacks to the feature
45
- formDisabledCallback(disabled) {
46
- this.faceUp.formDisabledCallback(disabled);
47
- }
48
-
49
- formResetCallback() {
50
- this.faceUp.formResetCallback();
51
- }
52
-
53
- formStateRestoreCallback(state, mode) {
54
- this.faceUp.formStateRestoreCallback(state, mode);
55
- }
56
45
  }
57
46
 
58
47
  // assignFeatures calls FaceUp.onAssigned which sets static formAssociated = true
@@ -63,25 +52,26 @@ await customElements.assignFeatures(MyInput, {
63
52
  customElements.define('my-input', MyInput);
64
53
  ```
65
54
 
66
- Note: `static formAssociated = true` is set automatically by `FaceUp.onAssigned` — you don't need to declare it yourself.
55
+ That's it. No propagator, no manual callback forwarding, no event wiring.
56
+
57
+ - `static formAssociated = true` is set automatically by `FaceUp.onAssigned`.
58
+ - Form lifecycle callbacks are forwarded automatically via `callbackForwarding`.
59
+ - Property changes sync to `ElementInternals` via the feature's setters.
67
60
 
68
61
  ## Setting Values
69
62
 
70
- Dispatch events on the host's `propagator` to update the form value:
63
+ Because `faceUp` is a getter-only property, `assignGingerly` merges directly into the feature instance. Set properties however you like:
71
64
 
72
65
  ```js
73
- // From within the custom element:
74
- this.propagator.dispatchEvent(
75
- new Event('value')
76
- );
77
- ```
78
-
79
- Or set the value directly on the feature instance:
66
+ // Direct property access
67
+ el.faceUp.value = 'hello';
80
68
 
81
- ```js
82
- el.faceUp.value = 'new value';
69
+ // Via assignGingerly (merges into the feature instance)
70
+ el.assignGingerly({ faceUp: { value: 'hello', required: true } });
83
71
  ```
84
72
 
73
+ Both approaches trigger the setter, which calls `setFormValue()` on the internals automatically.
74
+
85
75
  ## Validation
86
76
 
87
77
  Set a custom validation message:
@@ -104,6 +94,8 @@ el.faceUp.validationMessage = '';
104
94
  el.faceUp.setValidity({});
105
95
  ```
106
96
 
97
+ Built-in `required` validation is automatic — if `required` is `true` and `value` is `null` or `''`, the control is marked invalid with `valueMissing`.
98
+
107
99
  ## Form State Restoration
108
100
 
109
101
  Pass a `state` parameter alongside `value` to enable proper form restoration:
@@ -138,23 +130,57 @@ The `state` is stored internally by the browser and passed back to `formStateRes
138
130
  | `reportValidity()` | `boolean` | Shows browser validation UI if invalid |
139
131
  | `setValidity(flags, message?, anchor?)` | `void` | Sets custom validity flags |
140
132
 
141
- ### Form Lifecycle Methods
133
+ ### Form Lifecycle Callbacks (forwarded via `callbackForwarding`)
142
134
 
143
- | Method | Description |
144
- |--------|-------------|
135
+ | Callback | Description |
136
+ |----------|-------------|
145
137
  | `formDisabledCallback(disabled)` | Called when disabled state changes |
146
138
  | `formResetCallback()` | Called when the form is reset |
147
139
  | `formStateRestoreCallback(state, mode)` | Called when browser restores form state |
148
140
 
141
+ These are forwarded automatically by `assign-gingerly`'s `callbackForwarding` mechanism — the host element does not need to implement them manually.
142
+
143
+ ## How it integrates
144
+
145
+ The key insight: because `assignFeatures` installs `faceUp` as a **getter-only** property on the prototype, `assignGingerly` automatically merges object values into the existing feature instance rather than replacing it. This means:
146
+
147
+ 1. The feature's setters fire on every property assignment.
148
+ 2. Each setter syncs to `ElementInternals` immediately.
149
+ 3. No event bus, no propagator, no intermediate layer — just property access.
150
+
151
+ ```js
152
+ // All of these trigger the setter → sync to internals:
153
+ el.faceUp.value = 'x';
154
+ el.assignGingerly({ faceUp: { value: 'x' } });
155
+ ```
156
+
149
157
  ## Requirements
150
158
 
151
159
  The host custom element **must**:
152
160
 
153
161
  1. Call `this.attachInternals()` in its constructor
154
162
  2. Pass the internals via `getSharedContext` in `supportedFeatures`
155
- 3. Forward form lifecycle callbacks to the feature
163
+ 3. Include the form lifecycle callbacks in `callbackForwarding`
164
+
165
+ Both `static formAssociated = true` and form lifecycle callback forwarding are handled automatically — no manual boilerplate needed in the host element.
166
+
167
+ ## Roundabout Integration
168
+
169
+ For projects using [roundabout](https://github.com/bahrus/roundabout), `face-up` exports merge rules that wire up property forwarding automatically:
170
+
171
+ ```js
172
+ import { faceUpMerges } from 'face-up/RAConfig.mjs';
173
+
174
+ export const raConfig = {
175
+ propagate: ['value', 'disabled', 'required', /* ... */],
176
+ merges: [
177
+ ...faceUpMerges,
178
+ // ...your other merges
179
+ ]
180
+ };
181
+ ```
156
182
 
157
- `static formAssociated = true` is handled automatically by `FaceUp.onAssigned` when `assignFeatures` is called.
183
+ When `value`, `state`, `disabled`, `required`, or `validationMessage` change on the view model, roundabout forwards them to the `faceUp` feature's setters, which sync to `ElementInternals` automatically.
158
184
 
159
185
  ## Dev
160
186
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "face-up",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "A Custom Element Feature that Adds Form Associated Behavior to a Custom Element",
5
5
  "homepage": "https://github.com/bahrus/face-up#readme",
6
6
  "bugs": {
@@ -14,6 +14,10 @@
14
14
  "author": "Bruce B. Anderson <anderson.bruce.b@gmail.com>",
15
15
  "type": "module",
16
16
  "main": "FaceUp.js",
17
+ "exports": {
18
+ "./FaceUp.js": "./FaceUp.js",
19
+ "./RAConfig.mjs": "./RAConfig.mjs"
20
+ },
17
21
  "scripts": {
18
22
  "serve": "node ./node_modules/spa-ssi/serve.js",
19
23
  "test": "playwright test",
@@ -22,7 +26,7 @@
22
26
  "chrome": "npx playwright cr http://localhost:8000"
23
27
  },
24
28
  "devDependencies": {
25
- "assign-gingerly": "0.0.42",
29
+ "assign-gingerly": "0.0.43",
26
30
  "@playwright/test": "1.60.0",
27
31
  "spa-ssi": "0.0.27"
28
32
  },
@@ -0,0 +1,57 @@
1
+ # Support Auto Forwarding With Roundabout Merging
2
+
3
+ ---
4
+
5
+ ## Human Ask
6
+
7
+ I am interested in making it really easy to provide property forwarding to this feature for those who make use of the [roundabout library](https://github.com/bahrus/roundabout).
8
+
9
+ The *time-ticker* custom element library is such a scenario. I've copied that project into this folder temporarily for easy inspection. Note how, in that project, the cef.json file is built from cef.mjs via npm run build for good typescript support with a kiro hook. Look closely at the "merges" section to see how property forwarding is done. The roundabout library actually creates the properties automaically from these merges.
10
+
11
+ Please create an RAConfig.mjs in this project that provides similar property forwarding configuration for all the relevant forwarding, which can then be imported by projects like time-ticker.
12
+
13
+ ---
14
+
15
+ ## Implementation Notes
16
+
17
+ ### What was created
18
+
19
+ `RAConfig.mjs` — exports `faceUpMerges` and `props` for consumers using roundabout.
20
+
21
+ ### How it works
22
+
23
+ In the roundabout pattern, `merges` are reactive rules: when a property on the view model changes (detected via `ifKeyIn`), the `assign` block runs `assignFrom(vm, assign, { from: vm })` — resolving the RHS path against the vm and assigning the result into the LHS path on the vm.
24
+
25
+ Since `faceUp` is a getter-only property (installed by `assignFeatures`), `assignGingerly` merges into the feature instance. This means the LHS path `?.faceUp?.value` triggers the feature's `value` setter, which calls `setFormValue()` on the internals.
26
+
27
+ The forwarding properties exposed are:
28
+ - `value` → `?.faceUp?.value` — the submittable form value
29
+ - `state` → `?.faceUp?.state` — internal state for form restoration
30
+ - `disabled` → `?.faceUp?.disabled` — disabled state
31
+ - `required` → `?.faceUp?.required` — required constraint
32
+ - `validationMessage` → `?.faceUp?.validationMessage` — custom validation error
33
+
34
+ ### Consumer usage (e.g., time-ticker's cef.mjs)
35
+
36
+ ```js
37
+ import { faceUpMerges } from 'face-up/RAConfig.mjs';
38
+
39
+ export const raConfig = {
40
+ propagate: ['value', 'disabled', 'required', /* ...other props */],
41
+ merges: [
42
+ ...faceUpMerges,
43
+ // ...project-specific merges
44
+ ],
45
+ // ...rest of config
46
+ };
47
+ ```
48
+
49
+ This eliminates the need for consumers to manually write the merge rules for form-associated behavior — they just spread `faceUpMerges` into their config.
50
+
51
+ ### Relationship to time-ticker
52
+
53
+ In time-ticker's `cef.mjs`, the existing merges forward `duration` and `disabled` to the `timeTicker` feature. With `faceUpMerges`, the time-ticker project could additionally spread these merges to forward `value`, `disabled`, etc. to the `faceUp` feature. The `disabled` merge would forward to both features (roundabout handles this naturally since each merge is independent).
54
+
55
+ ### Note on `hostPropagator`
56
+
57
+ The time-ticker's `time-ticker-element.js` still passes `hostPropagator` in `getSharedContext` for `faceUp`. Since we've removed the propagator dependency from FaceUp (it now relies purely on property setters + roundabout merges for reactivity), that `hostPropagator` field in `getSharedContext` is no longer needed for `faceUp`. The `getSharedContext` only needs to pass `internals`.
package/tests/test1.html CHANGED
@@ -11,20 +11,23 @@
11
11
 
12
12
  /**
13
13
  * A test custom element that uses FaceUp to become form-associated.
14
- * Note: static formAssociated = true is set automatically by FaceUp.onAssigned
14
+ * Note: static formAssociated = true is set automatically by FaceUp.onAssigned.
15
+ * Form lifecycle callbacks are forwarded automatically via callbackForwarding.
15
16
  */
16
17
  class TestInput extends HTMLElement {
17
- propagator = new EventTarget();
18
18
  #internals;
19
19
 
20
20
  static supportedFeatures = {
21
21
  faceUp: {
22
22
  fallbackSpawn: FaceUp,
23
- callbackForwarding: ['connectedCallback', 'disconnectedCallback'],
23
+ callbackForwarding: [
24
+ 'formDisabledCallback',
25
+ 'formResetCallback',
26
+ 'formStateRestoreCallback'
27
+ ],
24
28
  getSharedContext(instance) {
25
29
  return {
26
- internals: instance.#internals,
27
- hostPropagator: instance.propagator
30
+ internals: instance.#internals
28
31
  };
29
32
  }
30
33
  }
@@ -46,36 +49,14 @@
46
49
  connectedCallback() {
47
50
  const input = this.shadowRoot.querySelector('input');
48
51
  input.addEventListener('input', () => {
49
- this.propagator.dispatchEvent(
50
- new CustomEvent('value', { detail: input.value })
51
- );
52
+ // Simply set the feature property — the setter syncs to internals
53
+ this.faceUp.value = input.value;
52
54
  });
53
55
  }
54
56
 
55
57
  // Expose name for form submission
56
58
  get name() { return this.getAttribute('name'); }
57
59
  get type() { return this.localName; }
58
-
59
- // Form lifecycle callbacks forwarded to the feature
60
- formDisabledCallback(disabled) {
61
- this.faceUp.formDisabledCallback(disabled);
62
- const input = this.shadowRoot.querySelector('input');
63
- input.disabled = disabled;
64
- }
65
-
66
- formResetCallback() {
67
- this.faceUp.formResetCallback();
68
- const input = this.shadowRoot.querySelector('input');
69
- input.value = '';
70
- }
71
-
72
- formStateRestoreCallback(state, mode) {
73
- this.faceUp.formStateRestoreCallback(state, mode);
74
- if (state) {
75
- const input = this.shadowRoot.querySelector('input');
76
- input.value = typeof state === 'string' ? state : '';
77
- }
78
- }
79
60
  }
80
61
 
81
62
  // Inject the FaceUp feature (onAssigned sets static formAssociated = true)
@@ -426,7 +426,7 @@ customElements.assignFeatures(MyElement, {
426
426
  3. On first `connectedCallback`, the lazy getter is triggered — spawning the feature at the correct lifecycle moment (when the element is in the DOM and computed styles are available).
427
427
  4. For async features, forwarding is skipped until the real instance is available.
428
428
 
429
- **Supported callbacks:** `connectedCallback`, `disconnectedCallback`, `attributeChangedCallback`, `adoptedCallback`
429
+ **Supported callbacks:** `connectedCallback`, `disconnectedCallback`, `attributeChangedCallback`, `adoptedCallback`, `formAssociatedCallback`, `formDisabledCallback`, `formResetCallback`, `formStateRestoreCallback`
430
430
 
431
431
  **When to use it:**
432
432
 
@@ -434,6 +434,7 @@ customElements.assignFeatures(MyElement, {
434
434
  - The feature needs `getComputedStyle` (which requires the element to be in the DOM)
435
435
  - The feature sets up event listeners that should be cleaned up on disconnect
436
436
  - The feature needs to handle elements created via cloned templates (where the constructor fires before DOM insertion)
437
+ - The feature manages form-associated behavior (`formDisabledCallback`, `formResetCallback`, `formStateRestoreCallback`) — e.g., face-up
437
438
 
438
439
  **Avoiding double-connect on initial spawn:**
439
440
 
@@ -480,7 +481,6 @@ This pattern eliminates all manual wiring in the consumer's constructor — the
480
481
 
481
482
  ```javascript
482
483
  class MyElement extends HTMLElement {
483
- propagator = new EventTarget();
484
484
  #internals;
485
485
 
486
486
  static supportedFeatures = {
@@ -489,8 +489,7 @@ class MyElement extends HTMLElement {
489
489
  callbackForwarding: ['connectedCallback', 'disconnectedCallback'],
490
490
  getSharedContext(instance) {
491
491
  return {
492
- internals: instance.#internals,
493
- hostPropagator: instance.propagator
492
+ internals: instance.#internals
494
493
  };
495
494
  }
496
495
  }
@@ -510,6 +509,18 @@ customElements.assignFeatures(MyElement, {
510
509
  customElements.define('my-element', MyElement);
511
510
  ```
512
511
 
512
+ Because the feature is a getter-only property, `assignGingerly` merges directly into the instance. The consumer simply sets properties on the feature and the setters handle side effects:
513
+
514
+ ```javascript
515
+ // Direct property access — triggers the setter
516
+ el.myFeature.myProp = 'new value';
517
+
518
+ // Via assignGingerly — merges into the existing instance
519
+ el.assignGingerly({ myFeature: { myProp: 'new value' } });
520
+ ```
521
+
522
+ This eliminates the need for an intermediate event bus or propagator — property access is the integration point.
523
+
513
524
  **Async features and `callbackForwarding`:**
514
525
 
515
526
  For async features (where `spawn` is an async function), the feature is instantiated when the async import resolves. Since the constructor already handles initial connection, `callbackForwarding` only needs to forward *subsequent* lifecycle events. If you wrap an async feature around a sync one (lazy-loading pattern), delegate lifecycle calls to the inner feature once it's loaded:
@@ -1,14 +1,12 @@
1
1
  import { SpawnContext } from "../assign-gingerly/types";
2
2
 
3
3
  /**
4
- * Shared context passed via getSharedContext — contains everything
5
- * FaceUp needs to manage form association for the host element.
4
+ * Shared context passed via getSharedContext — contains the
5
+ * ElementInternals instance needed for form control APIs.
6
6
  */
7
7
  export interface FaceUpSharedContext {
8
8
  /** The ElementInternals instance for form control APIs */
9
9
  internals: ElementInternals;
10
- /** The EventTarget the host dispatches property change events on */
11
- hostPropagator: EventTarget;
12
10
  }
13
11
 
14
12
  /**
@@ -43,6 +41,11 @@ export interface ValidationFlags {
43
41
  * Properties that the FaceUp feature exposes.
44
42
  * These allow the host custom element to participate in HTML forms
45
43
  * via the ElementInternals API.
44
+ *
45
+ * Because the feature is installed as a getter-only property,
46
+ * assignGingerly merges directly into the instance. The consumer
47
+ * simply sets properties (e.g., el.faceUp.value = x) and the
48
+ * setters handle syncing to ElementInternals automatically.
46
49
  */
47
50
  export interface FaceUpProps {
48
51
  /**
@@ -72,12 +75,6 @@ export interface FaceUpProps {
72
75
  * marks the control as invalid with customError.
73
76
  */
74
77
  validationMessage: string;
75
-
76
- /**
77
- * The EventTarget that the host element uses to propagate property change events.
78
- * Provided via getSharedContext.
79
- */
80
- hostPropagator: EventTarget | null;
81
78
  }
82
79
 
83
80
  /**
@@ -93,11 +90,6 @@ export interface AllProps extends FaceUpProps {
93
90
  * The ElementInternals instance from the host (for form control APIs)
94
91
  */
95
92
  internals: ElementInternals | null;
96
-
97
- /**
98
- * AbortController for cleaning up event listeners
99
- */
100
- abortController: AbortController | null;
101
93
  }
102
94
 
103
95
  export type AP = AllProps;