@kubex/zinc 1.1.116 → 1.1.118

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.
@@ -358,27 +358,60 @@ This ensures forms remain usable on all devices without additional configuration
358
358
  </zn-form-group>
359
359
  ```
360
360
 
361
+ ### Sticky Label
362
+
363
+ The label column sticks to the top of the scroll container, so on a long form it stays beside the inputs instead of scrolling away. Use the `--zn-form-group-sticky-top` custom property to change the offset it settles at — useful when the scroll container has a sticky header of its own.
364
+
365
+ ```html:preview
366
+ <div style="max-height: 300px; overflow-y: auto;">
367
+ <zn-form-group label="Delivery Details" help-text="This label follows the inputs as you scroll">
368
+ <zn-input label="Recipient" placeholder="Full name"></zn-input>
369
+ <zn-input label="Street Address" placeholder="123 Main Street"></zn-input>
370
+ <zn-input label="Apartment/Unit" placeholder="Apt 4B"></zn-input>
371
+ <zn-input label="City" placeholder="New York"></zn-input>
372
+ <zn-input label="Postal Code" placeholder="10001"></zn-input>
373
+ <zn-input label="Phone" type="tel" placeholder="(555) 123-4567"></zn-input>
374
+ <zn-textarea label="Delivery Notes" placeholder="Leave with the concierge..." rows="4"></zn-textarea>
375
+ </zn-form-group>
376
+ </div>
377
+ ```
378
+
361
379
  ### Styling with CSS Parts
362
380
 
363
- Form groups expose several CSS parts that can be styled to customize their appearance. This example demonstrates how to create custom layouts using CSS grid.
381
+ Form groups expose several CSS parts that can be styled to customize their appearance. `form-control-text` is the column holding the label, help text and chip — it is the part that sticks as the fields scroll, so give it the same background as the group to stop the fields showing through underneath it.
364
382
 
365
383
  ```html:preview
366
- <zn-form-group class="custom-form-group" label="Custom Styled Form Group" help-text="This form group has custom spacing and borders">
367
- <zn-input label="Field 1" span="3"></zn-input>
368
- <zn-input label="Field 2" span="3"></zn-input>
369
- <zn-input label="Field 3" span="2"></zn-input>
370
- <zn-input label="Field 4" span="2"></zn-input>
371
- <zn-input label="Field 5" span="2"></zn-input>
372
- </zn-form-group>
384
+ <div class="custom-form-group-scroller">
385
+ <zn-form-group class="custom-form-group" label="Custom Styled Form Group" help-text="This form group has custom spacing and borders">
386
+ <zn-input label="Field 1" span="3"></zn-input>
387
+ <zn-input label="Field 2" span="3"></zn-input>
388
+ <zn-input label="Field 3" span="2"></zn-input>
389
+ <zn-input label="Field 4" span="2"></zn-input>
390
+ <zn-input label="Field 5" span="2"></zn-input>
391
+ <zn-input label="Field 6" span="3"></zn-input>
392
+ <zn-input label="Field 7" span="3"></zn-input>
393
+ <zn-textarea label="Field 8" rows="4"></zn-textarea>
394
+ </zn-form-group>
395
+ </div>
373
396
 
374
397
  <style>
398
+ .custom-form-group-scroller {
399
+ max-height: 320px;
400
+ overflow-y: auto;
401
+ }
402
+
375
403
  .custom-form-group::part(form-control) {
376
- padding: var(--zn-spacing-large);
404
+ padding: var(--zn-spacing-medium);
377
405
  border: 2px solid var(--zn-color-primary-300);
378
406
  border-radius: var(--zn-border-radius);
379
407
  background-color: var(--zn-color-primary-50);
380
408
  }
381
409
 
410
+ .custom-form-group::part(form-control-text) {
411
+ padding-bottom: var(--zn-spacing-small);
412
+ background-color: var(--zn-color-primary-50);
413
+ }
414
+
382
415
  .custom-form-group::part(form-control-label) {
383
416
  color: var(--zn-color-primary-700);
384
417
  }
@@ -6,13 +6,16 @@ layout: component
6
6
  ---
7
7
 
8
8
  The Translation Group component wraps multiple `zn-translations` components in a panel-styled container with a shared
