@node-projects/web-component-designer-visualization-addons 0.3.0 → 1.0.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.
@@ -58,7 +58,7 @@ export class BindingsEditor extends BaseCustomWebComponentConstructorAppend {
58
58
  <input id="namedConverterInput" value="{{?this.convertersString::change}}">
59
59
  <button id="namedConverterBrowse" class="signal-select-btn" @click="[[this._selectNamedConverter()]]">...</button>
60
60
  </div>
61
- <div class="bordered-section">
61
+ <div id="converterSection" class="bordered-section">
62
62
  <div class="signal-row signal-header">
63
63
  <span class="converter-header-col">condition</span>
64
64
  <span class="converter-header-col">value</span>
@@ -84,10 +84,15 @@ export class BindingsEditor extends BaseCustomWebComponentConstructorAppend {
84
84
  }
85
85
 
86
86
  #converterListContainer {
87
- max-height: 72px;
88
87
  overflow-y: auto;
89
88
  }
90
89
 
90
+ #converterSection {
91
+ display: grid;
92
+ grid-template-rows: min-content minmax(22px, 1fr) min-content;
93
+ min-height: 0;
94
+ }
95
+
91
96
  .input-headline {
92
97
  height: 30px;
93
98
  }
@@ -105,7 +110,7 @@ export class BindingsEditor extends BaseCustomWebComponentConstructorAppend {
105
110
  #root {
106
111
  padding: 2px 10px;
107
112
  display: grid;
108
- grid-template-rows: min-content min-content;
113
+ grid-template-rows: min-content min-content minmax(0, 1fr);
109
114
  overflow: auto;
110
115
  height: calc(100% - 4px)
111
116
  }
