cotomy 0.3.16 → 0.4.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.
package/README.md CHANGED
@@ -38,6 +38,7 @@ The View layer provides thin wrappers around DOM elements and window events.
38
38
  - Scoped CSS
39
39
  - `scopeId: string` - Returns the value stored in the element's `data-cotomy-scopeid` attribute
40
40
  - `[scope]` placeholder in provided CSS is replaced by `[data-cotomy-scopeid="..."]`
41
+ - Scoped CSS text is kept on the instance; if the `<style id="css-${scopeId}">` is missing when the element is attached, it will be re-generated automatically
41
42
  - `stylable: boolean` - False for tags like `script`, `style`, `link`, `meta`
42
43
  - Static helpers
43
44
  - `CotomyElement.encodeHtml(text)`
@@ -77,7 +78,7 @@ The View layer provides thin wrappers around DOM elements and window events.
77
78
  - `insertBefore(sibling): this` / `insertAfter(sibling): this`
78
79
  - `appendTo(target): this` / `prependTo(target): this`
79
80
  - `comesBefore(target): boolean` / `comesAfter(target): boolean` — Checks DOM order (returns `false` for the same element or disconnected nodes)
80
- - `clone(type?): CotomyElement` - Returns a deep-cloned element, optionally typed, and reassigns new `data-cotomy-instance`/`data-cotomy-scopeid` values (and strips `data-cotomy-moving`). Cloning an invalidated element (`data-cotomy-invalidated`) throws.
81
+ - `clone(type?): CotomyElement` - Returns a deep-cloned element, optionally typed, and reassigns a new `data-cotomy-instance` while preserving the `data-cotomy-scopeid` for scoped CSS sharing (strips `data-cotomy-moving`). Cloning an invalidated element (`data-cotomy-invalidated`) throws.
81
82
  - `clear(): this` — Removes all descendants and text
82
83
  - `remove(): void` — Explicitly non-chainable after removal
83
84
  - Geometry & visibility
@@ -107,6 +108,7 @@ The View layer provides thin wrappers around DOM elements and window events.
107
108
  - Layout (custom): `resize`, `scroll`, `changelayout` — requires `listenLayoutEvents()` on the element
108
109
  - Move lifecycle: `cotomy:transitstart`, `cotomy:transitend` — emitted automatically by `append`, `prepend`, `insertBefore/After`, `appendTo`, and `prependTo`. While moving, the element (and its descendants) receive a temporary `data-cotomy-moving` attribute so removal observers know the node is still in transit.
109
110
  - Removal: `removed` — fired when an element actually leaves the DOM (MutationObserver-backed). Because `cotomy:transitstart`/`transitend` manage the `data-cotomy-moving` flag, `removed` only runs for true detachments, making it safe for cleanup.
111
+ - Registry isolation: イベントレジストリは `data-cotomy-instance`(インスタンスID)単位で管理され、クローンは新しいインスタンスIDを持つため、同じ `scopeId` を共有していてもリスナーが混線しません。
110
112
  - File: `filedrop(handler: (files: File[]) => void)`
111
113
 
112
114
  Example (scoped CSS and events):
@@ -128,18 +130,20 @@ document.body.appendChild(panel.element);
128
130
 
129
131
  ## Testing
130
132
 
131
- The scoped CSS replacement and scope-id isolation logic are covered by `tests/view.spec.ts`. Run the focused specs below to verify the behavior:
133
+ Scoped CSS sharing/re-hydrationとインスタンス単位のイベント管理は `tests/view.spec.ts` に含まれています。主に確認したい場合は以下のコマンドで個別に実行できます:
132
134
 
