@kubex/zinc 1.1.115 → 1.1.117

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,11 +1,17 @@
1
1
  ---
2
2
  meta:
3
3
  title: Translations
4
- description: A component for managing multi-language text input with a tabbed interface.
4
+ description: A component for managing multi-language text input behind a language select.
5
5
  layout: component
6
6
  ---
7
7
 
8
- The Translations component provides a user-friendly interface for managing multi-language text input. It features a tabbed navigation bar for switching between languages and supports inline editing of translations.
8
+ The Translations component provides a user-friendly interface for managing multi-language text input. A select above
9
+ the field chooses the language being edited, and each language it offers carries a chip saying whether it has a
10
+ translation of its own or falls back to English; closed, it carries how many languages are done as a `1/5` chip.
11
+ The field itself is a plain input, or a textarea with `input-type="textarea"`.
12
+
13
+ Several fields that should share one language picker belong in a
14
+ [`zn-translation-group`](/components/translation-group) instead.
9
15
 
10
16
  ```html:preview
11
17
  <zn-translations
@@ -48,58 +54,25 @@ Use the `label` slot for rich HTML content in the label.
48
54
  </zn-translations>
49
55
  ```
50
56
 
51
- ### Custom Languages
57
+ ### Setting Values
52
58
 
53
- Provide a custom list of available languages via the `languages` property. Languages are displayed as tabs with their full names.
59
+ `values` takes an object keyed by language code. `value` is the same thing as a JSON string, for setting it from
60
+ markup or from a server-rendered template — set one or the other, not both.
54
61
 
55
62
  ```html:preview
56
63
  <zn-translations
57
- label="Multi-Language Content"
58
- languages='{"en":"English","fr":"French","de":"German","es":"Spanish","it":"Italian","pt":"Portuguese"}'
59
- ></zn-translations>
60
- ```
61
-
62
- ### Pre-filled Values
63
-
64
- Set initial translations using the `values` property.
65
-
66
- ```html:preview
67
- <zn-translations
68
- label="Welcome Message"
64
+ label="Set with values"
69
65
  languages='{"en":"English","fr":"French","es":"Spanish"}'
70
66
  values='{"en":"Hello World","fr":"Bonjour le monde","es":"Hola Mundo"}'
71
67
  ></zn-translations>
72
- ```
73
-
74
- ### JSON Value Attribute
75
-
76
- Alternatively, use the `value` attribute with a JSON string.
77
-
78
- ```html:preview
68
+ <br />
79
69
  <zn-translations
80
- label="Description"
70
+ label="Set with value"
81
71
  languages='{"en":"English","fr":"French"}'
82
72
  value='{"en":"This is a product description","fr":"Ceci est une description de produit"}'
83
73
  ></zn-translations>
84
74
  ```
85
75
 
86
- ### Required Field
87
-
88
- Mark the translations as required for form validation.
89
-
90
- ```html:preview
91
- <form>
92
- <zn-translations
93
- name="description"
94
- label="Product Description"
95
- required
96
- languages='{"en":"English","fr":"French"}'
97
- ></zn-translations>
98
- <br />
99
- <zn-button type="submit" color="success">Submit</zn-button>
100
- </form>
101
- ```
102
-
103
76
  ### Disabled State
104
77
 
105
78
  Disable editing of translations.
@@ -125,24 +98,38 @@ Remove padding for a more compact appearance.
125
98
  ></zn-translations>
126
99
  ```
127
100
 
128
- ### With Custom Action Button
101
+ ### Textarea
129
102
 
130
- Use the `expand` slot to add custom buttons or actions.
103
+ Use `input-type="textarea"` for longer copy, and `textarea-rows` to set its height.
131
104
 
132
105
  ```html:preview
133
106
  <zn-translations
