@jinntec/fore 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/fore-dev.js +36088 -9
  2. package/dist/fore.js +35918 -9
  3. package/index.js +3 -1
  4. package/package.json +10 -4
  5. package/resources/fore.css +30 -5
  6. package/src/DataObserver.js +181 -0
  7. package/src/DependencyNotifyingDomFacade.js +27 -21
  8. package/src/DependentXPathQueries.js +32 -0
  9. package/src/ForeElementMixin.js +60 -26
  10. package/src/actions/abstract-action.js +24 -29
  11. package/src/actions/fx-append.js +25 -2
  12. package/src/actions/fx-call.js +2 -2
  13. package/src/actions/fx-delete.js +58 -21
  14. package/src/actions/fx-hide.js +1 -1
  15. package/src/actions/fx-insert.js +62 -48
  16. package/src/actions/fx-load.js +7 -2
  17. package/src/actions/fx-refresh.js +15 -5
  18. package/src/actions/fx-replace.js +18 -3
  19. package/src/actions/fx-reset.js +6 -0
  20. package/src/actions/fx-send.js +10 -7
  21. package/src/actions/fx-setattribute.js +12 -0
  22. package/src/actions/fx-setfocus.js +11 -8
  23. package/src/actions/fx-setvalue.js +20 -2
  24. package/src/actions/fx-show.js +4 -2
  25. package/src/actions/fx-toggle.js +1 -1
  26. package/src/extract-predicate-deps.js +57 -0
  27. package/src/extractPredicateDependencies.js +36 -0
  28. package/src/fore.js +78 -36
  29. package/src/fx-bind.js +128 -23
  30. package/src/fx-connection.js +24 -7
  31. package/src/fx-fore.js +552 -306
  32. package/src/fx-instance.js +9 -11
  33. package/src/fx-model.js +154 -65
  34. package/src/fx-submission.js +45 -51
  35. package/src/fx-var.js +5 -0
  36. package/src/getInScopeContext.js +8 -8
  37. package/src/modelitem.js +218 -72
  38. package/src/tools/fx-action-log.js +21 -19
  39. package/src/tools/fx-log-settings.js +4 -2
  40. package/src/ui/TemplateExpression.js +12 -0
  41. package/src/ui/UIElement.js +206 -0
  42. package/src/ui/abstract-control.js +15 -7
  43. package/src/ui/fx-case.js +15 -3
  44. package/src/ui/fx-container.js +10 -3
  45. package/src/ui/fx-control-menu.js +207 -0
  46. package/src/ui/fx-control.js +116 -32
  47. package/src/ui/fx-dialog.js +2 -2
  48. package/src/ui/fx-group.js +14 -0
  49. package/src/ui/fx-items.js +10 -4
  50. package/src/ui/fx-repeat-attributes.js +111 -35
  51. package/src/ui/fx-repeat.js +364 -87
  52. package/src/ui/fx-repeat.updated.js +821 -0
  53. package/src/ui/fx-repeatitem.js +23 -20
  54. package/src/ui/fx-switch.js +5 -3
  55. package/src/ui/fx-upload.js +36 -40
  56. package/src/ui/repeat-base.js +532 -0
  57. package/src/withDraggability.js +8 -4
  58. package/src/xpath-evaluation.js +26 -8
  59. package/src/xpath-path.js +79 -0
  60. package/src/xpath-util.js +107 -11
  61. package/dist/fore-dev.js.map +0 -1
  62. package/dist/fore.js.map +0 -1
  63. package/src/ui/fx-select.js +0 -89
