face-up 0.0.2 → 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,23 +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
28
  callbackForwarding: [
30
- 'connectedCallback',
31
- 'disconnectedCallback',
32
29
  'formDisabledCallback',
33
30
  'formResetCallback',
34
31
  'formStateRestoreCallback'
35
32
  ],
36
33
  getSharedContext(instance) {
37
34
  return {
38
- internals: instance.#internals,
39
- hostPropagator: instance.propagator
35
+ internals: instance.#internals
40
36
  };
41
37
  }
42
38
  }
@@ -56,27 +52,26 @@ await customElements.assignFeatures(MyInput, {
56
52
  customElements.define('my-input', MyInput);
57
53
  ```
58
54
 
59
- 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.
60
56
 
61
- Form lifecycle callbacks (`formDisabledCallback`, `formResetCallback`, `formStateRestoreCallback`) are forwarded automatically via `callbackForwarding` — no manual forwarding code needed in the host element.
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.
62
60
 
63
61
  ## Setting Values
64
62
 
65
- 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:
66
64
 
67
65
  ```js
68
- // From within the custom element:
69
- this.propagator.dispatchEvent(
70
- new Event('value')
71
- );
72
- ```
73
-
74
- Or set the value directly on the feature instance:
66
+ // Direct property access
67
+ el.faceUp.value = 'hello';
75
68
 
76
- ```js
77
- el.faceUp.value = 'new value';
69
+ // Via assignGingerly (merges into the feature instance)
70
+ el.assignGingerly({ faceUp: { value: 'hello', required: true } });
78
71
  ```
79
72
 
73
+ Both approaches trigger the setter, which calls `setFormValue()` on the internals automatically.
74
+
80
75
  ## Validation
81
76
 
82
77
  Set a custom validation message:
@@ -99,6 +94,8 @@ el.faceUp.validationMessage = '';
99
94
  el.faceUp.setValidity({});
100
95
  ```
101
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
+
102
99
  ## Form State Restoration
103
100
 
104
101
  Pass a `state` parameter alongside `value` to enable proper form restoration:
@@ -143,6 +140,20 @@ The `state` is stored internally by the browser and passed back to `formStateRes
143
140
 
144
141
  These are forwarded automatically by `assign-gingerly`'s `callbackForwarding` mechanism — the host element does not need to implement them manually.
145
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
+
146
157
  ## Requirements
147
158
 
148
159
  The host custom element **must**:
@@ -153,6 +164,24 @@ The host custom element **must**:
153
164
 
154
165
  Both `static formAssociated = true` and form lifecycle callback forwarding are handled automatically — no manual boilerplate needed in the host element.
155
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
+ ```
182
+
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.
184
+
156
185
  ## Dev
157
186
 
158
187
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "face-up",
3
- "version": "0.0.2",
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",
@@ -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,26 +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
23
  callbackForwarding: [
24
- 'connectedCallback',
25
- 'disconnectedCallback',
26
24
  'formDisabledCallback',
27
25
  'formResetCallback',
28
26
  'formStateRestoreCallback'
29
27
  ],
30
28
  getSharedContext(instance) {
31
29
  return {
32
- internals: instance.#internals,
33
- hostPropagator: instance.propagator
30
+ internals: instance.#internals
34
31
  };
35
32
  }
36
33
  }
@@ -52,9 +49,8 @@
52
49
  connectedCallback() {
53
50
  const input = this.shadowRoot.querySelector('input');
54
51
  input.addEventListener('input', () => {
55
- this.propagator.dispatchEvent(
56
- new CustomEvent('value', { detail: input.value })
57
- );
52
+ // Simply set the feature property — the setter syncs to internals
53
+ this.faceUp.value = input.value;
58
54
  });
59
55
  }
60
56
 
@@ -481,7 +481,6 @@ This pattern eliminates all manual wiring in the consumer's constructor — the
481
481
 
482
482
  ```javascript
483
483
  class MyElement extends HTMLElement {
484
- propagator = new EventTarget();
485
484
  #internals;
486
485
 
487
486
  static supportedFeatures = {
@@ -490,8 +489,7 @@ class MyElement extends HTMLElement {
490
489
  callbackForwarding: ['connectedCallback', 'disconnectedCallback'],
491
490
  getSharedContext(instance) {
492
491
  return {
493
- internals: instance.#internals,
494
- hostPropagator: instance.propagator
492
+ internals: instance.#internals
495
493
  };
496
494
  }
497
495
  }
@@ -511,6 +509,18 @@ customElements.assignFeatures(MyElement, {
511
509
  customElements.define('my-element', MyElement);
512
510
  ```
513
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
+
514
524
  **Async features and `callbackForwarding`:**
515
525
 
516
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;