@swiftwc/ui 0.0.0-dev.55 → 0.0.0-dev.57

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.
@@ -1,6 +1,16 @@
1
1
  import { FormAssociatedBase } from '../internal/class/form-associated-base';
2
2
  declare const pickerStyles: readonly ["menu", "inline", "navigation-link", "sheet", "automatic"];
3
3
  export type PickerStyle = (typeof pickerStyles)[number];
4
+ /**
5
+ *
6
+ * @attr {menu|inline|navigation-link|sheet|automatic} picker-style
7
+ *
8
+ * @attr {string} help - Adds a help tooltip to the trigger of the picker, if style supports one
9
+ *
10
+ * @attr {DictEntry[]} dictionary - Renders all options using this array
11
+ *
12
+ * @slot list
13
+ */
4
14
  export declare class PickerView extends FormAssociatedBase {
5
15
  #private;
6
16
  static get ATTR(): {
@@ -11,6 +21,7 @@ export declare class PickerView extends FormAssociatedBase {
11
21
  SEARCHABLE: string;
12
22
  CURRENT_VALUE_LABEL: string;
13
23
  TRIGGER_HELP: string;
24
+ DICTIONARY: string;
14
25
  };
15
26
  static get observedAttributes(): string[];
16
27
  get selection(): string;
@@ -62,13 +62,53 @@ const reflectSpawnedElement = (current, source) => {
62
62
  const extractTagFromOption = (node) => {
63
63
  if (devFlags.debug)
64
64
  console.debug(`PickerView: extractTagFromOption`);
65
- return ((node.getAttribute('value') ?? node.textContent?.trim()) || node.getAttribute('label')) ?? '';
66
- };
67
- const extractCurrentValueFromOption = (node) => {
65
+ return node instanceof HTMLOptionElement ? (((node.getAttribute('value') ?? node.textContent?.trim()) || node.getAttribute('label')) ?? '') : (node.value ?? '');
66
+ }, extractCurrentValueFromOption = (node) => {
68
67
  if (devFlags.debug)
69
68
  console.debug(`PickerView: extractCurrentValueFromOption`);
70
- return (node.getAttribute('label') ?? node.getAttribute('value') ?? node.textContent?.trim()) || '';
69
+ return node instanceof HTMLOptionElement ? (node.getAttribute('label') ?? node.getAttribute('value') ?? node.textContent?.trim()) || '' : (node.label ?? node.value ?? '');
70
+ }, extractLabelFromGroup = (node) => {
71
+ if (devFlags.debug)
72
+ console.debug(`PickerView: extractLabelFromGroup`);
73
+ return node instanceof Element ? node.getAttribute('DATALIST' === node.tagName ? 'data-label' : 'label') : (node.label ?? node.value ?? null);
74
+ }, extractImgFromGroup = (node) => {
75
+ if (devFlags.debug)
76
+ console.debug(`PickerView: extractImgFromGroup`);
77
+ return node instanceof Element ? node.getAttribute('data-system-image') : (node.systemImage ?? null);
78
+ };
79
+ const allLeaves = (node) => node.children.every((c) => 0 === c.children.length);
80
+ const parseDictionary = (value) => {
81
+ let dictionary = [];
82
+ try {
83
+ dictionary = JSON.parse(value ?? '[]');
84
+ }
85
+ catch {
86
+ console.error('invalid-dictionary');
87
+ }
88
+ return dictionary;
71
89
  };
90
+ const indexGroups = (nodes, parentPath = '') => {
91
+ const map = new Map();
92
+ nodes.forEach((node, i) => {
93
+ const id = parentPath ? `${parentPath}.${i}` : `${i}`;
94
+ map.set(id, node); // no DOM write — works identically for Element and DictEntry
95
+ const children = node instanceof Element ? Array.from(node.children) : node.children;
96
+ for (const [cid, cnode] of indexGroups(children, id))
97
+ map.set(cid, cnode);
98
+ });
99
+ return map;
100
+ };
101
+ const collectLeafValues = (node) => (node.children.length ? node.children.flatMap(collectLeafValues) : [node.value]);
102
+ /**
103
+ *
104
+ * @attr {menu|inline|navigation-link|sheet|automatic} picker-style
105
+ *
106
+ * @attr {string} help - Adds a help tooltip to the trigger of the picker, if style supports one
107
+ *
108
+ * @attr {DictEntry[]} dictionary - Renders all options using this array
109
+ *
110
+ * @slot list
111
+ */
72
112
  export class PickerView extends FormAssociatedBase {
73
113
  static get ATTR() {
74
114
  return {
@@ -79,6 +119,7 @@ export class PickerView extends FormAssociatedBase {
79
119
  SEARCHABLE: 'searchable',
80
120
  CURRENT_VALUE_LABEL: 'current-value-label',
81
121
  TRIGGER_HELP: 'help',
122
+ DICTIONARY: 'dictionary',
82
123
  };
83
124
  }
84
125
  static get observedAttributes() {
@@ -86,7 +127,25 @@ export class PickerView extends FormAssociatedBase {
86
127
  }
87
128
  static #templates = new Map();
88
129
  #spawn;
89
- #spawnPage = (tag, searchable = false, title, node) => {
130
+ #makeGroupClickHandler(el, groupId) {
131
+ return async (evt) => {
132
+ evt.stopImmediatePropagation();
133
+ evt.preventDefault();
134
+ const { target } = evt;
135
+ if (!(target instanceof HTMLElement))
136
+ return;
137
+ const newPage = this.#spawnPage(el instanceof Element ? Array.from(el.children) : el.children, 'body-view', groupId, this.hasAttribute('searchable'), extractLabelFromGroup(el));
138
+ if (!newPage)
139
+ return;
140
+ newPage.dataset.groupId = groupId; // <-- tag the spawned page
141
+ const path = new NavigationPath(target)?.hydrate();
142
+ await startViewTransition(target, 'forwards', async () => {
143
+ update(path, newPage);
144
+ this.#reflectSelectionOnButtons();
145
+ });
146
+ };
147
+ }
148
+ #spawnPage = (elements, tag, parentGroupId, searchable = false, title) => {
90
149
  if (devFlags.debug)
91
150
  console.debug(`${_a.name} #spawnPage`);
92
151
  const body = $(`<${'sheet-view' === tag ? 'dialog is="sheet-view"' : 'body-view'}><scroll-view><v-stack placement="leading fill"><list-view preferred-expanded-style="inset"></list-view></v-stack></scroll-view><tool-bar><tool-bar-item slot="top-bar-leading"><button type="button" tabindex="0"><label-view system-image="caret-left"></label-view></button></tool-bar-item></tool-bar></${'sheet-view' === tag ? 'dialog' : 'body-view'}>`, '>1'), sv = body.querySelector('scroll-view'), list = body.querySelector('list-view'), backBtn = body.querySelector('button');
@@ -138,54 +197,148 @@ export class PickerView extends FormAssociatedBase {
138
197
  component?.remove();
139
198
  });
140
199
  });
141
- const elements = node ? Array.from(node.children) : (this.#slots?.get('list')?.assignedElements() ?? []);
142
- for (const el of elements) {
143
- if (!(el instanceof HTMLElement))
144
- continue;
145
- switch (el.tagName) {
146
- case 'OPTGROUP': {
147
- const details = _a.#wrapOptgroupTag(el);
148
- _a.#reflectButtons([...el.children], details);
149
- for (const btn of details.querySelectorAll(':scope>button'))
200
+ for (const [i, el] of elements.entries()) {
201
+ if (el instanceof Element)
202
+ switch (el.tagName) {
203
+ case 'OPTGROUP': {
204
+ const group = _a.#wrapOptgroupTag(el);
205
+ _a.#reflectButtons([...el.children], group);
206
+ for (const btn of group.querySelectorAll(':scope>button'))
207
+ btn.addEventListener('click', this.#handlePageBtnClick);
208
+ list?.appendChild(group);
209
+ break;
210
+ }
211
+ case 'OPTION': {
212
+ const btn = _a.#wrapOptionTag(el);
150
213
  btn.addEventListener('click', this.#handlePageBtnClick);
151
- list?.appendChild(details);
152
- break;
214
+ list?.appendChild(btn);
215
+ break;
216
+ }
217
+ default: {
218
+ const btn = _a.#wrapOptionSpawnTag(el);
219
+ const groupId = parentGroupId ? `${parentGroupId}.${i}` : `${i}`;
220
+ // const btn = $<HTMLButtonElement>(
221
+ // `<button type="button" tabindex="0" navigation-link><h-stack distribution="leading" template="auto spacer"><label-view data-role="check" style="visibility: hidden"><image-view slot="icon" system-name="check"></image-view></label-view><label-view><span></span></label-view></h-stack></button>`,
222
+ // '>1'
223
+ // ),
224
+ // label = btn.querySelector<LabelView>(':scope>h-stack>label-view:nth-child(2)')
225
+ // const node = el as HTMLElement
226
+ // if (label && node.dataset.label) renderLabelTitle(label, node.dataset.label) //label?.setAttribute('title', el.dataset.label)
227
+ btn.dataset.groupId = groupId;
228
+ btn.addEventListener('click', this.#makeGroupClickHandler(el, groupId));
229
+ list?.appendChild(btn);
230
+ break;
231
+ }
153
232
  }
154
- case 'OPTION': {
233
+ else {
234
+ if (el.children.length)
235
+ if (allLeaves(el)) {
236
+ const group = _a.#wrapOptgroupTag(el);
237
+ _a.#reflectButtons(el.children, group);
238
+ for (const btn of group.querySelectorAll(':scope>button'))
239
+ btn.addEventListener('click', this.#handlePageBtnClick);
240
+ list?.appendChild(group);
241
+ }
242
+ else {
243
+ const btn = _a.#wrapOptionSpawnTag(el);
244
+ const groupId = parentGroupId ? `${parentGroupId}.${i}` : `${i}`;
245
+ btn.dataset.groupId = groupId;
246
+ btn.addEventListener('click', this.#makeGroupClickHandler(el, groupId));
247
+ list?.appendChild(btn);
248
+ }
249
+ else {
155
250
  const btn = _a.#wrapOptionTag(el);
156
251
  btn.addEventListener('click', this.#handlePageBtnClick);
157
252
  list?.appendChild(btn);
158
- break;
159
- }
160
- default: {
161
- const btn = $(`<button type="button" tabindex="0" navigation-link><h-stack distribution="leading" template="auto spacer"><label-view data-role="check" style="visibility: hidden"><image-view slot="icon" system-name="check"></image-view></label-view><label-view><span></span></label-view></h-stack></button>`, '>1'), label = btn.querySelector(':scope>h-stack>label-view:nth-child(2)');
162
- if (label && el.dataset.label)
163
- renderLabelTitle(label, el.dataset.label); //label?.setAttribute('title', el.dataset.label)
164
- btn.addEventListener('click', async (evt) => {
165
- evt.stopImmediatePropagation();
166
- evt.preventDefault();
167
- const { target } = evt;
168
- if (!(target instanceof HTMLElement))
169
- return;
170
- const newPage = this.#spawnPage('body-view', this.hasAttribute('searchable'), el.dataset.label, el);
171
- if (!newPage)
172
- return;
173
- const path = new NavigationPath(target)?.hydrate();
174
- await startViewTransition(target, 'forwards', async () => {
175
- update(path, newPage);
176
- this.#reflectSelectionOnButtons();
177
- });
178
- });
179
- list?.appendChild(btn);
180
- break;
181
253
  }
182
254
  }
183
255
  }
184
256
  return body;
185
257
  };
186
- #renderSlotted = (entries) => {
258
+ #resyncSpawnedPages = (freshRoot) => {
259
+ if (!this.#spawn)
260
+ return;
261
+ const groupMap = indexGroups(freshRoot);
262
+ for (const el of this.#spawn.querySelectorAll('body-view')) {
263
+ const depth = $.ancestors('body-view,[is=sheet-view]', el).indexOf(this.#spawn);
264
+ if (0 >= depth)
265
+ continue;
266
+ const groupId = el.dataset.groupId;
267
+ const source = groupId ? groupMap.get(groupId) : undefined;
268
+ if (!source) {
269
+ el.remove();
270
+ continue;
271
+ }
272
+ const children = source instanceof Element ? Array.from(source.children) : source.children;
273
+ const title = source instanceof Element ? extractLabelFromGroup(source) : (source.label ?? null);
274
+ const newPage = this.#spawnPage(children, 'body-view', groupId, this.hasAttribute('searchable'), title);
275
+ newPage.dataset.groupId = groupId;
276
+ reflectSpawnedElement(el, newPage);
277
+ }
278
+ };
279
+ #renderButtons(input) {
187
280
  if (devFlags.debug)
188
- console.debug(`${_a.name} ⚡️ mutation`);
281
+ console.debug(`${_a.name} #renderButtons`);
282
+ switch (input.mode) {
283
+ case 'dictionary': {
284
+ const flattenDictionary = (tree) => {
285
+ const out = {};
286
+ const stack = [tree];
287
+ const idx = [0];
288
+ while (stack.length) {
289
+ const frame = stack[stack.length - 1];
290
+ const i = idx[idx.length - 1];
291
+ if (i >= frame.length) {
292
+ stack.pop();
293
+ idx.pop();
294
+ continue;
295
+ }
296
+ idx[idx.length - 1]++;
297
+ const { value, label, children } = frame[i];
298
+ out[value] = label;
299
+ if (children.length) {
300
+ stack.push(children);
301
+ idx.push(0);
302
+ }
303
+ }
304
+ return out;
305
+ };
306
+ this.#lastRenderedLabelMap = flattenDictionary(input.source);
307
+ // const collectLeafValues = (node: DictEntry): string[] => (node.children.length ? node.children.flatMap(collectLeafValues) : [node.value])
308
+ // collectGroups = (nodes: DictEntry[]): string[][] => {
309
+ // const groups: string[][] = []
310
+ // const walk = (list: DictEntry[]) => {
311
+ // for (const node of list) {
312
+ // if (!node.children.length) continue
313
+ // if (!allLeaves(node)) groups.push(collectLeafValues(node))
314
+ // walk(node.children)
315
+ // }
316
+ // }
317
+ // walk(nodes)
318
+ // return groups
319
+ // }
320
+ this.#lastIndexedRoot = input.source; //this.#lastRenderedGroupMap = collectGroups(dictionary)
321
+ break;
322
+ }
323
+ case 'list': {
324
+ this.#lastRenderedLabelMap = {};
325
+ for (const el of input.source)
326
+ if (el.matches('option')) {
327
+ const opt = el;
328
+ this.#lastRenderedLabelMap[extractTagFromOption(opt)] = opt.getAttribute('label') ?? undefined;
329
+ }
330
+ else
331
+ for (const opt of el.querySelectorAll('option'))
332
+ this.#lastRenderedLabelMap[extractTagFromOption(opt)] = opt.getAttribute('label') ?? undefined;
333
+ this.#lastIndexedRoot = input.source;
334
+ // this.#lastRenderedGroupMap = assigned.flatMap<string[]>((el) =>
335
+ // [...(el.matches('datalist') ? [el as HTMLDataListElement] : []), ...el.querySelectorAll<HTMLDataListElement>('datalist')].map((dl) =>
336
+ // Array.from(dl.options).map((opt) => extractTagFromOption(opt))
337
+ // )
338
+ // )
339
+ break;
340
+ }
341
+ }
189
342
  switch (this.pickerStyle) {
190
343
  case 'sheet':
191
344
  case 'navigation-link': {
@@ -206,18 +359,19 @@ export class PickerView extends FormAssociatedBase {
206
359
  if (!this.#spawn)
207
360
  break;
208
361
  // rerender level 0
209
- reflectSpawnedElement(this.#spawn, this.#spawnPage('body-view', this.hasAttribute('searchable'), this.getAttribute('label')));
210
- for (const el of this.#spawn.querySelectorAll('body-view')) {
211
- const depth = $.ancestors('body-view,[is=sheet-view]', el).indexOf(this.#spawn);
212
- if (0 >= depth)
213
- continue;
214
- const datalist = this.querySelector(`:scope>${Array.from({ length: depth }, () => 'datalist').join('>')}`);
215
- if (!datalist) {
216
- el.remove();
217
- break;
218
- }
219
- reflectSpawnedElement(el, this.#spawnPage('body-view', this.hasAttribute('searchable'), datalist.dataset.label, datalist));
220
- }
362
+ reflectSpawnedElement(this.#spawn, this.#spawnPage(input.source, 'body-view', undefined, this.hasAttribute('searchable'), this.getAttribute('label')));
363
+ this.#resyncSpawnedPages(input.source);
364
+ // // FIXME:
365
+ // for (const el of this.#spawn.querySelectorAll<HTMLElement>('body-view')) {
366
+ // const depth = $.ancestors('body-view,[is=sheet-view]', el).indexOf(this.#spawn)
367
+ // if (0 >= depth) continue
368
+ // const datalist = this.querySelector<HTMLElement>(`:scope>${Array.from({ length: depth }, () => 'datalist').join('>')}`)
369
+ // if (!datalist) {
370
+ // el.remove()
371
+ // break
372
+ // }
373
+ // reflectSpawnedElement(el, this.#spawnPage(Array.from(datalist.children), 'body-view', this.hasAttribute('searchable'), datalist.dataset.label))
374
+ // }
221
375
  break;
222
376
  }
223
377
  case 'menu': {
@@ -236,7 +390,7 @@ export class PickerView extends FormAssociatedBase {
236
390
  }
237
391
  if (this.hasAttribute('help'))
238
392
  currentValueLabel?.setAttribute('help', this.getAttribute('help') ?? '');
239
- _a.#reflectButtons([...(this.#slots?.get('list')?.assignedElements() ?? [])], menu);
393
+ _a.#reflectButtons(input.source, menu);
240
394
  break;
241
395
  }
242
396
  case 'inline':
@@ -256,12 +410,25 @@ export class PickerView extends FormAssociatedBase {
256
410
  renderLabelTitle(label, value); //label.setAttribute('title', value)
257
411
  section.insertAdjacentElement('beforeend', label);
258
412
  }
259
- _a.#reflectButtons([...(this.#slots?.get('list')?.assignedElements({ flatten: true }) ?? [])].filter((el) => el.matches('option')), section);
413
+ if ('dictionary' === input.mode)
414
+ _a.#reflectButtons(input.source.filter((el) => 0 === el.children.length), section);
415
+ else
416
+ _a.#reflectButtons(input.source.filter((el) => el.matches('option')), section);
260
417
  break;
261
418
  }
262
419
  }
263
420
  this.#reflectSelectionOnButtons();
264
- this.#reflectCurrentValueLabel();
421
+ this.#reflectSelectionOnCurrentValueLabel();
422
+ }
423
+ #renderDictionary = (dictionary) => {
424
+ if (devFlags.debug)
425
+ console.debug(`${_a.name} ⚡️ mutation`);
426
+ this.#renderButtons({ mode: 'dictionary', source: dictionary });
427
+ };
428
+ #renderSlotted = (entries) => {
429
+ if (devFlags.debug)
430
+ console.debug(`${_a.name} ⚡️ mutation`);
431
+ this.#renderButtons({ mode: 'list', source: this.#slots?.get('list')?.assignedElements({ flatten: true }) ?? [] });
265
432
  };
266
433
  #renderValidityMsgs = (entries) => {
267
434
  if (devFlags.debug)
@@ -283,9 +450,15 @@ export class PickerView extends FormAssociatedBase {
283
450
  return;
284
451
  this.#selection = v;
285
452
  this.#reflectSelectionOnButtons();
286
- this.#reflectCurrentValueLabel();
453
+ this.#reflectSelectionOnCurrentValueLabel();
287
454
  this.#sendValueToForm();
288
455
  }
456
+ #lastRenderedLabelMap = {};
457
+ #lastIndexedRoot = [];
458
+ get #currentValueLabel() {
459
+ const cvl = this.#lastRenderedLabelMap[this.#selection];
460
+ return (this.getAttribute('current-value-label') ?? '').replaceAll('{{selection}}', this.#selection).replaceAll('{{currentValueLabel}}', this.#selection) || cvl || this.#selection;
461
+ }
289
462
  get #internals() {
290
463
  return getInternals(this);
291
464
  }
@@ -410,12 +583,17 @@ export class PickerView extends FormAssociatedBase {
410
583
  break;
411
584
  case this.constructor.ATTR.CURRENT_VALUE_LABEL:
412
585
  // if (oldValue === newValue) break
413
- this.#reflectCurrentValueLabel();
586
+ this.#reflectSelectionOnCurrentValueLabel();
414
587
  break;
415
588
  case this.constructor.ATTR.TRIGGER_HELP:
416
589
  // if (oldValue === newValue) break
417
590
  this.#reflectTriggerHelp();
418
591
  break;
592
+ case this.constructor.ATTR.DICTIONARY:
593
+ if (oldValue === newValue)
594
+ break;
595
+ this.#renderDictionary(parseDictionary(newValue));
596
+ break;
419
597
  case this.constructor.ATTR.SELECTION:
420
598
  // nothing happens
421
599
  break;
@@ -495,7 +673,7 @@ export class PickerView extends FormAssociatedBase {
495
673
  if (!(target instanceof HTMLElement))
496
674
  return;
497
675
  this.#spawn?.remove?.();
498
- const level0 = this.#spawnPage('sheet' === this.pickerStyle ? 'sheet-view' : 'body-view', this.hasAttribute('searchable'), this.getAttribute('label'));
676
+ const level0 = this.#spawnPage(this.hasAttribute(this.constructor.ATTR.DICTIONARY) ? parseDictionary(this.getAttribute('dictionary')) : (this.#slots?.get('list')?.assignedElements() ?? []), 'sheet' === this.pickerStyle ? 'sheet-view' : 'body-view', undefined, this.hasAttribute('searchable'), this.getAttribute('label'));
499
677
  if (!level0)
500
678
  return;
501
679
  const path = new NavigationPath(target)?.hydrate();
@@ -519,7 +697,7 @@ export class PickerView extends FormAssociatedBase {
519
697
  this.#spawn?.remove();
520
698
  this.#selection = btn.getAttribute('value') ?? '';
521
699
  this.#reflectSelectionOnButtons();
522
- this.#reflectCurrentValueLabel();
700
+ this.#reflectSelectionOnCurrentValueLabel();
523
701
  this.#sendValueToForm();
524
702
  };
525
703
  const { body } = new NavigationPath(this.#spawn)?.hydrate();
@@ -539,7 +717,7 @@ export class PickerView extends FormAssociatedBase {
539
717
  return;
540
718
  this.#selection = btn.getAttribute('value') ?? '';
541
719
  this.#reflectSelectionOnButtons();
542
- this.#reflectCurrentValueLabel();
720
+ this.#reflectSelectionOnCurrentValueLabel();
543
721
  this.#sendValueToForm();
544
722
  }
545
723
  #handleValiditiesSlotchange = ({ type, target: slot }) => {
@@ -562,6 +740,20 @@ export class PickerView extends FormAssociatedBase {
562
740
  // if (0 < assigned.length)
563
741
  this.#renderSlotted([]);
564
742
  };
743
+ static #wrapOptionSpawnTag(node) {
744
+ if (devFlags.debug)
745
+ console.debug(`${_a.name} #wrapOptionSpawnTag`);
746
+ const btn = $(`<button type="button" tabindex="0" navigation-link><h-stack distribution="leading" template="auto spacer"><label-view data-role="check" style="visibility: hidden"><image-view slot="icon" system-name="check"></image-view></label-view><label-view><span></span></label-view></h-stack></button>`, '>1'), hStack = btn.querySelector(':scope>h-stack');
747
+ if (hStack)
748
+ renderLabel(hStack, ':scope>label-view:nth-child(2)', `<label-view><span></span></label-view>`, extractLabelFromGroup(node), extractImgFromGroup(node));
749
+ // label = btn.querySelector<LabelView>(':scope>h-stack>label-view:nth-child(2)')
750
+ // if (label) {
751
+ // const lbl = extractLabelFromGroup(node as HTMLDataListElement),
752
+ // img =extractImgFromGroup(node as HTMLDataListElement)
753
+ // if(lbl) renderLabelTitle(label, node.dataset.label) //label?.setAttribute('title', el.dataset.label)
754
+ // }
755
+ return btn;
756
+ }
565
757
  static #wrapOptionTag(node) {
566
758
  if (devFlags.debug)
567
759
  console.debug(`${_a.name} #wrapOptionTag`);
@@ -583,35 +775,54 @@ export class PickerView extends FormAssociatedBase {
583
775
  // if (node.hasAttribute('label')) summaryLabel.setAttribute('title', node.getAttribute('label') ?? '')
584
776
  // if (node.hasAttribute('data-system-image')) summaryLabel.setAttribute('system-image', node.getAttribute('data-system-image') ?? '')
585
777
  if (hStack)
586
- renderLabel(hStack, ':scope>label-view:nth-child(2)', labelT, node.getAttribute('label'), node.getAttribute('data-system-image'));
778
+ renderLabel(hStack, ':scope>label-view:nth-child(2)', labelT, extractLabelFromGroup(node), extractImgFromGroup(node));
587
779
  return group;
588
780
  }
781
+ // static #reflectButtons(nodes: Element[], container: Element): void
782
+ // static #reflectButtons(nodes: Dictionary, container: Element): void
589
783
  static #reflectButtons(nodes, container) {
590
784
  if (devFlags.debug)
591
785
  console.debug(`${_a.name} #reflectButtons`);
592
786
  for (const node of nodes)
593
- switch (node.tagName) {
594
- case 'DATALIST': {
595
- const group = $(`<menu-view tabindex="0"></menu-view>`, '>1');
596
- // label = group.querySelector(':scope>label-view[slot=label]') ?? group.appendChild($(`<label-view slot="label"></label-view>`, '>1'))
597
- // if (node.hasAttribute('data-label')) label.setAttribute('title', node.getAttribute('data-label') ?? '')
598
- // if (node.hasAttribute('data-system-image')) label.setAttribute('system-image', node.getAttribute('data-system-image') ?? '')
599
- renderLabel(group, ':scope>label-view[slot=label]', `<label-view slot="label"><span></span></label-view>`, node.getAttribute('data-label'), node.getAttribute('data-system-image'));
600
- _a.#reflectButtons([...node.children], group);
601
- container.appendChild(group);
602
- break;
603
- }
604
- case 'OPTGROUP': {
605
- const group = _a.#wrapOptgroupTag(node);
606
- _a.#reflectButtons([...node.children], group);
607
- container.appendChild(group);
608
- break;
787
+ if (node instanceof Element)
788
+ switch (node.tagName) {
789
+ case 'DATALIST': {
790
+ const group = $(`<menu-view tabindex="0"></menu-view>`, '>1');
791
+ // label = group.querySelector(':scope>label-view[slot=label]') ?? group.appendChild($(`<label-view slot="label"></label-view>`, '>1'))
792
+ // if (node.hasAttribute('data-label')) label.setAttribute('title', node.getAttribute('data-label') ?? '')
793
+ // if (node.hasAttribute('data-system-image')) label.setAttribute('system-image', node.getAttribute('data-system-image') ?? '')
794
+ renderLabel(group, ':scope>label-view[slot=label]', `<label-view slot="label"><span></span></label-view>`, extractLabelFromGroup(node), extractImgFromGroup(node));
795
+ _a.#reflectButtons([...node.children], group);
796
+ container.appendChild(group);
797
+ break;
798
+ }
799
+ case 'OPTGROUP': {
800
+ const group = _a.#wrapOptgroupTag(node);
801
+ _a.#reflectButtons([...node.children], group);
802
+ container.appendChild(group);
803
+ break;
804
+ }
805
+ case 'OPTION':
806
+ default: {
807
+ container.appendChild(_a.#wrapOptionTag(node));
808
+ break;
809
+ }
609
810
  }
610
- case 'OPTION':
611
- default: {
811
+ else {
812
+ if (node.children.length)
813
+ if (allLeaves(node)) {
814
+ const group = _a.#wrapOptgroupTag(node);
815
+ _a.#reflectButtons(node.children, group);
816
+ container.appendChild(group);
817
+ }
818
+ else {
819
+ const group = $(`<menu-view tabindex="0"></menu-view>`, '>1');
820
+ renderLabel(group, ':scope>label-view[slot=label]', `<label-view slot="label"><span></span></label-view>`, extractLabelFromGroup(node), extractImgFromGroup(node));
821
+ _a.#reflectButtons(node.children, group);
822
+ container.appendChild(group);
823
+ }
824
+ else
612
825
  container.appendChild(_a.#wrapOptionTag(node));
613
- break;
614
- }
615
826
  }
616
827
  }
617
828
  #reflectPlaceholder(value) {
@@ -634,55 +845,69 @@ export class PickerView extends FormAssociatedBase {
634
845
  #reflectSelectionOnButtons() {
635
846
  if (devFlags.debug)
636
847
  console.debug(`${_a.name} #reflectSelectionOnButtons`);
848
+ const groupMap = indexGroups(this.#lastIndexedRoot);
849
+ const groupContainsSelection = (source) => source instanceof Element
850
+ ? [...source.querySelectorAll('option')].some((opt) => extractTagFromOption(opt) === this.#selection)
851
+ : collectLeafValues(source).includes(this.#selection);
637
852
  const syncButtons = (root) => {
638
- // 1. Sync plain value buttons (existing behavior)
853
+ // 1. plain value buttons unchanged
639
854
  for (const el of root.querySelectorAll('button[value]:not([slot])'))
640
855
  el.querySelector('label-view[data-role="check"]')?.style.setProperty('visibility', el.getAttribute('value') === this.#selection ? 'visible' : 'hidden');
641
- // 2. Sync `details` (optgroups)open/mark if any descendant option matches selection
856
+ // 2. details/optgroups — unchanged
642
857
  for (const details of root.querySelectorAll('details[is="disclosure-group"]')) {
643
858
  const hasSelectedDescendant = [...details.querySelectorAll('button[value]')].some((btn) => btn.getAttribute('value') === this.#selection);
644
- // Show/hide the check on the summary's label-view
645
859
  details.querySelector(':scope>summary label-view[data-role="check"]')?.style.setProperty('visibility', hasSelectedDescendant ? 'visible' : 'hidden');
646
860
  }
647
- // 3. Sync nav-link buttons (those without `value` that spawn sub-pages)
648
- // These wrap <datalist> children — mark them if any descendant value matches
861
+ // 3. nav-link buttons resolved by groupId, same map used for resync
649
862
  for (const btn of root.querySelectorAll('button[navigation-link]:not([value])')) {
650
- // Find the matching datalist node by label
651
- const btnLabel = btn.querySelector('label-view span')?.textContent?.trim();
652
- const matchingDatalist = [...(this.#slots?.get('list')?.assignedElements() ?? [])]
653
- .flatMap((el) => [...el.querySelectorAll('datalist'), ...(el.tagName === 'DATALIST' ? [el] : [])])
654
- .find((dl) => dl.getAttribute('data-label') === btnLabel);
655
- const hasSelectedDescendant = matchingDatalist ? [...matchingDatalist.querySelectorAll('option')].some((opt) => extractTagFromOption(opt) === this.#selection) : false;
863
+ const source = btn.dataset.groupId ? groupMap.get(btn.dataset.groupId) : undefined;
864
+ const hasSelectedDescendant = source ? groupContainsSelection(source) : false;
656
865
  btn.querySelector('label-view[data-role="check"]')?.style.setProperty('visibility', hasSelectedDescendant ? 'visible' : 'hidden');
657
866
  }
658
867
  };
659
- // Run on the host element (inline/menu styles)
660
868
  syncButtons(this);
661
- // Also sync the spawn if open (sheet/navigation-link styles)
662
869
  if (this.#spawn)
663
870
  syncButtons(this.#spawn);
664
- // // walk all rendered buttons (inline has buttons in list, menu has buttons in menu-view)
665
- // for (const el of this.querySelectorAll<HTMLButtonElement>('button[value]:not([slot])'))
666
- // el.querySelector<HTMLElement>('label-view[data-role="check"]')?.style.setProperty('visibility', el.getAttribute('value') === this.#selection ? 'visible' : 'hidden')
667
- // // also sync the spawn (sheet/navigation) if open
668
- // if (this.#spawn)
669
- // for (const el of this.#spawn.querySelectorAll<HTMLButtonElement>('list-view button[value]:not([slot])'))
670
- // el.querySelector<HTMLElement>('label-view[data-role="check"]')?.style.setProperty('visibility', el.getAttribute('value') === this.#selection ? 'visible' : 'hidden')
671
- }
672
- get #currentTag() {
673
- return this.#slots
674
- ?.get('list')
675
- ?.assignedElements({ flatten: true })
676
- .flatMap((el) => [...(el.matches('option') ? [el] : []), ...el.querySelectorAll('option')])
677
- .find((el) => this.#selection === extractTagFromOption(el));
678
- }
679
- get #currentValueLabel() {
680
- const cvl = this.#currentTag ? extractCurrentValueFromOption(this.#currentTag) : '';
681
- return (this.getAttribute('current-value-label') ?? '').replaceAll('{{selection}}', this.#selection).replaceAll('{{currentValueLabel}}', this.#selection) || cvl || this.#selection;
682
871
  }
683
- #reflectCurrentValueLabel() {
872
+ // #reflectSelectionOnButtons() {
873
+ // if (devFlags.debug) console.debug(`${PickerView.name} #reflectSelectionOnButtons`)
874
+ // const syncButtons = (root: Element | HTMLElement) => {
875
+ // // 1. Sync plain value buttons (existing behavior)
876
+ // for (const el of root.querySelectorAll<HTMLButtonElement>('button[value]:not([slot])'))
877
+ // el.querySelector<HTMLElement>('label-view[data-role="check"]')?.style.setProperty('visibility', el.getAttribute('value') === this.#selection ? 'visible' : 'hidden')
878
+ // // 2. Sync `details` (optgroups) — open/mark if any descendant option matches selection
879
+ // for (const details of root.querySelectorAll<HTMLElement>('details[is="disclosure-group"]')) {
880
+ // // ORRR details.matches(':has(> button[value]:not([slot]) label-view[data-role="check"][style*=visible])'))
881
+ // const hasSelectedDescendant = [...details.querySelectorAll<HTMLButtonElement>('button[value]')].some((btn) => btn.getAttribute('value') === this.#selection)
882
+ // // Show/hide the check on the summary's label-view
883
+ // details.querySelector<HTMLElement>(':scope>summary label-view[data-role="check"]')?.style.setProperty('visibility', hasSelectedDescendant ? 'visible' : 'hidden')
884
+ // }
885
+ // // 3. Sync nav-link buttons (those without `value` that spawn sub-pages)
886
+ // // These wrap <datalist> children — mark them if any descendant value matches
887
+ // const navLinkBtns = root.querySelectorAll<HTMLButtonElement>('button[navigation-link]:not([value])')
888
+ // navLinkBtns.forEach((btn, i) => {
889
+ // const hasSelectedDescendant = this.#lastRenderedGroupMap[i]?.includes(this.#selection) ?? false
890
+ // btn.querySelector<HTMLElement>('label-view[data-role="check"]')?.style.setProperty('visibility', hasSelectedDescendant ? 'visible' : 'hidden')
891
+ // })
892
+ // }
893
+ // // Run on the host element (inline/menu styles)
894
+ // syncButtons(this)
895
+ // // Also sync the spawn if open (sheet/navigation-link styles)
896
+ // if (this.#spawn) syncButtons(this.#spawn)
897
+ // // // walk all rendered buttons (inline has buttons in list, menu has buttons in menu-view)
898
+ // // for (const el of this.querySelectorAll<HTMLButtonElement>('button[value]:not([slot])'))
899
+ // // el.querySelector<HTMLElement>('label-view[data-role="check"]')?.style.setProperty('visibility', el.getAttribute('value') === this.#selection ? 'visible' : 'hidden')
900
+ // // // also sync the spawn (sheet/navigation) if open
901
+ // // if (this.#spawn)
902
+ // // for (const el of this.#spawn.querySelectorAll<HTMLButtonElement>('list-view button[value]:not([slot])'))
903
+ // // el.querySelector<HTMLElement>('label-view[data-role="check"]')?.style.setProperty('visibility', el.getAttribute('value') === this.#selection ? 'visible' : 'hidden')
904
+ // }
905
+ /**
906
+ * Overwrite cvlabel with the label prop of the current(find[value === #selection]) option/dictentry
907
+ */
908
+ #reflectSelectionOnCurrentValueLabel() {
684
909
  if (devFlags.debug)
685
- console.debug(`${_a.name} #reflectCurrentValueLabel`);
910
+ console.debug(`${_a.name} #reflectSelectionOnCurrentValueLabel`);
686
911
  switch (this.pickerStyle) {
687
912
  case 'sheet':
688
913
  case 'navigation-link': {
@@ -784,7 +1009,7 @@ export class PickerView extends FormAssociatedBase {
784
1009
  formResetCallback = () => {
785
1010
  this.#selection = '';
786
1011
  this.#reflectSelectionOnButtons();
787
- this.#reflectCurrentValueLabel();
1012
+ this.#reflectSelectionOnCurrentValueLabel();
788
1013
  this.#sendValueToForm();
789
1014
  };
790
1015
  }
@@ -4513,6 +4513,22 @@
4513
4513
  place-self: end;
4514
4514
  grid-column: 1/-1;
4515
4515
  }
4516
+ :where(table-view[preferred-compact-template="title:trailing"] > [is=table-row]) {
4517
+ grid-template-columns: 1fr 1fr;
4518
+ grid-template-rows: repeat(2, auto);
4519
+ grid-template-areas: "title trailing" "subtitle _";
4520
+ }
4521
+ :where(table-view[preferred-compact-template="title:trailing"] > [is=table-row] > :nth-child(-n+2)) {
4522
+ display: grid;
4523
+ }
4524
+ :where(table-view[preferred-compact-template="title:trailing"] > [is=table-row] > :nth-child(1)) {
4525
+ grid-area: title;
4526
+ }
4527
+ :where(table-view[preferred-compact-template="title:trailing"] > [is=table-row] > :nth-child(2)) {
4528
+ grid-area: trailing;
4529
+ place-self: end;
4530
+ grid-column: 1/-1;
4531
+ }
4516
4532
  :where(table-view[preferred-compact-template="*"] > [is=table-row] > :nth-child(n+2)) {
4517
4533
  display: grid;
4518
4534
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftwc/ui",
3
- "version": "0.0.0-dev.55",
3
+ "version": "0.0.0-dev.57",
4
4
  "description": "Elegant SwiftUI-inspired web components for standalone web apps and web extensions.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -255,7 +255,49 @@
255
255
  ]
256
256
  },
257
257
  {
258
- "name": "picker-view"
258
+ "name": "picker-view",
259
+ "description": "",
260
+ "attributes": [
261
+ {
262
+ "name": "picker",
263
+ "description": "Value Type: “menu” | “inline” | “navigation-link” | “sheet” | “automatic”\n\nDescription: style",
264
+ "values": [
265
+ {
266
+ "name": "menu"
267
+ },
268
+ {
269
+ "name": "inline"
270
+ },
271
+ {
272
+ "name": "navigation-link"
273
+ },
274
+ {
275
+ "name": "sheet"
276
+ },
277
+ {
278
+ "name": "automatic"
279
+ }
280
+ ]
281
+ },
282
+ {
283
+ "name": "help",
284
+ "description": "Value Type: “string”\n\nDescription: Adds a help tooltip to the trigger of the picker, if style supports one",
285
+ "values": [
286
+ {
287
+ "name": "string"
288
+ }
289
+ ]
290
+ },
291
+ {
292
+ "name": "dictionary",
293
+ "description": "Value Type: “DictEntry[]”\n\nDescription: Renders all options using this array",
294
+ "values": [
295
+ {
296
+ "name": "DictEntry[]"
297
+ }
298
+ ]
299
+ }
300
+ ]
259
301
  },
260
302
  {
261
303
  "name": "plain-button"