@omicronenergy/oscd-shell 0.0.13 → 0.0.15

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 (36) hide show
  1. package/dist/landing-page/landing-page.js +3 -2
  2. package/dist/landing-page/landing-page.js.map +1 -1
  3. package/dist/landing-page/landing-page.spec.js +4 -2
  4. package/dist/landing-page/landing-page.spec.js.map +1 -1
  5. package/dist/locales/de.d.ts +8 -0
  6. package/dist/locales/de.js +9 -0
  7. package/dist/locales/de.js.map +1 -1
  8. package/dist/menus/files-menu.js +4 -5
  9. package/dist/menus/files-menu.js.map +1 -1
  10. package/dist/menus/plugins-menu.d.ts +6 -3
  11. package/dist/menus/plugins-menu.js +40 -6
  12. package/dist/menus/plugins-menu.js.map +1 -1
  13. package/dist/menus/plugins-menu.spec.js +1 -0
  14. package/dist/menus/plugins-menu.spec.js.map +1 -1
  15. package/dist/oscd-shell-design-tokens.js +139 -64
  16. package/dist/oscd-shell-design-tokens.js.map +1 -1
  17. package/dist/oscd-shell.d.ts +31 -22
  18. package/dist/oscd-shell.js +136 -73
  19. package/dist/oscd-shell.js.map +1 -1
  20. package/dist/side-panel/build-tree-nodes.spec.d.ts +1 -0
  21. package/dist/side-panel/build-tree-nodes.spec.js +50 -0
  22. package/dist/side-panel/build-tree-nodes.spec.js.map +1 -0
  23. package/dist/side-panel/editor-plugins-panel.d.ts +79 -7
  24. package/dist/side-panel/editor-plugins-panel.js +829 -92
  25. package/dist/side-panel/editor-plugins-panel.js.map +1 -1
  26. package/dist/side-panel/editor-plugins-panel.spec.js +469 -24
  27. package/dist/side-panel/editor-plugins-panel.spec.js.map +1 -1
  28. package/dist/utils/plugin-utils.d.ts +29 -5
  29. package/dist/utils/plugin-utils.js +73 -14
  30. package/dist/utils/plugin-utils.js.map +1 -1
  31. package/dist/utils/plugin-utils.spec.js +113 -4
  32. package/dist/utils/plugin-utils.spec.js.map +1 -1
  33. package/dist/utils/testing/plugin-helpers.d.ts +7 -3
  34. package/dist/utils/testing/plugin-helpers.js +21 -9
  35. package/dist/utils/testing/plugin-helpers.js.map +1 -1
  36. package/package.json +37 -38
@@ -1,165 +1,902 @@
1
1
  import { __decorate } from "tslib";
2
2
  import { css, html, LitElement, nothing } from 'lit';
3
- import { property } from 'lit/decorators.js';
3
+ import { property, state } from 'lit/decorators.js';
4
+ import { classMap } from 'lit/directives/class-map.js';
4
5
  import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js';
5
- import { localized } from '@lit/localize';
6
+ import { localized, msg, str } from '@lit/localize';
6
7
  import { OscdIconButton } from '@omicronenergy/oscd-ui/iconbutton/OscdIconButton.js';
7
8
  import { OscdIcon } from '@omicronenergy/oscd-ui/icon/OscdIcon.js';
8
- import { OscdList } from '@omicronenergy/oscd-ui/list/OscdList.js';
9
9
  import { OscdListItem } from '@omicronenergy/oscd-ui/list/OscdListItem.js';