@@ -0,0 +1,206 @@
1
+ import ForeElementMixin from '../ForeElementMixin.js';
2
+ import { Fore } from '../fore.js';
3
+ import { evaluateXPath, evaluateXPathToBoolean, resolveId } from '../xpath-evaluation';
4
+ import { DependencyNotifyingDomFacade } from '../DependencyNotifyingDomFacade';
5
+
6
+ export class UIElement extends ForeElementMixin {
7
+ constructor() {
8
+ super();
9
+
10
+ this._removeEventListeners = [];
11
+ }
12
+
13
+ connectedCallback() {
14
+ super.connectedCallback();
15
+ this.ondemand = this.hasAttribute('on-demand');
16
+ this.wasOnDemandInitially = this.ondemand;
17
+
18
+ if (this.ondemand) {
19
+ this.addEventListener('show-control', () => {
20
+ this.removeAttribute('on-demand');
21
+ });
22
+ this.addTrashIcon();
23
+ }
24
+
25
+ const ref = this.getAttribute('ref');
26
+ // TODO: make this smarter, handling multiple index functions etc
27
+ if (ref && ref.includes('index(')) {
28
+ const repeatId = ref.match(/index\(['"](?<repeatId>[^'"]*)['"]\)/)?.groups?.repeatId;
29
+ if (repeatId) {
30
+ /**
31
+ * @type {import('./fx-repeat.js').FxRepeat}
32
+ */
33
+ const repeat = resolveId(repeatId, this, 'fx-repeat');
34
+ const onRepeatItemChanged = () => {
35
+ this.getOwnerForm().addToBatchedNotifications(this);
36
+ };
37
+ repeat.addEventListener('item-changed', onRepeatItemChanged);
38
+ this._removeEventListeners.push(() =>
39
+ repeat.removeEventListener('item-changed', onRepeatItemChanged),
40
+ );
41
+ }
42
+ }
43
+ }
44
+
45
+ disconnectedCallback() {
46
+ if (this.modelItem && typeof this.modelItem.removeObserver === 'function') {
47
+ console.log(`[UIElement] Removing observer for ref="${this.ref}"`);
48
+ this.modelItem.removeObserver(this);
49
+ }
50
+
51
+ for (const removeEventListener of this._removeEventListeners) {
52
+ removeEventListener();
53
+ }
54
+ }
55
+ /*
56
+ evalInContext() {
57
+ this.dependencies.resetDependencies();
58
+ const model = this.getModel();
59
+ if (!model) return;
60
+
61
+ const touchedNodes = new Set();
62
+ const domFacade = new DependencyNotifyingDomFacade(node => touchedNodes.add(node));
63
+
64
+ const context = this.getInScopeContext();
65
+ const result = evaluateXPath(this.ref, context, this, domFacade);
66
+ this.nodeset = Array.isArray(result) ? result : [result];
67
+
68
+ touchedNodes.forEach(node => {
69
+ const mi = model.getModelItem(node);
70
+ if (mi) {
71
+ mi.addObserver(this);
72
+ console.log(`[UIElement] Dynamically observing ${mi.path} due to XPath dependency`);
73
+ }
74
+ });
75
+
76
+ // Manually evaluate predicate parts to ensure detection
77
+ const predicateRegex = /\[(.*?)\]/g;
78
+ let match;
79
+ while ((match = predicateRegex.exec(this.ref)) !== null) {
80
+ const predicate = match[1];
81
+ try {
82
+ const predicateContext = model.getDefaultInstance().getDefaultContext();
83
+ const predDomFacade = new DependencyNotifyingDomFacade(n => touchedNodes.add(n));
84
+ evaluateXPathToBoolean(predicate, predicateContext, this, predDomFacade);
85
+
86
+ touchedNodes.forEach(node => {
87
+ const mi = model.getModelItem(node);
88
+ if (mi) {
89
+ mi.addObserver(this);
90
+ console.log(`[UIElement] Observing ${mi.path} (from predicate: ${predicate})`);
91
+ }
92
+ });
93
+ } catch (e) {
94
+ console.warn('Predicate evaluation failed for dependency tracking:', predicate, e);
95
+ }
96
+ }
97
+ }
98
+ */
99
+
100
+ attachObserver() {
101
+ const modelItem = this.getModelItem();
102
+ if (!modelItem || typeof modelItem.addObserver !== 'function') return;
103
+
104
+ if (!modelItem.observers) {
105
+ modelItem.observers = new Set();
106
+ }
107
+ if (modelItem.observers.has(this)) {
108
+ // console.log(`[UIElement] Observer already registered for ref="${this.ref}"`);
109
+ return;
110
+ }
111
+ modelItem.addObserver(this);
112
+ // console.log(`[UIElement] attaching observer for ref="${this.ref}"`, this);
113
+
114
+ // if (typeof this.update === 'function') {
115
+ // this.update(modelItem);
116
+ // }
117
+ }
118
+
119
+ /**
120
+ * Called by ModelItem when it changes
121
+ * @param {import('../modelitem.js').ModelItem} modelItem - The ModelItem that changed
122
+ */
123
+ update(modelItem) {
124
+ if (this.isBound()) {
125
+ // console.log('[UIElement] update()', modelItem);
126
+ // this.getOwnerForm().addToBatchedNotifications(modelItem);
127
+ this.refresh();
128
+ }
129
+ }
130
+
131
+ // init() {
132
+ // throw new Error('You have to implement the method init!');
133
+ // }
134
+
135
+ async refresh(force) {
136
+ console.log(`🔄 [UIElement] refresh() called for ref="${this.ref}"`);
137
+ }
138
+
139
+ async refreshChildren(force) {
140
+ await Fore.refreshChildren(this, force);
141
+ }
142
+
143
+ activate() {
144
+ console.log('UIElement.activate() called');
145
+ this.removeAttribute('on-demand');
146
+ this.style.display = '';
147
+ if (this.isBound()) {
148
+ this.refresh(true);
149
+ }
150
+ Fore.dispatch(this, 'show-group', {});
151
+ }
152
+
153
+ attributeChangedCallback(name, _oldValue, newValue) {
154
+ if (name === 'on-demand') {
155
+ this.ondemand = newValue !== null;
156
+ if (!newValue && !this.wasOnDemandInitially) {
157
+ this.removeTrashIcon();
158
+ } else {
159
+ this.wasOnDemandInitially = true;
160
+ this.addTrashIcon();
161
+ }
162
+ }
163
+ }
164
+
165
+ static get observedAttributes() {
166
+ return ['on-demand'];
167
+ }
168
+
169
+ addTrashIcon() {
170
+ if (!this.closest('[show-icon]')) return;
171
+ const trash = this.querySelector('.trash');
172
+ if (trash) return;
173
+
174
+ const icon = document.createElement('span');
175
+ icon.innerHTML = `
176
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none"
177
+ stroke="currentColor" stroke-width="2" stroke-linecap="round"
178
+ stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
179
+ <path d="M17.94 17.94C16.13 19.12 14.13 20 12 20C7 20 2.73 15.88 1 12C1.6 10.66 2.43 9.47 3.46 8.48M10.58 10.58C10.21 11.01 10 11.5 10 12C10 13.11 10.89 14 12 14C12.5 14 12.99 13.79 13.42 13.42M6.53 6.53C7.87 5.54 9.39 5 12 5C17 5 21.27 9.12 23 12C22.4 13.34 21.57 14.53 20.54 15.52M1 1L23 23"/>
180
+ </svg>
181
+ `;
182
+ icon.classList.add('trash');
183
+ icon.setAttribute('title', 'Hide');
184
+ icon.style.cursor = 'pointer';
185
+ icon.style.marginLeft = '0.5em';
186
+
187
+ icon.addEventListener('click', e => {
188
+ e.stopPropagation();
189
+ this.setAttribute('on-demand', 'true');
190
+ this.style.display = 'none';
191
+ document.dispatchEvent(new CustomEvent('update-control-menu'));
192
+ Fore.dispatch(this, 'hide-control', {});
193
+ });
194
+
195
+ this.appendChild(icon);
196
+ }
197
+
198
+ removeTrashIcon() {
199
+ const icon = this.querySelector('.trash');
200
+ if (icon) icon.remove();
201
+ }
202
+ }
203
+
204
+ if (!customElements.get('ui-element')) {
205
+ customElements.define('ui-element', UIElement);
206
+ }
@@ -1,9 +1,9 @@
1
1
  import '../fx-model.js';