134
- label="Description"
135
- languages='{"en":"English","fr":"French","de":"German"}'>
136
- <zn-button slot="expand" color="transparent" icon="translate">
137
- Auto-Translate
138
- </zn-button>
139
- </zn-translations>
107
+ label="Confirmation Message"
108
+ input-type="textarea"
109
+ textarea-rows="3"
110
+ languages='{"en":"English","de":"German"}'
111
+ values='{"en":"Thanks — your order is on its way.","de":"Danke — Ihre Bestellung ist unterwegs."}'
112
+ ></zn-translations>
113
+ ```
114
+
115
+ ### Inline Editing
116
+
117
+ Add `inline-edit` to read the translation as text until it is clicked, through
118
+ [`zn-inline-edit`](/components/inline-edit), rather than showing an input outright.
119
+
120
+ ```html:preview
121
+ <zn-translations
122
+ inline-edit
123
+ label="Welcome Message"
124
+ languages='{"en":"English","fr":"French"}'
125
+ values='{"en":"Hello World","fr":"Bonjour le monde"}'
126
+ ></zn-translations>
140
127
  ```
141
128
 
142
129
  ### Many Languages
143
130
 
144
- When the language buttons don't all fit on one line, the overflow collapses into a chevron dropdown so the row stays a
145
- single line. Resizing the container re-measures and expands the buttons back out when space allows.
131
+ Each language becomes an option labelled `Name (CODE)` — or the code alone where the configured name already is the
132
+ code. The select takes any number of them, and its listbox scrolls once the list is longer than the space below it.
146
133
 
147
134
  ```html:preview
148
135
  <zn-translations
@@ -152,19 +139,17 @@ single line. Resizing the container re-measures and expands the buttons back out
152
139
  ></zn-translations>
153
140
  ```
154
141
 
155
- ### Constrained Width
142
+ ### Blank Languages Fall Back to English
156
143
 
157
- Placing the component in a narrow container forces the chevron overflow to kick in. Selecting a language from the
158
- dropdown switches the active language just like clicking a visible button.
144
+ A language you have not translated yet is marked `English` in the select rather than hidden, and its field shows the
145
+ English text as a placeholder. Leave it blank and the English text is what gets used.
159
146
 
160
147
  ```html:preview
161
- <div style="max-width: 320px;">
162
- <zn-translations
163
- label="Narrow Container"
164
- languages='{"en":"English","fr":"French","de":"German","es":"Spanish","it":"Italian","pt":"Portuguese","ja":"Japanese","ko":"Korean","zh-hans":"Simplified Chinese"}'
165
- values='{"en":"Hello","fr":"Bonjour","de":"Hallo","es":"Hola","it":"Ciao","pt":"Olá","ja":"こんにちは","ko":"안녕하세요","zh-hans":"你好"}'
166
- ></zn-translations>
167
- </div>
148
+ <zn-translations
149
+ label="Product Name"
150
+ languages='{"en":"English","fr":"French","de":"German","pl":"Polish"}'
151
+ values='{"en":"Premium Wireless Headphones","de":"Premium kabellose Kopfhörer"}'
152
+ ></zn-translations>
168
153
  ```
169
154
 
170
155
  ### RTL Language Support
@@ -209,7 +194,8 @@ The component emits `zn-change` events when translation values change.
209
194
 
210
195
  ### Form Integration
211
196
 
212
- Use with standard HTML forms for submission.
197
+ The component submits its translations as a JSON object under `name`. `required` marks the label, though validity is
198
+ not enforced per language.
213
199
 
214
200
  ```html:preview
215
201
  <form class="translations-form">
@@ -275,54 +261,6 @@ Access and modify translation values via JavaScript.
275
261
  </script>
