@kubex/zinc 1.1.98 → 1.1.99

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 (56) hide show
  1. package/AGENTS.md +5 -0
  2. package/CLAUDE.md +5 -0
  3. package/dist/custom-elements.json +2930 -2072
  4. package/dist/vscode.html-custom-data.json +198 -59
  5. package/dist/web-types.json +501 -207
  6. package/dist/zn.d.ts +203 -9
  7. package/dist/zn.min.js +343 -297
  8. package/docs/pages/components/background.md +143 -0
  9. package/docs/pages/components/checkbox-group.md +13 -1
  10. package/docs/pages/components/checkbox.md +59 -1
  11. package/docs/pages/components/radio-group.md +12 -0
  12. package/docs/pages/components/radio.md +188 -0
  13. package/docs/pages/components/remarkd-editor.md +22 -0
  14. package/docs/pages/components/scroll-container.md +14 -15
  15. package/package.json +1 -1
  16. package/src/components/background/background.component.ts +111 -0
  17. package/src/components/background/background.scss +236 -0
  18. package/src/components/background/background.test.ts +120 -0
  19. package/src/components/background/index.ts +12 -0
  20. package/src/components/checkbox/checkbox.component.ts +62 -6
  21. package/src/components/checkbox/checkbox.scss +1 -0
  22. package/src/components/checkbox/checkbox.test.ts +56 -0
  23. package/src/components/checkbox-group/checkbox-group.component.ts +15 -0
  24. package/src/components/checkbox-group/checkbox-group.scss +23 -5
  25. package/src/components/checkbox-group/checkbox-group.test.ts +15 -0
  26. package/src/components/confirm/confirm.component.ts +3 -3
  27. package/src/components/confirm/confirm.test.ts +15 -0
  28. package/src/components/dialog/dialog.component.ts +3 -2
  29. package/src/components/editor/editor.component.ts +7 -6
  30. package/src/components/header/header.scss +1 -1
  31. package/src/components/navbar/navbar.scss +11 -0
  32. package/src/components/radio/radio.component.ts +63 -7
  33. package/src/components/radio/radio.scss +1 -0
  34. package/src/components/radio/radio.test.ts +56 -0
  35. package/src/components/radio-group/radio-group.component.ts +16 -3
  36. package/src/components/radio-group/radio-group.scss +23 -4
  37. package/src/components/radio-group/radio-group.test.ts +15 -0
  38. package/src/components/remarkd-editor/remarkd-editor.component.ts +118 -5
  39. package/src/components/remarkd-editor/remarkd-editor.scss +8 -1
  40. package/src/components/remarkd-editor/remarkd-editor.test.ts +96 -0
  41. package/src/components/scroll-container/scroll-container.component.ts +15 -1
  42. package/src/components/scroll-container/scroll-container.scss +4 -2
  43. package/src/components/scroll-container/scroll-container.test.ts +13 -0
  44. package/src/components/slideout/slideout.component.ts +3 -2
  45. package/src/components/thumbnail-group/thumbnail-group.scss +0 -1
  46. package/src/components/tile/tile.scss +4 -2
  47. package/src/components/translation-group/translation-group.test.ts +22 -0
  48. package/src/components/translations/translations.component.ts +18 -3
  49. package/src/form-control.scss +3 -1
  50. package/src/internal/conditional.ts +2 -2
  51. package/src/internal/form.ts +2 -1
  52. package/src/internal/selection-card.ts +19 -0
  53. package/src/selection-card.scss +187 -0
  54. package/src/utilities/query.test.ts +18 -1
  55. package/src/utilities/query.ts +8 -0
  56. package/src/zinc.ts +1 -0