133
135
  ```bash
134
- npx vitest run tests/view.spec.ts -t "constructs from multiple sources and applies scoped css"
135
- npx vitest run tests/view.spec.ts -t "assigns fresh scope ids when cloning, including descendants"
136
- npx vitest run tests/view.spec.ts -t "regenerates instance ids and lifecycle hooks when cloning"
137
- npx vitest run tests/view.spec.ts -t "strips moving flags when cloning"
138
- npx vitest run tests/view.spec.ts -t "throws when cloning an invalidated element"
139
- npx vitest run tests/view.spec.ts -t "compares document order with comesBefore/comesAfter"
136
+ npm test -- --run tests/view.spec.ts -t "constructs from multiple sources and applies scoped css"
137
+ npm test -- --run tests/view.spec.ts -t "preserves scope ids when cloning, including descendants"
138
+ npm test -- --run tests/view.spec.ts -t "regenerates instance ids and lifecycle hooks when cloning"
139
+ npm test -- --run tests/view.spec.ts -t "keeps event handlers isolated by instance even when sharing scope"
140
+ npm test -- --run tests/view.spec.ts -t "rehydrates scoped css when a clone shares scope after the original was removed"
141
+ npm test -- --run tests/view.spec.ts -t "strips moving flags when cloning"
142
+ npm test -- --run tests/view.spec.ts -t "throws when cloning an invalidated element"
143
+ npm test -- --run tests/view.spec.ts -t "compares document order with comesBefore/comesAfter"
140
144
  ```
141
145
 
142
- The first command ensures `[scope]` expands to `[data-cotomy-scopeid="..."]` in injected styles, the second confirms that cloning reassigns new `data-cotomy-scopeid` attributes to the cloned tree, the third verifies fresh `data-cotomy-instance` values and lifecycle hooks, and the last two cover stripping transit flags and rejecting invalidated nodes during cloning.
146
+ 普段は `npm test` で全体を実行できます。上記のコマンドでは `[scope]` 展開、スコープID共有のクローン挙動、インスタンス単位のイベント隔離、クローン後のスコープCSS再生成、移動フラグの除去、無効化要素のクローン拒否、DOM順序判定などをピンポイントで確認できます。
143
147
 
144
148
  ### CotomyMetaElement
145
149
 
@@ -233,9 +237,9 @@ The Form layer builds on `CotomyElement` for common form flows.
233
237
  - Data loading and field filling
234
238
  - `initialize()` — Adds default fillers and triggers `loadAsync()` on `CotomyWindow.ready`
235
239
  - `reloadAsync()` — Alias to `loadAsync()`
236
- - `loadAsync(): Promise<CotomyApiResponse>` — Calls `CotomyApi.getAsync` when `canLoad()` is true
237
- - `loadActionUrl(): string` — Defaults to `actionUrl`; override for custom endpoints
238
- - `canLoad(): boolean` — Defaults to `hasEntityKey`
240
+ - `loadAsync(): Promise<CotomyApiResponse>` — Calls `CotomyApi.getAsync` when `canLoad` is true
241
+ - `loadActionUrl: string` — Defaults to `actionUrl`; override or set for custom endpoints
242
+ - `canLoad: boolean` — Defaults to `hasEntityKey`
239
243
  - Naming & binding
240
244
  - `bindNameGenerator(): ICotomyBindNameGenerator` — Defaults to `CotomyBracketBindNameGenerator` (`user[name]`)
241
245
  - `renderer(): CotomyViewRenderer` — Applies `[data-cotomy-bind]` to view elements
@@ -289,7 +293,7 @@ form.submitFailed(e => console.warn("Submit failed", e.response.status));
289
293
  Attach `data-cotomy-entity-key="<id>"` to the form when editing an existing entity; omit the attribute (or leave it empty) to issue a `POST` to the base `action` URL.
290
294
  On `201 Created`, the form reads the `Location` header and stores the generated key back into `data-cotomy-entity-key`, enabling subsequent `PUT` submissions.
291
295
  Composite or natural keys are no longer supported—migrate any legacy markup that relied on `data-cotomy-keyindex` or multiple key inputs to the new surrogate-key flow.
292
- When you must integrate with endpoints that still expect natural identifiers, subclass `CotomyEntityApiForm`/`CotomyEntityFillApiForm`, override `canLoad()` to supply your own load condition, and adjust `loadActionUrl()` (plus any submission hooks) to build the appropriate URL fragments.
296
+ When you must integrate with endpoints that still expect natural identifiers, subclass `CotomyEntityApiForm`/`CotomyEntityFillApiForm`, override `canLoad` to supply your own load condition, and adjust `loadActionUrl` (plus any submission hooks) to build the appropriate URL fragments.
293
297
 
294
298
  The core of Cotomy is `CotomyElement`, which is constructed as a wrapper for `Element`.
295
299
  By passing HTML and CSS strings to the constructor, it is possible to generate Element designs with a limited scope.
@@ -811,11 +811,11 @@ class EventRegistry {
811
811
  return this._instance ?? (this._instance = new EventRegistry());
812
812
  }