@@ -283,6 +288,7 @@ export class BindingsEditor extends BaseCustomWebComponentConstructorAppend {
283
288
 
284
289
  .add-signal-btn {
285
290
  align-self: flex-start;
291
+ justify-self: start;
286
292
  margin-top: 4px;
287
293
  height: 22px;
288
294
  cursor: pointer;
@@ -0,0 +1,23 @@
1
+ import { BindingTarget } from '@node-projects/web-component-designer/dist/elements/item/BindingTarget.js';
2
+ import { IBinding } from '@node-projects/web-component-designer/dist/elements/item/IBinding.js';
3
+ import { IDesignItem } from '@node-projects/web-component-designer/dist/elements/item/IDesignItem.js';
4
+ import { RefreshMode } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/IPropertiesService.js';
5
+ import { IProperty } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/IProperty.js';
6
+ import { ValueType } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/ValueType.js';
7
+ import { AbstractPropertiesService } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/services/AbstractPropertiesService.js';
8
+ /** Adapter for host binding editors: a class token is a boolean binding target. */
9
+ export declare class ClassBindingsPropertiesService extends AbstractPropertiesService {
10
+ getRefreshMode(): RefreshMode;
11
+ isHandledElement(designItem: IDesignItem): boolean;
12
+ getBindings(designItem: IDesignItem): IBinding[];
13
+ getProperties(designItem: IDesignItem): Promise<IProperty[]>;
14
+ getProperty(designItem: IDesignItem, name: string): Promise<IProperty>;
15
+ createProperty(name: string): IProperty;
16
+ getPropertyTarget(): BindingTarget;
17
+ getBinding(items: IDesignItem[], property: IProperty): IBinding;
18
+ isSet(items: IDesignItem[], property: IProperty): ValueType.none | ValueType.bound;
19
+ getValue(items: IDesignItem[], property: IProperty): string;
20
+ validateName(designItem: IDesignItem, name: string, previousName?: string): string;
21
+ rename(designItem: IDesignItem, binding: IBinding, name: string): void;
22
+ remove(designItem: IDesignItem, binding: IBinding): void;
23
+ }
@@ -0,0 +1,73 @@
1
+ import { BindingTarget } from '@node-projects/web-component-designer/dist/elements/item/BindingTarget.js';
2
+ import { RefreshMode } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/IPropertiesService.js';
3
+ import { PropertyType } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/PropertyType.js';
4
+ import { ValueType } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/ValueType.js';
5
+ import { AbstractPropertiesService } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/services/AbstractPropertiesService.js';
6
+ /** Adapter for host binding editors: a class token is a boolean binding target. */
7
+ export class ClassBindingsPropertiesService extends AbstractPropertiesService {
8
+ getRefreshMode() { return RefreshMode.fullOnValueChange; }
9
+ isHandledElement(designItem) { return !designItem.isRootItem; }
10
+ getBindings(designItem) {
11
+ return designItem.serviceContainer.getServices('bindingService')
12
+ .flatMap(service => (service.getBindings(designItem) ?? [])
13
+ .filter(binding => binding.target === BindingTarget.class)
14
+ .map(binding => ({ ...binding, service })));
15
+ }
16
+ async getProperties(designItem) {
17
+ return this.getBindings(designItem).map(binding => this.createProperty(binding.targetName));
18
+ }
19
+ async getProperty(designItem, name) {
20
+ return this.createProperty(name);
21
+ }
22
+ createProperty(name) {
23
+ return { name, type: 'boolean', propertyType: PropertyType.property, service: this };
24
+ }
25
+ getPropertyTarget() { return BindingTarget.class; }
26
+ getBinding(items, property) {
27
+ return this.getBindings(items[0]).find(binding => binding.targetName === property.name);
28
+ }
29
+ isSet(items, property) {
30
+ return this.getBinding(items, property) ? ValueType.bound : ValueType.none;
31
+ }
32
+ getValue(items, property) {
33
+ return this.getBinding(items, property)?.expression;
34
+ }
35
+ validateName(designItem, name, previousName) {
36
+ if (!name || /[\s"'<>/=]/.test(name) || name !== name.toLowerCase())
37
+ return 'Use a lowercase class name without spaces or HTML attribute delimiters, e.g. is-active.';
38
+ if (name !== previousName && this.getBindings(designItem).some(binding => binding.targetName === name))
39
+ return 'This class already has a binding.';
40
+ return null;
41
+ }
42
+ rename(designItem, binding, name) {
43
+ const error = this.validateName(designItem, name, binding.targetName);
44
+ if (error)
45
+ throw new Error(error);
46
+ if (name === binding.targetName)
47
+ return;
48
+ const group = designItem.openGroup(`rename class binding: ${binding.targetName} to ${name}`);
49
+ try {
50
+ if (!binding.service.setBinding(designItem, { ...binding, targetName: name }))
51
+ throw new Error('The binding service could not rename this binding.');
52
+ if (!binding.service.clearBinding(designItem, binding.targetName, BindingTarget.class))
53
+ throw new Error('The binding service could not remove the previous binding.');
54
+ group.commit();
55
+ }
56
+ catch (error) {
57
+ group.abort();
58
+ throw error;
59
+ }
60
+ }
61
+ remove(designItem, binding) {
62
+ const group = designItem.openGroup(`remove class binding: ${binding.targetName}`);
63
+ try {
64
+ if (!binding.service.clearBinding(designItem, binding.targetName, BindingTarget.class))
65
+ throw new Error('The binding service could not remove this binding.');
66
+ group.commit();
67
+ }
68
+ catch (error) {
69
+ group.abort();
70
+ throw error;
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,5 @@
1
+ import type { IProperty, IPropertyGridExtensionProvider, IPropertyGridExtension } from '@node-projects/web-component-designer';
2
+ /** Register in serviceContainer.propertyGridExtensions to enable visualization class bindings. */
3
+ export declare class ClassBindingsPropertyGridExtensionProvider implements IPropertyGridExtensionProvider {
4
+ createExtension(property: IProperty): IPropertyGridExtension | undefined;
5
+ }
@@ -0,0 +1,15 @@
1
+ import { PropertyType } from '@node-projects/web-component-designer/dist/elements/services/propertiesService/PropertyType.js';
2
+ import { PropertyGridClassBindings } from './PropertyGridClassBindings.js';
3
+ /** Register in serviceContainer.propertyGridExtensions to enable visualization class bindings. */
4
+ export class ClassBindingsPropertyGridExtensionProvider {
5
+ createExtension(property) {
6
+ if ((property.name !== 'class' && property.name !== 'className') || property.propertyType === PropertyType.cssValue)
7
+ return undefined;
8
+ const element = new PropertyGridClassBindings();
9
+ return {
10
+ element,
11
+ refresh: items => element.refresh(items),
12
+ getContextMenuItems: items => [{ title: 'Add class binding…', disabled: items?.length !== 1 || !items[0].serviceContainer.config.openBindingsEditor, action: () => element.addBinding() }]
13
+ };
14
+ }
15
+ }
@@ -0,0 +1,17 @@
1
+ import { BaseCustomWebComponentLazyAppend } from '@node-projects/base-custom-webcomponent';
2
+ import { IDesignItem } from '@node-projects/web-component-designer/dist/elements/item/IDesignItem.js';
3
+ export declare class PropertyGridClassBindings extends BaseCustomWebComponentLazyAppend {
4
+ private _items;
5
+ private _service;
6
+ private _rows;
7
+ private _add;
8
+ private _toggle;
9
+ private _signature;
10
+ private _editing;
11
+ static readonly style: CSSStyleSheet;
12
+ constructor();
13
+ refresh(items: IDesignItem[]): void;
14
+ addBinding(): void;
15
+ private editBinding;
16
+ private showNameDialog;
17
+ }
@@ -0,0 +1,231 @@
1
+ import { BaseCustomWebComponentLazyAppend, css } from '@node-projects/base-custom-webcomponent';
2
+ import { BindingTarget } from '@node-projects/web-component-designer/dist/elements/item/BindingTarget.js';
3
+ import { ContextMenu } from '@node-projects/web-component-designer/dist/elements/helper/contextMenu/ContextMenu.js';
4
+ import { ClassBindingsPropertiesService } from './ClassBindingsPropertiesService.js';
5
+ export class PropertyGridClassBindings extends BaseCustomWebComponentLazyAppend {
6
+ _items = [];
7
+ _service = new ClassBindingsPropertiesService();
8
+ _rows;
9
+ _add;
10
+ _toggle;
11
+ _signature;
12
+ _editing = false;
13
+ static style = css `
14
+ :host { display: block; grid-column: 1 / -1; min-width: 0; color: var(--wcd-color-text, white); }
15
+ :host([hidden]) { display: none !important; }
16
+ .header, .row { display: flex; align-items: center; gap: 8px; min-height: 28px; }
17
+ .row { padding-left: 16px; }
18
+ button { font: inherit; color: inherit; background: transparent; border: none; cursor: pointer; padding: 4px; }
19
+ button:focus-visible { outline: 2px solid var(--wcd-color-accent, #e91e63); outline-offset: -2px; }
20
+ button:disabled { opacity: .5; cursor: default; }
21
+ .toggle, .name { text-align: left; }
22
+ .toggle { flex: 1; }
23
+ .name { flex: 0 1 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
+ .summary { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--wcd-color-text-muted, #bdbdbd); }
25
+ .message { padding: 4px 16px; font-size: 12px; color: var(--wcd-color-text-muted, #bdbdbd); }
26
+ dialog { box-sizing: border-box; width: min(420px, calc(100vw - 32px)); color: inherit; background: var(--wcd-property-grid-background, var(--wcd-color-surface-raised, #2f3545)); border: 1px solid var(--wcd-color-border, #596c7a); padding: 16px; }
27
+ dialog::backdrop { background: #0006; }
28
+ form, label { display: grid; gap: 8px; }
29
+ form { gap: 16px; }
30
+ h3 { font: inherit; font-weight: 600; margin: 0; }
31
+ input { box-sizing: border-box; width: 100%; padding: 6px; font: inherit; color: inherit; background: transparent; border: 1px solid var(--wcd-color-border, #596c7a); }
32
+ .help { font-size: 12px; color: var(--wcd-color-text-muted, #bdbdbd); }
33
+ .error { color: var(--wcd-color-error, #ff8a80); font-size: 12px; }
34
+ .actions { display: flex; justify-content: flex-end; gap: 8px; }
35
+ .actions button { border: 1px solid var(--wcd-color-border, #596c7a); padding: 6px 12px; }
36
+ [hidden] { display: none !important; }
37
+ :host([appearance="modern"]) { color: var(--_wcd-pg-text); font-size: 13px; }
38
+ :host([appearance="modern"]) .header {
39
+ padding: 5px 12px;
40
+ min-height: 38px;
41
+ background: var(--_wcd-pg-raised);
42
+ border-bottom: 1px solid var(--_wcd-pg-border);
43
+ }
44
+ :host([appearance="modern"]) .toggle { font-weight: 600; }
45
+ :host([appearance="modern"]) .row {
46
+ display: grid;
47
+ grid-template-columns: minmax(0, .85fr) minmax(0, 1fr) 28px;
48
+ padding: 5px 12px 5px 24px;
49
+ min-height: 38px;
50
+ border-bottom: 1px solid var(--_wcd-pg-border);
51
+ }
52
+ :host([appearance="modern"]) .row:hover,
53
+ :host([appearance="modern"]) .row:focus-within { background: var(--_wcd-pg-hover); }
54
+ :host([appearance="modern"]) .summary { color: var(--_wcd-pg-muted); }
55
+ :host([appearance="modern"]) button { border-radius: 4px; }
56
+ :host([appearance="modern"]) button:hover { background: var(--_wcd-pg-hover); }
57
+ :host([appearance="modern"]) button:focus-visible { outline-color: var(--wcd-color-focus, var(--_wcd-pg-accent)); }
58
+ :host([appearance="modern"]) dialog {
59
+ padding: 24px;
60
+ border-radius: 10px;
61
+ color: var(--_wcd-pg-text);
62
+ background: var(--_wcd-pg-surface);
63
+ border-color: var(--_wcd-pg-border);
64
+ box-shadow: 0 16px 64px #0003;
65
+ }
66
+ :host([appearance="modern"]) h3 { font-size: 16px; }
67
+ :host([appearance="modern"]) input { border-radius: 5px; border-color: var(--_wcd-pg-border); padding: 8px; }
68
+ :host([appearance="modern"]) input:focus-visible { outline: 2px solid var(--wcd-color-focus, var(--_wcd-pg-accent)); outline-offset: 1px; }
69
+ :host([appearance="modern"]) .help { color: var(--_wcd-pg-muted); }
70
+ :host([appearance="modern"]) .actions button { border-color: var(--_wcd-pg-border); }
71
+ :host([appearance="modern"]) .actions button[type="submit"] {
72
+ background: var(--_wcd-pg-accent);
73
+ color: var(--wcd-color-accent-text, light-dark(white, #162b48));
74
+ border-color: transparent;
75
+ }
76
+ `;
77
+ constructor() {
78
+ super();
79
+ const header = document.createElement('div');
80
+ header.className = 'header';
81
+ this._toggle = document.createElement('button');
82
+ this._toggle.type = 'button';
83
+ this._toggle.className = 'toggle';
84
+ this._toggle.textContent = '▾ Class bindings';
85
+ this._toggle.setAttribute('aria-expanded', 'true');
86
+ this._toggle.onclick = () => {
87
+ this._rows.hidden = !this._rows.hidden;
88
+ this._toggle.textContent = (this._rows.hidden ? '▸' : '▾') + ' Class bindings';
89
+ this._toggle.setAttribute('aria-expanded', String(!this._rows.hidden));
90
+ };
91
+ this._add = document.createElement('button');
92
+ this._add.type = 'button';
93
+ this._add.textContent = '+';
94
+ this._add.title = 'Add class binding…';
95
+ this._add.setAttribute('aria-label', 'Add class binding');
96
+ this._add.onclick = () => this.addBinding();
97
+ header.append(this._toggle, this._add);
98
+ this._rows = document.createElement('div');
99
+ this.shadowRoot.append(header, this._rows);
100
+ }
101
+ refresh(items) {
102
+ const selectionChanged = this._items[0] !== items?.[0] || this._items.length !== items?.length;
103
+ if (selectionChanged)
104
+ this.shadowRoot.querySelector('dialog')?.close();
105
+ this._items = items ?? [];
106
+ const item = this._items.length === 1 ? this._items[0] : null;
107
+ const bindings = item ? this._service.getBindings(item) : [];
108
+ this._add.disabled = !item || !item.serviceContainer.config.openBindingsEditor;
109
+ const signature = JSON.stringify(bindings.map(({ service, ...binding }) => binding));
110
+ if (!selectionChanged && signature === this._signature)
111
+ return;
112
+ this._signature = signature;
113
+ this._rows.replaceChildren();
114
+ if (!item) {
115
+ const message = document.createElement('div');
116
+ message.className = 'message';
117
+ message.textContent = 'Select one element to edit class bindings.';
118
+ this._rows.appendChild(message);
119
+ return;
120
+ }
121
+ for (const binding of bindings) {
122
+ const row = document.createElement('div');
123
+ row.className = 'row';
124
+ const name = document.createElement('button');
125
+ name.type = 'button';
126
+ name.className = 'name';
127
+ name.textContent = binding.targetName;
128
+ name.title = `Edit class binding: ${binding.targetName}`;
129
+ name.onclick = () => this.editBinding(item, binding);
130
+ const summary = document.createElement('span');
131
+ summary.className = 'summary';
132
+ summary.textContent = '← ' + (binding.expression || binding.bindableObjectNames?.join(';') || binding.rawValue || 'binding');
133
+ summary.title = summary.textContent;
134
+ summary.ondblclick = () => this.editBinding(item, binding);
135
+ const menu = document.createElement('button');
136
+ menu.type = 'button';
137
+ menu.textContent = '⋯';
138
+ menu.setAttribute('aria-label', `Actions for class binding ${binding.targetName}`);
139
+ const openMenu = (event) => {
140
+ event.preventDefault();
141
+ ContextMenu.show([
142
+ { title: 'Edit binding…', action: () => this.editBinding(item, binding) },
143
+ { title: 'Rename class…', action: () => this.showNameDialog(item, binding) },
144
+ { title: 'Remove binding', action: () => { this._service.remove(item, binding); this.refresh(this._items); } }
145
+ ], event);
146
+ };
147
+ menu.onclick = openMenu;
148
+ row.oncontextmenu = openMenu;
149
+ row.append(name, summary, menu);
150
+ this._rows.appendChild(row);
151
+ }
152
+ }
153
+ addBinding() {
154
+ if (!this._add.disabled)
155
+ this.showNameDialog(this._items[0]);
156
+ }
157
+ async editBinding(item, binding, name = binding?.targetName) {
158
+ if (this._editing)
159
+ return;
160
+ const editor = item.serviceContainer.config.openBindingsEditor;
161
+ if (editor) {
162
+ this._editing = true;
163
+ try {
164
+ await editor(this._service.createProperty(name), [item], binding, BindingTarget.class);
165
+ this.refresh(this._items);
166
+ }
167
+ finally {
168
+ this._editing = false;
169
+ }
170
+ }
171
+ }
172
+ showNameDialog(item, binding, name = binding?.targetName ?? '') {
173
+ if (this.shadowRoot.querySelector('dialog'))
174
+ return;
175
+ const dialog = document.createElement('dialog');
176
+ const form = document.createElement('form');
177
+ const title = document.createElement('h3');
178
+ title.textContent = binding ? 'Rename class' : 'Add class binding';
179
+ const nameLabel = document.createElement('label');
180
+ nameLabel.textContent = 'Class name';
181
+ const input = document.createElement('input');
182
+ input.value = name;
183
+ input.required = true;
184
+ const help = document.createElement('span');
185
+ help.className = 'help';
186
+ help.textContent = 'Use lowercase names, e.g. is-active.';
187
+ nameLabel.append(input, help);
188
+ const error = document.createElement('div');
189
+ error.className = 'error';
190
+ error.setAttribute('role', 'alert');
191
+ error.hidden = true;
192
+ const actions = document.createElement('div');
193
+ actions.className = 'actions';
194
+ const cancel = document.createElement('button');
195
+ cancel.type = 'button';
196
+ cancel.textContent = 'Cancel';
197
+ cancel.onclick = () => dialog.close();
198
+ const save = document.createElement('button');
199
+ save.type = 'submit';
200
+ save.textContent = binding ? 'Save' : 'Next…';
201
+ actions.append(cancel, save);
202
+ form.append(title, nameLabel);
203
+ form.append(error, actions);
204
+ dialog.appendChild(form);
205
+ this.shadowRoot.appendChild(dialog);
206
+ dialog.onclose = () => dialog.remove();
207
+ form.onsubmit = async (event) => {
208
+ event.preventDefault();
209
+ const newName = input.value.trim();
210
+ const validation = this._service.validateName(item, newName, binding?.targetName);
211
+ try {
212
+ if (validation)
213
+ throw new Error(validation);
214
+ if (binding) {
215
+ this._service.rename(item, binding, newName);
216
+ }
217
+ dialog.close();
218
+ if (!binding)
219
+ await this.editBinding(item, undefined, newName);
220
+ this.refresh(this._items);
221
+ }
222
+ catch (exception) {
223
+ error.textContent = exception.message;
224
+ error.hidden = false;
225
+ }
226
+ };
227
+ dialog.showModal();
228
+ input.focus();
229
+ }
230
+ }
231
+ customElements.define('node-projects-property-grid-class-bindings', PropertyGridClassBindings);
@@ -351,6 +351,9 @@ export class BindingsHelper {
351
351
  if (binding.expressionTwoWay === null || binding.expressionTwoWay === '') {
352
352
  delete bindingCopy.expressionTwoWay;
353
353
  }
354
+ if (!binding.writeBackSignal) {
355
+ delete bindingCopy.writeBackSignal;
356
+ }
354
357
  if (binding.twoWay === null || binding.twoWay === false) {
355
358
  delete bindingCopy.twoWay;
356
359
  }