2
- import ForeElementMixin from '../ForeElementMixin.js';
3
2
  import { ModelItem } from '../modelitem.js';
4
3
  import { Fore } from '../fore.js';
5
4
  import getInScopeContext from '../getInScopeContext.js';
6
5
  import { evaluateXPathToFirstNode } from '../xpath-evaluation.js';
6
+ import { UIElement } from './UIElement.js';
7
7
 
8
8
  function isDifferent(oldNodeValue, oldControlValue, newControlValue) {
9
9
  if (oldNodeValue === null) {
@@ -13,7 +13,7 @@ function isDifferent(oldNodeValue, oldControlValue, newControlValue) {
13
13
  if the oldControlValue is null we know the widget is used for the first time and is not considered
14
14
  a value change.
15
15
  */
16
- if(oldControlValue === null) return false;
16
+ if (oldControlValue === null) return false;
17
17
 
18
18
  if (newControlValue && oldControlValue && newControlValue.nodeType && oldControlValue.nodeType) {
19
19
  return newControlValue.outerHTML !== oldControlValue.outerHTML;
@@ -31,7 +31,7 @@ function isDifferent(oldNodeValue, oldControlValue, newControlValue) {
31
31
  * is a general base class for control elements.
32
32
  *
33
33
  */
34
- export default class AbstractControl extends ForeElementMixin {
34
+ export default class AbstractControl extends UIElement {
35
35
  constructor() {
36
36
  super();
37
37
  this.value = null;
@@ -41,6 +41,7 @@ export default class AbstractControl extends ForeElementMixin {
41
41
  this.widget = null;
42
42
  this.visited = false;
43
43
  this.force = false;
44
+ this.ondemand = false;
44
45
  // this.attachShadow({ mode: 'open' });
45
46
  }
46
47
 
@@ -54,11 +55,13 @@ export default class AbstractControl extends ForeElementMixin {
54
55
  */
55
56
  async refresh(force) {
56
57
  if (force) this.force = true;
57
- // console.log('### AbstractControl.refresh on : ', this);
58
58
 
59
59
  // Save the old value of this control. this may be the stringified version, contrast to the node in `nodeset`
60
60
  const oldValue = this.value;
61
61
 
62
+ // if (this.ondemand && !this.value) return;
63
+ // console.log('### AbstractControl.refresh on : ', this);
64
+
62
65
  // if(this.repeated) return
63
66
  if (this.isNotBound()) return;
64
67
 
@@ -122,6 +125,7 @@ export default class AbstractControl extends ForeElementMixin {
122
125
  if (this.modelItem instanceof ModelItem) {
123
126
  // console.log('### XfAbstractControl.refresh modelItem : ', this.modelItem);
124
127
 
128
+ this.attachObserver();
125
129
  if (this.hasAttribute('as') && this.getAttribute('as') === 'node') {
126
130
  // console.log('as', this.nodeset);
127
131
  // this.modelItem.value = this.nodeset;
@@ -153,9 +157,11 @@ export default class AbstractControl extends ForeElementMixin {
153
157
  /*
154
158
  this is another case that highlights the fact that an init() function might make sense in general.
155
159
  */
160
+ /*
156
161
  if (!this.modelItem.boundControls.includes(this)) {
157
162
  this.modelItem.boundControls.push(this);
158
163
  }
164
+ */
159
165
 
160
166
  // console.log('>>>>>>>> abstract refresh ', this.control);
161
167
  // this.control[this.valueProp] = this.value;
@@ -170,14 +176,14 @@ export default class AbstractControl extends ForeElementMixin {
170
176
  // if oldVal is null we haven't received a concrete value yet
171
177
 
172
178
  if (!(this.localName === 'fx-control' || this.localName === 'fx-upload')) return;
173
- if (isDifferent(this.oldVal, oldValue, this.value)) {
179
+ if (isDifferent(this.oldVal, oldValue, this.value)) {
174
180
  const model = this.getModel();
175
181
  Fore.dispatch(this, 'value-changed', {
176
182
  path: this.modelItem.path,
177
183
  value: this.modelItem.value,
178
184
  oldvalue: oldValue,
179
- instanceId:this.modelItem.instanceId,
180
- foreId:this.getOwnerForm().id
185
+ instanceId: this.modelItem.instanceId,
186
+ foreId: this.getOwnerForm().id,
181
187
  });
182
188
  }
183
189
  }
@@ -315,9 +321,11 @@ export default class AbstractControl extends ForeElementMixin {
315
321
  // if (alert) alert.style.display = 'none';
316
322
  this._dispatchEvent('valid');
317
323
  this.setAttribute('valid', '');
324
+ this.getWidget().setAttribute('aria-invalid', 'false');
318
325
  this.removeAttribute('invalid');
319
326
  } else {
320
327
  this.setAttribute('invalid', '');
328
+ this.getWidget().setAttribute('aria-invalid', 'true');
321
329
  this.removeAttribute('valid');
322
330
  // ### constraint is invalid - handle alerts
323
331
  /*
package/src/ui/fx-case.js CHANGED
@@ -70,7 +70,7 @@ export class FxCase extends FxContainer {
70
70
  // calls. Save all important state first.
71
71
  const { parentNode } = this;
72
72
  const replacement = await this._loadFromSrc();
73
- if(!replacement){
73
+ if (!replacement) {
74
74
  Fore.dispatch(this, 'error', {
75
75
  detail: {
76
76
  message: `HTML page couldn't be loaded`,
@@ -81,9 +81,21 @@ export class FxCase extends FxContainer {
81
81
  await parentNode.replaceCase(this, replacement);
82
82
  }
83
83
  const model = ownerForm.getModel();
84
- model.updateModel();
85
- ownerForm.refresh(true);
84
+ ownerForm.addToBatchedNotifications(this);
85
+ ownerForm.refresh(false);
86
86
  });
87
+ this.addEventListener('deselect', (event) => {
88
+ const ownerForm = this.getOwnerForm();
89
+ ownerForm.addToBatchedNotifications(event.target);
90
+ });
91
+ }
92
+
93
+ async refresh(force) {
94
+ console.log(`🔄 fx-case ${this.id} refresh`, force);
95
+ await super.refresh(force);
96
+ if (!this.isBound()) {
97
+ Fore.refreshChildren(this, force);
98
+ }
87
99
  }
88
100
 
89
101
  /**
@@ -1,12 +1,12 @@
1
1
  import '../fx-model.js';
2
- import ForeElementMixin from '../ForeElementMixin.js';
2
+ import { UIElement } from './UIElement.js';
3
3
 
4
4
  /**
5
5
  * `fx-container` -
6
6
  * is a general class for container elements.
7
7
  *
8
8
  */
9
- export class FxContainer extends ForeElementMixin {
9
+ export class FxContainer extends UIElement {
10
10
  static get properties() {
11
11
  return {
12
12
  ...super.properties,
@@ -24,6 +24,8 @@ export class FxContainer extends ForeElementMixin {
24
24
  }
25
25
 
26
26
  connectedCallback() {
27
+ super.connectedCallback();
28
+
27
29
  this.src = this.hasAttribute('src') ? this.getAttribute('src') : null;
28
30
  const style = `
29
31
  :host {
@@ -41,7 +43,7 @@ export class FxContainer extends ForeElementMixin {
41
43
  </style>
42
44
  ${html}
43
45
  `;
44
- if(this.ref !== ''){
46
+ if (this.ref !== '') {
45
47
  this.getOwnerForm().registerLazyElement(this);
46
48
  }
47
49
 
@@ -76,9 +78,14 @@ export class FxContainer extends ForeElementMixin {
76
78
  if (this.isBound()) {
77
79
  this.evalInContext();
78
80
  this.modelItem = this.getModelItem();
81
+ /*
79
82
  if (this.modelItem && !this.modelItem.boundControls.includes(this)) {
80
83
  this.modelItem.boundControls.push(this);
81
84
  }
85
+ */
86
+ if (this.modelItem) {
87
+ this.attachObserver();
88
+ }
82
89
  this.handleModelItemProperties();
83
90
  }
84
91
 
@@ -0,0 +1,207 @@
1
+ import XfAbstractControl from './abstract-control.js';
2
+
3
+ /**
4
+ * This class finds and lists all elements with an 'on-demand' attribute and offers them
5
+ * in a popup list for activation. 'on-demand' is not a state like 'relevant' but just
6
+ * shows/hides controls on demand. The controls still behave as usual otherwise.
7
+ *
8
+ *
9
+ */
10
+ export class FxControlMenu extends XfAbstractControl {
11
+ connectedCallback() {
12
+ this.attachShadow({ mode: 'open' });
13
+ this.selectExpr = this.getAttribute('select');
14
+
15
+ const style = `
16
+ :host {
17
+ display: inline-block;
18
+ position: relative;
19
+ }
20
+
21
+ .menu {
22
+ display: none;
23
+ position: absolute;
24
+ top: 100%;
25
+ left: 0;
26
+ z-index: 10;
27
+ background: white;
28
+ border: 1px solid #ccc;
29
+ padding: 0.5em;
30
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
31
+ min-width: 10em;
32
+ white-space:nowrap;
33
+ }
34
+
35
+ .menu.visible {
36
+ display: block;
37
+ }
38
+
39
+ .menu a {
40
+ display: block;
41
+ padding: 0.25em 0.5em;
42
+ text-decoration: none;
43
+ color: black;
44
+ cursor: pointer;
45
+ }
46
+
47
+ .menu a:hover {
48
+ background-color: #eee;
49
+ }
50
+ `;
51
+
52
+ this.shadowRoot.innerHTML = `
53
+ <style>${style}</style>
54
+ <slot></slot>
55
+ <div class="menu" part="menu"></div>
56
+ `;
57
+
58
+ this.menuEl = this.shadowRoot.querySelector('.menu');
59
+
60
+ // Slotted button click
61
+ const slot = this.shadowRoot.querySelector('slot');
62
+ slot.addEventListener('slotchange', () => {
63
+ const nodes = slot.assignedNodes({ flatten: true });
64
+ const button = nodes.find(
65
+ node => node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BUTTON',
66
+ );
67
+ if (button) {
68
+ button.addEventListener('click', e => {
69
+ e.preventDefault();
70
+ e.stopPropagation();
71
+ this.updateMenu();
72
+ this.menuEl.classList.toggle('visible');
73
+ });
74
+ }
75
+ });
76
+
77
+ // Update menu on custom event
78
+ document.addEventListener('update-control-menu', () => {
79
+ this.updateMenu();
80
+ });
81
+
82
+ // Close on outside click
83
+ document.addEventListener('click', e => {
84
+ const inside = this.contains(e.target) || this.shadowRoot.contains(e.target);
85
+ if (!inside) {
86
+ this.menuEl.classList.remove('visible');
87
+ }
88
+ });
89
+
90
+ // Close on Escape
91
+ document.addEventListener('keydown', e => {
92
+ if (e.key === 'Escape') {
93
+ this.menuEl.classList.remove('visible');
94
+ }
95
+ });
96
+
97
+ if (this.getAttribute('mode') === 'hide-on-empty') {
98
+ this.getOwnerForm().addEventListener('ready', () => {
99
+ const container = document.querySelector(this.selectExpr);
100
+ if (!container) return;
101
+
102
+ const widgets = container.querySelectorAll('.widget');
103
+ widgets.forEach(widget => {
104
+ const value = widget.value?.trim();
105
+ const control = widget.closest('fx-control');
106
+ if (control && (value === '' || value == null)) {
107
+ control.setAttribute('on-demand', 'true');
108
+ }
109
+ });
110
+
111
+ // After marking empty controls, update the menu
112
+ this.updateMenu();
113
+ });
114
+ }
115
+
116
+ const container = document.querySelector(this.selectExpr);
117
+ container?.addEventListener('show-control', event => {
118
+ this.updateMenu();
119
+ });
120
+
121
+ this.updateMenu();
122
+ }
123
+
124
+ _getScopedContainer() {
125
+ const repeatItem = this.closest('fx-repeatitem');
126
+ if (repeatItem) return repeatItem;
127
+
128
+ if (this.selectExpr) {
129
+ return document.querySelector(this.selectExpr);
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ updateMenu() {
136
+ const container = this._getScopedContainer();
137
+ if (!container) return;
138
+
139
+ let targets = [];
140
+ // ✅ Include container itself if it has on-demand
141
+ if (container.hasAttribute('on-demand')) {
142
+ targets.push(container);
143
+ }
144
+
145
+ // ✅ Also include any descendant [on-demand] controls if not within repeat
146
+ if (container.nodeName !== 'FX-REPEAT') {
147
+ const innerTargets = Array.from(container.querySelectorAll('[on-demand]'));
148
+ targets.push(...innerTargets);
149
+ }
150
+
151
+ this._currentTargets = targets;
152
+ this.menuEl.innerHTML = '';
153
+
154
+ // Find the button to disable if needed
155
+ const slot = this.shadowRoot.querySelector('slot');
156
+ const assignedNodes = slot.assignedNodes({ flatten: true });
157
+ const button = assignedNodes.find(
158
+ node => node.nodeType === Node.ELEMENT_NODE && node.tagName === 'BUTTON',
159
+ );
160
+
161
+ if (button) {
162
+ button.disabled = targets.length === 0;
163
+ }
164
+
165
+ if (targets.length === 0) {
166
+ this.menuEl.classList.remove('visible');
167
+ return;
168
+ }
169
+
170
+ targets.forEach((el, index) => {
171
+ let label = el.getAttribute('aria-label');
172
+ if (!label) {
173
+ label = el.querySelector('label')?.textContent.trim() || `Item ${index + 1}`;
174
+ }
175
+ if (!label) {
176
+ console.warn(
177
+ 'no label found - cannot create menu entry for ',
178
+ el,
179
+ ' - please add aria-label or label element to control',
180
+ );
181
+ }
182
+ const item = document.createElement('a');
183
+ item.href = '#';
184
+ item.textContent = label;
185
+
186
+ item.addEventListener('click', e => {
187
+ e.preventDefault();
188
+ if (typeof el.activate === 'function') {
189
+ el.activate();
190
+ }
191
+
192
+ this.menuEl.classList.remove('visible');
193
+
194
+ // Wait one frame to let DOM updates (like on-demand removal) take effect
195
+ requestAnimationFrame(() => {
196
+ this.updateMenu();
197
+ });
198
+ });
199
+
200
+ this.menuEl.appendChild(item);
201
+ });
202
+ }
203
+ }
204
+
205
+ if (!customElements.get('fx-control-menu')) {
206
+ customElements.define('fx-control-menu', FxControlMenu);
207
+ }