alchemy-widget 0.3.0-alpha.2 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.3.0 (2026-01-21)
2
+
3
+ * Add widget picker
4
+ * Add `recreate()` method to `EditorToolbarManager` and `DocumentWatcher` for automatic recovery after server restart
5
+ * Update `EditorToolbarManager` and `DocumentWatcher` to inherit from `Alchemy.Syncable.Specialized` for automatic type derivation and recreation support
6
+ * Add `replaced` event handler to `BaseToolbarElement` to automatically switch to new manager instance after server restart
7
+ * Add error handling in `attachDocumentWatcher()` for broken WebSocket connections
8
+ * Add null check in `UserAvatarGroup.setUsers()` to handle missing user info gracefully
9
+ * Fix `DocumentWatcher.addWatcher(conduit)` not awaiting the async `addWatcher(user_id, scene_id)` call, which caused viewer avatars to not appear on first edit after server restart
10
+
1
11
  ## 0.3.0-alpha.2 (2025-07-10)
2
12
 
3
13
  * Make `main_class_names` widget config add classes to the first child of the wrapper
package/CLAUDE.md ADDED
@@ -0,0 +1,160 @@
1
+ # Alchemy Widget Development Guide
2
+
3
+ ## Overview
4
+
5
+ Widget system for AlchemyMVC. Widgets are configurable content blocks with drag-and-drop editing.
6
+
7
+ ## Creating Custom Widgets
8
+
9
+ Widgets need two files:
10
+ - **Widget class** (`app/helper/widgets/`) - Logic and schema
11
+ - **Element class** (`app/element/`) - HTML rendering
12
+
13
+ ```javascript
14
+ // app/helper/widgets/my_widget.js
15
+ const MyWidget = Function.inherits('Alchemy.Widget', function MyWidget(config) {
16
+ MyWidget.super.call(this, config);
17
+ });
18
+
19
+ // Metadata (optional) - category, description, icon, title
20
+ MyWidget.setCategory('data');
21
+ MyWidget.setDescription('Display custom data');
22
+ MyWidget.setIcon('cube');
23
+ MyWidget.setTitle('Data Table'); // Override auto-generated title
24
+
25
+ MyWidget.constitute(function prepareSchema() {
26
+ this.schema.addField('title', 'String');
27
+ this.schema.addField('style', 'Enum', {values: {default: 'Default', compact: 'Compact'}});
28
+ this.schema.addField('css_class', 'String', {widget_config_editable: true});
29
+
30
+ // Actions appear in widget toolbar
31
+ let refresh = this.createAction('refresh', 'Refresh');
32
+ refresh.setHandler((widget_el, handle) => widget_el.instance.rerender());
33
+ refresh.setTester(() => true);
34
+ refresh.setIcon('sync');
35
+ });
36
+
37
+ MyWidget.setMethod(function populateWidget() {
38
+ let element = this.createElement('my-widget-element');
39
+ element.title = this.config.title;
40
+ this.widget.append(element);
41
+ });
42
+ ```
43
+
44
+ ```javascript
45
+ // app/element/my_widget_element.js
46
+ const MyWidgetElement = Function.inherits('Alchemy.Element.App', 'MyWidgetElement');
47
+ MyWidgetElement.setTemplateFile('elements/my_widget_element');
48
+ MyWidgetElement.setAssignedProperty('title');
49
+ ```
50
+
51
+ ## Built-in Categories
52
+
53
+ | Category | Name | Icon | Description |
54
+ |----------|------|------|-------------|
55
+ | Layout | `layout` | table-columns | Structure and organize content |
56
+ | Text & Content | `text` | font | Text, headings, and rich content |
57
+ | Media | `media` | image | Images, videos, and embeds |
58
+ | Data & Forms | `data` | database | Tables, forms, and dynamic data |
59
+ | Navigation | `navigation` | compass | Menus, links, and navigation |
60
+ | Interactive | `interactive` | hand-pointer | Buttons, accordions, interactive elements |
61
+ | Advanced | `advanced` | code | Custom HTML, embeds, advanced widgets |
62
+
63
+ Custom categories: `MyWidget.setCategory('my-custom-category')` auto-generates title and uses puzzle-piece icon.
64
+
65
+ ## Registering Custom Categories
66
+
67
+ Projects can register their own categories using `Widget.registerCategory()`:
68
+
69
+ ```javascript
70
+ // In app/config/bootstrap.js or similar
71
+ STAGES.getStage('load_app').addPostTask(function registerMyCategories() {
72
+ const Widget = Classes.Alchemy.Widget.Widget;
73
+
74
+ Widget.registerCategory('MONITORING', {
75
+ name : 'monitoring',
76
+ icon : 'chart-line',
77
+ order : 70, // Lower = earlier in list
78
+ });
79
+ });
80
+ ```
81
+
82
+ Required fields: `name`, `icon`. Optional: `order` (default: 90).
83
+
84
+ Category titles are automatically translated using the category name as the microcopy key with `widget=true category=true` filters.
85
+
86
+ ## Toolbar Button Hooks
87
+
88
+ Plugins can register toolbar buttons without modifying shared code:
89
+
90
+ ```javascript
91
+ const EditorToolbarManager = Classes.Alchemy.Widget.EditorToolbarManager;
92
+
93
+ // Add buttons when a model is set (e.g., "Create" button)
94
+ EditorToolbarManager.registerModelButtonProvider((manager, model_name) => {
95
+ if (manager.scenario == 'my-scenario') {
96
+ manager.addTemplateToRender('buttons', 'my/toolbar/button', {model_name});
97
+ }
98
+ });
99
+
100
+ // Add buttons when a document is set (e.g., "Edit", "Preview" buttons)
101
+ EditorToolbarManager.registerDocumentButtonProvider((manager, doc, model, model_name, pk_val) => {
102
+ manager.addTemplateToRender('buttons', 'my/toolbar/edit_button', {
103
+ record_pk: pk_val,
104
+ });
105
+ });
106
+ ```
107
+
108
+ ## Widget Groups
109
+
110
+ The widget add-menu uses `alchemy.getClassGroup('widgets')`. Only widgets in this group appear.
111
+
112
+ **Direct inheritance (recommended):**
113
+ ```javascript
114
+ const MyWidget = Function.inherits('Alchemy.Widget', function MyWidget(config) {
115
+ MyWidget.super.call(this, config);
116
+ });
117
+ // → Classes.Alchemy.Widget.MyWidget
118
+ ```
119
+
120
+ **Abstract base class:**
121
+ ```javascript
122
+ // 00_myapp_widget.js - DO NOT call startNewGroup()
123
+ const MyAppWidget = Function.inherits('Alchemy.Widget', 'MyApp.Widget');
124
+ MyAppWidget.makeAbstractClass();
125
+ // → Classes.MyApp.Widget.Widget (a new namespace)
126
+
127
+ // stats_widget.js
128
+ const StatsWidget = Function.inherits('MyApp.Widget', function StatsWidget(config) {
129
+ StatsWidget.super.call(this, config);
130
+ });
131
+ // → Classes.MyApp.Widget.StatsWidget
132
+ ```
133
+
134
+ **Common mistake:**
135
+ ```javascript
136
+ // WRONG - widgets won't appear in add menu
137
+ MyAppWidget.startNewGroup('myapp_widgets');
138
+ ```
139
+
140
+ ## Key Methods
141
+
142
+ - `populateWidget()` - Render content, append to `this.widget`
143
+ - `syncConfig()` - Called when editor stops, return updated config
144
+ - `_startEditor()` / `_stopEditor()` - Editing mode hooks
145
+ - `rerender()` - Re-render widget after config changes
146
+
147
+ ## Built-in Widgets
148
+
149
+ `container`, `row`, `column`, `text`, `header`, `html`, `markdown`, `partial`, `alchemy_table`, `alchemy_form`, `alchemy_tabs`
150
+
151
+ ## Gotchas
152
+
153
+ 1. **`startNewGroup()` creates separate group** - Widgets won't appear in selector
154
+ 2. **Widget files in `app/helper/widgets/`** - Not `app/lib/`
155
+ 3. **`constitute()` doesn't need super** - All constitutes are queued and run in order automatically
156
+ 4. **Abstract classes need `makeAbstractClass()`** - Otherwise appear in selector
157
+ 5. **File numbering matters** - `00_base.js` loads before `10_child.js`
158
+ 6. **Toolbar requires permission** - `'alchemy.widgets.toolbar'`
159
+ 7. **Metadata before constitute()** - Call `setCategory()`, `setIcon()`, etc. after class definition but they can be anywhere (static methods set class properties)
160
+ 8. **Default category is 'advanced'** - Widgets without explicit category appear in Advanced section
@@ -0,0 +1,283 @@
1
+ al-widget-picker {
2
+ display: flex;
3
+ flex-direction: column;
4
+ min-width: 500px;
5
+ max-width: 700px;
6
+ max-height: 70vh;
7
+ overflow: hidden;
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
9
+ }
10
+
11
+ .widget-picker-loading {
12
+ display: flex;
13
+ flex-direction: column;
14
+ align-items: center;
15
+ justify-content: center;
16
+ padding: 60px 20px;
17
+ color: rgba(0, 0, 0, 0.5);
18
+
19
+ .spinner {
20
+ width: 32px;
21
+ height: 32px;
22
+ border: 3px solid rgba(0, 0, 0, 0.1);
23
+ border-top-color: #2563eb;
24
+ border-radius: 50%;
25
+ animation: widget-picker-spin 0.8s linear infinite;
26
+ }
27
+
28
+ p {
29
+ margin: 16px 0 0;
30
+ font-size: 14px;
31
+ }
32
+ }
33
+
34
+ @keyframes widget-picker-spin {
35
+ to {
36
+ transform: rotate(360deg);
37
+ }
38
+ }
39
+
40
+ .widget-picker {
41
+ display: flex;
42
+ flex-direction: column;
43
+ flex: 1;
44
+ min-height: 0;
45
+ }
46
+
47
+ .widget-picker-header {
48
+ padding: 16px;
49
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
50
+ flex-shrink: 0;
51
+ }
52
+
53
+ .widget-picker-search {
54
+ width: 100%;
55
+ padding: 10px 14px;
56
+ font-size: 14px;
57
+ border: 1px solid rgba(0, 0, 0, 0.15);
58
+ border-radius: 6px;
59
+ outline: none;
60
+ transition: border-color 0.15s, box-shadow 0.15s;
61
+
62
+ &:focus {
63
+ border-color: #2563eb;
64
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
65
+ }
66
+
67
+ &::placeholder {
68
+ color: rgba(0, 0, 0, 0.4);
69
+ }
70
+ }
71
+
72
+ .widget-picker-body {
73
+ display: flex;
74
+ flex: 1;
75
+ min-height: 0;
76
+ overflow: hidden;
77
+ }
78
+
79
+ .widget-picker-categories {
80
+ width: 180px;
81
+ flex-shrink: 0;
82
+ padding: 12px;
83
+ border-right: 1px solid rgba(0, 0, 0, 0.1);
84
+ overflow-y: auto;
85
+ background: rgba(0, 0, 0, 0.02);
86
+ }
87
+
88
+ .category-btn {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 10px;
92
+ width: 100%;
93
+ padding: 8px 12px;
94
+ margin-bottom: 4px;
95
+ font-size: 13px;
96
+ font-weight: 500;
97
+ color: rgba(0, 0, 0, 0.7);
98
+ background: transparent;
99
+ border: none;
100
+ border-radius: 6px;
101
+ cursor: pointer;
102
+ transition: background-color 0.15s, color 0.15s;
103
+ text-align: left;
104
+
105
+ al-icon {
106
+ font-size: 16px;
107
+ opacity: 0.7;
108
+ }
109
+
110
+ &:hover {
111
+ background: rgba(0, 0, 0, 0.05);
112
+ color: rgba(0, 0, 0, 0.9);
113
+ }
114
+
115
+ &.active {
116
+ background: #2563eb;
117
+ color: white;
118
+
119
+ al-icon {
120
+ opacity: 1;
121
+ }
122
+ }
123
+ }
124
+
125
+ .widget-picker-list {
126
+ flex: 1;
127
+ min-height: 0;
128
+ padding: 12px 16px;
129
+ overflow-y: auto;
130
+ }
131
+
132
+ .widget-category-group {
133
+ margin-bottom: 20px;
134
+
135
+ &:last-child {
136
+ margin-bottom: 0;
137
+ }
138
+
139
+ &[hidden] {
140
+ display: none;
141
+ }
142
+ }
143
+
144
+ .widget-category-title {
145
+ font-size: 11px;
146
+ font-weight: 600;
147
+ text-transform: uppercase;
148
+ letter-spacing: 0.5px;
149
+ color: rgba(0, 0, 0, 0.5);
150
+ margin: 0 0 8px 4px;
151
+ }
152
+
153
+ .widget-category-items {
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 4px;
157
+ }
158
+
159
+ .widget-item {
160
+ display: flex;
161
+ align-items: flex-start;
162
+ gap: 12px;
163
+ width: 100%;
164
+ padding: 10px 12px;
165
+ background: transparent;
166
+ border: 1px solid transparent;
167
+ border-radius: 8px;
168
+ cursor: pointer;
169
+ transition: background-color 0.15s, border-color 0.15s;
170
+ text-align: left;
171
+
172
+ &:hover {
173
+ background: rgba(0, 0, 0, 0.04);
174
+ border-color: rgba(0, 0, 0, 0.08);
175
+ }
176
+
177
+ &:focus {
178
+ outline: none;
179
+ background: rgba(37, 99, 235, 0.08);
180
+ border-color: rgba(37, 99, 235, 0.3);
181
+ }
182
+
183
+ &[hidden] {
184
+ display: none;
185
+ }
186
+ }
187
+
188
+ .widget-item-icon {
189
+ display: flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ width: 36px;
193
+ height: 36px;
194
+ background: rgba(0, 0, 0, 0.06);
195
+ border-radius: 8px;
196
+ flex-shrink: 0;
197
+
198
+ al-icon {
199
+ font-size: 18px;
200
+ color: rgba(0, 0, 0, 0.6);
201
+ }
202
+ }
203
+
204
+ .widget-item-content {
205
+ display: flex;
206
+ flex-direction: column;
207
+ gap: 2px;
208
+ min-width: 0;
209
+ }
210
+
211
+ .widget-item-title {
212
+ font-size: 14px;
213
+ font-weight: 500;
214
+ color: rgba(0, 0, 0, 0.85);
215
+ }
216
+
217
+ .widget-item-description {
218
+ font-size: 12px;
219
+ color: rgba(0, 0, 0, 0.5);
220
+ line-height: 1.4;
221
+ }
222
+
223
+ .widget-picker-empty {
224
+ display: flex;
225
+ flex-direction: column;
226
+ align-items: center;
227
+ justify-content: center;
228
+ padding: 40px 20px;
229
+ text-align: center;
230
+ color: rgba(0, 0, 0, 0.4);
231
+
232
+ al-icon {
233
+ font-size: 32px;
234
+ margin-bottom: 12px;
235
+ opacity: 0.5;
236
+ }
237
+
238
+ p {
239
+ margin: 0;
240
+ font-size: 14px;
241
+ }
242
+
243
+ &[hidden] {
244
+ display: none;
245
+ }
246
+ }
247
+
248
+ // Responsive: collapse categories on small screens
249
+ @media (max-width: 600px) {
250
+ al-widget-picker {
251
+ min-width: auto;
252
+ width: 100%;
253
+ }
254
+
255
+ .widget-picker-body {
256
+ flex-direction: column;
257
+ }
258
+
259
+ .widget-picker-categories {
260
+ width: 100%;
261
+ flex-direction: row;
262
+ flex-wrap: wrap;
263
+ gap: 4px;
264
+ padding: 8px 12px;
265
+ border-right: none;
266
+ border-bottom: 1px solid rgba(0, 0, 0, 0.1);
267
+ }
268
+
269
+ .category-btn {
270
+ width: auto;
271
+ margin-bottom: 0;
272
+ padding: 6px 10px;
273
+ font-size: 12px;
274
+
275
+ span {
276
+ display: none;
277
+ }
278
+
279
+ al-icon {
280
+ margin: 0;
281
+ }
282
+ }
283
+ }
@@ -20,15 +20,13 @@ let AddArea = Function.inherits('Alchemy.Element.Widget.Base', function WidgetAd
20
20
  });