9
- language select. The select sits at the top right of the header, opposite the caption. Closed, it carries how many
10
- languages are done as a `1/5` chip; open, each language is marked translated, partial or falling back to English.
11
- Choosing a language switches every child at once.
9
+ language select. The fields sit in a [form group](/components/form-group), so the caption, help text and the select
10
+ share its label column and the fields line up with every other form group around them. Closed, the select carries how
11
+ many languages are done as a `1/5` chip; open, each language is marked translated, partial or falling back to English.
12
+ Type to filter the list — by name or by language code, so `de` finds German. Choosing a language switches every child
13
+ at once.
12
14
 
13
15
  ```html:preview
14
16
  <zn-translation-group
15
17
  label="Product Content"
18
+ help-text="Every field is translated one language at a time"
16
19
  languages='{"en":"English","fr":"French","de":"German"}'>
17
20
  <zn-translations
18
21
  label="Name"
@@ -40,14 +43,16 @@ A translation group with two translation inputs sharing one language select.
40
43
  </zn-translation-group>
41
44
  ```
42
45
 
43
- ### With Label
46
+ ### Label and Help Text
44
47
 
45
- Use the `label` attribute to add a caption on the left of the header, opposite the language select. `language-label`
46
- sets the select's accessible name — it is not shown, since the caption names the section on screen.
48
+ `label` names the section in the form group's label column, and `help-text` sits under it — the pair behave as they do
49
+ on any other form group. The language select follows them, in the group's chip position. `language-label` sets the
50
+ select's accessible name — it is not shown, since the caption names the section on screen.
47
51
 
48
52
  ```html:preview
49
53
  <zn-translation-group
50
54
  label="Page Translations"
55
+ help-text="Pick a language to edit every field in it"
51
56
  languages='{"en":"English","fr":"French","es":"Spanish"}'>
52
57
  <zn-translations label="Heading" name="heading"></zn-translations>
53
58
  <zn-translations label="Body" name="body"></zn-translations>
@@ -209,7 +214,8 @@ The group emits a `zn-language-change` event when the active language changes.
209
214
 
210
215
  | Property | Type | Default | Description |
211
216
  |------------------|--------------------------|--------------------|---------------------------------------------------|
212
- | `label` | `string` | `''` | Caption displayed in the panel header |
217
+ | `label` | `string` | `''` | Names the section, in the form group's label column |
218
+ | `help-text` | `string` | `''` | Sits under the label, above the language select |
213
219
  | `language-label` | `string` | `'Edit Languages'` | The select's accessible name; not shown on screen |
214
220
  | `inline` | `boolean` | `false` | Drops the panel border, background and padding |
215
221
  | `languages` | `Record<string, string>` | `{en: "EN"}` | Object mapping language codes to display names |
@@ -226,19 +232,15 @@ The group emits a `zn-language-change` event when the active language changes.
226
232
  | Slot | Description |
227
233
  |-----------|------------------------------------------------------------|
228
234
  | (default) | Place `<zn-translations>` elements here |
229
- | `label` | Alternative to the `label` attribute for rich HTML content |
230
235
  | `actions` | Buttons for the bottom of the body; `align="start"` on a child moves it to the left |
231
236
  | `footer` | Content displayed in the grey panel footer |
232
237
 
233
- The header carries the caption and the language select alone; nothing else is slotted into it.
234
-
235
238
  ## CSS Parts
236
239
 
237
- | Part | Description |
238
- |-------------------|-----------------------------------------------------------------|
239
- | `base` | The outer panel wrapper |
240
- | `header` | The header area containing the caption and the language select |
241
- | `language-field` | The container holding the language select |
242
- | `language-select` | The select itself |
243
- | `actions` | The row of buttons at the bottom of the body |
244
- | `translations` | The body container wrapping the slotted children |
240
+ | Part | Description |
241
+ |-------------------|----------------------------------------------------------------------|
242
+ | `base` | The outer panel wrapper |
243
+ | `form-group` | The form group holding the caption, the language select and the fields |
244
+ | `language-field` | The container holding the language select, in the group's chip slot |
245
+ | `language-select` | The select itself |
246
+ | `actions` | The row of buttons at the bottom of the body |
@@ -128,8 +128,9 @@ Add `inline-edit` to read the translation as text until it is clicked, through
128
128
 
129
129
  ### Many Languages
130
130
 
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.
131
+ Each language becomes an option labelled with its configured name — or its code, where `languages` does not name it.
132
+ The select takes any number of them, and its listbox scrolls once the list is longer than the space below it. Type to
133
+ filter the list: matching runs over the code as well as the name, so `de` finds German.
133
134
 
134
135
  ```html:preview
