@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.
- package/dist/custom-elements.json +209 -2
- package/dist/vscode.html-custom-data.json +1 -1
- package/dist/web-types.json +2 -2
- package/dist/zn.d.ts +36 -1
- package/dist/zn.min.js +280 -280
- package/docs/pages/components/form-group.md +42 -9
- package/package.json +1 -1
- package/src/components/form-group/form-group.component.ts +156 -2
- package/src/components/form-group/form-group.scss +18 -3
- package/src/components/form-group/form-group.test.ts +64 -0
|
@@ -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.
|
|
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
|
-
<
|
|
367
|
-
<zn-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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-
|
|
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,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
|
});
|