@ixfx/components 0.6.2 → 0.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 (152) hide show
  1. package/bundle/index.d.ts +1490 -615
  2. package/bundle/index.d.ts.map +1 -1
  3. package/bundle/index.js +20477 -15855
  4. package/bundle/index.js.map +1 -1
  5. package/bundle/style.css +1 -1
  6. package/dist/ac-text.js +3 -2
  7. package/dist/ac-text.js.map +1 -1
  8. package/dist/ac-token.js +3 -2
  9. package/dist/ac-token.js.map +1 -1
  10. package/dist/{button-Bn0BuLpO.js → button-Dat2yHAU.js} +4 -3
  11. package/dist/button-Dat2yHAU.js.map +1 -0
  12. package/dist/button.js +1 -1
  13. package/dist/checkbox.js +1 -1
  14. package/dist/{colour-picker-0stHB90J.js → colour-picker-BSDBL-QL.js} +4 -3
  15. package/dist/{colour-picker-0stHB90J.js.map → colour-picker-BSDBL-QL.js.map} +1 -1
  16. package/dist/colour-picker.js +1 -1
  17. package/dist/crumbs.js +4 -2
  18. package/dist/crumbs.js.map +1 -1
  19. package/dist/data-display.js +1 -1
  20. package/dist/data-grid.js +4 -2
  21. package/dist/data-grid.js.map +1 -1
  22. package/dist/{defaults-BCiDhZbF.js → defaults-BkWCPJFL.js} +3 -20
  23. package/dist/defaults-BkWCPJFL.js.map +1 -0
  24. package/dist/editable-label-rzXkjf1H.d.ts +75 -0
  25. package/dist/editable-label-rzXkjf1H.d.ts.map +1 -0
  26. package/dist/editable-label.d.ts +2 -75
  27. package/dist/editable-label.js +2 -1
  28. package/dist/editable-label.js.map +1 -1
  29. package/dist/{fallbacks-DEE1nCTt.js → fallbacks-DNI9xt0F.js} +2 -2
  30. package/dist/{fallbacks-DEE1nCTt.js.map → fallbacks-DNI9xt0F.js.map} +1 -1
  31. package/dist/{grouped-item-lister-_pMDwIo1.d.ts → grouped-item-lister-DnWB-eFT.d.ts} +2 -2
  32. package/dist/{grouped-item-lister-_pMDwIo1.d.ts.map → grouped-item-lister-DnWB-eFT.d.ts.map} +1 -1
  33. package/dist/grouped-item-lister.d.ts +1 -1
  34. package/dist/{icons-Bm2ByQ3s.js → icon-CvnPanhA.js} +3 -6
  35. package/dist/icon-CvnPanhA.js.map +1 -0
  36. package/dist/icons.js +8 -2
  37. package/dist/icons.js.map +1 -0
  38. package/dist/index-CG2vIgPv.d.ts +101 -0
  39. package/dist/index-CG2vIgPv.d.ts.map +1 -0
  40. package/dist/index.d.ts +655 -65
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +3682 -924
  43. package/dist/index.js.map +1 -1
  44. package/dist/interaction-D5XdKzbR.js +2 -0
  45. package/dist/labelled-radial-input.js +2 -1
  46. package/dist/labelled-radial-input.js.map +1 -1
  47. package/dist/labelled-slider-input-DzigjMwP.js +808 -0
  48. package/dist/labelled-slider-input-DzigjMwP.js.map +1 -0
  49. package/dist/labelled-slider-input.d.ts +2 -0
  50. package/dist/labelled-slider-input.js +2 -0
  51. package/dist/{types-DlVNJez_.d.ts → list-selection-types-DSuRNWpx.d.ts} +10 -2
  52. package/dist/list-selection-types-DSuRNWpx.d.ts.map +1 -0
  53. package/dist/menu-BjRvlgPy.js +825 -0
  54. package/dist/menu-BjRvlgPy.js.map +1 -0
  55. package/dist/menu-item-B-zYrTTi.js +797 -0
  56. package/dist/menu-item-B-zYrTTi.js.map +1 -0
  57. package/dist/menu.js +2 -1
  58. package/dist/miller.d.ts +1 -1
  59. package/dist/miller.js +3 -2
  60. package/dist/miller.js.map +1 -1
  61. package/dist/narrowed-text.js +4 -2
  62. package/dist/narrowed-text.js.map +1 -1
  63. package/dist/notifier.js +3 -2
  64. package/dist/notifier.js.map +1 -1
  65. package/dist/panel.d.ts +13 -13
  66. package/dist/panel.d.ts.map +1 -1
  67. package/dist/panel.js +37 -253
  68. package/dist/panel.js.map +1 -1
  69. package/dist/plots.js +1 -1
  70. package/dist/polar-pad.js +1 -1
  71. package/dist/{radial-input-Dk8Wydp5.js → radial-input-Dp5qz0ly.js} +2 -2
  72. package/dist/{radial-input-Dk8Wydp5.js.map → radial-input-Dp5qz0ly.js.map} +1 -1
  73. package/dist/radial-input.js +1 -1
  74. package/dist/range-input.js +1 -1
  75. package/dist/range.js +1 -1
  76. package/dist/registry-CkW7q09Q.js +21 -0
  77. package/dist/registry-CkW7q09Q.js.map +1 -0
  78. package/dist/select-horiz.js +3 -2
  79. package/dist/select-horiz.js.map +1 -1
  80. package/dist/{slider-input-XppH3vcy.d.ts → slider-input-BfRUW2ZB.d.ts} +16 -3
  81. package/dist/slider-input-BfRUW2ZB.d.ts.map +1 -0
  82. package/dist/{slider-input-CT4gcF-o.js → slider-input-De4eoT11.js} +382 -67
  83. package/dist/slider-input-De4eoT11.js.map +1 -0
  84. package/dist/slider-input.d.ts +1 -1
  85. package/dist/slider-input.js +1 -1
  86. package/dist/snackbar.js +1 -1
  87. package/dist/split-layout-BFSbUZQv.js +969 -0
  88. package/dist/split-layout-BFSbUZQv.js.map +1 -0
  89. package/dist/split-layout-JK3xWVX2.d.ts +179 -0
  90. package/dist/split-layout-JK3xWVX2.d.ts.map +1 -0
  91. package/dist/split-layout.d.ts +2 -60
  92. package/dist/split-layout.js +1 -617
  93. package/dist/style.css +1 -1
  94. package/dist/swipe.js +1 -1
  95. package/dist/{tab-list-D_1cYhB_.d.ts → tab-list-MPwq_1Jj.d.ts} +2 -1
  96. package/dist/tab-list-MPwq_1Jj.d.ts.map +1 -0
  97. package/dist/tabs.d.ts +1 -1
  98. package/dist/tabs.js +96 -14
  99. package/dist/tabs.js.map +1 -1
  100. package/dist/{tickled-controller-BtevrRLV.d.ts → tickled-controller-CRzdIEjJ.d.ts} +7 -1
  101. package/dist/tickled-controller-CRzdIEjJ.d.ts.map +1 -0
  102. package/dist/{tickled-controller-DkAU-0qL.js → tickled-controller-h9GmJ_bU.js} +8 -2
  103. package/dist/tickled-controller-h9GmJ_bU.js.map +1 -0
  104. package/dist/titlebar-DaCfpliv.d.ts +50 -0
  105. package/dist/titlebar-DaCfpliv.d.ts.map +1 -0
  106. package/dist/titlebar.d.ts +2 -0
  107. package/dist/titlebar.js +324 -0
  108. package/dist/titlebar.js.map +1 -0
  109. package/dist/{tree-BO6BXI2b.js → tree-Ch4W8qXQ.js} +4 -3
  110. package/dist/{tree-BO6BXI2b.js.map → tree-Ch4W8qXQ.js.map} +1 -1
  111. package/dist/tree.d.ts +1 -1
  112. package/dist/tree.js +1 -1
  113. package/dist/vertical-list-ClnqTkF3.js +814 -0
  114. package/dist/vertical-list-ClnqTkF3.js.map +1 -0
  115. package/dist/vertical-list.d.ts +8 -9
  116. package/dist/vertical-list.d.ts.map +1 -1
  117. package/dist/vertical-list.js +1 -736
  118. package/dist/{xy-axis-B6yMjckW.js → xy-axis-CxHC8z5E.js} +109 -3
  119. package/dist/xy-axis-CxHC8z5E.js.map +1 -0
  120. package/dist/xy-pad.js +1 -1
  121. package/docs-user/README.md +5 -0
  122. package/docs-user/detail-list.md +398 -0
  123. package/docs-user/dock.md +310 -0
  124. package/docs-user/index.json +27 -2
  125. package/docs-user/labelled-slider-input.md +198 -0
  126. package/docs-user/llms.txt +6 -1
  127. package/docs-user/panel.md +20 -1
  128. package/docs-user/slider-input.md +55 -15
  129. package/docs-user/split-layout.md +136 -170
  130. package/docs-user/tabs.md +34 -0
  131. package/docs-user/titlebar.md +102 -0
  132. package/docs-user/toolbar.md +95 -22
  133. package/docs-user/twosplit-layout.md +254 -0
  134. package/docs-user/user-catalog.md +8 -1
  135. package/llms.txt +6 -1
  136. package/package.json +1 -2
  137. package/dist/button-Bn0BuLpO.js.map +0 -1
  138. package/dist/defaults-BCiDhZbF.js.map +0 -1
  139. package/dist/editable-label.d.ts.map +0 -1
  140. package/dist/icons-Bm2ByQ3s.js.map +0 -1
  141. package/dist/menu-DIfU70AA.js +0 -1609
  142. package/dist/menu-DIfU70AA.js.map +0 -1
  143. package/dist/slider-input-CT4gcF-o.js.map +0 -1
  144. package/dist/slider-input-XppH3vcy.d.ts.map +0 -1
  145. package/dist/split-layout.d.ts.map +0 -1
  146. package/dist/split-layout.js.map +0 -1
  147. package/dist/tab-list-D_1cYhB_.d.ts.map +0 -1
  148. package/dist/tickled-controller-BtevrRLV.d.ts.map +0 -1
  149. package/dist/tickled-controller-DkAU-0qL.js.map +0 -1
  150. package/dist/types-DlVNJez_.d.ts.map +0 -1
  151. package/dist/vertical-list.js.map +0 -1
  152. package/dist/xy-axis-B6yMjckW.js.map +0 -1