276
262
  ```
277
263
 
278
- ### Real-World Use Case: Content Management
279
-
280
- A complete example showing product content management with translations.
281
-
282
- ```html:preview
283
- <div style="max-width: 800px;">
284
- <zn-panel caption="Product Details" icon="inventory_2">
285
- <div style="display: flex; flex-direction: column; gap: 1rem;">
286
- <zn-input
287
- label="Product SKU"
288
- value="PROD-12345"
289
- readonly
290
- ></zn-input>
291
-
292
- <zn-translations
293
- name="name"
294
- label="Product Name"
295
- required
296
- languages='{"en":"English","fr":"French","de":"German","es":"Spanish"}'
297
- values='{"en":"Premium Wireless Headphones","fr":"Écouteurs sans fil premium","de":"Premium kabellose Kopfhörer","es":"Auriculares inalámbricos premium"}'
298
- ></zn-translations>
299
-
300
- <zn-translations
301
- name="description"
302
- label="Product Description"
303
- languages='{"en":"English","fr":"French","de":"German","es":"Spanish"}'
304
- values='{"en":"High-quality wireless headphones with active noise cancellation","fr":"Écouteurs sans fil de haute qualité avec suppression active du bruit","de":"Hochwertige kabellose Kopfhörer mit aktiver Geräuschunterdrückung","es":"Auriculares inalámbricos de alta calidad con cancelación activa de ruido"}'
305
- >
306
- <zn-button slot="expand" color="transparent" icon="smart_toy">
307
- AI Translate
308
- </zn-button>
309
- </zn-translations>
310
-
311
- <zn-input
312
- label="Price"
313
- type="currency"
314
- value="299.99"
315
- ></zn-input>
316
- </div>
317
-
318
- <div slot="footer" style="display: flex; gap: 0.5rem; justify-content: flex-end;">
319
- <zn-button color="secondary">Cancel</zn-button>
320
- <zn-button color="success">Save Product</zn-button>
321
- </div>
322
- </zn-panel>
323
- </div>
324
- ```
325
-
326
264
  ### Help Text
327
265
 
328
266
  Use the `help-text` attribute to describe how the field should be filled in. It sits below the field and applies to
@@ -382,6 +320,10 @@ way `Enter` otherwise would.
382
320
  | `flush` | `boolean` | `false` | Removes padding for compact layout |
383
321
  | `languages` | `Record<string, string>` | `{en: "EN"}` | Object mapping language codes to display names |
384
322
  | `values` | `Record<string, string>` | `{}` | Object mapping language codes to translation text |
323
+ | `grouped` | `boolean` | `false` | Hides the language select; a parent `zn-translation-group` drives it |
324
+ | `input-type` | `'text' \| 'number' \| 'textarea'` | `'text'` | The control each translation is edited through |
325
+ | `textarea-rows` | `number` | — | Rows of the textarea, when `input-type` is `textarea` |
326
+ | `inline-edit` | `boolean` | `false` | Edits through `zn-inline-edit` instead of a plain input or textarea |
385
327
  | `slash-items` | `SlashMenuItem[]` | `[]` | Quick insertions offered by the slash menu |
386
328
  | `slash-preset` | `string` | `''` | Registered item sets to offer, comma separated |
387
329
  | `slash-trigger` | `string` | `'/'` | The characters that open the slash menu |
@@ -399,7 +341,6 @@ way `Enter` otherwise would.
399
341
  | Slot | Description |
400
342
  |-------------|------------------------------------------------------------------|
401
343
  | `label` | Alternative to the `label` attribute for rich HTML content |
402
- | `expand` | Action button displayed in the navbar (e.g., translate button) |
403
344
  | `help-text` | Alternative to the `help-text` attribute for rich HTML content |
404
345
 
405
346
  ## Methods
@@ -413,10 +354,19 @@ way `Enter` otherwise would.
413
354
 
414
355
  ## CSS Parts
415
356
 
416
- The component uses `zn-navbar` and `zn-inline-edit` internally, which expose their own CSS parts for advanced styling.
357
+ | Part | Description |
358
+ |----------------------|---------------------------------------------------|
359
+ | `form-control` | The component's base wrapper |
360
+ | `form-control-label` | The label's wrapper |
361
+ | `form-control-input` | The wrapper around the field being edited |
362
+ | `language-select` | The select that chooses the language being edited |
363
+
364
+ The component uses `zn-select` for the language and `zn-input`, `zn-textarea` or `zn-inline-edit` for the field, each
365
+ of which exposes its own CSS parts for advanced styling.
417
366
 
418
367
  ## Accessibility
419
368
 
420
369
  - The component automatically detects RTL languages (Arabic, Hebrew) and applies proper text direction
421
- - Keyboard navigation is supported within the inline edit fields
422
- - Form integration ensures proper submission behavior with the Enter key
370
+ - The language select is a standard combobox: it opens on `Enter` or `Space` and moves through the languages with the
371
+ arrow keys
372
+ - `Enter` submits the form from a single-line field, and inserts a newline in a textarea
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.115",
3
+ "version": "1.1.117",
4
4
  "description": "A collection of web components for building web applications based off of @shoelace-style/Shoelace",
5
5
  "keywords": [
6
6
  "web components",
@@ -235,7 +235,8 @@ export default class ZnDialog extends ZincElement {
235
235
  <h2 part="title" class="dialog__title" id="title">
236
236
  <slot name="header-icon"></slot>
237
237
  <slot name="announcement-intro"></slot>
238
- <slot name="label"> ${this.label && this.label.length > 0 ? this.label : String.fromCharCode(65279)}
238
+ <slot name="label" class="dialog__label">
239
+ ${this.label && this.label.length > 0 ? this.label : String.fromCharCode(65279)}
239
240
  </slot>
240
241
  </h2>
241
242
  <div part="header-actions" class="dialog__header-actions">
@@ -55,6 +55,8 @@
55
55
  align-items: center;
56
56
  font: inherit;
57
57
  margin: 0;
58
+ width: 100%;
59
+ overflow: hidden;
58
60
  }
59
61
 
60
62
  &__header-actions {
@@ -75,6 +77,12 @@
75
77
  }
76
78
  }
77
79
 
80
+ &__label {
81
+ overflow: hidden;
82
+ word-break: break-word;
83
+ display: inline-block;
84
+ }
85
+
78
86
  &__body {
79
87
  flex: 1 1 auto;
80
88
  min-height: 0;
@@ -1,5 +1,5 @@
1
1
  import { classMap } from "lit/directives/class-map.js";
2
- import { type CSSResultGroup, html, unsafeCSS } from 'lit';
2
+ import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
3
3
  import { HasSlotController } from "../../internal/slot";
4
4
  import { property } from 'lit/decorators.js';
5
5
  import ZincElement from '../../internal/zinc-element';
@@ -16,6 +16,10 @@ import styles from './form-group.scss';
16
16
  * @slot - The default slot.
17
17
  * @slot chip - A chip displayed under the form group's help text.
18
18
  *
19
+ * @csspart form-control-text - The column holding the label, help text and chip.
20
+ *
21
+ * @cssproperty --zn-form-group-sticky-top - Offset the label column sticks at while the inputs scroll past.
22
+ *
19
23
  */
20
24
  export default class ZnFormGroup extends ZincElement {
21
25
  static styles: CSSResultGroup = [unsafeCSS(formControlStyles), unsafeCSS(styles)];
@@ -41,6 +45,156 @@ export default class ZnFormGroup extends ZincElement {
41
45
 
42
46
  @property({ attribute: 'pad', type: Boolean }) pad: boolean = false;
43
47
 
48
+ /** The scroller the label is tracked against by hand; null while native sticky is enough. */
49
+ private tracked: HTMLElement | null = null;
50
+ private frame: number = 0;
51
+ private rebind: boolean = false;
52
+ private offset: number = 0;
53
+ private resizeObserver: ResizeObserver | null = null;
54
+
55
+ connectedCallback() {
56
+ super.connectedCallback();
57
+
58
+ // Whether an ancestor scrolls depends on how tall this form has grown.
59
+ this.resizeObserver ??= new ResizeObserver(() => this.schedule(true));
60
+ this.resizeObserver.observe(this);
61
+ window.addEventListener('resize', this.onViewportResize);
62
+ }
63
+
64
+ disconnectedCallback() {
65
+ super.disconnectedCallback();
66
+ this.resizeObserver?.disconnect();
67
+ window.removeEventListener('resize', this.onViewportResize);
68
+ this.trackScroller(null);
69
+ cancelAnimationFrame(this.frame);
70
+ this.frame = 0;
71
+ }
72
+
73
+ protected firstUpdated(changedProperties: PropertyValues) {
74
+ super.firstUpdated(changedProperties);
75
+ this.schedule(true);
76
+ }
77
+
78
+ private get labelColumn(): HTMLElement | null {
79
+ return this.shadowRoot?.querySelector('.form-control__text') ?? null;
80
+ }
81
+
82
+ /** Coalesces scroll and resize work into one frame, and out of the ResizeObserver callback. */
83
+ private schedule(rebind: boolean = false) {
84
+ this.rebind ||= rebind;
85
+ if (this.frame) return;
86
+
87
+ this.frame = requestAnimationFrame(() => {
88
+ this.frame = 0;
89
+ if (this.rebind) {
90
+ this.rebind = false;
91
+ this.findScroller();
92
+ }
93
+ this.positionLabel();
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Native sticky only follows the nearest scroll container. Where that container isn't the one
99
+ * the user actually scrolls — a `zn-panel` body sized to its content inside a scrolling
100
+ * slideout, say — the label never moves, so it gets translated by hand instead.
101
+ */
102
+ private findScroller() {
103
+ const column = this.labelColumn;
104
+ if (!column) return;
105
+
106
+ const anchor = this.nearestScrollContainer(column);
107
+ const scroller = anchor ? this.scrollingAncestor(column) : null;
108
+ this.trackScroller(scroller === anchor ? null : scroller);
109
+ }
110
+
111
+ private trackScroller(scroller: HTMLElement | null) {
112
+ if (scroller === this.tracked) return;
113
+
114
+ this.scrollTarget(this.tracked)?.removeEventListener('scroll', this.onScroll);
115
+ this.tracked = scroller;
116
+ this.scrollTarget(this.tracked)?.addEventListener('scroll', this.onScroll, { passive: true });
117
+ }
118
+
119
+ /** The document scrolls through the window, every other scroller reports its own events. */
120
+ private scrollTarget(scroller: HTMLElement | null): EventTarget | null {
121
+ if (!scroller) return null;
122
+ return scroller === document.scrollingElement ? window : scroller;
123
+ }
124
+
125
+ private readonly onScroll = () => this.schedule();
126
+
127
+ // A shorter viewport can make an ancestor scrollable without changing this form's size.
128
+ private readonly onViewportResize = () => this.schedule(true);
129
+
130
+ private positionLabel() {
131
+ const column = this.labelColumn;
132
+ const fieldset = this.shadowRoot?.querySelector<HTMLElement>('.form-control');
133
+ if (!column || !fieldset) return;
134
+
135
+ let offset = 0;
136
+
137
+ // The stylesheet drops sticky while the columns are stacked; tracking has to stand down too.
138
+ if (this.tracked && getComputedStyle(column).position === 'sticky') {
139
+ const visibleTop = this.tracked === document.scrollingElement
140
+ ? 0
141
+ : this.tracked.getBoundingClientRect().top;
142
+ const stickyTop = parseFloat(getComputedStyle(column).top) || 0;
143
+ const restingTop = column.getBoundingClientRect().top - this.offset;
144
+ const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
145
+
146
+ offset = Math.min(Math.max(visibleTop + stickyTop - restingTop, 0), travel);
147
+ }
148
+
149
+ if (Math.round(offset) === Math.round(this.offset)) return;
150
+
151
+ this.offset = offset;
152
+ column.style.transform = offset ? `translateY(${offset}px)` : '';
153
+ }
154
+
155
+ /** The box native sticky would anchor to, whether or not it can be scrolled. */
156
+ private nearestScrollContainer(from: HTMLElement): HTMLElement | null {
157
+ return this.ancestors(from).find(element => {
158
+ const style = getComputedStyle(element);
159
+ return this.isScrollContainer(style.overflowY) || this.isScrollContainer(style.overflowX);
160
+ }) ?? null;
161
+ }
162
+
163
+ /** The nearest ancestor the user can actually scroll, falling back to the document. */
164
+ private scrollingAncestor(from: HTMLElement): HTMLElement | null {
165
+ const scroller = this.ancestors(from).find(element => {
166
+ const overflow = getComputedStyle(element).overflowY;
167
+ return (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay')
168
+ && element.scrollHeight > element.clientHeight + 1;
169
+ });
170
+
171
+ if (scroller) return scroller;
172
+
173
+ const root = document.scrollingElement as HTMLElement | null;
174
+ return root && root.scrollHeight > root.clientHeight + 1 ? root : null;
175
+ }
176
+
177
+ private isScrollContainer(overflow: string) {
178
+ return overflow === 'auto' || overflow === 'scroll' || overflow === 'hidden' || overflow === 'overlay';
179
+ }
180
+
181
+ /** Walks the flattened tree, so slots and shadow boundaries are crossed the way layout does. */
182
+ private ancestors(from: HTMLElement): HTMLElement[] {
183
+ const out: HTMLElement[] = [];
184
+ let node: Node | null = from;
185
+
186
+ while (node) {
187
+ const parent: Node | null = node instanceof Element && node.assignedSlot
188
+ ? node.assignedSlot
189
+ : node.parentNode instanceof ShadowRoot ? node.parentNode.host : node.parentNode;
190
+
191
+ if (parent instanceof HTMLElement) out.push(parent);
192
+ node = parent;
193
+ }
194
+
195
+ return out;
196
+ }
197
+
44
198
  render() {
45
199
  const hasLabelSlot = this.hasSlotController.test('label');
46
200
  const hasLabelTooltipSlot = this.hasSlotController.test('label-tooltip');
@@ -65,7 +219,7 @@ export default class ZnFormGroup extends ZincElement {
65
219
 
66
220
  <zn-cols layout="${this.layout}" part="form-control-container" class="form-control__container">
67
221
  ${hasLabel || hasHelpText || hasChip || this.forceCols ? html`