135
136
  <zn-translations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.116",
3
+ "version": "1.1.118",
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",
@@ -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,165 @@ 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 stickyTop: number = 0;
51
+ private frame: number = 0;
52
+ private rebind: boolean = false;
53
+ private offset: number = 0;
54
+ private resizeObserver: ResizeObserver | null = null;
55
+
56
+ connectedCallback() {
57
+ super.connectedCallback();
58
+
59
+ // Whether an ancestor scrolls depends on how tall this form has grown.
60
+ this.resizeObserver ??= new ResizeObserver(() => this.schedule(true));
61
+ this.resizeObserver.observe(this);
62
+ window.addEventListener('resize', this.onViewportResize);
63
+ }
64
+
65
+ disconnectedCallback() {
66
+ super.disconnectedCallback();
67
+ this.resizeObserver?.disconnect();
68
+ window.removeEventListener('resize', this.onViewportResize);
69
+ this.trackScroller(null);
70
+ cancelAnimationFrame(this.frame);
71
+ this.frame = 0;
72
+ }
73
+
74
+ protected firstUpdated(changedProperties: PropertyValues) {
75
+ super.firstUpdated(changedProperties);
76
+ this.schedule(true);
77
+ }
78
+
79
+ private get labelColumn(): HTMLElement | null {
80
+ return this.shadowRoot?.querySelector('.form-control__text') ?? null;
81
+ }
82
+
83
+ /** Coalesces scroll and resize work into one frame, and out of the ResizeObserver callback. */
84
+ private schedule(rebind: boolean = false) {
85
+ this.rebind ||= rebind;
86
+ if (this.frame) return;
87
+
88
+ this.frame = requestAnimationFrame(() => {
89
+ this.frame = 0;
90
+ if (this.rebind) {
91
+ this.rebind = false;
92
+ this.findScroller();
93
+ }
94
+ this.positionLabel();
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Native sticky only follows the nearest scroll container. Where that container isn't the one
100
+ * the user actually scrolls — a `zn-panel` body sized to its content inside a scrolling
101
+ * slideout, say — the label never moves, so it gets translated by hand instead.
102
+ */
103
+ private findScroller() {
104
+ const column = this.labelColumn;
105
+ if (!column) return;
106
+
107
+ column.style.top = '';
108
+ this.stickyTop = parseFloat(getComputedStyle(column).top) || 0;
109
+
110
+ const anchor = this.nearestScrollContainer(column);
111
+ const scroller = anchor ? this.scrollingAncestor(column) : null;
112
+ const anchored = !anchor || scroller === anchor;
113
+
114
+ this.trackScroller(anchored ? null : scroller);
115
+
116
+ // A `top` inset against a box that never scrolls has nothing to hold the label back from:
117
+ // it only pushes the label down the page, so drop it and let the transform do the work.
118
+ if (!anchored) column.style.top = '0px';
119
+ }
120
+
121
+ private trackScroller(scroller: HTMLElement | null) {
122
+ if (scroller === this.tracked) return;
123
+
124
+ this.scrollTarget(this.tracked)?.removeEventListener('scroll', this.onScroll);
125
+ this.tracked = scroller;
126
+ this.scrollTarget(this.tracked)?.addEventListener('scroll', this.onScroll, { passive: true });
127
+ }
128
+
129
+ /** The document scrolls through the window, every other scroller reports its own events. */
130
+ private scrollTarget(scroller: HTMLElement | null): EventTarget | null {
131
+ if (!scroller) return null;
132
+ return scroller === document.scrollingElement ? window : scroller;
133
+ }
134
+
135
+ private readonly onScroll = () => this.schedule();
136
+
137
+ // A shorter viewport can make an ancestor scrollable without changing this form's size.
138
+ private readonly onViewportResize = () => this.schedule(true);
139
+
140
+ private positionLabel() {
141
+ const column = this.labelColumn;
142
+ const fieldset = this.shadowRoot?.querySelector<HTMLElement>('.form-control');
143
+ if (!column || !fieldset) return;
144
+
145
+ let offset = 0;
146
+
147
+ // The stylesheet drops sticky while the columns are stacked; tracking has to stand down too.
148
+ if (this.tracked && getComputedStyle(column).position === 'sticky') {
149
+ const visibleTop = this.tracked === document.scrollingElement
150
+ ? 0
151
+ : this.tracked.getBoundingClientRect().top;
152
+ const restingTop = column.getBoundingClientRect().top - this.offset;
153
+ const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
154
+
155
+ offset = Math.min(Math.max(visibleTop + this.stickyTop - restingTop, 0), travel);
156
+ }
157
+
158
+ if (Math.round(offset) === Math.round(this.offset)) return;
159
+
160
+ this.offset = offset;
161
+ column.style.transform = offset ? `translateY(${offset}px)` : '';
162
+ }
163
+
164
+ /** The box native sticky would anchor to, whether or not it can be scrolled. */
165
+ private nearestScrollContainer(from: HTMLElement): HTMLElement | null {
166
+ return this.ancestors(from).find(element => {
167
+ const style = getComputedStyle(element);
168
+ return this.isScrollContainer(style.overflowY) || this.isScrollContainer(style.overflowX);
169
+ }) ?? null;
170
+ }
171
+
172
+ /** The nearest ancestor the user can actually scroll, falling back to the document. */
173
+ private scrollingAncestor(from: HTMLElement): HTMLElement | null {
174
+ const scroller = this.ancestors(from).find(element => {
175
+ const overflow = getComputedStyle(element).overflowY;
176
+ return (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay')
177
+ && element.scrollHeight > element.clientHeight + 1;
178
+ });
179
+
180
+ if (scroller) return scroller;
181
+
182
+ const root = document.scrollingElement as HTMLElement | null;
183
+ return root && root.scrollHeight > root.clientHeight + 1 ? root : null;
184
+ }
185
+
186
+ private isScrollContainer(overflow: string) {
187
+ return overflow === 'auto' || overflow === 'scroll' || overflow === 'hidden' || overflow === 'overlay';
188
+ }
189
+
190
+ /** Walks the flattened tree, so slots and shadow boundaries are crossed the way layout does. */
191
+ private ancestors(from: HTMLElement): HTMLElement[] {
192
+ const out: HTMLElement[] = [];
193
+ let node: Node | null = from;
194
+
195
+ while (node) {
196
+ const parent: Node | null = node instanceof Element && node.assignedSlot
197
+ ? node.assignedSlot
198
+ : node.parentNode instanceof ShadowRoot ? node.parentNode.host : node.parentNode;
199
+
200
+ if (parent instanceof HTMLElement) out.push(parent);
201
+ node = parent;
202
+ }
203
+
204
+ return out;
205
+ }
206
+
44
207
  render() {
45
208
  const hasLabelSlot = this.hasSlotController.test('label');
46
209
  const hasLabelTooltipSlot = this.hasSlotController.test('label-tooltip');
@@ -65,7 +228,7 @@ export default class ZnFormGroup extends ZincElement {
65
228
 
66
229
  <zn-cols layout="${this.layout}" part="form-control-container" class="form-control__container">
67
230
  ${hasLabel || hasHelpText || hasChip || this.forceCols ? html`
68
- <div class="form-control__text">
231
+ <div part="form-control-text" class="form-control__text">
69
232
 
70
233
  ${hasLabel ? html`
71
234
  <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,84 @@ 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('leaves the label level with the inputs while nothing scrolls', async () => {
81
+ const el = await fixture<HTMLElement>(html`
82
+ <div style="width: 900px">
83
+ <zn-panel>
84
+ <zn-form-group label="Sticky"><zn-input label="Name"></zn-input></zn-form-group>
85
+ </zn-panel>
86
+ </div>`);
87
+ await new Promise(resolve => setTimeout(resolve, 100));
88
+
89
+ const root = el.querySelector('zn-form-group')!.shadowRoot!;
90
+ const label = root.querySelector('.form-control__text')!.getBoundingClientRect().top;
91
+ const inputs = root.querySelector('.form-control-input')!.getBoundingClientRect().top;
92
+
93
+ expect(label).to.be.closeTo(inputs, 2);
94
+ });
95
+
96
+ it('holds the label in view when a panel sits between the form and the scroll container', async () => {
97
+ const el = await fixture<HTMLElement>(html`
98
+ <div style="max-height: 300px; overflow-y: auto">
99
+ <zn-panel>
100
+ <zn-form-group label="Sticky"></zn-form-group>
101
+ </zn-panel>
102
+ </div>`);
103
+
104
+ const {before, after} = await scrollPast(el.querySelector('zn-form-group')!);
105
+
106
+ expect(after).to.be.closeTo(before, 4);
107
+ });
108
+ });
29
109
  });