10
- import { classMap } from 'lit/directives/class-map.js';
10
+ import { OscdMenu } from '@omicronenergy/oscd-ui/menu/OscdMenu.js';
11
+ import { OscdMenuItem } from '@omicronenergy/oscd-ui/menu/OscdMenuItem.js';
12
+ import { OscdTree, } from '@omicronenergy/oscd-ui/tree/OscdTree.js';
13
+ import { filterBySearchTerm, filterByPinned, isPluginGroup, flattenPluginEntries, } from '../utils/plugin-utils.js';
14
+ import { OscdTreeItem } from '@omicronenergy/oscd-ui/tree/OscdTreeItem.js';
15
+ import { OscdDivider } from '@omicronenergy/oscd-ui/divider/OscdDivider.js';
16
+ import { localstorage } from '@omicronenergy/oscd-ui/decorators/localstorage.js';
17
+ import { OscdOutlinedSearchField } from '@omicronenergy/oscd-ui/search-field/OscdOutlinedSearchField.js';
18
+ const unpinnedIcon = html `<svg
19
+ xmlns="http://www.w3.org/2000/svg"
20
+ width="28"
21
+ height="28"
22
+ viewBox="0 0 28 28"
23
+ fill="none"
24
+ >
25
+ <path
26
+ d="M16.391 14.4188L16.8773 16.2406L16.2096 17.3947L13.3244 15.7253L11.3211 19.1876L10.4102 19.4307L10.1671 18.5198L12.1703 15.0576L9.28512 13.3882L9.95287 12.2341L11.7747 11.7478L14.1118 7.70854L13.5348 7.37467L14.2025 6.22059L19.9729 9.55934L19.3052 10.7134L18.7281 10.3795L16.391 14.4188ZM11.5974 13.1857L15.2328 15.2891L14.9531 14.2415L17.574 9.71179L15.2659 8.37629L12.645 12.906L11.5974 13.1857Z"
27
+ fill="currentColor"
28
+ />
29
+ </svg>`;
30
+ const pinnedIcon = html `<svg
31
+ xmlns="http://www.w3.org/2000/svg"
32
+ width="28"
33
+ height="28"
34
+ viewBox="0 0 28 28"
35
+ fill="none"
36
+ >
37
+ <path
38
+ d="M16.4379 14.5274L16.9273 16.3484L16.2615 17.5036L13.3735 15.8391L11.3761 19.3047L10.4656 19.5494L10.2209 18.6389L12.2183 15.1733L9.33034 13.5088L9.99614 12.3536L11.8171 11.8642L14.1475 7.821L13.5699 7.4881L14.2357 6.3329L20.0117 9.66194L19.3459 10.8171L18.7683 10.4842L16.4379 14.5274Z"
39
+ fill="currentColor"
40
+ />
41
+ </svg>`;
42
+ export function buildTreeNodes(plugins) {
43
+ function pluginEntryToTreeNode(pluginEntry) {
44
+ return {
45
+ ...pluginEntry,
46
+ id: pluginEntry.tagName,
47
+ children: [], //explicitly set children to an empty array for leaf nodes
48
+ };
49
+ }
50
+ return plugins.map((editor, index) => {
51
+ const isGroup = isPluginGroup(editor);
52
+ if (isGroup) {
53
+ const children = editor.plugins.map(pluginEntryToTreeNode);
54
+ return {
55
+ ...editor,
56
+ id: `group:${index}:${editor.name}`,
57
+ children,
58
+ };
59
+ }
60
+ return pluginEntryToTreeNode(editor);
61
+ });
62
+ }
63
+ function renderFlyoutPlaceholder() {
64
+ return html `<oscd-menu-item disabled>
65
+ <div slot="headline">${msg('Items you pin will appear here')}</div>
66
+ </oscd-menu-item>`;
67
+ }
11
68
  let EditorPluginsPanel = class EditorPluginsPanel extends ScopedElementsMixin(LitElement) {
12
69
  constructor() {
13
70
  super(...arguments);
71
+ this.id = 'editor-plugins-panel';
14
72
  this.editors = [];
15
- this.editorIndex = 0;
73
+ /**
74
+ * Transient "search mode": the collapsed rail's search icon opens the full
75
+ * panel to let the user search, WITHOUT persisting the panel as expanded.
76
+ * It is reflected purely so `:host([search-mode])` can drive the open width.
77
+ * Exited on editor selection or Escape (see `exitSearchMode`).
78
+ */
79
+ this.searchMode = false;
80
+ this.editorTreeNodes = [];
81
+ this.pinnedTreeNodes = [
82
+ {
83
+ id: 'pinned',
84
+ name: 'Pinned',
85
+ icon: 'keep',
86
+ plugins: [],
87
+ children: [],
88
+ },
89
+ ];
90
+ this.searchValue = '';
91
+ this.focusedTree = null;
92
+ this.handleKeydown = (event) => {
93
+ const fromSearchField = event.currentTarget?.localName ===
94
+ 'oscd-outlined-search-field' ||
95
+ event
96
+ .composedPath()
97
+ .some(target => target.localName === 'oscd-outlined-search-field');
98
+ if (event.key === 'Escape' && this.searchMode) {
99
+ event.stopPropagation();
100
+ this.exitSearchMode();
101
+ return;
102
+ }
103
+ if (fromSearchField &&
104
+ (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
105
+ event.preventDefault();
106
+ event.stopPropagation();
107
+ this.startTreeNavigation(event.key === 'ArrowDown' ? 'down' : 'up');
108
+ return;
109
+ }
110
+ if (fromSearchField && event.key === 'Enter') {
111
+ event.preventDefault();
112
+ event.stopPropagation();
113
+ this.activateKeyboardTarget();
114
+ }
115
+ };
16
116
  }
17
- // eslint-disable-next-line class-methods-use-this
18
- get expanded() {
19
- const expandedStr = localStorage.getItem('editorsPanel.expanded');
20
- return expandedStr === 'false' ? false : true;
117
+ /** True when the panel is visually open (persisted expanded OR transient search). */
118
+ get isOpen() {
119
+ return this.expanded || this.searchMode;
21
120
  }
22
- // eslint-disable-next-line class-methods-use-this
23
- set expanded(expanded) {
24
- localStorage.setItem('editorsPanel.expanded', String(expanded));
121
+ willUpdate(changedProperties) {
122
+ if (changedProperties.has('editors') ||
123
+ changedProperties.has('searchValue') ||
124
+ changedProperties.has('locale')) {
125
+ const searchResults = filterBySearchTerm(this.editors, this.searchValue, this.locale);
126
+ this.editorTreeNodes = buildTreeNodes(searchResults);
127
+ }
128
+ if (changedProperties.has('editors') ||
129
+ changedProperties.has('pinnedPluginIds') ||
130
+ changedProperties.has('locale')) {
131
+ const pinnedPlugins = filterByPinned(this.editors, this.pinnedPluginIds);
132
+ const pinnedChildren = pinnedPlugins.length
133
+ ? buildTreeNodes(pinnedPlugins)
134
+ : [
135
+ {
136
+ kind: 'placeholder',
137
+ name: msg('Items you pin will appear here'),
138
+ id: 'pinned-placeholder',
139
+ children: [],
140
+ },
141
+ ];
142
+ this.pinnedTreeNodes = [
143
+ {
144
+ id: 'pinned',
145
+ name: msg('Pinned'),
146
+ icon: 'keep',
147
+ plugins: [],
148
+ children: pinnedChildren,
149
+ },
150
+ ];
151
+ }
25
152
  }
26
- render() {
153
+ togglePin(id) {
154
+ if (this.pinnedPluginIds.includes(id)) {
155
+ this.pinnedPluginIds = this.pinnedPluginIds.filter(pinnedId => pinnedId !== id);
156
+ }
157
+ else {
158
+ this.pinnedPluginIds = [...this.pinnedPluginIds, id];
159
+ this.pinnedExpanded = ['pinned']; // Expand the pinned section when a new plugin is pinned
160
+ }
161
+ }
162
+ selectEditor(selectedIds) {
163
+ const [selectedId] = selectedIds;
164
+ if (!selectedId) {
165
+ return;
166
+ }
167
+ const editor = flattenPluginEntries(this.editors).find(editor => editor.tagName === selectedId);
168
+ if (!editor) {
169
+ return;
170
+ }
171
+ this.dispatchEditorSelect(editor);
172
+ }
173
+ /**
174
+ * Dispatches the `editor-select` event and leaves transient search mode, if
175
+ * active. Used by both the expanded trees and the collapsed rail flyouts.
176
+ */
177
+ dispatchEditorSelect(editor) {
178
+ this.dispatchEvent(new CustomEvent('editor-select', {
179
+ detail: { editor },
180
+ bubbles: true,
181
+ composed: true,
182
+ }));
183
+ if (this.searchMode) {
184
+ this.exitSearchMode();
185
+ }
186
+ }
187
+ connectedCallback() {
188
+ super.connectedCallback();
189
+ this.addEventListener('keydown', this.handleKeydown);
190
+ }
191
+ disconnectedCallback() {
192
+ this.removeEventListener('keydown', this.handleKeydown);
193
+ super.disconnectedCallback();
194
+ }
195
+ /** Opens the panel transiently for searching (does not persist `expanded`). */
196
+ enterSearchMode() {
197
+ this.searchMode = true;
198
+ this.focusSearch(true);
199
+ }
200
+ /** Focuses the search field, optionally selecting its current query. */
201
+ focusSearch(selectQuery = false) {
202
+ const focusField = () => {
203
+ const searchField = this.shadowRoot?.querySelector('oscd-outlined-search-field');
204
+ searchField?.focus();
205
+ if (selectQuery) {
206
+ searchField?.select();
207
+ }
208
+ };
209
+ this.updateComplete.then(() => {
210
+ focusField();
211
+ requestAnimationFrame(() => requestAnimationFrame(focusField));
212
+ });
213
+ }
214
+ /** Returns the panel to the collapsed rail and clears the search query. */
215
+ exitSearchMode() {
216
+ this.searchMode = false;
217
+ this.searchValue = '';
218
+ this.focusedTree = null;
219
+ }
220
+ getTree(kind) {
221
+ return (this.shadowRoot?.querySelector(`oscd-tree.${kind}-tree`) ?? null);
222
+ }
223
+ focusTree(kind, activeId) {
224
+ const tree = this.getTree(kind);
225
+ if (!tree || !activeId) {
226
+ return false;
227
+ }
228
+ tree.activeId = activeId;
229
+ this.focusedTree = kind;
230
+ tree.focus();
231
+ return true;
232
+ }
233
+ startTreeNavigation(direction) {
234
+ const first = direction === 'down';
235
+ const pinnedTree = this.getTree('pinned');
236
+ if (this.searchValue.trim().length === 0 && pinnedTree) {
237
+ const activeId = this.focusTree('pinned', first ? pinnedTree.getFirstNodeId() : pinnedTree.getLastNodeId());
238
+ if (activeId) {
239
+ return;
240
+ }
241
+ }
242
+ const editorsTree = this.getTree('editors');
243
+ this.focusTree('editors', editorsTree
244
+ ? first
245
+ ? editorsTree.getFirstNodeId()
246
+ : editorsTree.getLastNodeId()
247
+ : null);
248
+ }
249
+ handleTreeActiveChanged(kind) {
250
+ this.focusedTree = kind;
251
+ }
252
+ handleTreeFocus(kind) {
253
+ this.focusedTree = kind;
254
+ }
255
+ handleTreeSelection(kind, selectedIds) {
256
+ const tree = this.getTree(kind);
257
+ const selectedId = selectedIds[0];
258
+ const selectedNode = tree?.data.find(node => node.id === selectedId);
259
+ if (tree && selectedNode?.children?.length && selectedId) {
260
+ tree.toggle(selectedId);
261
+ return;
262
+ }
263
+ this.selectEditor(selectedIds);
264
+ }
265
+ handleTreeBoundary(kind, direction) {
266
+ if (kind === 'pinned' && direction === 'last') {
267
+ this.focusTree('editors', this.getTree('editors')?.getFirstNodeId() ?? null);
268
+ }
269
+ else if (kind === 'editors' &&
270
+ direction === 'first' &&
271
+ this.searchValue.trim().length === 0) {
272
+ this.focusTree('pinned', this.getTree('pinned')?.getLastNodeId() ?? null);
273
+ }
274
+ }
275
+ activateKeyboardTarget() {
276
+ if (!this.focusedTree) {
277
+ if (this.shadowRoot?.activeElement?.localName ===
278
+ 'oscd-outlined-search-field' &&
279
+ flattenPluginEntries(filterBySearchTerm(this.editors, this.searchValue, this.locale)).length === 1) {
280
+ this.dispatchEditorSelect(flattenPluginEntries(filterBySearchTerm(this.editors, this.searchValue, this.locale))[0]);
281
+ }
282
+ return;
283
+ }
284
+ }
285
+ /** Toggles the persisted expanded/collapsed state via the footer control. */
286
+ toggleExpanded() {
287
+ if (this.isOpen) {
288
+ this.expanded = false;
289
+ this.exitSearchMode();
290
+ }
291
+ else {
292
+ this.expanded = true;
293
+ }
294
+ }
295
+ toggleFlyout(anchorId) {
296
+ const menu = this.shadowRoot?.querySelector(`oscd-menu[data-flyout="${anchorId}"]`);
297
+ if (!menu) {
298
+ return;
299
+ }
300
+ if (menu.open) {
301
+ menu.close();
302
+ }
303
+ else {
304
+ menu.show();
305
+ }
306
+ }
307
+ renderPluginItem({ node, level, disabled, active, }) {
308
+ const label = node.translations?.[this.locale] ?? node.name;
309
+ return html `<oscd-tree-item ?disabled=${disabled} ?active=${active}>
310
+ ${level === 1 && !('kind' in node)
311
+ ? html `<oscd-icon slot="start">${node.icon}</oscd-icon>`
312
+ : nothing}
313
+ <span slot="headline" title=${label}>${label}</span>
314
+ </oscd-tree-item>`;
315
+ }
316
+ renderLeafAccessory({ node, id }) {
317
+ const pinned = this.pinnedPluginIds.includes(id);
318
+ return html `<button
319
+ aria-label=${pinned
320
+ ? msg(str `Unpin ${node.name}`)
321
+ : msg(str `Pin ${node.name}`)}
322
+ aria-pressed=${pinned}
323
+ @click=${() => this.togglePin(id)}
324
+ >
325
+ <oscd-icon>${pinned ? pinnedIcon : unpinnedIcon}</oscd-icon>
326
+ </button>`;
327
+ }
328
+ renderExpanded() {
27
329
  return html `
28
- <oscd-list class="editors-list" role="tablist">
29
- ${this.editors.map((editor, index) => html `<oscd-list-item
30
- class=${classMap({ active: this.editorIndex === index })}
31
- type="button"
32
- @click=${() => {
33
- this.dispatchEvent(new CustomEvent('editor-select', {
34
- detail: { editor, index },
35
- bubbles: true,
36
- composed: true,
37
- }));
330
+ <div class="tree-container">
331
+ <oscd-outlined-search-field
332
+ placeholder=${msg('Search')}
333
+ clearLabel=${msg('Clear search')}
334
+ .value=${this.searchValue}
335
+ @input=${(event) => {
336
+ this.searchValue = event.target.value;
38
337
  }}
39
- >
40
- <oscd-icon slot="start">${editor.icon}</oscd-icon>
41
- ${this.expanded
42
- ? html `<span
43
- >${editor.translations?.[this.locale] ||
44
- editor.name}</span
45
- >`
338
+ ></oscd-outlined-search-field>
339
+ <div class="tree-scroll">
340
+ ${this.searchValue.trim().length === 0
341
+ ? html `<oscd-tree
342
+ .data=${this.pinnedTreeNodes}
343
+ .expandedIds=${this.pinnedExpanded}
344
+ .selectionMode=${'single'}
345
+ .selectedIds=${this.selectedEditor
346
+ ? [this.selectedEditor.tagName]
347
+ : []}
348
+ .isDisabled=${(node) => 'kind' in node && node.kind === 'placeholder'}
349
+ .isSelectable=${(node) => !('kind' in node && node.kind === 'placeholder')}
350
+ class="pinned-tree"
351
+ ?keyboard-active=${this.focusedTree === 'pinned'}
352
+ @focusin=${() => this.handleTreeFocus('pinned')}
353
+ .renderItem=${(context) => this.renderPluginItem(context)}
354
+ .renderLeafAccessory=${(context) => this.renderLeafAccessory(context)}
355
+ toggle-position="trailing"
356
+ collapse-icon="arrow_drop_up"
357
+ expand-icon="arrow_drop_down"
358
+ @selected-ids-changed=${(event) => this.handleTreeSelection('pinned', event.detail.selectedIds)}
359
+ @expanded-ids-changed=${(event) => {
360
+ this.pinnedExpanded = event.detail.expandedIds;
361
+ }}
362
+ @active-changed=${() => this.handleTreeActiveChanged('pinned')}
363
+ @navigation-boundary=${(event) => this.handleTreeBoundary('pinned', event.detail.direction)}
364
+ ></oscd-tree>
365
+ <oscd-divider></oscd-divider>`
46
366
  : nothing}
47
- </oscd-list-item>`)}
48
- </oscd-list>
49
- <div class="footer">
50
- <oscd-icon-button
51
- class="toggle-button"
52
- @click=${() => {
53
- this.expanded = !this.expanded;
367
+ <oscd-tree
368
+ class="editors-tree"
369
+ ?keyboard-active=${this.focusedTree === 'editors'}
370
+ @focusin=${() => this.handleTreeFocus('editors')}
371
+ .data=${this.editorTreeNodes}
372
+ .expandedIds=${this.searchValue.length === 0
373
+ ? this.expandedIds
374
+ : this.editorTreeNodes.map(node => node.id)}
375
+ .selectionMode=${'single'}
376
+ .selectedIds=${this.selectedEditor
377
+ ? [this.selectedEditor.tagName]
378
+ : []}
379
+ .isDisabled=${(node) => 'kind' in node && node.kind === 'placeholder'}
380
+ .isSelectable=${(node) => !('kind' in node && node.kind === 'placeholder')}
381
+ .renderItem=${(context) => this.renderPluginItem(context)}
382
+ .renderLeafAccessory=${(context) => this.renderLeafAccessory(context)}
383
+ toggle-position="trailing"
384
+ collapse-icon="arrow_drop_down"
385
+ expand-icon="arrow_drop_up"
386
+ @selected-ids-changed=${(event) => this.handleTreeSelection('editors', event.detail.selectedIds)}
387
+ @expanded-ids-changed=${(event) => {
388
+ this.expandedIds = event.detail.expandedIds;
54
389
  }}
390
+ @active-changed=${() => this.handleTreeActiveChanged('editors')}
391
+ @navigation-boundary=${(event) => this.handleTreeBoundary('editors', event.detail.direction)}
392
+ ></oscd-tree>
393
+ </div>
394
+ </div>
395
+ `;
396
+ }
397
+ renderRail() {
398
+ const pinnedPlugins = flattenPluginEntries(filterByPinned(this.editors, this.pinnedPluginIds));
399
+ return html `
400
+ <div class="rail">
401
+ <oscd-icon-button
402
+ class="rail-item"
403
+ aria-label=${msg('Search')}
404
+ @click=${() => this.enterSearchMode()}
55
405
  >
56
- <oscd-icon
57
- >${this.expanded
58
- ? 'left_panel_close'
59
- : 'left_panel_open'}</oscd-icon
60
- ></oscd-icon-button
61
- >
406
+ <oscd-icon>search</oscd-icon>
407
+ </oscd-icon-button>
408
+ ${this.renderRailGroup({ name: msg('Pinned'), icon: 'keep', plugins: pinnedPlugins }, 'pinned', true)}
409
+ <oscd-divider></oscd-divider>
410
+ ${this.editors.map((entry, index) => isPluginGroup(entry)
411
+ ? this.renderRailGroup(entry, `group-${index}`)
412
+ : this.renderRailLeaf(entry))}
62
413
  </div>
63
414
  `;
64
415
  }
416
+ renderRailGroup(group, anchorId, showEmptyPlaceholder = false) {
417
+ const label = group.translations?.[this.locale] ?? group.name;
418
+ const active = group.plugins.some(plugin => plugin.tagName === this.selectedEditor?.tagName);
419
+ const anchor = `rail-${anchorId}`;
420
+ return html `
421
+ <oscd-icon-button
422
+ id=${anchor}
423
+ class=${classMap({ 'rail-item': true, active })}
424
+ aria-haspopup="menu"
425
+ aria-label=${label}
426
+ @click=${() => this.toggleFlyout(anchorId)}
427
+ >
428
+ <oscd-icon>${group.icon}</oscd-icon>
429
+ </oscd-icon-button>
430
+ <oscd-menu
431
+ class="rail-flyout"
432
+ data-flyout=${anchorId}
433
+ anchor=${anchor}
434
+ anchor-corner="start-end"
435
+ menu-corner="start-start"
436
+ positioning="popover"
437
+ quick
438
+ >
439
+ <div class="flyout-header" role="presentation">${label}</div>
440
+ <oscd-divider class="flyout-divider"></oscd-divider>
441
+ ${group.plugins.length > 0
442
+ ? group.plugins.map(plugin => this.renderFlyoutItem(plugin))
443
+ : showEmptyPlaceholder
444
+ ? renderFlyoutPlaceholder()
445
+ : nothing}
446
+ </oscd-menu>
447
+ `;
448
+ }
449
+ renderRailLeaf(plugin) {
450
+ const label = plugin.translations?.[this.locale] ?? plugin.name;
451
+ const active = plugin.tagName === this.selectedEditor?.tagName;
452
+ return html `
453
+ <oscd-icon-button
454
+ class=${classMap({ 'rail-item': true, active })}
455
+ aria-label=${label}
456
+ @click=${() => this.dispatchEditorSelect(plugin)}
457
+ >
458
+ <oscd-icon>${plugin.icon}</oscd-icon>
459
+ </oscd-icon-button>
460
+ `;
461
+ }
462
+ renderFlyoutItem(plugin) {
463
+ const label = plugin.translations?.[this.locale] ?? plugin.name;
464
+ const selected = plugin.tagName === this.selectedEditor?.tagName;
465
+ return html `
466
+ <oscd-menu-item
467
+ .selected=${selected}
468
+ @click=${() => this.dispatchEditorSelect(plugin)}
469
+ >
470
+ <div slot="headline">${label}</div>
471
+ </oscd-menu-item>
472
+ `;
473
+ }
474
+ renderFooter() {
475
+ const open = this.isOpen;
476
+ const label = open ? msg('Collapse sidebar') : msg('Expand sidebar');
477
+ const icon = open ? 'left_panel_close' : 'left_panel_open';
478
+ return html `
479
+ <div class="footer">
480
+ ${open
481
+ ? html `<oscd-list-item
482
+ class="toggle-button"
483
+ type="button"
484
+ aria-label=${label}
485
+ @click=${() => this.toggleExpanded()}
486
+ >
487
+ <oscd-icon slot="start">${icon}</oscd-icon>
488
+ <span slot="headline">${label}</span>
489
+ </oscd-list-item>`
490
+ : html `<oscd-icon-button
491
+ class="toggle-button"
492
+ aria-label=${label}
493
+ @click=${() => this.toggleExpanded()}
494
+ >
495
+ <oscd-icon>${icon}</oscd-icon>
496
+ </oscd-icon-button>`}
497
+ </div>
498
+ `;
499
+ }
500
+ render() {
501
+ return html `
502
+ ${this.isOpen ? this.renderExpanded() : this.renderRail()}
503
+ <oscd-divider class="footer-divider"></oscd-divider>
504
+ ${this.renderFooter()}
505
+ `;
506
+ }
65
507
  };
66
508
  EditorPluginsPanel.scopedElements = {
67
509
  'oscd-icon-button': OscdIconButton,
68
510
  'oscd-icon': OscdIcon,
69
- 'oscd-list': OscdList,
70
511
  'oscd-list-item': OscdListItem,
512
+ 'oscd-tree': OscdTree,
513
+ 'oscd-tree-item': OscdTreeItem,
514
+ 'oscd-outlined-search-field': OscdOutlinedSearchField,
515
+ 'oscd-divider': OscdDivider,
516
+ 'oscd-menu': OscdMenu,
517
+ 'oscd-menu-item': OscdMenuItem,
71
518
  };
72
519
  EditorPluginsPanel.styles = css `
73
520
  :host {
74
- width: var(--editor-plugins-panel-width);
521
+ /* Collapsed rail is the default width; the panel widens to its full width
522
+ when persistently expanded OR while in transient search mode. The shell
523
+ grid column follows this intrinsic width (grid-template-columns: auto). */
524
+ width: var(--editor-plugins-panel-collapsed-width);
75
525
  height: calc(100% - var(--editor-plugins-panel-padding-top));
76
526
  display: grid;
77
- grid-template-rows: 1fr auto;
527
+ /* Row 1 (tree-container/rail) is the only scrollable region; the divider
528
+ and footer rows are sized to content so they stay pinned and always
529
+ visible below it, however tall the tree content grows. */
530
+ grid-template-rows: 1fr auto auto;
78
531
  min-height: 0;
79
532
  padding-top: var(--editor-plugins-panel-padding-top);
80
533
  transition: width 0.1s ease-in-out;
81
- overflow-y: auto;
534
+ /* Clip transient horizontal overflow while the width animates on
535
+ expand/collapse: the content swaps to its full width before the host
536
+ finishes resizing, and this stops that from forcing horizontal reflow /
537
+ scrollbar churn every animation frame. Steady-state label overrun is
538
+ handled by the tree row ellipsis, not by this. */
82
539
  overflow-x: hidden;
540
+ /* The panel paints its own surface color; it must never depend on
541
+ page/body background showing through, or hosts that don't set a
542
+ background image get a blank panel. */
543
+ background-color: var(--editor-plugins-panel-background-color);
544
+ }
545
+
546
+ /* Material colour scheme for the panel's content. By default the panel's
547
+ background-color (above) is the dark --oscd-primary surface, so its
548
+ content is light ("white"/--oscd-base3) on a dark surface. We set the
549
+ *system* colours once here — not each component's final colour — so
550
+ resting text/icons AND every derived hover/pressed state layer resolve
551
+ to the light content colour from one place. (The flyout menus are a
552
+ light surface and reset these back to the shell defaults; see
553
+ .rail-flyout.)
554
+
555
+ NB: these are set on the content containers rather than :host on
556
+ purpose. A :host declaration would sit on the same element that
557
+ inherits --editor-plugins-panel-item-text-color, which is itself
558
+ derived from --md-sys-color-on-surface; declaring on the containers
559
+ keeps the source token a plain inherited value and so cannot form a
560
+ reference cycle. See "Layers are read upward" in THEMING.md. */
561
+ .rail,
562
+ .tree-container,
563
+ .footer {
564
+ --md-sys-color-on-surface: var(--editor-plugins-panel-item-text-color);
565
+ --md-sys-color-on-surface-variant: var(
566
+ --editor-plugins-panel-item-icon-color
567
+ );
568
+
569
+ --md-sys-color-surface: var(--editor-plugins-panel-background-color);
570
+ }
571
+
572
+ :host([expanded]),
573
+ :host([search-mode]) {
574
+ width: var(--editor-plugins-panel-width);
575
+ }
576
+
577
+ /* Base divider styling, overridden by the more specific rail/flyout/footer
578
+ dividers below where the Figma spec calls for different sizing. */
579
+ oscd-divider {
580
+ --md-divider-color: var(--editor-plugins-panel-divider-color);
581
+ /* No margin: .tree-container's 12px gap owns the spacing on both sides. */
582
+ margin-block: 0;
83
583
  }
84
584
 
85
- .editors-list {
585
+ /* --- Expanded panel: search field + scrollable tree --- */
586
+
587
+ .tree-container {
588
+ margin-inline: 16px;
589
+ min-width: 0;
86
590
  min-height: 0;
87
- --md-list-item-leading-space: var(
88
- --editor-plugins-panel-item-leading-space
591
+ display: flex;
592
+ flex-direction: column;
593
+ gap: 12px;
594
+ }
595
+
596
+ oscd-outlined-search-field * {
597
+ --md-icon-button-state-layer-width: 32px;
598
+ --md-icon-button-state-layer-height: 32px;
599
+ --md-icon-button-state-layer-shape: 50%;
600
+ }
601
+
602
+ oscd-outlined-search-field {
603
+ background-color: color-mix(
604
+ in srgb,
605
+ var(
606
+ --md-outlined-text-field-input-text-color,
607
+ var(--md-sys-color-on-surface, #1d1b20)
608
+ )
609
+ 20%,
610
+ transparent
611
+ );
612
+ border-radius: 5px;
613
+ --md-outlined-text-field-top-space: 6px;
614
+ --md-outlined-text-field-bottom-space: 6px;
615
+ --md-outlined-text-field-leading-space: 8px;
616
+ --md-outlined-text-field-trailing-space: 8px;
617
+ --md-outlined-text-field-with-leading-icon-leading-space: 10px;
618
+ --md-outlined-field-with-trailing-content-trailing-space: 8px;
619
+ --md-outlined-text-field-icon-input-space: 10px;
620
+ --md-icon-button-state-layer-width: 32px;
621
+ --md-icon-button-state-layer-height: 32px;
622
+ --md-icon-button-state-layer-shape: 50%;
623
+
624
+ --md-outlined-text-field-trailing-icon-size: 32px;
625
+
626
+ --md-icon-button-icon-size: 24px;
627
+
628
+ /* keep the outline light in every state */
629
+ --md-outlined-text-field-focus-outline-color: var(
630
+ --editor-plugins-panel-item-text-color
89
631
  );
90
- --md-list-item-trailing-space: var(
91
- --editor-plugins-panel-item-trailing-space
632
+ --md-outlined-text-field-hover-outline-color: var(
633
+ --editor-plugins-panel-item-text-color
92
634
  );
93
- --md-icon-size: var(--editor-plugins-panel-item-icon-size);
94
- --md-list-container-color: rgba(0, 0, 0, 0);
95
- --md-list-item-label-text-color: var(
635
+ --md-outlined-text-field-outline-color: var(
96
636
  --editor-plugins-panel-item-text-color
97
637
  );
98
- --md-list-item-leading-icon-color: var(
99
- --editor-plugins-panel-item-icon-color
638
+ /* hide the outline border entirely, in every state */
639
+ --md-outlined-text-field-outline-width: 0px;
640
+ --md-outlined-text-field-hover-outline-width: 0px;
641
+ --md-outlined-text-field-focus-outline-width: 0px;
642
+
643
+ /* caret follows the light text instead of the primary accent */
644
+ --md-outlined-text-field-caret-color: var(
645
+ --editor-plugins-panel-item-text-color
646
+ );
647
+ --md-outlined-text-field-focus-caret-color: var(
648
+ --editor-plugins-panel-item-text-color
100
649
  );
101
650
  }
102
651
 
103
- .editors-list .active {
104
- background-color: var(--editor-plugins-panel-item-active-bg);
652
+ .tree-scroll {
653
+ display: flex;
654
+ flex-direction: column;
655
+ gap: 12px;
656
+ min-height: 0;
657
+ overflow-y: auto;
658
+
659
+ /* Recolour the scrollbar to sit on the panel's dark-blue surface instead
660
+ of the browser's default light-themed bar. Firefox reads
661
+ scrollbar-color; Chromium/Safari (and Firefox with the layout flag
662
+ off) read the ::-webkit-scrollbar-* pseudo-elements below. */
663
+ scrollbar-color: var(--editor-plugins-panel-divider-color) transparent;
664
+ scrollbar-width: thin;
105
665
  }
106
666
 
107
- .editors-list oscd-list-item span {
108
- /* prevents jitter when collapsing */
109
- white-space: nowrap;
667
+ .tree-scroll::-webkit-scrollbar {
668
+ width: 8px;
110
669
  }
111
670
 
112
- .footer {
113
- /* setting this to display:none until re-design is fixed and its safe to remove */
114
- display: none;
115
- /* justify-self: center;
116
- justify-content: center;
117
- padding-block: 22px; */
671
+ .tree-scroll::-webkit-scrollbar-track {
672
+ background: transparent;
118
673
  }
119
674
 
120
- .toggle-button {
121
- --md-icon-color: var(--editor-plugins-panel-item-icon-color);
122
- --md-icon-button-icon-size: var(--editor-plugins-panel-item-icon-size);
123
- --md-icon-button-hover-state-layer-color: var(
124
- --editor-plugins-panel-item-icon-color
675
+ .tree-scroll::-webkit-scrollbar-thumb {
676
+ background-color: var(--editor-plugins-panel-divider-color);
677
+ border-radius: 4px;
678
+ }
679
+
680
+ oscd-tree {
681
+ margin-inline: 0;
682
+ min-height: 0;
683
+ /* .tree-scroll is the sole flex item allowed to shrink/scroll; without
684
+ this, a too-short container shrinks each tree's box below its
685
+ rendered content instead of letting the scroll container overflow,
686
+ and the (unclipped) shadow-DOM rows bleed past the shrunk box into
687
+ whichever tree follows. */
688
+ flex-shrink: 0;
689
+ --oscd-tree-row-height: 36px;
690
+ --oscd-tree-item-min-height: 36px;
691
+ --oscd-tree-row-gap: 4px;
692
+ --oscd-tree-row-padding-start: 8px;
693
+ --oscd-tree-row-padding-end: 8px;
694
+ --oscd-tree-indent-step: 36px;
695
+ --oscd-tree-row-shape: 5px;
696
+ --oscd-tree-toggle-icon-size: 28px;
697
+ --oscd-tree-trailing-toggle-gap: 8px;
698
+ /* Pin accessory hidden at rest, revealed on row hover / keyboard focus
699
+ (see Figma side-panel spec); its icon size inherits the 28px toggle. */
700
+ --oscd-tree-accessory-rest-opacity: 0;
701
+ --md-icon-size: var(--editor-plugins-panel-item-icon-size);
702
+ /* oscd-tree-item colours its leading/trailing icon slots explicitly
703
+ (not via inherited 'color'), so — unlike the label — they don't
704
+ automatically pick up the selected-row text colour above. Tying them
705
+ to currentColor makes the icon track whatever colour the row itself
706
+ resolves to (resting on-surface ink, or the active/selected colour),
707
+ matching the label in both states. */
708
+ --oscd-tree-item-leading-icon-color: currentColor;
709
+ --oscd-tree-item-trailing-icon-color: currentColor;
710
+ --oscd-tree-row-selected-color: var(
711
+ --editor-plugins-panel-item-active-bg
125
712
  );
126
- --md-icon-button-hover-state-layer-opacity: 0.08;
127
- --md-icon-button-icon-color: var(--editor-plugins-panel-item-icon-color);
128
- --md-icon-button-hover-icon-color: var(
129
- --editor-plugins-panel-item-icon-color
713
+ --oscd-tree-row-selected-text-color: var(
714
+ --editor-plugins-panel-item-active-color
130
715
  );
131
- --md-icon-button-focus-icon-color: var(
132
- --editor-plugins-panel-item-icon-color
716
+ --oscd-tree-row-active-border-width: 0px;
717
+ /* The keyboard-active row highlight (outline + optional text override)
718
+ must stay visible whether that row is merely keyboard-highlighted at
719
+ rest (panel surface bg, on-surface ink text) or also selected
720
+ (--editor-plugins-panel-item-active-bg, active-color text). Using
721
+ currentColor/inherit ties the outline and text to whichever of those
722
+ two colours the row already resolves to, instead of hardcoding the
723
+ selected-row colour (which vanishes against the resting surface). */
724
+ --oscd-tree-row-active-border-color: currentColor;
725
+ --oscd-tree-row-active-text-color: inherit;
726
+ --oscd-tree-row-focus-ring-color: currentColor;
727
+ }
728
+
729
+ oscd-tree.keyboard-active {
730
+ --oscd-tree-row-active-border-width: 1px;
731
+ }
732
+
733
+ oscd-tree.pinned-tree {
734
+ --oscd-tree-leaf-toggle-size: 0px;
735
+ --oscd-tree-leaf-toggle-gap: 0px;
736
+ }
737
+
738
+ /* --- Collapsed icon rail --- */
739
+
740
+ /* Collapsed icon rail: a flat column of 44px icon buttons (28px glyph + 8px
741
+ padding), inset 16px so the glyphs land at the same x (24px) as both the
742
+ search icon and the tree-item icons in the expanded panel. */
743
+ .rail {
744
+ display: flex;
745
+ flex-direction: column;
746
+ align-items: flex-start;
747
+ padding-inline: 16px;
748
+ min-width: 0;
749
+ min-height: 0;
750
+ overflow-y: auto;
751
+ }
752
+
753
+ .rail-item {
754
+ --md-icon-button-icon-size: 28px;
755
+ --md-icon-button-state-layer-height: 44px;
756
+ --md-icon-button-state-layer-width: 44px;
757
+ border-radius: 5px;
758
+ }
759
+
760
+ /* Active group/editor: dark rounded square behind the glyph. */
761
+ .rail-item.active {
762
+ background: var(--editor-plugins-panel-item-active-bg);
763
+ }
764
+
765
+ .rail oscd-divider {
766
+ /* 44px wide (aligned with the icon column), 12px clearance above and
767
+ below, per the Figma collapsed spec. */
768
+ width: 44px;
769
+ margin-block: 12px;
770
+ --md-divider-color: var(--editor-plugins-panel-divider-color);
771
+ }
772
+
773
+ /* Flyout menu opened from a collapsed group icon. Figma "Links container":
774
+ padding 8px, gap 4px, 1px border, 5px radius, light surface + shadow.
775
+ Unlike the rest of the panel this is a LIGHT surface, so it resets the
776
+ system colours back to the shell defaults (dark content on a light
777
+ surface); everything inside then derives correctly. */
778
+ .rail-flyout {
779
+ --md-sys-color-surface: var(--plugins-menu-container-color);
780
+ --md-sys-color-surface-container: var(--plugins-menu-container-color);
781
+ --md-sys-color-on-surface: var(--plugins-menu-item-label-color);
782
+ --md-sys-color-on-surface-variant: var(
783
+ --plugins-menu-item-leading-icon-color
133
784
  );
134
- --md-icon-button-pressed-icon-color: var(
135
- --editor-plugins-panel-item-icon-color
785
+ --md-menu-container-color: var(--plugins-menu-container-color);
786
+ min-width: 200px;
787
+ }
788
+
789
+ .flyout-header {
790
+ /* Group name heading: Material label-large, in the secondary blue, per the
791
+ Figma "Links container" header. */
792
+ padding: 8px 12px;
793
+ font-family: var(--oscd-text-font, Roboto), sans-serif;
794
+ font-size: 14px;
795
+ font-weight: 500;
796
+ line-height: 20px;
797
+ letter-spacing: 0.1px;
798
+ color: var(--editor-plugins-panel-flyout-header-text-color);
799
+ }
800
+
801
+ .rail-flyout .flyout-divider {
802
+ /* Separates the group heading from its editor items. */
803
+ --md-divider-color: var(--editor-plugins-panel-divider-color);
804
+ margin-block: 4px;
805
+ width: 80%;
806
+ margin: auto;
807
+ }
808
+
809
+ .rail-flyout oscd-menu-item {
810
+ width: 100%;
811
+ --md-menu-item-selected-container-color: var(
812
+ --plugins-menu-item-selected-container-color
813
+ );
814
+ --md-menu-item-selected-label-text-color: var(
815
+ --plugins-menu-item-selected-label-color
136
816
  );
137
- --md-icon-button-state-layer-height: 48px;
138
- --md-icon-button-state-layer-width: 48px;
139
817
  }
140
818
 
141
- :host([expanded]) {
142
- width: var(--editor-plugins-panel-width);
819
+ /* --- Footer: collapse/expand toggle --- */
820
+
821
+ .footer-divider {
822
+ padding-inline: 16px;
823
+ margin-block: 12px 0;
143
824
  }
144
825
 
145
- /* :host([expanded]) .footer {
146
- justify-self: flex-end;
147
- justify-content: flex-end;
148
- padding-inline: 22px;
149
- } */
826
+ .footer {
827
+ /* Persistent collapse/expand control, bottom-left in both modes. The 16px
828
+ inline inset matches the tree-container (so the expanded list-item spans
829
+ the same width as the rows) and the rail items; 16px below is the Figma
830
+ bottom margin. */
831
+ display: flex;
832
+ align-items: center;
833
+ justify-content: flex-start;
834
+ padding-inline: 16px;
835
+ padding-block: 0 16px;
836
+ }
837
+
838
+ /* Expanded: an interactive list item so it reads as "one more row" — full
839
+ width with a full-width hover/ripple state layer, left-aligned, no
840
+ container fill at rest. 8px leading space lands the 28px glyph at x24,
841
+ exactly like the search field and tree rows above it. */
842
+ oscd-list-item.toggle-button {
843
+ width: 100%;
844
+ border-radius: 5px;
845
+ --md-list-item-leading-space: 8px;
846
+ --md-list-item-trailing-space: 8px;
847
+ --md-list-item-one-line-container-height: 40px;
848
+ --md-list-item-top-space: 8px;
849
+ --md-list-item-bottom-space: 8px;
850
+ --md-list-item-label-text-size: 16px;
851
+ }
852
+
853
+ oscd-list-item.toggle-button oscd-icon[slot='start'] {
854
+ /* Every icon in the side panel is 28px (matches the tree icons). */
855
+ --md-icon-size: 28px;
856
+ }
857
+
858
+ /* Collapsed rail: icon-only toggle, matching the rail items (44px target,
859
+ 28px glyph, glyph lands at x24 like the other rail icons). */
860
+ oscd-icon-button.toggle-button {
861
+ --md-icon-button-icon-size: 28px;
862
+ --md-icon-button-state-layer-height: 44px;
863
+ --md-icon-button-state-layer-width: 44px;
864
+ }
150
865
  `;
151
866
  __decorate([
152
867
  property({ type: Array })
153
868
  ], EditorPluginsPanel.prototype, "editors", void 0);
154
869
  __decorate([
155
870
  property({ type: Number })
156
- ], EditorPluginsPanel.prototype, "editorIndex", void 0);
871
+ ], EditorPluginsPanel.prototype, "selectedEditor", void 0);
157
872
  __decorate([
158
873
  property({ type: String })
159
874
  ], EditorPluginsPanel.prototype, "locale", void 0);
160
875
  __decorate([
876
+ localstorage({ default: true }),
161
877
  property({ type: Boolean, reflect: true })
162
- ], EditorPluginsPanel.prototype, "expanded", null);
878
+ ], EditorPluginsPanel.prototype, "expanded", void 0);
879
+ __decorate([
880
+ property({ type: Boolean, reflect: true, attribute: 'search-mode' })
881
+ ], EditorPluginsPanel.prototype, "searchMode", void 0);
882
+ __decorate([
883
+ state()
884
+ ], EditorPluginsPanel.prototype, "searchValue", void 0);
885
+ __decorate([
886
+ state()
887
+ ], EditorPluginsPanel.prototype, "focusedTree", void 0);
888
+ __decorate([
889
+ localstorage({ default: [] }),
890
+ state()
891
+ ], EditorPluginsPanel.prototype, "expandedIds", void 0);
892
+ __decorate([
893
+ localstorage({ default: [] }),
894
+ state()
895
+ ], EditorPluginsPanel.prototype, "pinnedPluginIds", void 0);
896
+ __decorate([
897
+ localstorage({ default: [] }),
898
+ state()
899
+ ], EditorPluginsPanel.prototype, "pinnedExpanded", void 0);
163
900
  EditorPluginsPanel = __decorate([
164
901
  localized()
165
902
  ], EditorPluginsPanel);