@kubex/zinc 1.1.116 → 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.
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.116",
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",
@@ -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
  });