@@ -0,0 +1,398 @@
1
+ # `ixfx-detail-list`
2
+
3
+ A list that scales horizontally, not vertically. Items are laid out in equal-width columns; they flow top→bottom down a column, then wrap to the top of the next column to the right. The component has a fixed vertical extent and scrolls horizontally.
4
+
5
+ Selection semantics match `ixfx-vertical-list` — same modes, same `list-*` events, same keyboard gestures — but navigation is a 2-D grid (Up/Down move within a column, Left/Right jump columns).
6
+
7
+ ---
8
+
9
+ ## Contents
10
+
11
+ 1. [Quick start](#quick-start)
12
+ 2. [Item types](#item-types)
13
+ 3. [Properties](#properties)
14
+ 4. [Selection](#selection)
15
+ 5. [Interaction modes](#interaction-modes)
16
+ 6. [Custom checkbox](#custom-checkbox)
17
+ 7. [Events](#events)
18
+ 8. [Keyboard navigation](#keyboard-navigation)
19
+ 9. [Incremental search](#incremental-search)
20
+ 10. [Filter predicate](#filter-predicate)
21
+ 11. [Sorting](#sorting)
22
+ 12. [Asynchronous data](#asynchronous-data)
23
+ 13. [CSS variables](#css-variables)
24
+ 14. [CSS parts](#css-parts)
25
+
26
+ ---
27
+
28
+ ## Quick start
29
+
30
+ ### Declarative HTML items
31
+
32
+ ```html
33
+ <ixfx-detail-list id="my-list" selection-mode="single">
34
+ <li>Alpha</li>
35
+ <li>Bravo</li>
36
+ <li>Charlie</li>
37
+ </ixfx-detail-list>
38
+
39
+ <script type="module">
40
+ import '@ixfx/components';
41
+
42
+ const list = document.querySelector('#my-list');
43
+ list.addEventListener('list-select', ({ detail }) => {
44
+ const [item] = detail.selected;
45
+ console.log('selected:', item?.textContent);
46
+ });
47
+ </script>
48
+ ```
49
+
50
+ Items flow down columns (vertically) then wrap to the next column:
51
+
52
+ ```
53
+ Alpha Golf November Uniform
54
+ Bravo Hotel Oscar Victor
55
+ Charlie India Papa Whiskey
56
+ Delta Juliett Quebec X-ray
57
+ Echo Kilo Romeo Yankee
58
+ Foxtrot Lima Sierra Zulu
59
+ ```
60
+
61
+ ### Programmatic items
62
+
63
+ ```typescript
64
+ import type { DetailListElement } from '@ixfx/components';
65
+
66
+ const list = document.querySelector<DetailListElement>('ixfx-detail-list')!;
67
+
68
+ list.addItem('Document A');
69
+ list.addItem('Image 01.png');
70
+
71
+ // Or pass an element directly
72
+ const li = document.createElement('li');
73
+ li.textContent = 'Custom item';
74
+ list.addItem(li);
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Item types
80
+
81
+ ### Plain `<li>` elements
82
+
83
+ The simplest item type. Items are positioned by CSS flexbox into columns.
84
+
85
+ ```html
86
+ <ixfx-detail-list>
87
+ <li>Item one</li>
88
+ <li>Item two</li>
89
+ </ixfx-detail-list>
90
+ ```
91
+
92
+ **Attributes supported on `<li>`:**
93
+
94
+ | Attribute | Description |
95
+ |---|---|
96
+ | `data-search-label` | Override searchable text (defaults to `textContent`) |
97
+ | `data-key` | Stable identity for selection persistence across sort/rebuild |
98
+ | `data-value` | Alternative to `data-key` |
99
+ | `data-icon` | Icon name to display (injects `<ixfx-icon>`) |
100
+ | `not-checked` | In `checked` mode, renders without a checkbox and cannot be checked |
101
+ | `hidden` | Hides the item from view and navigation |
102
+
103
+ ### Object items
104
+
105
+ Pass an array of objects and configure how to display them:
106
+
107
+ ```typescript
108
+ const files = [
109
+ { name: 'Report Q1.pdf', size: 220, kind: 'doc' },
110
+ { name: 'Photo 01.png', size: 1400, kind: 'img' },
111
+ { name: 'Notes.txt', size: 3, kind: 'doc' },
112
+ ];
113
+
114
+ list.items = files;
115
+ list.displayProperty = 'name';
116
+ list.keyProperty = 'name'; // Preserves selection across rebuilds
117
+ ```
118
+
119
+ **Object-related properties:**
120
+
121
+ | Property | Type | Description |
122
+ |---|---|---|
123
+ | `items` | `readonly unknown[]` | Array of objects to render |
124
+ | `displayProperty` | `string` | Property name to use as label |
125
+ | `keyProperty` | `string` | Property name for stable identity |
126
+ | `iconProperty` | `string` | Property name for icon name |
127
+ | `formatter` | `(item: unknown, index: number) => string \| Node` | Custom renderer |
128
+
129
+ ```typescript
130
+ list.formatter = (item, index) => {
131
+ const file = item as { name: string; size: number };
132
+ return `${file.name} (${file.size} KB)`;
133
+ };
134
+ ```
135
+
136
+ Retrieve the original data for an element:
137
+
138
+ ```typescript
139
+ const data = list.getItemData(liElement);
140
+ ```
141
+
142
+ ---
143
+
144
+ ## Properties
145
+
146
+ | Property | Attribute | Type | Default | Description |
147
+ |---|---|---|---|---|
148
+ | `selectionMode` | `selection-mode` | `'single' \| 'multiple' \| 'none'` | `'single'` | How selection works |
149
+ | `interactionMode` | `interaction-mode` | `'implicit' \| 'standard' \| 'manual' \| 'checked' \| 'sticky' \| 'vscode'` | `'standard'` | Click/keyboard behavior |
150
+ | `sort` | `sort` | `'none' \| 'ascending' \| 'descending'` | `'none'` | Sort direction |
151
+ | `sortBy` | `sort-by` | `string` | — | Object property to sort by |
152
+ | `sortComparator` | — | `(a, b) => number` | — | Custom sort function |
153
+ | `filterPredicate` | — | `(el: Element) => boolean` | — | Filter visible items |
154
+ | `itemWrap` | `item-wrap` | `'stop' \| 'wrap'` | `'stop'` | Keyboard wrap behavior |
155
+ | `provider` | — | `IDataProvider` | — | Async data source |
156
+ | `checkboxRenderer` | — | `function \| object` | — | Custom checkbox |
157
+
158
+ ---
159
+
160
+ ## Selection
161
+
162
+ ### Getters
163
+
164
+ ```typescript
165
+ const selected = list.selectedItem; // First selected element or undefined
166
+ const allSelected = list.selectedItems; // Array of all selected elements
167
+ ```
168
+
169
+ ### Methods
170
+
171
+ ```typescript
172
+ list.select(item); // Select an item
173
+ list.deselect(item); // Deselect an item
174
+ list.selectMany([item1, item2]); // Select multiple
175
+ list.clearSelection(); // Clear all selections
176
+ list.selectAll(); // Select all visible items (if permitted)
177
+ ```
178
+
179
+ ---
180
+
181
+ ## Interaction modes
182
+
183
+ | Mode | Description |
184
+ |---|---|
185
+ | `standard` | Click replaces selection. Ctrl+click toggles. Shift+click creates range. Cmd+A selects all. |
186
+ | `implicit` | Click always replaces selection (no modifier support). |
187
+ | `checked` | Checkbox column toggles selection; rows marked `not-checked` have no checkbox. |
188
+ | `sticky` | Every click toggles. Cmd+A selects all. |
189
+ | `vscode` | Like `standard`, but Shift+Arrow extends selection range. |
190
+ | `manual` | No auto-selection; drive state via `select()` / `deselect()` programmatically. |
191
+
192
+ ---
193
+
194
+ ## Custom checkbox
195
+
196
+ In `checked` mode, provide a custom checkbox renderer:
197
+
198
+ ```typescript
199
+ list.checkboxRenderer = (checked) => checked
200
+ ? html`<span>✓</span>`
201
+ : html``;
202
+ ```
203
+
204
+ Or use the shorthand object form:
205
+
206
+ ```typescript
207
+ list.checkboxRenderer = { checked: '✓', unchecked: '' };
208
+ ```
209
+
210
+ ---
211
+
212
+ ## Events
213
+
214
+ | Event | Detail | Description |
215
+ |---|---|---|
216
+ | `list-select` | `{ selected: Element[] }` | Selection changed |
217
+ | `list-tickle` | `{ item: Element }` | Hover/focus cursor moved |
218
+ | `list-activate` | `{ item: Element }` | Item activated (Enter/double-click) |
219
+ | `list-item-click` | `{ item: Element }` | Item clicked |
220
+
221
+ ```typescript
222
+ list.addEventListener('list-select', (e) => {
223
+ console.log('Selected:', [...e.detail.selected].map(el => el.textContent));
224
+ });
225
+ ```
226
+
227
+ ---
228
+
229
+ ## Keyboard navigation
230
+
231
+ The list must have focus for keyboard navigation. Click an item to focus, or set `tabindex="0"` on the element.
232
+
233
+ | Key | Action |
234
+ |---|---|
235
+ | ↑ / ↓ | Move up/down within the current column |
236
+ | ← / → | Jump to previous/next column (same row) |
237
+ | Home | Jump to first item |
238
+ | End | Jump to last item |
239
+ | Enter | Activate item (fires `list-activate`) |
240
+ | Space | Toggle selection (in modes that support it) |
241
+ | Escape | Clear selection / close search |
242
+ | Ctrl+F | Open incremental search |
243
+ | Ctrl+A | Select all (in modes that support it) |
244
+
245
+ ### Wrapping
246
+
247
+ Set `item-wrap="wrap"` to enable wraparound navigation:
248
+
249
+ ```html
250
+ <ixfx-detail-list item-wrap="wrap">...</ixfx-detail-list>
251
+ ```
252
+
253
+ With wrapping:
254
+ - Down from last row → first row of next column (or first column if at end)
255
+ - Up from first row → last row of previous column (or last column if at start)
256
+ - Right from last column → first column
257
+ - Left from first column → last column
258
+
259
+ ---
260
+
261
+ ## Incremental search
262
+
263
+ Press Ctrl+F (or Cmd+F on Mac) to open the search overlay. Type to filter items in real-time using fuzzy matching.
264
+
265
+ Search matches against `data-search-label` (or `textContent` as fallback). Use `data-search-label` to match against a different string than what's displayed:
266
+
267
+ ```html
268
+ <li data-search-label="document a alpha">Document A</li>
269
+ ```
270
+
271
+ ### Highlight styling
272
+
273
+ Matched characters are highlighted with the `detail-list-search` highlight key. Style matches with:
274
+
275
+ ```css
276
+ ::highlight(detail-list-search) {
277
+ background: yellow;
278
+ color: black;
279
+ }
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Filter predicate
285
+
286
+ Use `filterPredicate` to programmatically hide items:
287
+
288
+ ```typescript
289
+ // Show only .doc files
290
+ list.filterPredicate = (el) => el.getAttribute('data-kind') === 'doc';
291
+
292
+ // Clear filter
293
+ list.filterPredicate = undefined;
294
+ ```
295
+
296
+ Filtered items are hidden from view, keyboard navigation, and selection.
297
+
298
+ ---
299
+
300
+ ## Sorting
301
+
302
+ ### Simple sort
303
+
304
+ ```typescript
305
+ list.sort = 'ascending'; // or 'descending'
306
+ ```
307
+
308
+ ### Sort by property
309
+
310
+ ```typescript
311
+ list.sortBy = 'size'; // Sort by 'size' property
312
+ list.sort = 'ascending';
313
+ ```
314
+
315
+ ### Custom comparator
316
+
317
+ ```typescript
318
+ list.sortComparator = (a, b) => {
319
+ // Sort by kind first, then by label
320
+ const kindCompare = a.data.kind.localeCompare(b.data.kind);
321
+ if (kindCompare !== 0) return kindCompare;
322
+ return a.label.localeCompare(b.label);
323
+ };
324
+ ```
325
+
326
+ The comparator receives `{ element, data, label }` where:
327
+ - `element` — the `<li>` DOM element
328
+ - `data` — the original object from `items` array
329
+ - `label` — the display string
330
+
331
+ ---
332
+
333
+ ## Asynchronous data
334
+
335
+ Use a `provider` for async data loading:
336
+
337
+ ### Promise provider
338
+
339
+ ```typescript
340
+ list.provider = async () => {
341
+ const response = await fetch('/api/items');
342
+ return response.json();
343
+ };
344
+ ```
345
+
346
+ ### Streaming provider
347
+
348
+ ```typescript
349
+ list.provider = async function*() {
350
+ for (let i = 0; i < 100; i++) {
351
+ yield [{ id: i, name: `Item ${i}` }];
352
+ await delay(100);
353
+ }
354
+ }();
355
+ ```
356
+
357
+ The provider function can return:
358
+ - A promise that resolves to an array
359
+ - An async iterable that yields arrays (streaming)
360
+
361
+ Streaming providers extend columns to the right as batches arrive.
362
+
363
+ ---
364
+
365
+ ## CSS variables
366
+
367
+ | Variable | Default | Description |
368
+ |---|---|---|
369
+ | `--detail-list-bg` | `surface-3` | Background color |
370
+ | `--detail-list-text` | `surface-3-text` | Text color |
371
+ | `--detail-list-border` | `border` | Border color |
372
+ | `--detail-list-radius` | `radius-s` | Border radius |
373
+ | `--detail-list-column-width` | `220px` | Width of each column |
374
+ | `--detail-list-column-gap` | `8px` | Gap between columns |
375
+ | `--detail-list-item-height` | `28px` | Height of each item |
376
+ | `--detail-list-item-padding` | `0 var(--space-m)` | Item padding |
377
+ | `--detail-list-scrollbar-width` | `auto` | Scrollbar width |
378
+ | `--detail-list-scrollbar-gutter` | `auto` | Scrollbar gutter |
379
+ | `--detail-list-scrollbar-color` | `auto` | Scrollbar color |
380
+
381
+ Plus all `--item-bg-*`, `--item-text-*` variables from the theme for selection/tickle states.
382
+
383
+ ---
384
+
385
+ ## CSS parts
386
+
387
+ | Part | Description |
388
+ |---|---|
389
+ | `list` | The `<ul>` container |
390
+ | `search-overlay` | The Ctrl+F search overlay |
391
+ | `checkbox` | The checkbox element (in `checked` mode) |
392
+ | `loading` | The loading indicator |
393
+
394
+ ```css
395
+ ixfx-detail-list::part(search-overlay) {
396
+ background: rgba(0, 0, 0, 0.8);
397
+ }
398
+ ```
@@ -0,0 +1,310 @@
1
+ # Dock (`ixfx-dock-host` / `ixfx-dock-panel`)
2
+
3
+ Dockable, movable panels like VS Code / Photoshop. Drag a panel by its
4
+ titlebar (solo) or its tab (tabbed) over another panel; depending on cursor
5
+ position it becomes a **tab** in that group or **splits** the target to the
6
+ top/bottom/left/right. Infinitely composable, serializable, and restrictable.
7
+
8
+ ```
9
+ <ixfx-dock-host layout='{"version":1,"root":{…}}'>
10
+ <ixfx-dock-panel panel-id="explorer" title="Explorer" closable>…</ixfx-dock-panel>
11
+ <ixfx-dock-panel panel-id="editor" title="Editor" closable>…</ixfx-dock-panel>
12
+ </ixfx-dock-host>
13
+ ```
14
+
15
+ ## Architecture
16
+
17
+ - **Flat panel pool + model-driven host.** Panels stay direct light-DOM
18
+ children of the host for their whole life — they are never reparented
19
+ within a host. The host owns an in-memory `DockNode` tree (`dock-model.ts`),
20
+ renders split/tab scaffolding in shadow DOM, and projects panels into leaves
21
+ via `slot="dock-panel-{id}"`. Moving a panel = model mutation → re-render →
22
+ slot reassignment; panel DOM and state are never destroyed.
23
+ - **Split nodes** render as a single `ixfx-split-layout` (n-way) with one
24
+ wrapper child per child node. Splits of the *other* direction are emitted as
25
+ nested `<ixfx-split-layout>` directly (the split element recurses into them).
26
+ Per-child weights ride on `size` attributes; stack collapse rides on
27
+ `collapsed` / `when-small` attributes.
28
+ - **Stack (leaf) nodes** always render the same chrome: an `ixfx-tab-list`
29
+ strip (with generated `ixfx-tab-list-item`s, one per panel) plus a single
30
+ body slot for the active panel — so titlebars are consistent whether a stack
31
+ holds one panel or many. Docked panels are `presentation="tabbed"`
32
+ (content only); the tab item is the drag handle and carries title / icon /
33
+ close / per-panel toolbar. `ixfx-tab-panels` is deliberately not used — the
34
+ host owns slot projection. Panels outside a host (or not currently docked)
35
+ render `presentation="solo"` with an `ixfx-titlebar` and flat content.
36
+
37
+ ## Data model (`types.ts` / `dock-model.ts`)
38
+
39
+ ```ts
40
+ type DockLayout = { version: 1; root: DockNode };
41
+ type DockNode = DockSplitNode | DockStackNode;
42
+ type DockSplitNode = { kind: 'split'; direction: 'row' | 'column';
43
+ sizes: number[]; children: DockNode[] };
44
+ type DockStackNode = { kind: 'stack'; panels: string[]; active: string;
45
+ collapsed?: boolean; preCollapseSize?: string };
46
+ ```
47
+
48
+ `direction` uses `ixfx-split-layout`'s flexbox sense: `row` = side by side,
49
+ `column` = stacked. A "split horizontal" drop (horizontal divider, panels
50
+ stacked vertically) maps to `direction: "column"`.
51
+
52
+ `normalize()` runs after every mutation: empty stacks are removed, ≤1-child
53
+ splits dissolve, same-direction child splits flatten into their parent,
54
+ `sizes` stays parallel to `children`, and `active` always points at a member.
55
+ The tree is plain and mutable, owned exclusively by the host.
56
+
57
+ ## Drop targeting — the 5-zone model
58
+
59
+ While dragging, the host overlay highlights the resulting region:
60
+
61
+ | Region | Operation | Result |
62
+ |---|---|---|
63
+ | leaf centre (~50% box) | `tabs` | insert into that stack (at the hovered strip gap when over the strip) |
64
+ | leaf left / right band | `split-vertical` | new stack beside the leaf |
65
+ | leaf top / bottom band | `split-horizontal` | new stack above/below the leaf |
66
+ | host gutter / edges (outside all leaves) | split **root** | new stack against the whole host |
67
+
68
+ When the target's parent split already matches the drop direction the new
69
+ stack is inserted beside it (50/50 weight split); otherwise the target is
70
+ wrapped in a new split. `Escape` or `pointercancel` cancels with no mutation.
71
+
72
+ ## Junction resize
73
+
74
+ Where a parent gutter line meets a nested split's gutter (T and + crossings
75
+ of three or four panels), the host renders a small hover-revealed handle
76
+ (colour: `--dock-junction-color`, default `var(--accent)`). Dragging it
77
+ starts the vertical and horizontal gutter drags at once via
78
+ `ixfx-split-layout`'s `beginGutterDrag()`, resizing all adjacent panels with
79
+ one gesture — as in VS Code. Handle positions are recomputed on renders,
80
+ split resizes, and host resizes; handles are hidden during dock drags.
81
+
82
+ ## Restrictions
83
+
84
+ - Host `accepts` attribute: space-separated `tabs`, `horizontal`, `vertical`,
85
+ convenience `all`; `""` / `none` rejects everything. Default allows all.
86
+ - Panel `accepts` attribute: applies when that panel is a **solo** drop
87
+ target. Default unrestricted.
88
+ - `host.canAccept = (panelId, op) => boolean` — finest-grained veto, consulted
89
+ per candidate zone.
90
+
91
+ A zone lights up only if all three allow it; disallowed zones are inert.
92
+
93
+ ## Events
94
+
95
+ `ixfx-dock-host`: `dock-change` (`{ layout }` after structural mutations),
96
+ `dock-panel-added`, `dock-panel-removed`, `dock-panel-activated`,
97
+ `dock-panel-drag-start`.
98
+
99
+ Cross-window (see [Cross-window docking](#cross-window-docking-electron-etc)):
100
+ `dock-panel-drag-out` / `dock-panel-drag-in`
101
+ (`{ panelId, clientX, clientY, screenX, screenY }`) when the drag pointer
102
+ leaves / re-enters every host's bounds, and `dock-panel-tear-out`
103
+ (`{ panelId, snapshot, clientX, clientY, screenX, screenY }`) when a panel is
104
+ released with no host under the pointer.
105
+
106
+ `ixfx-dock-panel`: `dock-panel-drag-start` (`{ panelId, pointerId, clientX,
107
+ clientY }`), plus `close` / `toggle` forwarded from its `ixfx-titlebar`.
108
+ The panel removes itself on close; the host's pool sync routes that through
109
+ the model and emits `dock-panel-removed` + `dock-change`.
110
+
111
+ The host consumes `ixfx-split-layout` events: `ixfx-split-resize` /
112
+ `ixfx-split-change` (live gutter weights folded into `DockSplitNode.sizes`),
113
+ `ixfx-split-collapse` / `ixfx-split-expand` (→ `DockStackNode.collapsed`), and
114
+ `ixfx-split-close` (cancelable — prevented and rerouted so panel elements
115
+ survive, re-homed into the first other stack).
116
+
117
+ ## Public API — `ixfx-dock-host`
118
+
119
+ ```ts
120
+ saveLayout(): DockLayout // folds live split weights from the DOM
121
+ loadLayout(layout: DockLayout): void
122
+ addPanel(panel: DockPanelElement | DockPanelInit, target?: {
123
+ stack?: string; index?: number; op?: DockOperation // stack = a panel-id in the target stack
124
+ }): void
125
+ removePanel(panelId: string): boolean // removes the element from the DOM too
126
+ activatePanel(panelId: string): boolean
127
+ getPanelIds(): string[]
128
+ canAccept?: (panelId: string, op: DockOperation) => boolean
129
+
130
+ // Cross-window hooks (serializable in/out; no element handoff)
131
+ getPanelSnapshot(panelId: string): DockPanelSnapshot | null
132
+ detachPanel(panelId: string): DockPanelSnapshot | null // snapshot + removePanel
133
+ queryDropTarget(x: number, y: number, panelId?: string): DockDropTarget | null
134
+ previewExternalDrop(x: number, y: number, panelId?: string): DockDropTarget | null
135
+ endExternalDrop(): void
136
+ acceptExternalPanel(snap: DockPanelSnapshot | DockPanelInit,
137
+ at?: { clientX: number; clientY: number }): DockPanelElement | null
138
+ ```
139
+
140
+ The optional `layout` attribute accepts `DockLayout` JSON as the initial
141
+ layout; without it, all pooled panels go into one root stack in source order.
142
+ Panels present in the pool but missing from a loaded layout are appended to
143
+ the first stack.
144
+
145
+ ## Cross-host drags
146
+
147
+ Panels can be dragged between hosts (register via the shared drag
148
+ coordinator automatically). The source host releases the panel from its model
149
+ and the target host adopts the element — the one sanctioned reparent
150
+ (`connectedCallback` re-runs on the panel; it holds no per-host state).
151
+ `panel-id` must be unique across all hosts that can exchange panels.
152
+
153
+ This works only *within one document*. The drag coordinator is module state, so
154
+ it never spans separate windows (`BrowserWindow` / `WebContentsView` / a popped
155
+ `window.open`). Moving a panel across that boundary is described next.
156
+
157
+ ## Cross-window docking (Electron, etc.)
158
+
159
+ The dock deliberately ships **no** window/IPC/Node dependency. Instead it
160
+ exposes the events and methods an outer coordinator (your Electron main
161
+ process, a `SharedWorker`, `BroadcastChannel`, `postMessage`…) needs to move a
162
+ panel between windows. Nothing DOM crosses the boundary — you transport a
163
+ plain, structured-clone-safe **`DockPanelSnapshot`** and rebuild the panel on
164
+ the far side.
165
+
166
+ ```ts
167
+ interface DockPanelSnapshot { // structured-clone safe
168
+ panelId: string; title: string; iconName: string;
169
+ closable: boolean; collapsible: boolean; accepts: string;
170
+ contentHTML: string; // best-effort innerHTML snapshot; see note below
171
+ }
172
+ interface DockDropTarget { // serializable hit-test result
173
+ operation: 'tabs' | 'split-horizontal' | 'split-vertical';
174
+ targetPath: number[]; // stack path in the target host model; [] = root
175
+ side: 'before' | 'after';
176
+ gapIndex?: number;
177
+ }
178
+ ```
179
+
180
+ > **`contentHTML`** is a convenience for panels whose body is static markup.
181
+ > If a panel holds live state, framework-managed DOM, canvas/WebGL, or running
182
+ > timers, ignore it and rebuild the body from your own model in the new window
183
+ > (you already do this to render the panel the first time). The snapshot is
184
+ > identity + chrome; content ownership stays with your app.
185
+
186
+ ### 1. Panel → new window (tear-out)
187
+
188
+ While a drag is in progress the host fires boundary events as the pointer
189
+ leaves and re-enters the union of all host rects:
190
+
191
+ | Event | When | Use |
192
+ |---|---|---|
193
+ | `dock-panel-drag-out` | pointer left every host | start a native drag image / detached preview window that follows the cursor |
194
+ | `dock-panel-drag-in` | pointer came back over a host | tear down that preview; the in-page drop overlay takes over again |
195
+ | `dock-panel-tear-out` | released with no host under the pointer | create the real window and move the panel |
196
+
197
+ All three carry `clientX/clientY` (source viewport) **and** `screenX/screenY`
198
+ (desktop) so the receiver can place a window. `dock-panel-tear-out` also
199
+ carries a ready-made `snapshot`.
200
+
201
+ ```ts
202
+ host.addEventListener('dock-panel-tear-out', (e) => {
203
+ const { snapshot, screenX, screenY } = e.detail;
204
+ window.dockBridge.openPanelWindow({ snapshot, screenX, screenY }); // → main process
205
+ host.detachPanel(snapshot.panelId); // snapshot already taken; drop it here
206
+ });
207
+ ```
208
+
209
+ ```ts
210
+ // main process
211
+ ipcMain.handle('dock:open-panel-window', (_e, { snapshot, screenX, screenY }) => {
212
+ const win = new BrowserWindow({ x: screenX - 40, y: screenY - 20, width: 480, height: 360, /* … */ });
213
+ win.loadFile('panel-window.html');
214
+ win.webContents.once('did-finish-load', () =>
215
+ win.webContents.send('dock:mount-panel', snapshot));
216
+ });
217
+ ```
218
+
219
+ ```ts
220
+ // panel-window.html renderer — a host that starts empty
221
+ ipcRenderer.on('dock:mount-panel', (_e, snapshot) => {
222
+ document.querySelector('ixfx-dock-host').acceptExternalPanel(snapshot);
223
+ });
224
+ ```
225
+
226
+ Because `pointerup` outside the OS window is not reliably delivered to a
227
+ renderer, prefer starting a native follow-the-cursor window on
228
+ `dock-panel-drag-out` and finalising from the **main** process on the global
229
+ mouse-up, rather than depending on `dock-panel-tear-out` firing. `tear-out`
230
+ is the best-effort path for release inside the window but between hosts.
231
+
232
+ ### 2. Window → this host (tear-in)
233
+
234
+ Detecting an OS-level window drag is the app's job — Electron gives you the
235
+ screen cursor (`screen.getCursorScreenPoint()`) and window bounds
236
+ (`win.getBounds()`), so the main process can tell, per frame, when a dragged
237
+ panel-window is hovering another window and where. Forward that to the target
238
+ renderer, which drives the host imperatively:
239
+
240
+ ```ts
241
+ // target renderer, fed pointer position + the dragged panel's snapshot over IPC
242
+ ipcRenderer.on('dock:external-drag-move', (_e, { snapshot, clientX, clientY }) => {
243
+ host.previewExternalDrop(clientX, clientY, snapshot.panelId); // lights the overlay
244
+ });
245
+ ipcRenderer.on('dock:external-drag-leave', () => host.endExternalDrop());
246
+ ipcRenderer.on('dock:external-drag-drop', (_e, { snapshot, clientX, clientY }) => {
247
+ const placed = host.acceptExternalPanel(snapshot, { clientX, clientY });
248
+ if (placed) ipcRenderer.send('dock:close-source-window'); // panel now lives here
249
+ });
250
+ ```
251
+
252
+ `previewExternalDrop` / `queryDropTarget` return the `DockDropTarget` that a
253
+ drop at that point would produce (or `null` when the point misses the host or
254
+ the zone is vetoed by `accepts` / `canAccept`), so the coordinator can also
255
+ show a yes/no cursor on the source side. `acceptExternalPanel` honours the
256
+ same restrictions and falls back to the first stack when `at` is omitted or
257
+ outside the host.
258
+
259
+ ### Coordinate spaces
260
+
261
+ Events give you both. `client*` is the source renderer's viewport;
262
+ `screen*` is the desktop. To convert a desktop point into a target renderer's
263
+ viewport, the app subtracts that window's content bounds (main process:
264
+ `win.getContentBounds()`), accounting for `devicePixelRatio`. Keep the
265
+ conversion in the coordinator — the component only ever speaks the two spaces
266
+ it can measure.
267
+
268
+ ### Identity
269
+
270
+ `panelId` must be unique across every host that can exchange panels, in every
271
+ window. `acceptExternalPanel` returns `null` if that id is already docked in
272
+ the target host (adopt / focus it yourself instead).
273
+
274
+ ## Serialization
275
+
276
+ `saveLayout()` folds live gutter weights from each rendered
277
+ `ixfx-split-layout` (via `getSizes()`) into `DockSplitNode.sizes` before
278
+ serializing, so a save → drag → save round-trip preserves user adjustments.
279
+ `DockStackNode.collapsed` / `preCollapseSize` serialize so a saved layout can
280
+ restore collapse state without a live element.
281
+
282
+ ## CSS variables
283
+
284
+ | Variable | Purpose | Default |
285
+ |---|---|---|
286
+ | `--dock-drop-zone-color` | drop highlight colour | `var(--accent)` |
287
+ | `--dock-drop-zone-opacity` | drop highlight opacity | `0.25` |
288
+ | `--dock-collapsed-size` | collapsed stack track size | `40px` |
289
+ | `--dock-tab-height` | tab item height, uniform across all leaf strips | `30px` |
290
+ | `--dock-split-bar-size` | thickness of the split-layout bars between leaves | `2px` |
291
+ | `--dock-junction-color` | junction resize handle affordance colour | `var(--accent)` |
292
+
293
+ Splitter styling delegates to `ixfx-split-layout` (`--split-*`); solo panel
294
+ chrome delegates to `ixfx-titlebar`, and the leaf body is the docked panel
295
+ surface. Each leaf carries a hairline border (`var(--border-subtle)`) with
296
+ slight rounding (`var(--radius-m)`). An empty
297
+ host renders a placeholder; replace its content with
298
+ `<div slot="empty">…</div>`.
299
+
300
+ ## Deviations from the original plan (and why)
301
+
302
+ - **Tab-strip reorder** is handled by the dock drag coordinator (gap-index
303
+ insertion) rather than `ixfx-tab-list`'s own `drag-mode="reorder"` — running
304
+ both would race two ghosts for the same pointer. The gap computation
305
+ reuses the tab-list midpoint rule.
306
+ - **Closing a leaf via `ixfx-split-close`** re-homes its panels into the first
307
+ other stack (or the pool if none) instead of dropping them on the floor.
308
+ - **Group toolbar** renders per-strip split buttons (`↕` / `↔`) that split the
309
+ stack's active panel out — host-authored chrome in the strip's toolbar slot,
310
+ as the plan describes.