813
813
  map(target) {
814
- const scopeId = target.scopeId;
815
- let registry = this._registry.get(scopeId);
814
+ const instanceId = target.instanceId;
815
+ let registry = this._registry.get(instanceId);
816
816
  if (!registry) {
817
817
  registry = new HandlerRegistory(target);
818
- this._registry.set(scopeId, registry);
818
+ this._registry.set(instanceId, registry);
819
819
  }
820
820
  return registry;
821
821
  }
@@ -824,7 +824,7 @@ class EventRegistry {
824
824
  registry.add(event, entry);
825
825
  }
826
826
  off(event, target, entry) {
827
- const registry = this._registry.get(target.scopeId);
827
+ const registry = this._registry.get(target.instanceId);
828
828
  if (!registry)
829
829
  return;
830
830
  if (entry) {
@@ -834,11 +834,11 @@ class EventRegistry {
834
834
  registry.remove(event);
835
835
  }
836
836
  if (registry.empty) {
837
- this._registry.delete(target.scopeId);
837
+ this._registry.delete(target.instanceId);
838
838
  }
839
839
  }
840
840
  clear(target) {
841
- this._registry.delete(target.scopeId);
841
+ this._registry.delete(target.instanceId);
842
842
  }
843
843
  }
844
844
  class CotomyScrollOptions {
@@ -856,7 +856,7 @@ class CotomyScrollOptions {
856
856
  if (init.inline !== undefined)
857
857
  this.inline = init.inline;
858
858
  }
859
- resolveBehavior() {
859
+ get resolveBehavior() {
860
860
  return this.behavior;
861
861
  }
862
862
  static from(options) {
@@ -1004,6 +1004,7 @@ class CotomyElement {
1004
1004
  }
1005
1005
  useScopedCss(css) {
1006
1006
  if (css && this.stylable) {
1007
+ this._scopedCss = css;
1007
1008
  const cssid = this.scopedCssElementId;
1008
1009
  CotomyElement.find(`#${cssid}`).forEach(e => e.remove());
1009
1010
  const element = document.createElement("style");
@@ -1015,11 +1016,22 @@ class CotomyElement {
1015
1016
  || new CotomyElement({ html: `<head></head>` }).prependTo(new CotomyElement(document.documentElement));
1016
1017
  head.append(new CotomyElement(element));
1017
1018
  this.removed(() => {
1018
- CotomyElement.find(`#${cssid}`).forEach(e => e.remove());
1019
+ const hasSameScope = document.querySelector(`[data-cotomy-scopeid="${this.scopeId}"]`) !== null;
1020
+ if (!hasSameScope) {
1021
+ CotomyElement.find(`#${cssid}`).forEach(e => e.remove());
1022
+ }
1019
1023
  });
1020
1024
  }
1021
1025
  return this;
1022
1026
  }
1027
+ ensureScopedCss() {
1028
+ if (!this._scopedCss || !this.stylable)
1029
+ return;
1030
+ const cssid = this.scopedCssElementId;
1031
+ if (!document.getElementById(cssid)) {
1032
+ this.useScopedCss(this._scopedCss);
1033
+ }
1034
+ }
1023
1035
  listenLayoutEvents() {
1024
1036
  this.attribute(CotomyElement.LISTEN_LAYOUT_EVENTS_ATTRIBUTE, "");
1025
1037
  return this;
@@ -1038,7 +1050,7 @@ class CotomyElement {
1038
1050
  }
1039
1051
  clone(type) {
1040
1052
  const ctor = (type ?? CotomyElement);
1041
- const ATTRIBUTES_TO_STRIP = ["data-cotomy-instance", "data-cotomy-scopeid", "data-cotomy-moving"];
1053
+ const ATTRIBUTES_TO_STRIP = ["data-cotomy-instance", "data-cotomy-moving"];
1042
1054
  const clonedElement = this.element.cloneNode(true);
1043
1055
  if (clonedElement.hasAttribute("data-cotomy-invalidated")) {
1044
1056
  throw new Error("Cannot clone an invalidated CotomyElement.");
@@ -1048,6 +1060,7 @@ class CotomyElement {
1048
1060
  clonedElement.querySelectorAll(`[${attr}]`).forEach(el => el.removeAttribute(attr));
1049
1061
  });
1050
1062
  const cloned = new ctor(clonedElement);
1063
+ cloned._scopedCss = this._scopedCss;
1051
1064
  return cloned;
1052
1065
  }
1053
1066
  get tagname() {
@@ -1391,7 +1404,7 @@ class CotomyElement {
1391
1404
  if (!this.attached)
1392
1405
  return this;
1393
1406
  const resolved = CotomyScrollOptions.from(options);
1394
- const behavior = resolved.resolveBehavior();
1407
+ const behavior = resolved.resolveBehavior;
1395
1408
  const onlyIfNeeded = resolved.onlyIfNeeded;
1396
1409
  const block = resolved.block;
1397
1410
  const inline = resolved.inline;
@@ -1605,12 +1618,14 @@ class CotomyElement {
1605
1618
  CotomyElement.runWithMoveEvents(prepend, () => {
1606
1619
  this.element.prepend(prepend.element);
1607
1620
  });
1621
+ prepend.ensureScopedCss();
1608
1622
  return this;
1609
1623
  }
1610
1624
  append(target) {
1611
1625
  CotomyElement.runWithMoveEvents(target, () => {
1612
1626
  this.element.append(target.element);
1613
1627
  });
1628
+ target.ensureScopedCss();
1614
1629
  return this;
1615
1630
  }
1616
1631
  appendAll(targets) {
@@ -1621,24 +1636,28 @@ class CotomyElement {
1621
1636
  CotomyElement.runWithMoveEvents(append, () => {
1622
1637
  this.element.before(append.element);
1623
1638
  });
1639
+ append.ensureScopedCss();
1624
1640
  return this;
1625
1641
  }
1626
1642
  insertAfter(append) {
1627
1643
  CotomyElement.runWithMoveEvents(append, () => {
1628
1644
  this.element.after(append.element);
1629
1645
  });
1646
+ append.ensureScopedCss();
1630
1647
  return this;
1631
1648
  }
1632
1649
  appendTo(target) {
1633
1650
  CotomyElement.runWithMoveEvents(this, () => {
1634
1651
  target.element.append(this.element);
1635
1652
  });
1653
+ this.ensureScopedCss();
1636
1654
  return this;
1637
1655
  }
1638
1656
  prependTo(target) {
1639
1657
  CotomyElement.runWithMoveEvents(this, () => {
1640
1658
  target.element.prepend(this.element);
1641
1659
  });
1660
+ this.ensureScopedCss();
1642
1661
  return this;
1643
1662
  }
1644
1663
  trigger(event, e) {
@@ -2990,7 +3009,7 @@ class CotomyEntityFillApiForm extends CotomyEntityApiForm {
2990
3009
  async reloadAsync() {
2991
3010
  await this.loadAsync();
2992
3011
  }
2993
- loadActionUrl() {
3012
+ get loadActionUrl() {
2994
3013
  return this.actionUrl;
2995
3014
  }
2996
3015
  bindNameGenerator() {
@@ -2999,16 +3018,16 @@ class CotomyEntityFillApiForm extends CotomyEntityApiForm {
2999
3018
  renderer() {
3000
3019
  return new CotomyViewRenderer(this, this.bindNameGenerator());
3001
3020
  }
3002
- canLoad() {
3021
+ get canLoad() {
3003
3022
  return this.hasEntityKey;
3004
3023
  }
3005
3024
  async loadAsync() {
3006
- if (!this.canLoad()) {
3025
+ if (!this.canLoad) {
3007
3026
  return new CotomyApiResponse();
3008
3027
  }
3009
3028
  const api = this.apiClient();
3010
3029
  try {
3011
- const response = await api.getAsync(this.loadActionUrl());
3030
+ const response = await api.getAsync(this.loadActionUrl);
3012
3031
  await this.fillAsync(response);
3013
3032
  return response;
3014
3033
  }
@@ -3178,11 +3197,11 @@ class CotomyPageController {
3178
3197
  }
3179
3198
  return form;
3180
3199
  }
3181
- forms() {
3200
+ get forms() {
3182
3201
  return Object.values(this._forms);
3183
3202
  }
3184
3203
  async restoreAsync() {
3185
- for (const f of this.forms()) {
3204
+ for (const f of this.forms) {
3186
3205
  if (CotomyWindow.instance.reloading) {
3187
3206
  break;
3188
3207
  }