@@ -110,6 +110,8 @@ const SLASH_ITEMS: SlashMenuItem[] = BLOCK_TYPES.map(item => item.image || item.
110
110
  * @csspart image-controls - The caption / alignment / size panel shown when an image block is clicked.
111
111
  * @csspart include - The chip rendered in place of an `include::` directive.
112
112
  * @csspart include-picker - The inline Include picker opened from the toolbar or "/include".
113
+ *
114
+ * @cssproperty --remarkd-editor-max-height - The tallest the editor grows before its body scrolls. Defaults to `100dvh`.
113
115
  */
114
116
  export default class ZnRemarkdEditor extends ZincElement implements ZincFormControl {
115
117
  static styles: CSSResultGroup = unsafeCSS(styles);
@@ -165,6 +167,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
165
167
  private dragStartX = 0;
166
168
  private dragStartY = 0;
167
169
  private dragGhost: HTMLElement | null = null;
170
+ private dragPointerY = 0;
171
+ private autoScrollFrame: number | null = null;
168
172
 
169
173
  /** The name of the control, submitted as part of form data. */
170
174
  @property() name = '';
@@ -440,14 +444,14 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
440
444
  this.updateBlocks(blocks);
441
445
  }
442
446
 
443
- private startEdit(index: number, draft?: string) {
447
+ private startEdit(index: number, draft?: string, align: 'nearest' | 'center' = 'nearest') {
444
448
  if (this.disabled || this.readonly) return;
445
449
  this.editingDraft = draft ?? this.blocks[index] ?? '';
446
450
  this.editingIndex = index;
447
451
  this.imageEdit = null;
448
452
  this.editShell = this.computeEditShell(this.editingDraft);
449
453
  this.slashController.close();
450
- void this.focusInput();
454
+ void this.focusInput(align);
451
455
  }
452
456
 
453
457
  private updateImageEdit(patch: Partial<ImageBlockData>) {
@@ -507,24 +511,83 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
507
511
  return '';
508
512
  }
509
513
 
510
- private async focusInput() {
514
+ private async focusInput(align: 'nearest' | 'center' = 'nearest') {
511
515
  await this.updateComplete;
512
516
  const input = this.shadowRoot?.querySelector<HTMLTextAreaElement>('.remarkd-editor__input');
513
517
  if (input) {
514
518
  // Each edit renders its own textarea, so the slash menu is re-pointed at it.
515
519
  this.slashController.attach(input);
516
520
  this.autosize(input);
517
- input.focus();
521
+ // preventScroll because the browser's own focus scroll walks every scrollable
522
+ // ancestor — including the surrounding page panel. reveal() stays in the editor.
523
+ input.focus({preventScroll: true});
518
524
  input.setSelectionRange(input.value.length, input.value.length);
525
+ this.reveal(input, align);
519
526
  }
520
527
  this.suppressBlurCommit = false;
521
528
  }
522
529
 
530
+ /**
531
+ * Scrolls the editor's own body to bring `el` into view. Never `scrollIntoView()` and
532
+ * never `focus()` without preventScroll: both walk every scrollable ancestor, and an
533
+ * `overflow: hidden`/`auto` panel around the editor is scrollable too — that shifts a
534
+ * container the user never asked to move.
535
+ */
536
+ private reveal(el: Element | null | undefined, align: 'nearest' | 'center' = 'nearest') {
537
+ const body = this.shadowRoot?.querySelector('.remarkd-editor__body');
538
+ if (!el || !body) return;
539
+
540
+ const target = el.getBoundingClientRect();
541
+ const view = body.getBoundingClientRect();
542
+
543
+ // scrollTop clamps itself, so an overshoot at either end lands at the limit.
544
+ if (align === 'center') {
545
+ // Taller than the view — a large image, say — can't be centred without hiding its
546
+ // top, so anchor the top and let the rest run off the bottom.
547
+ body.scrollTop += target.height > view.height
548
+ ? target.top - view.top
549
+ : (target.top + target.height / 2) - (view.top + view.height / 2);
550
+ } else if (target.top < view.top) {
551
+ body.scrollTop += target.top - view.top;
552
+ } else if (target.bottom > view.bottom) {
553
+ body.scrollTop += target.bottom - view.bottom;
554
+ }
555
+ }
556
+
557
+ private async revealAfterUpdate(selector: string, align: 'nearest' | 'center' = 'nearest') {
558
+ await this.updateComplete;
559
+ this.reveal(this.shadowRoot?.querySelector(selector), align);
560
+ }
561
+
562
+ /**
563
+ * Centres a block added without opening an editor. An image has no height until it
564
+ * loads, so the first pass scrolls against a layout that is still short — the second
565
+ * pass corrects it once the real dimensions are in.
566
+ */
567
+ private async revealBlock(index: number) {
568
+ await this.updateComplete;
569
+ const blockAt = () => this.shadowRoot?.querySelectorAll('.remarkd-editor__block')[index];
570
+ const block = blockAt();
571
+ if (!block) return;
572
+ this.reveal(block, 'center');
573
+
574
+ const loading = [...block.querySelectorAll('img')].filter(img => !img.complete);
575
+ if (!loading.length) return;
576
+
577
+ await Promise.all(loading.map(img => new Promise<void>(resolve => {
578
+ img.addEventListener('load', () => resolve(), {once: true});
579
+ img.addEventListener('error', () => resolve(), {once: true});
580
+ })));
581
+ await new Promise(requestAnimationFrame);
582
+ this.reveal(blockAt(), 'center');
583
+ }
584
+
523
585
  private addBlockAt(index: number, content: string) {
524
586
  if (this.disabled || this.readonly) return;
525
587
  const blocks = [...this.blocks];
526
588
  blocks.splice(index, 0, content);
527
589
  this.updateBlocks(blocks);
590
+ void this.revealBlock(index);
528
591
  }
529
592
 
530
593
  private deleteBlock(index: number) {
@@ -540,7 +603,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
540
603
  const blocks = [...this.blocks];
541
604
  blocks.splice(index, 0, '');
542
605
  this.blocks = blocks;
543
- this.startEdit(index, prefill);
606
+ this.startEdit(index, prefill, 'center');
544
607
  }
545
608
 
546
609
  /**
@@ -714,10 +777,56 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
714
777
  document.body.style.cursor = 'grabbing';
715
778
  }
716
779
 
780
+ this.dragPointerY = e.clientY;
717
781
  this.moveDragGhost(e.clientX, e.clientY);
718
782
  this.dropIndicator = this.insertionIndexFromY(e.clientY);
783
+ this.autoScrollFrame ??= requestAnimationFrame(this.stepAutoScroll);
719
784
  };
720
785
 
786
+ /**
787
+ * Holding the pointer near a scroll edge keeps the content moving, so a block can
788
+ * be dragged past the visible slice of a long document. Runs per frame rather than
789
+ * per pointermove — a pointer parked at the edge stops emitting moves.
790
+ */
791
+ private stepAutoScroll = () => {
792
+ this.autoScrollFrame = null;
793
+ if (this.dragIndex === null || !this.autoScroll()) return;
794
+ // The content moved under a possibly stationary pointer, so the drop target shifts.
795
+ this.dropIndicator = this.insertionIndexFromY(this.dragPointerY);
796
+ this.autoScrollFrame = requestAnimationFrame(this.stepAutoScroll);
797
+ };
798
+
799
+ /** Scrolls the editor body, falling through to the page once the body is at its limit. */
800
+ private autoScroll(): boolean {
801
+ const body = this.shadowRoot?.querySelector('.remarkd-editor__body');
802
+ if (body) {
803
+ const {top, bottom} = body.getBoundingClientRect();
804
+ const delta = this.edgeScrollDelta(top, bottom);
805
+ const before = body.scrollTop;
806
+ if (delta) {
807
+ body.scrollTop += delta;
808
+ if (body.scrollTop !== before) return true;
809
+ }
810
+ }
811
+
812
+ const pageDelta = this.edgeScrollDelta(0, window.innerHeight);
813
+ if (!pageDelta) return false;
814
+ const before = window.scrollY;
815
+ window.scrollBy(0, pageDelta);
816
+ return window.scrollY !== before;
817
+ }
818
+
819
+ /** Scroll step for this frame: nothing until the pointer is within `edge` of a boundary. */
820
+ private edgeScrollDelta(top: number, bottom: number): number {
821
+ const edge = 56;
822
+ const max = 18;
823
+ const ramp = (depth: number) => Math.ceil((Math.min(depth, edge) / edge) * max);
824
+
825
+ if (this.dragPointerY < top + edge) return -ramp(top + edge - this.dragPointerY);
826
+ if (this.dragPointerY > bottom - edge) return ramp(this.dragPointerY - (bottom - edge));
827
+ return 0;
828
+ }
829
+
721
830
  private handleDragPointerUp = (e: PointerEvent) => {
722
831
  const from = this.dragIndex;
723
832
  let to = from !== null ? (this.dropIndicator ?? this.insertionIndexFromY(e.clientY)) : null;
@@ -732,6 +841,8 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
732
841
 
733
842
  private cancelDrag = () => {
734
843
  this.pendingDragHandle = null;
844
+ if (this.autoScrollFrame !== null) cancelAnimationFrame(this.autoScrollFrame);
845
+ this.autoScrollFrame = null;
735
846
  document.removeEventListener('pointermove', this.handleDragPointerMove);
736
847
  document.removeEventListener('pointerup', this.handleDragPointerUp);
737
848
  document.removeEventListener('pointercancel', this.cancelDrag);
@@ -761,6 +872,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
761
872
  if (this.disabled || this.readonly) return;
762
873
  this.includePickerIndex = null;
763
874
  this.imagePickerIndex = index;
875
+ void this.revealAfterUpdate('.remarkd-editor__image-picker', 'center');
764
876
  }
765
877
 
766
878
  private pickInclude(index: number) {
@@ -769,6 +881,7 @@ export default class ZnRemarkdEditor extends ZincElement implements ZincFormCont
769
881
  this.includeQuery = '';
770
882
  this.includePickerIndex = index;
771
883
  void this.loadIncludeOptions();
884
+ void this.revealAfterUpdate('.remarkd-editor__include-picker', 'center');
772
885
  }
773
886
 
774
887
  private closeIncludePicker = () => {
@@ -4,10 +4,16 @@
4
4
 
5
5
  :host {
6
6
  display: block;
7
+ overflow: hidden;
7
8
  }
8
9
 
9
10
  .remarkd-editor {
10
11
  position: relative;
12
+ display: flex;
13
+ flex-direction: column;
14
+ // A long document scrolls the body instead of growing the page past one screenful,
15
+ // which also keeps the toolbar in reach.
16
+ max-height: var(--remarkd-editor-max-height, 100dvh);
11
17
  border: 1px solid rgb(var(--zn-border-color));
12
18
  border-radius: var(--zn-border-radius);
13
19
  background-color: rgba(var(--zn-panel), var(--zn-panel-opacity));
@@ -15,6 +21,7 @@
15
21
 
16
22
  .remarkd-editor__toolbar {
17
23
  display: flex;
24
+ flex: none;
18
25
  gap: 2px;
19
26
  flex-wrap: wrap;
20
27
  padding: var(--zn-spacing-x-small);
@@ -24,6 +31,7 @@
24
31
  .remarkd-editor__body {
25
32
  padding: var(--zn-spacing-large) var(--zn-spacing-large) var(--zn-spacing-large) 52px;
26
33
  min-height: 10rem;
34
+ overflow-y: auto;
27
35
  }
28
36
 
29
37
  // Raw mode has no gutter handles, so it reclaims the left padding.
@@ -92,7 +100,6 @@
92
100
  }
93
101
 
94
102
 
95
-
96
103
  .remarkd-editor__drag-handle {
97
104
  display: inline-flex;
98
105
  align-items: center;
@@ -118,6 +118,102 @@ Second"></zn-remarkd-editor>`);
118
118
  await expectNamedIconButtons(el, el.shadowRoot!.querySelector('.remarkd-editor__image-controls')!);
119
119
  });
120
120
 
121
+ /** A document tall enough to overflow the editor's max height. */
122
+ const longValue = Array.from({length: 60}, (_, i) => `Paragraph ${i}`).join('\n\n');
123
+
124
+ it('should not grow taller than the viewport', async () => {
125
+ const el = await fixture<ZnRemarkdEditor>(html`
126
+ <zn-remarkd-editor value=${longValue}></zn-remarkd-editor>`);
127
+ expect(el.getBoundingClientRect().height).to.be.at.most(window.innerHeight + 1);
128
+ });
129
+
130
+ /** Adds a block below the middle block via its gutter button. */
131
+ async function addBlockMidDocument(el: ZnRemarkdEditor) {
132
+ const blocks = el.shadowRoot!.querySelectorAll('.remarkd-editor__block');
133
+ blocks[Math.floor(blocks.length / 2)].querySelector('.remarkd-editor__actions zn-button')!
134
+ .dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true, cancelable: true}));
135
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__input'), 'no block opened');
136
+ }
137
+
138
+ it('should centre a newly added block in its own body', async () => {
139
+ const el = await fixture<ZnRemarkdEditor>(html`
140
+ <zn-remarkd-editor value=${longValue}></zn-remarkd-editor>`);
141
+ const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!;
142
+ await addBlockMidDocument(el);
143
+
144
+ const bodyBox = body.getBoundingClientRect();
145
+ const inputBox = el.shadowRoot!.querySelector('.remarkd-editor__input')!.getBoundingClientRect();
146
+ expect(body.scrollTop, 'the body never scrolled').to.be.greaterThan(0);
147
+ expect(Math.abs((inputBox.top - bodyBox.top) - (bodyBox.bottom - inputBox.bottom)),
148
+ 'the new block is not centred').to.be.lessThan(60);
149
+ });
150
+
151
+ it('should not scroll an enclosing panel when a block is added', async () => {
152
+ // An `overflow: hidden` panel has no scrollbar but is still scrollable in code, so
153
+ // scrollIntoView() or a plain focus() would shift it with no way to scroll back.
154
+ const panel = await fixture<HTMLDivElement>(html`
155
+ <div style="height: 200px; overflow: hidden">
156
+ <zn-remarkd-editor value=${longValue}></zn-remarkd-editor>
157
+ <div style="height: 900px"></div>
158
+ </div>`);
159
+ const el = panel.querySelector<ZnRemarkdEditor>('zn-remarkd-editor')!;
160
+ await el.updateComplete;
161
+ const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!;
162
+ await addBlockMidDocument(el);
163
+
164
+ expect(body.scrollTop, 'the editor body should still scroll').to.be.greaterThan(0);
165
+ expect(panel.scrollTop, 'the enclosing panel was scrolled').to.equal(0);
166
+ });
167
+
168
+ it('should not scroll an enclosing scroller when an image block is added', async () => {
169
+ const panel = await fixture<HTMLDivElement>(html`
170
+ <div style="height: 200px; overflow: auto">
171
+ <zn-remarkd-editor value=${longValue}></zn-remarkd-editor>
172
+ <div style="height: 900px"></div>
173
+ </div>`);
174
+ const el = panel.querySelector<ZnRemarkdEditor>('zn-remarkd-editor')!;
175
+ await el.updateComplete;
176
+ const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!;
177
+ expect(panel.scrollHeight - panel.clientHeight, 'the panel needs room to scroll')
178
+ .to.be.greaterThan(0);
179
+
180
+ // A 1x1 gif: an image block whose height only lands once it loads.
181
+ const src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
182
+ el.value = `${longValue}\n\nimage::${src}[pixel]`;
183
+ await el.updateComplete;
184
+ await waitUntil(() => el.shadowRoot!.querySelector('.remarkd-editor__block img'), 'no image block');
185
+
186
+ expect(panel.scrollTop, 'the enclosing scroller was scrolled').to.equal(0);
187
+ expect(body.scrollTop, 'the body should be scrollable').to.be.at.least(0);
188
+ });
189
+
190
+ it('should keep scrolling while a drag is held at the edge', async () => {
191
+ const el = await fixture<ZnRemarkdEditor>(html`
192
+ <zn-remarkd-editor value=${longValue}></zn-remarkd-editor>`);
193
+ const body = el.shadowRoot!.querySelector('.remarkd-editor__body')!;
194
+ const handle = el.shadowRoot!.querySelectorAll('.remarkd-editor__block')[1]
195
+ .querySelector('.remarkd-editor__drag-handle')!;
196
+ const from = handle.getBoundingClientRect();
197
+ const drag = (type: string, clientY: number, target: EventTarget, buttons = 1) =>
198
+ target.dispatchEvent(new PointerEvent(type, {
199
+ bubbles: true, composed: true, cancelable: true,
200
+ clientX: Math.round(from.left + 5), clientY, button: 0, buttons,
201
+ }));
202
+
203
+ drag('pointerdown', Math.round(from.top + 5), handle);
204
+ drag('pointermove', Math.round(from.top + 20), document);
205
+ expect(el.shadowRoot!.querySelector('.remarkd-editor__ghost'), 'the drag never started').to.exist;
206
+
207
+ // Park the pointer in the bottom edge zone — the frame loop scrolls without further moves.
208
+ drag('pointermove', Math.round(body.getBoundingClientRect().bottom - 20), document);
209
+ await waitUntil(() => body.scrollTop > 0, 'a drag held at the edge never scrolled');
210
+
211
+ drag('pointercancel', 0, document, 0);
212
+ const settled = body.scrollTop;
213
+ await new Promise(resolve => setTimeout(resolve, 100));
214
+ expect(body.scrollTop, 'scrolling continued after the drag ended').to.equal(settled);
215
+ });
216
+
121
217
  it('should open zn-slash-menu with the block types when "/" starts an empty block', async () => {
122
218
  const el = await fixture<ZnRemarkdEditor>(html`
123
219
  <zn-remarkd-editor value="Hello"></zn-remarkd-editor>`);
@@ -21,11 +21,14 @@ import styles from './scroll-container.scss';
21
21
  *
22
22
  * @csspart base - The component's base wrapper.
23
23
  *
24
- * @cssproperty --example - An example CSS custom property.
24
+ * @cssproperty --zn-scroll-container-height - The height and maximum height of the scroll container.
25
25
  */
26
26
  export default class ZnScrollContainer extends ZincElement {
27
27
  static styles: CSSResultGroup = unsafeCSS(styles);
28
28
 
29
+ /** The height and maximum height of the scroll container. */
30
+ @property({type: String}) height = '';
31
+
29
32
  @property({attribute: 'start-scrolled', type: Boolean, reflect: true}) startScrolled: boolean = false;
30
33
 
31
34
  @query('.scroll-container')
@@ -44,6 +47,17 @@ export default class ZnScrollContainer extends ZincElement {
44
47
  }
45
48
  }
46
49
 
50
+ protected updated(changedProperties: PropertyValues) {
51
+ super.updated(changedProperties);
52
+ if (changedProperties.has('height')) {
53
+ if (this.height) {
54
+ this.style.setProperty('--zn-scroll-container-height-property', this.height);
55
+ } else {
56
+ this.style.removeProperty('--zn-scroll-container-height-property');
57
+ }
58
+ }
59
+ }
60
+
47
61
  disconnectedCallback() {
48
62
  super.disconnectedCallback();
49
63
  if (this._footerHeightFrame !== null) {
@@ -2,8 +2,10 @@
2
2
 
3
3
  :host {
4
4
  @include wc.scrollbars;
5
-
6
- display: contents;
5
+
6
+ display: block;
7
+ height: var(--zn-scroll-container-height-property, var(--zn-scroll-container-height, 100%));
8
+ max-height: var(--zn-scroll-container-height-property, var(--zn-scroll-container-height, 100%));
7
9
  --zn-scroll-footer-height: 0px;
8
10
  --zn-scroll-header-height: 0px;
9
11
  }
@@ -7,4 +7,17 @@ describe('<zn-scroll-container>', () => {
7
7
 
8
8
  expect(el).to.exist;
9
9
  });
10
+
11
+ it('should render as a block-level container', async () => {
12
+ const el = await fixture(html` <zn-scroll-container></zn-scroll-container> `);
13
+
14
+ expect(getComputedStyle(el).display).to.equal('block');
15
+ });
16
+
17
+ it('should apply height as the height and maximum height', async () => {
18
+ const el = await fixture(html` <zn-scroll-container height="240px"></zn-scroll-container> `);
19
+
20
+ expect(getComputedStyle(el).height).to.equal('240px');
21
+ expect(getComputedStyle(el).maxHeight).to.equal('240px');
22
+ });
10
23
  });
@@ -4,6 +4,7 @@ import {type CSSResultGroup, html, type PropertyValues, unsafeCSS} from 'lit';
4
4
  import {LocalizeController} from "../../utilities/localize";
5
5
  import {property, query} from 'lit/decorators.js';
6
6
  import {getAnimation, setDefaultAnimation} from "../../utilities/animation-registry";
7
+ import {idSelector} from "../../utilities/query";
7
8
  import {unlockBodyScrolling} from "../../internal/scroll";
8
9
  import ZincElement from '../../internal/zinc-element';
9
10
  import ZnButton from "../button";
@@ -83,7 +84,7 @@ export default class ZnSlideout extends ZincElement {
83
84
  this.shadowRoot?.addEventListener('click', this.closeClickHandler);
84
85
 
85
86
  if (this.trigger) {
86
- const trigger = this.parentNode?.querySelector('#' + this.trigger);
87
+ const trigger = this.parentNode?.querySelector(idSelector(this.trigger));
87
88
  if (trigger) {
88
89
  trigger.addEventListener('click', () => this.show());
89
90
  }
@@ -98,7 +99,7 @@ export default class ZnSlideout extends ZincElement {
98
99
  this.removeEventListener('click', this.closeClickHandler);
99
100
 
100
101
  if (this.trigger) {
101
- const trigger = this.parentElement?.querySelector('#' + this.trigger);
102
+ const trigger = this.parentElement?.querySelector(idSelector(this.trigger));
102
103
  if (trigger) {
103
104
  trigger.removeEventListener('click', () => this.show());
104
105
  }
@@ -19,7 +19,6 @@
19
19
  @include wc.text-style(h1);
20
20
 
21
21
  min-width: 0;
22
- overflow: hidden;
23
22
  text-overflow: ellipsis;
24
23
  white-space: nowrap;
25
24
  }
@@ -10,6 +10,7 @@
10
10
  align-items: center;
11
11
  justify-content: space-between;
12
12
  min-height: 48px;
13
+ padding: var(--zn-base-gap);
13
14
  column-gap: var(--zn-spacing-large);
14
15
 
15
16
  font-size: var(--zn-text-table-content-font-size);
@@ -23,14 +24,15 @@
23
24
  &--has-properties,
24
25
  &--has-actions {
25
26
  padding-inline: var(--zn-base-gap);
27
+ min-height: 85px;
26
28
  }
27
29
 
28
30
  &--has-description#{&}--has-caption {
29
- padding-block: var(--zn-spacing-small);
31
+ padding-block: var(--zn-base-gap);
30
32
  }
31
33
 
32
34
  &--has-image {
33
- padding: var(--zn-spacing-small);
35
+ padding: var(--zn-base-gap);
34
36
  }
35
37
 
36
38
  &--has-href {
@@ -0,0 +1,22 @@
1
+ import '../../../dist/zn.min.js';
2
+ import { expect, fixture, html } from '@open-wc/testing';
3
+ import type ZnInlineEdit from '../inline-edit/inline-edit.component';
4
+ import type ZnTranslations from '../translations/translations.component';
5
+
6
+ describe('<zn-translation-group>', () => {
7
+ // The group syncs its children from its own first update, which runs before theirs, so a child's
8
+ // pending value attribute must survive the language keys the group adds.
9
+ it("keeps a child's rendered value when it syncs the group language", async () => {
10
+ const group = await fixture(html`
11
+ <zn-translation-group label="Text" .languages=${{en: 'English', fr: 'French'}}>
12
+ <zn-translations name="heading" value='{"en":"Chat to us"}'></zn-translations>
13
+ </zn-translation-group>`);
14
+
15
+ const child = group.querySelector<ZnTranslations>('zn-translations')!;
16
+ await child.updateComplete;
17
+
18
+ expect(child.values).to.deep.equal({en: 'Chat to us'});
19
+ const input = child.shadowRoot!.querySelector<ZnInlineEdit>('zn-inline-edit')!;
20
+ expect(input.value).to.equal('Chat to us');
21
+ });
22
+ });
@@ -150,15 +150,30 @@ export default class ZnTranslations extends ZincElement implements ZincFormContr
150
150
 
151
151
  /** Adds a language key to this component's values if not already present. Used by zn-translation-group. */
152
152
  public addLanguageKey(languageCode: string) {
153
- if (!Object.prototype.hasOwnProperty.call(this.values, languageCode)) {
154
- this.values = {...this.values, [languageCode]: ''};
153
+ const values = this.pendingValues();
154
+ if (!Object.prototype.hasOwnProperty.call(values, languageCode)) {
155
+ this.values = {...values, [languageCode]: ''};
155
156
  this.updateValue();
156
157
  }
157
158
  }
158
159
 
159
160
  /** Returns all language codes that have values. */
160
161
  public getValueLanguages(): string[] {
161
- return Object.keys(this.values);
162
+ return Object.keys(this.pendingValues());
163
+ }
164
+
165
+ /**
166
+ * `values`, falling back to the `value` attribute it is built from while that is still pending. A parent
167
+ * zn-translation-group syncs its children from its own first update, which runs before theirs, so reading
168
+ * `values` alone would see it empty and overwrite the value the server rendered.
169
+ */
170
+ private pendingValues(): Record<string, string> {
171
+ if (Object.keys(this.values).length > 0) return this.values;
172
+ try {
173
+ return JSON.parse(this.value || '{}') as Record<string, string>;
174
+ } catch {
175
+ return this.values;
176
+ }
162
177
  }
163
178
 
164
179
  disconnectedCallback() {
@@ -123,6 +123,8 @@
123
123
  margin-top: var(--zn-spacing-3x-small);
124
124
  }
125
125
 
126
- .form-control--checkbox-contained-wrapper {
126
+ /* Let contained radios/checkboxes fill their grid row so cards in a row match height. */
127
+ .form-control--checkbox-contained-wrapper,
128
+ .form-control--radio-contained-wrapper {
127
129
  height: 100%;
128
130
  }
@@ -1,4 +1,4 @@
1
- import {deepQuerySelectorAll} from '../utilities/query';
1
+ import {deepQuerySelectorAll, idSelector} from '../utilities/query';
2
2
  import type {ReactiveController, ReactiveControllerHost} from 'lit';
3
3
 
4
4
  export interface ConditionalHost {
@@ -26,7 +26,7 @@ export class ConditionalController implements ReactiveController {
26
26
  const ids = this.host.conditional.split(',').map(id => id.trim());
27
27
 
28
28
  ids.forEach(id => {
29
- const byId = deepQuerySelectorAll(`#${id}`, document.documentElement, '') as (Element & {
29
+ const byId = deepQuerySelectorAll(idSelector(id), document.documentElement, '') as (Element & {
30
30
  value: string | string[];
31
31
  })[];
32
32
  const byName = deepQuerySelectorAll(`[name="${id}"]`, document.documentElement, '') as (Element & {
@@ -1,4 +1,5 @@
1
1
  import {getFormNavigationController} from "./form-navigation";
2
+ import {idSelector} from "../utilities/query";
2
3
  import {Store} from "./storage";
3
4
  import type {ReactiveController, ReactiveControllerHost} from "lit";
4
5
  import type {ZincFormControl} from "./zinc-element";
@@ -256,7 +257,7 @@ export class FormControlController implements ReactiveController {
256
257
 
257
258
  if (formId) {
258
259
  const root = input.getRootNode() as Document | ShadowRoot | HTMLElement;
259
- const form = root.querySelector(`#${formId}`);
260
+ const form = root.querySelector(idSelector(formId));
260
261
 
261
262
  if (form) {
262
263
  return form as HTMLFormElement;
@@ -0,0 +1,19 @@
1
+ /** Where a selection card renders its image, relative to the card's text. */
2
+ export type SelectionCardImagePosition = 'top' | 'right' | 'bottom' | 'left';
3
+
4
+ /**
5
+ * Where a selection card renders its radio/checkbox indicator. `start` keeps the standard
6
+ * inline position; `none` hides the indicator so the card itself shows the selected state.
7
+ */
8
+ export type SelectionCardControlPosition =
9
+ | 'start'
10
+ | 'none'
11
+ | 'top-left'
12
+ | 'top-center'
13
+ | 'top-right'
14
+ | 'center-left'
15
+ | 'center'
16
+ | 'center-right'
17
+ | 'bottom-left'
18
+ | 'bottom-center'
19
+ | 'bottom-right';