68
- <div class="form-control__text">
222
+ <div part="form-control-text" class="form-control__text">
69
223
 
70
224
  ${hasLabel ? html`
71
225
  <label
@@ -4,7 +4,8 @@
4
4
  display: block;
5
5
  line-height: var(--zn-line-height-dense);
6
6
  max-width: var(--zn-container-lg);
7
- --zn-col-gap: calc(var(--zn-spacing-medium, 20px) * 3)
7
+ --zn-col-gap: calc(var(--zn-spacing-medium, 20px) * 3);
8
+ --zn-form-group-sticky-top: var(--zn-spacing-medium, 20px);
8
9
  }
9
10
 
10
11
  .form-control {
@@ -27,6 +28,18 @@
27
28
  --zn-col-basis: 200px;
28
29
  }
29
30
 
31
+ // Only while the label sits beside the inputs: once zn-cols wraps them into one column a sticky
32
+ // label would scroll over the fields it labels. zn-cols wraps below the two columns' bases plus
33
+ // the gap, so 670px = --zn-col-basis * (1 + 2) + 70px — keep in step with both values below.
34
+ @container (min-width: 670px) {
35
+ .form-control__text {
36
+ position: sticky;
37
+ top: var(--zn-form-group-sticky-top);
38
+ // A stretched flex item is as tall as the row, leaving sticky nothing to travel through.
39
+ align-self: flex-start;
40
+ }
41
+ }
42
+
30
43
  .form-control__chip {
31
44
  margin-top: var(--zn-spacing-x-small);
32
45
  }
@@ -45,11 +58,13 @@
45
58
 
46
59
  .form-control-input {
47
60
  display: grid;
48
- grid-template-columns: 1fr;
61
+ grid-template-columns: minmax(0, 1fr);
49
62
  gap: var(--zn-spacing-medium) var(--zn-spacing-small);
50
63
 
64
+ // `1fr` floors each track at its min-content width, so one long label steals width from
65
+ // the other tracks and the spans stop lining up.
51
66
  @include wc.media-query(md) {
52
- grid-template-columns: repeat(6, 1fr);
67
+ grid-template-columns: repeat(6, minmax(0, 1fr));
53
68
  }
54
69
  }
55
70
 
@@ -26,4 +26,68 @@ describe('<zn-form-group>', () => {
26
26
 
27
27
  expect(el.shadowRoot!.querySelector('[part="form-control-chip"]')).to.not.exist;
28
28
  });
29
+
30
+ describe('sticky label', () => {
31
+ const tallForm = Array.from({length: 25}, (_, i) => `<zn-input label="Field ${i}"></zn-input>`).join('');
32
+
33
+ async function scrollPast(group: HTMLElement) {
34
+ group.innerHTML = tallForm;
35
+ await new Promise(resolve => setTimeout(resolve, 400));
36
+
37
+ const label = group.shadowRoot!.querySelector<HTMLElement>('.form-control__text')!;
38
+ const before = label.getBoundingClientRect().top;
39
+
40
+ for (let node: Node | null = label; node; node = flatParent(node)) {
41
+ if (node instanceof HTMLElement && node.scrollHeight > node.clientHeight + 1) node.scrollTop = 500;
42
+ }
43
+ await new Promise(resolve => setTimeout(resolve, 200));
44
+
45
+ return {before, after: label.getBoundingClientRect().top};
46
+ }
47
+
48
+ function flatParent(node: Node): Node | null {
49
+ if (node instanceof Element && node.assignedSlot) return node.assignedSlot;
50
+ return node.parentNode instanceof ShadowRoot ? node.parentNode.host : node.parentNode;
51
+ }
52
+
53
+ it('holds the label in view when the scroll container is the nearest one', async () => {
54
+ const el = await fixture<HTMLElement>(html`
55
+ <div style="max-height: 300px; overflow-y: auto">
56
+ <zn-form-group label="Sticky"></zn-form-group>
57
+ </div>`);
58
+
59
+ const {before, after} = await scrollPast(el.querySelector('zn-form-group')!);
60
+
61
+ expect(after).to.be.closeTo(before, 4);
62
+ });
63
+
64
+ it('gives up sticky once the columns stack', async () => {
65
+ const wide = await fixture<HTMLElement>(html`
66
+ <div style="width: 900px"><zn-form-group label="Sticky"></zn-form-group></div>`);
67
+ const narrow = await fixture<HTMLElement>(html`
68
+ <div style="width: 500px"><zn-form-group label="Sticky"></zn-form-group></div>`);
69
+ await new Promise(resolve => setTimeout(resolve, 100));
70
+
71
+ const position = (root: HTMLElement) => {
72
+ const group = root.querySelector('zn-form-group')!;
73
+ return getComputedStyle(group.shadowRoot!.querySelector('.form-control__text')!).position;
74
+ };
75
+
76
+ expect(position(wide)).to.equal('sticky');
77
+ expect(position(narrow)).to.equal('static');
78
+ });
79
+
80
+ it('holds the label in view when a panel sits between the form and the scroll container', async () => {
81
+ const el = await fixture<HTMLElement>(html`
82
+ <div style="max-height: 300px; overflow-y: auto">
83
+ <zn-panel>
84
+ <zn-form-group label="Sticky"></zn-form-group>
85
+ </zn-panel>
86
+ </div>`);
87
+
88
+ const {before, after} = await scrollPast(el.querySelector('zn-form-group')!);
89
+
90
+ expect(after).to.be.closeTo(before, 4);
91
+ });
92
+ });
29
93
  });
@@ -22,6 +22,7 @@ import styles from './header.scss';
22
22
  * @slot example - An example slot.
23
23
  *
24
24
  * @csspart base - The component's base wrapper.
25
+ * @csspart header-right - The container that wraps the actions slot.
25
26
  *
26
27
  * @cssproperty --example - An example CSS custom property.
27
28
  */
@@ -159,7 +160,7 @@ export default class ZnHeader extends ZincElement {
159
160
  </span>
160
161
  </div>
161
162
 
162
- <div class="header__right">
163
+ <div part="header-right" class="header__right">
163
164
  <slot name="actions"></slot>
164
165
  </div>
165
166
  </div>
@@ -41,6 +41,8 @@ import styles from './select.scss';
41
41
  * @slot label-tooltip - Used to add text that is displayed in a tooltip next to the label. Alternatively, you can use the `label-tooltip` attribute.
42
42
  * @slot context-note - Used to add contextual text that is displayed above the select, on the right. Alternatively, you can use the `context-note` attribute.
43
43
  * @slot prefix - Used to prepend a presentational icon or similar element to the combobox.
44
+ * @slot suffix - Used to append a presentational element — a chip marking the selected option's state, for
45
+ * instance — to the combobox, between the value and the clear and expand icons.
44
46
  * @slot clear-icon - An icon to use in lieu of the default clear icon.
45
47
  * @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close.
46
48
  * @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
@@ -66,6 +68,7 @@ import styles from './select.scss';
66
68
  * @csspart form-control-help-text - The help text's wrapper.
67
69
  * @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button.
68
70
  * @csspart prefix - The container that wraps the prefix slot.
71
+ * @csspart suffix - The container that wraps the suffix slot.
69
72
  * @csspart display-input - The element that displays the selected option's label, an `<input>` element.
70
73
  * @csspart listbox - The listbox container where options are slotted.
71
74
  * @csspart tags - The container that houses option tags when `multiselect` is used.
@@ -1918,6 +1921,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
1918
1921
  @invalid=${this.handleInvalid}
1919
1922
  />
1920
1923
 
1924
+ <slot part="suffix" name="suffix" class="select__suffix"></slot>
1925
+
1921
1926
  ${hasClearIcon
1922
1927
  ? html`
1923
1928
  <button
@@ -314,6 +314,17 @@
314
314
  padding-inline-start: 0;
315
315
  }
316
316
 
317
+ /* Suffix */
318
+ .select__suffix {
319
+ flex: 0 0 auto;
320
+ display: inline-flex;
321
+ align-items: center;
322
+ }
323
+
324
+ .select__suffix::slotted(*) {
325
+ margin-inline-start: var(--zn-spacing-x-small);
326
+ }
327
+
317
328
  /* Clear button */
318
329
  .select__clear {
319
330
  display: inline-flex;