21
21
 
22
22
  /**
23
- * Show the types to add
23
+ * Show the widget picker dialog
24
24
  *
25
25
  * @author Jelle De Loecker <jelle@elevenways.be>
26
26
  * @since 0.1.0
27
- * @version 0.2.0
27
+ * @version 0.3.0
28
28
  */
29
- AddArea.setMethod(function showTypes(event) {
30
-
31
- let that = this;
29
+ AddArea.setMethod(async function showTypes(event) {
32
30
 
33
31
  let context_button = document.querySelector('al-widget-context');
34
32
 
@@ -36,25 +34,25 @@ AddArea.setMethod(function showTypes(event) {
36
34
  context_button.forceUnselection();
37
35
  }
38
36
 
39
- let context = this.createElement('he-context-menu');
40
-
41
- let widgets = Object.values(alchemy.getClassGroup('widgets')).sortByPath(1, 'title');
42
-
43
- for (let widget of widgets) {
37
+ // Create the widget picker element
38
+ let picker = this.createElement('al-widget-picker');
39
+ picker.target_container = this.parentElement;
40
+ picker.addEventListener('select', (e) => {
41
+ this.parentElement.addWidget(e.detail.type_name);
42
+ });
44
43
 
45
- if (!widget.canBeAdded(that.parentElement)) {
46
- continue;
47
- }
44
+ // Create the dialog
45
+ let dialog = this.createElement('he-dialog');
46
+ dialog.setAttribute('dialog-title', 'Add Widget');
47
+ dialog.classList.add('widget-picker-dialog');
48
48
 
49
- context.addEntry({
50
- title : widget.title,
51
- icon : null,
52
- }, e => {
53
- that.parentElement.addWidget(widget.type_name);
54
- });
55
- }
49
+ // Wrap picker in a slot container
50
+ let slot = document.createElement('div');
51
+ slot.setAttribute('slot', 'main');
52
+ slot.append(picker);
53
+ dialog.append(slot);
56
54
 
57
- context.show(event);
55
+ document.body.append(dialog);
58
56
  });
59
57
 
60
58
  /**