@kubex/zinc 1.1.119 → 1.1.120
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 +2177 -2060
- package/dist/vscode.html-custom-data.json +182 -182
- package/dist/web-types.json +441 -441
- package/dist/zn.d.ts +33 -14
- package/dist/zn.min.js +1 -1
- package/docs/pages/components/form-group.md +1 -1
- package/package.json +1 -1
- package/src/components/form-group/form-group.component.ts +160 -50
- package/src/components/form-group/form-group.test.ts +11 -11
- package/src/declaration.d.ts +22 -0
package/dist/zn.d.ts
CHANGED
|
@@ -7322,30 +7322,49 @@ declare module "components/form-group/form-group.component" {
|
|
|
7322
7322
|
forceCols: boolean;
|
|
7323
7323
|
layout: string;
|
|
7324
7324
|
pad: boolean;
|
|
7325
|
-
/** The
|
|
7326
|
-
private
|
|
7325
|
+
/** The scroller the label is moved against by hand; null while native sticky is enough. */
|
|
7326
|
+
private tracked;
|
|
7327
|
+
/** Set while the compositor is running the movement off a scroll timeline instead. */
|
|
7328
|
+
private animation;
|
|
7329
|
+
private stickyTop;
|
|
7327
7330
|
private frame;
|
|
7331
|
+
private rebind;
|
|
7332
|
+
private offset;
|
|
7328
7333
|
private resizeObserver;
|
|
7329
7334
|
connectedCallback(): void;
|
|
7330
7335
|
disconnectedCallback(): void;
|
|
7331
7336
|
protected firstUpdated(changedProperties: PropertyValues): void;
|
|
7332
7337
|
private get labelColumn();
|
|
7333
|
-
/** Coalesces resize work into one frame, and out of the ResizeObserver callback. */
|
|
7338
|
+
/** Coalesces scroll and resize work into one frame, and out of the ResizeObserver callback. */
|
|
7334
7339
|
private schedule;
|
|
7335
|
-
private readonly onViewportResize;
|
|
7336
7340
|
/**
|
|
7337
|
-
* Native sticky
|
|
7338
|
-
*
|
|
7339
|
-
*
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
|
|
7343
|
-
|
|
7344
|
-
|
|
7341
|
+
* Native sticky only follows the nearest scroll container. Where that container isn't the one
|
|
7342
|
+
* the user actually scrolls — a `zn-panel` body sized to its content inside a scrolling
|
|
7343
|
+
* slideout, say — the label never moves, so it is moved against the real scroller instead.
|
|
7344
|
+
*/
|
|
7345
|
+
private bind;
|
|
7346
|
+
/**
|
|
7347
|
+
* The label's whole journey, as the scroll positions it turns at: still until the fieldset's top reaches the sticky
|
|
7348
|
+
* line, then a pixel for every pixel of scroll until it has crossed the fieldset. `fill: both` holds it at either
|
|
7349
|
+
* end, which is the clamp the scroll handler applies by hand.
|
|
7350
|
+
*/
|
|
7351
|
+
private scrollLinkedTravel;
|
|
7352
|
+
/** Drops everything this component has put on the label or on the scroller. */
|
|
7353
|
+
private detach;
|
|
7354
|
+
/** The document scrolls through the window, every other scroller reports its own events. */
|
|
7355
|
+
private scrollTarget;
|
|
7356
|
+
/** Where the scroller's own top edge sits, which for the document is the top of the viewport. */
|
|
7357
|
+
private visibleTop;
|
|
7358
|
+
private get fieldset();
|
|
7359
|
+
private readonly onScroll;
|
|
7360
|
+
private readonly onScrollEnd;
|
|
7361
|
+
private readonly onViewportResize;
|
|
7362
|
+
private positionLabel;
|
|
7345
7363
|
/** The box native sticky would anchor to, whether or not it can be scrolled. */
|
|
7346
|
-
private
|
|
7364
|
+
private nearestScrollContainer;
|
|
7365
|
+
/** The nearest ancestor the user can actually scroll, falling back to the document. */
|
|
7366
|
+
private scrollingAncestor;
|
|
7347
7367
|
private isScrollContainer;
|
|
7348
|
-
private overflows;
|
|
7349
7368
|
/** Walks the flattened tree, so slots and shadow boundaries are crossed the way layout does. */
|
|
7350
7369
|
private ancestors;
|
|
7351
7370
|
render(): import("lit-html").TemplateResult<1>;
|
package/dist/zn.min.js
CHANGED
|
@@ -3385,7 +3385,7 @@ ${Na(this.code(i,t))}
|
|
|
3385
3385
|
</zn-button>`:""}
|
|
3386
3386
|
<zn-button part="submit-button" color="primary" icon="${this.submitIcon}" @click="${this.handleSubmit}">
|
|
3387
3387
|
${this.submitText}
|
|
3388
|
-
</zn-button>`}};gr.styles=z(V_),gr.dependencies={"zn-button":De},c([p({attribute:"submit-text"})],gr.prototype,"submitText",2),c([p({attribute:"submit-icon"})],gr.prototype,"submitIcon",2),c([p({attribute:"with-cancel",type:Boolean})],gr.prototype,"withCancel",2),c([p({attribute:"cancel-text"})],gr.prototype,"cancelText",2),c([p({attribute:"cancel-icon"})],gr.prototype,"cancelIcon",2),c([p({attribute:"with-reset",type:Boolean})],gr.prototype,"withReset",2),c([p({attribute:"reset-text"})],gr.prototype,"resetText",2),c([p({attribute:"reset-icon"})],gr.prototype,"resetIcon",2),c([p()],gr.prototype,"form",2);var QM=gr;gr.define("zn-form-actions");var U_=_`@keyframes rotate-gradient{from{--rotate:0deg}to{--rotate:360deg}}:host{text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-transform:none}*,*::before,*::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:rgb(var(--zn-border-color))}[hidden]{display:none !important}.width-container,.form-container,.wide-form-container{height:100%;width:100%;margin:var(--zn-default-margin,0 auto)}.width-container{max-width:var(--zn-container)}.form-container{max-width:var(--zn-container-ph)}.wide-form-container{max-width:var(--zn-container-lg)}:host{display:block;line-height:var(--zn-line-height-dense);max-width:var(--zn-container-lg);--zn-col-gap:calc(var(--zn-spacing-medium,20px) * 3);--zn-form-group-sticky-top:var(--zn-spacing-medium,20px)}.form-control{position:relative;border:none;padding:0;margin:0}.form-control__label{padding:0}.form-control--has-label .form-control__label{font-weight:var(--zn-font-weight-bold);font-size:var(--zn-font-size-large)}.form-control__container{--zn-col-basis:200px}@container (min-width:670px){.form-control__text{position:sticky;top:var(--zn-form-group-sticky-top);align-self:flex-start}}.form-control__chip{margin-top:var(--zn-spacing-x-small)}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.form-control-input{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--zn-spacing-medium) var(--zn-spacing-small)}@media (min-width:768px){.form-control-input{grid-template-columns:repeat(6,minmax(0,1fr))}}.form-control-input>::slotted(*){grid-column:span 6/span 6}@media (min-width:768px){.form-control-input>::slotted([span="1"]){grid-column:span 1/span 1}}@media (min-width:768px){.form-control-input>::slotted([span="2"]){grid-column:span 2/span 2}}@media (min-width:768px){.form-control-input>::slotted([span="3"]){grid-column:span 3/span 3}}@media (min-width:768px){.form-control-input>::slotted([span="4"]){grid-column:span 4/span 4}}@media (min-width:768px){.form-control-input>::slotted([span="5"]){grid-column:span 5/span 5}}zn-cols::part(base){gap:70px}.form-control--pad .form-control__text{padding-top:var(--zn-base-gap);padding-left:var(--zn-base-gap)}`;var ki=class extends C{constructor(){super(...arguments);this.hasSlotController=new Y(this,"help-text","label","chip");this.label="";this.labelTooltip="";this.helpText="";this.forceCols=!1;this.layout="1,2";this.pad=!1;this.
|
|
3388
|
+
</zn-button>`}};gr.styles=z(V_),gr.dependencies={"zn-button":De},c([p({attribute:"submit-text"})],gr.prototype,"submitText",2),c([p({attribute:"submit-icon"})],gr.prototype,"submitIcon",2),c([p({attribute:"with-cancel",type:Boolean})],gr.prototype,"withCancel",2),c([p({attribute:"cancel-text"})],gr.prototype,"cancelText",2),c([p({attribute:"cancel-icon"})],gr.prototype,"cancelIcon",2),c([p({attribute:"with-reset",type:Boolean})],gr.prototype,"withReset",2),c([p({attribute:"reset-text"})],gr.prototype,"resetText",2),c([p({attribute:"reset-icon"})],gr.prototype,"resetIcon",2),c([p()],gr.prototype,"form",2);var QM=gr;gr.define("zn-form-actions");var U_=_`@keyframes rotate-gradient{from{--rotate:0deg}to{--rotate:360deg}}:host{text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-transform:none}*,*::before,*::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:rgb(var(--zn-border-color))}[hidden]{display:none !important}.width-container,.form-container,.wide-form-container{height:100%;width:100%;margin:var(--zn-default-margin,0 auto)}.width-container{max-width:var(--zn-container)}.form-container{max-width:var(--zn-container-ph)}.wide-form-container{max-width:var(--zn-container-lg)}:host{display:block;line-height:var(--zn-line-height-dense);max-width:var(--zn-container-lg);--zn-col-gap:calc(var(--zn-spacing-medium,20px) * 3);--zn-form-group-sticky-top:var(--zn-spacing-medium,20px)}.form-control{position:relative;border:none;padding:0;margin:0}.form-control__label{padding:0}.form-control--has-label .form-control__label{font-weight:var(--zn-font-weight-bold);font-size:var(--zn-font-size-large)}.form-control__container{--zn-col-basis:200px}@container (min-width:670px){.form-control__text{position:sticky;top:var(--zn-form-group-sticky-top);align-self:flex-start}}.form-control__chip{margin-top:var(--zn-spacing-x-small)}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.form-control-input{display:grid;grid-template-columns:minmax(0,1fr);gap:var(--zn-spacing-medium) var(--zn-spacing-small)}@media (min-width:768px){.form-control-input{grid-template-columns:repeat(6,minmax(0,1fr))}}.form-control-input>::slotted(*){grid-column:span 6/span 6}@media (min-width:768px){.form-control-input>::slotted([span="1"]){grid-column:span 1/span 1}}@media (min-width:768px){.form-control-input>::slotted([span="2"]){grid-column:span 2/span 2}}@media (min-width:768px){.form-control-input>::slotted([span="3"]){grid-column:span 3/span 3}}@media (min-width:768px){.form-control-input>::slotted([span="4"]){grid-column:span 4/span 4}}@media (min-width:768px){.form-control-input>::slotted([span="5"]){grid-column:span 5/span 5}}zn-cols::part(base){gap:70px}.form-control--pad .form-control__text{padding-top:var(--zn-base-gap);padding-left:var(--zn-base-gap)}`;var ki=class extends C{constructor(){super(...arguments);this.hasSlotController=new Y(this,"help-text","label","chip");this.label="";this.labelTooltip="";this.helpText="";this.forceCols=!1;this.layout="1,2";this.pad=!1;this.tracked=null;this.animation=null;this.stickyTop=0;this.frame=0;this.rebind=!1;this.offset=0;this.resizeObserver=null;this.onScroll=()=>this.schedule();this.onScrollEnd=()=>this.schedule(!0);this.onViewportResize=()=>this.schedule(!0)}connectedCallback(){super.connectedCallback(),this.resizeObserver??(this.resizeObserver=new ResizeObserver(()=>this.schedule(!0))),this.resizeObserver.observe(this),window.addEventListener("resize",this.onViewportResize)}disconnectedCallback(){super.disconnectedCallback(),this.resizeObserver?.disconnect(),window.removeEventListener("resize",this.onViewportResize),this.detach(),cancelAnimationFrame(this.frame),this.frame=0}firstUpdated(t){super.firstUpdated(t),this.schedule(!0)}get labelColumn(){return this.shadowRoot?.querySelector(".form-control__text")??null}schedule(t=!1){this.rebind||(this.rebind=t),!this.frame&&(this.frame=requestAnimationFrame(()=>{this.frame=0,this.rebind&&(this.rebind=!1,this.bind()),this.positionLabel()}))}bind(){let t=this.labelColumn;if(!t)return;this.detach(),t.style.top="",this.stickyTop=parseFloat(getComputedStyle(t).top)||0;let r=this.nearestScrollContainer(t);if(!r)return;let n=this.scrollingAncestor(t);if(n!==r&&(t.style.top="0px",!!n)){if(this.tracked=n,this.animation=this.scrollLinkedTravel(n),this.animation){this.animation.play(),this.scrollTarget(n)?.addEventListener("scrollend",this.onScrollEnd,{passive:!0});return}this.scrollTarget(n)?.addEventListener("scroll",this.onScroll,{passive:!0})}}scrollLinkedTravel(t){let r=this.labelColumn,n=this.fieldset;if(!r||!n||typeof ScrollTimeline>"u"||getComputedStyle(r).position!=="sticky")return null;let a=Math.max(0,n.clientHeight-r.offsetHeight),s=t.scrollHeight-t.clientHeight;if(a<1||s<1)return null;let l=t.scrollTop+r.getBoundingClientRect().top-this.visibleTop(t)-this.stickyTop,d=f=>Math.min(Math.max(f-l,0),a),m=[0,...[l,l+a].filter(f=>f>0&&f<s),s].map(f=>({offset:Math.min(Math.max(f/s,0),1),transform:`translateY(${d(f)}px)`}));return new Animation(new KeyframeEffect(r,m,{fill:"both"}),new ScrollTimeline({source:t,axis:"block"}))}detach(){let t=this.scrollTarget(this.tracked);t?.removeEventListener("scroll",this.onScroll),t?.removeEventListener("scrollend",this.onScrollEnd),this.tracked=null,this.animation?.cancel(),this.animation=null;let r=this.labelColumn;r&&(this.offset=0,r.style.transform="")}scrollTarget(t){return t?t===document.scrollingElement?window:t:null}visibleTop(t){return t===document.scrollingElement?0:t.getBoundingClientRect().top}get fieldset(){return this.shadowRoot?.querySelector(".form-control")??null}positionLabel(){if(this.animation)return;let t=this.labelColumn,r=this.fieldset;if(!t||!r)return;let n=0;if(this.tracked&&getComputedStyle(t).position==="sticky"){let a=this.visibleTop(this.tracked),s=t.getBoundingClientRect().top-this.offset,l=Math.max(0,r.clientHeight-t.offsetHeight);n=Math.min(Math.max(a+this.stickyTop-s,0),l)}Math.round(n)!==Math.round(this.offset)&&(this.offset=n,t.style.transform=n?`translateY(${n}px)`:"")}nearestScrollContainer(t){return this.ancestors(t).find(r=>{let n=getComputedStyle(r);return this.isScrollContainer(n.overflowY)||this.isScrollContainer(n.overflowX)})??null}scrollingAncestor(t){let r=this.ancestors(t).find(a=>{let s=getComputedStyle(a).overflowY;return(s==="auto"||s==="scroll"||s==="overlay")&&a.scrollHeight>a.clientHeight+1});if(r)return r;let n=document.scrollingElement;return n&&n.scrollHeight>n.clientHeight+1?n:null}isScrollContainer(t){return t==="auto"||t==="scroll"||t==="hidden"||t==="overlay"}ancestors(t){let r=[],n=t;for(;n;){let a=n instanceof Element&&n.assignedSlot?n.assignedSlot:n.parentNode instanceof ShadowRoot?n.parentNode.host:n.parentNode;a instanceof HTMLElement&&r.push(a),n=a}return r}render(){let t=this.hasSlotController.test("label"),r=this.hasSlotController.test("label-tooltip"),n=this.hasSlotController.test("help-text"),a=this.label?!0:t,s=this.labelTooltip?!0:r,l=this.helpText?!0:n,d=this.hasSlotController.test("chip");return u`
|
|
3389
3389
|
<fieldset
|
|
3390
3390
|
part="form-control"
|
|
3391
3391
|
class="${q({"form-control":!0,"form-control--has-label":a,"form-control--has-label-tooltip":s,"form-control--has-help-text":l,"form-control--pad":this.pad})}"
|
|
@@ -362,7 +362,7 @@ This ensures forms remain usable on all devices without additional configuration
|
|
|
362
362
|
|
|
363
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
364
|
|
|
365
|
-
|
|
365
|
+
Where a container clips its overflow but never scrolls — a `zn-panel` body sized to its content, say — native sticky anchors to it and the label would sit still for the whole scroll. The group follows the container that is actually scrolled instead, off a scroll timeline where the browser has them so the compositor keeps the label with the fields, and off a scroll handler where it does not.
|
|
366
366
|
|
|
367
367
|
```html:preview
|
|
368
368
|
<div style="max-height: 300px; overflow-y: auto;">
|
package/package.json
CHANGED
|
@@ -45,16 +45,21 @@ export default class ZnFormGroup extends ZincElement {
|
|
|
45
45
|
|
|
46
46
|
@property({ attribute: 'pad', type: Boolean }) pad: boolean = false;
|
|
47
47
|
|
|
48
|
-
/** The
|
|
49
|
-
private
|
|
48
|
+
/** The scroller the label is moved against by hand; null while native sticky is enough. */
|
|
49
|
+
private tracked: HTMLElement | null = null;
|
|
50
|
+
/** Set while the compositor is running the movement off a scroll timeline instead. */
|
|
51
|
+
private animation: Animation | null = null;
|
|
52
|
+
private stickyTop: number = 0;
|
|
50
53
|
private frame: number = 0;
|
|
54
|
+
private rebind: boolean = false;
|
|
55
|
+
private offset: number = 0;
|
|
51
56
|
private resizeObserver: ResizeObserver | null = null;
|
|
52
57
|
|
|
53
58
|
connectedCallback() {
|
|
54
59
|
super.connectedCallback();
|
|
55
60
|
|
|
56
61
|
// Whether an ancestor scrolls depends on how tall this form has grown.
|
|
57
|
-
this.resizeObserver ??= new ResizeObserver(() => this.schedule());
|
|
62
|
+
this.resizeObserver ??= new ResizeObserver(() => this.schedule(true));
|
|
58
63
|
this.resizeObserver.observe(this);
|
|
59
64
|
window.addEventListener('resize', this.onViewportResize);
|
|
60
65
|
}
|
|
@@ -63,93 +68,198 @@ export default class ZnFormGroup extends ZincElement {
|
|
|
63
68
|
super.disconnectedCallback();
|
|
64
69
|
this.resizeObserver?.disconnect();
|
|
65
70
|
window.removeEventListener('resize', this.onViewportResize);
|
|
66
|
-
this.
|
|
71
|
+
this.detach();
|
|
67
72
|
cancelAnimationFrame(this.frame);
|
|
68
73
|
this.frame = 0;
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
protected firstUpdated(changedProperties: PropertyValues) {
|
|
72
77
|
super.firstUpdated(changedProperties);
|
|
73
|
-
this.schedule();
|
|
78
|
+
this.schedule(true);
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
private get labelColumn(): HTMLElement | null {
|
|
77
82
|
return this.shadowRoot?.querySelector('.form-control__text') ?? null;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
/** Coalesces resize work into one frame, and out of the ResizeObserver callback. */
|
|
81
|
-
private schedule() {
|
|
85
|
+
/** Coalesces scroll and resize work into one frame, and out of the ResizeObserver callback. */
|
|
86
|
+
private schedule(rebind: boolean = false) {
|
|
87
|
+
this.rebind ||= rebind;
|
|
82
88
|
if (this.frame) return;
|
|
83
89
|
|
|
84
90
|
this.frame = requestAnimationFrame(() => {
|
|
85
91
|
this.frame = 0;
|
|
86
|
-
this.
|
|
92
|
+
if (this.rebind) {
|
|
93
|
+
this.rebind = false;
|
|
94
|
+
this.bind();
|
|
95
|
+
}
|
|
96
|
+
this.positionLabel();
|
|
87
97
|
});
|
|
88
98
|
}
|
|
89
99
|
|
|
90
|
-
// A shorter viewport can make an ancestor scroll without changing this form's size.
|
|
91
|
-
private readonly onViewportResize = () => this.schedule();
|
|
92
|
-
|
|
93
100
|
/**
|
|
94
|
-
* Native sticky
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* user actually scrolls, moved by the compositor rather than by hand.
|
|
101
|
+
* Native sticky only follows the nearest scroll container. Where that container isn't the one
|
|
102
|
+
* the user actually scrolls — a `zn-panel` body sized to its content inside a scrolling
|
|
103
|
+
* slideout, say — the label never moves, so it is moved against the real scroller instead.
|
|
98
104
|
*/
|
|
99
|
-
private
|
|
105
|
+
private bind() {
|
|
100
106
|
const column = this.labelColumn;
|
|
101
107
|
if (!column) return;
|
|
102
108
|
|
|
103
|
-
|
|
109
|
+
// Measured with nothing of ours on the label, so what follows reads its resting position.
|
|
110
|
+
this.detach();
|
|
111
|
+
column.style.top = '';
|
|
112
|
+
this.stickyTop = parseFloat(getComputedStyle(column).top) || 0;
|
|
113
|
+
|
|
114
|
+
const anchor = this.nearestScrollContainer(column);
|
|
115
|
+
if (!anchor) return;
|
|
116
|
+
|
|
117
|
+
const scroller = this.scrollingAncestor(column);
|
|
118
|
+
if (scroller === anchor) return;
|
|
119
|
+
|
|
120
|
+
// A `top` inset against a box that never scrolls has nothing to hold the label back from:
|
|
121
|
+
// it only pushes the label down the page, so drop it and stand in for sticky below.
|
|
122
|
+
column.style.top = '0px';
|
|
123
|
+
if (!scroller) return;
|
|
124
|
+
|
|
125
|
+
this.tracked = scroller;
|
|
126
|
+
|
|
127
|
+
/*
|
|
128
|
+
* Scrolling is composited, so a transform written from a scroll handler lands a frame late and the label swims
|
|
129
|
+
* against the fields. A scroll timeline hands the same movement to the compositor; the handler is the fallback
|
|
130
|
+
* for browsers without one.
|
|
131
|
+
*/
|
|
132
|
+
this.animation = this.scrollLinkedTravel(scroller);
|
|
133
|
+
if (this.animation) {
|
|
134
|
+
this.animation.play();
|
|
135
|
+
// Content arriving above the form moves the scroll position the label has to start from, and nothing resizes
|
|
136
|
+
// to say so. Re-measured at the end of a gesture, which is the soonest it can be seen.
|
|
137
|
+
this.scrollTarget(scroller)?.addEventListener('scrollend', this.onScrollEnd, { passive: true });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
104
140
|
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
this.scrollTarget(scroller)?.addEventListener('scroll', this.onScroll, { passive: true });
|
|
142
|
+
}
|
|
107
143
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
144
|
+
/**
|
|
145
|
+
* The label's whole journey, as the scroll positions it turns at: still until the fieldset's top reaches the sticky
|
|
146
|
+
* line, then a pixel for every pixel of scroll until it has crossed the fieldset. `fill: both` holds it at either
|
|
147
|
+
* end, which is the clamp the scroll handler applies by hand.
|
|
148
|
+
*/
|
|
149
|
+
private scrollLinkedTravel(scroller: HTMLElement): Animation | null {
|
|
150
|
+
const column = this.labelColumn;
|
|
151
|
+
const fieldset = this.fieldset;
|
|
152
|
+
if (!column || !fieldset || typeof ScrollTimeline === 'undefined') return null;
|
|
153
|
+
|
|
154
|
+
// The stylesheet drops sticky while the columns are stacked, and there is nothing to follow.
|
|
155
|
+
if (getComputedStyle(column).position !== 'sticky') return null;
|
|
156
|
+
|
|
157
|
+
const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
|
|
158
|
+
const range = scroller.scrollHeight - scroller.clientHeight;
|
|
159
|
+
if (travel < 1 || range < 1) return null;
|
|
160
|
+
|
|
161
|
+
const start = scroller.scrollTop + column.getBoundingClientRect().top
|
|
162
|
+
- this.visibleTop(scroller) - this.stickyTop;
|
|
163
|
+
const held = (scroll: number) => Math.min(Math.max(scroll - start, 0), travel);
|
|
164
|
+
|
|
165
|
+
const knees = [start, start + travel].filter(scroll => scroll > 0 && scroll < range);
|
|
166
|
+
const keyframes = [0, ...knees, range].map(scroll => ({
|
|
167
|
+
offset: Math.min(Math.max(scroll / range, 0), 1),
|
|
168
|
+
transform: `translateY(${held(scroll)}px)`
|
|
169
|
+
}));
|
|
170
|
+
|
|
171
|
+
return new Animation(
|
|
172
|
+
new KeyframeEffect(column, keyframes, { fill: 'both' }),
|
|
173
|
+
new ScrollTimeline({ source: scroller, axis: 'block' })
|
|
174
|
+
);
|
|
175
|
+
}
|
|
111
176
|
|
|
112
|
-
|
|
113
|
-
|
|
177
|
+
/** Drops everything this component has put on the label or on the scroller. */
|
|
178
|
+
private detach() {
|
|
179
|
+
const target = this.scrollTarget(this.tracked);
|
|
180
|
+
target?.removeEventListener('scroll', this.onScroll);
|
|
181
|
+
target?.removeEventListener('scrollend', this.onScrollEnd);
|
|
182
|
+
this.tracked = null;
|
|
183
|
+
|
|
184
|
+
this.animation?.cancel();
|
|
185
|
+
this.animation = null;
|
|
114
186
|
|
|
115
|
-
|
|
116
|
-
|
|
187
|
+
const column = this.labelColumn;
|
|
188
|
+
if (!column) return;
|
|
189
|
+
this.offset = 0;
|
|
190
|
+
column.style.transform = '';
|
|
117
191
|
}
|
|
118
192
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
element.style.overflowX = 'clip';
|
|
125
|
-
element.style.overflowY = 'clip';
|
|
193
|
+
/** The document scrolls through the window, every other scroller reports its own events. */
|
|
194
|
+
private scrollTarget(scroller: HTMLElement | null): EventTarget | null {
|
|
195
|
+
if (!scroller) return null;
|
|
196
|
+
return scroller === document.scrollingElement ? window : scroller;
|
|
197
|
+
}
|
|
126
198
|
|
|
127
|
-
|
|
128
|
-
|
|
199
|
+
/** Where the scroller's own top edge sits, which for the document is the top of the viewport. */
|
|
200
|
+
private visibleTop(scroller: HTMLElement) {
|
|
201
|
+
return scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top;
|
|
129
202
|
}
|
|
130
203
|
|
|
131
|
-
private
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
204
|
+
private get fieldset(): HTMLElement | null {
|
|
205
|
+
return this.shadowRoot?.querySelector('.form-control') ?? null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private readonly onScroll = () => this.schedule();
|
|
209
|
+
|
|
210
|
+
private readonly onScrollEnd = () => this.schedule(true);
|
|
211
|
+
|
|
212
|
+
// A shorter viewport can make an ancestor scrollable without changing this form's size.
|
|
213
|
+
private readonly onViewportResize = () => this.schedule(true);
|
|
214
|
+
|
|
215
|
+
private positionLabel() {
|
|
216
|
+
if (this.animation) return;
|
|
217
|
+
|
|
218
|
+
const column = this.labelColumn;
|
|
219
|
+
const fieldset = this.fieldset;
|
|
220
|
+
if (!column || !fieldset) return;
|
|
221
|
+
|
|
222
|
+
let offset = 0;
|
|
223
|
+
|
|
224
|
+
// The stylesheet drops sticky while the columns are stacked; tracking has to stand down too.
|
|
225
|
+
if (this.tracked && getComputedStyle(column).position === 'sticky') {
|
|
226
|
+
const visibleTop = this.visibleTop(this.tracked);
|
|
227
|
+
const restingTop = column.getBoundingClientRect().top - this.offset;
|
|
228
|
+
const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
|
|
229
|
+
|
|
230
|
+
offset = Math.min(Math.max(visibleTop + this.stickyTop - restingTop, 0), travel);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (Math.round(offset) === Math.round(this.offset)) return;
|
|
234
|
+
|
|
235
|
+
this.offset = offset;
|
|
236
|
+
column.style.transform = offset ? `translateY(${offset}px)` : '';
|
|
139
237
|
}
|
|
140
238
|
|
|
141
239
|
/** The box native sticky would anchor to, whether or not it can be scrolled. */
|
|
142
|
-
private
|
|
143
|
-
|
|
144
|
-
|
|
240
|
+
private nearestScrollContainer(from: HTMLElement): HTMLElement | null {
|
|
241
|
+
return this.ancestors(from).find(element => {
|
|
242
|
+
const style = getComputedStyle(element);
|
|
243
|
+
return this.isScrollContainer(style.overflowY) || this.isScrollContainer(style.overflowX);
|
|
244
|
+
}) ?? null;
|
|
145
245
|
}
|
|
146
246
|
|
|
147
|
-
|
|
148
|
-
|
|
247
|
+
/** The nearest ancestor the user can actually scroll, falling back to the document. */
|
|
248
|
+
private scrollingAncestor(from: HTMLElement): HTMLElement | null {
|
|
249
|
+
const scroller = this.ancestors(from).find(element => {
|
|
250
|
+
const overflow = getComputedStyle(element).overflowY;
|
|
251
|
+
return (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay')
|
|
252
|
+
&& element.scrollHeight > element.clientHeight + 1;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (scroller) return scroller;
|
|
256
|
+
|
|
257
|
+
const root = document.scrollingElement as HTMLElement | null;
|
|
258
|
+
return root && root.scrollHeight > root.clientHeight + 1 ? root : null;
|
|
149
259
|
}
|
|
150
260
|
|
|
151
|
-
private
|
|
152
|
-
return
|
|
261
|
+
private isScrollContainer(overflow: string) {
|
|
262
|
+
return overflow === 'auto' || overflow === 'scroll' || overflow === 'hidden' || overflow === 'overlay';
|
|
153
263
|
}
|
|
154
264
|
|
|
155
265
|
/** Walks the flattened tree, so slots and shadow boundaries are crossed the way layout does. */
|
|
@@ -78,9 +78,8 @@ describe('<zn-form-group>', () => {
|
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
it('leaves the label level with the inputs while nothing scrolls', async () => {
|
|
81
|
-
// Held clear of the viewport's top edge, which sticky's own inset holds the label back from wherever it is.
|
|
82
81
|
const el = await fixture<HTMLElement>(html`
|
|
83
|
-
<div style="width: 900px
|
|
82
|
+
<div style="width: 900px">
|
|
84
83
|
<zn-panel>
|
|
85
84
|
<zn-form-group label="Sticky"><zn-input label="Name"></zn-input></zn-form-group>
|
|
86
85
|
</zn-panel>
|
|
@@ -94,23 +93,24 @@ describe('<zn-form-group>', () => {
|
|
|
94
93
|
expect(label).to.be.closeTo(inputs, 2);
|
|
95
94
|
});
|
|
96
95
|
|
|
97
|
-
it('
|
|
96
|
+
it('hands the movement to a scroll timeline where the browser has one', async () => {
|
|
97
|
+
if (!window.ScrollTimeline) return;
|
|
98
|
+
|
|
98
99
|
const el = await fixture<HTMLElement>(html`
|
|
99
|
-
<div style="
|
|
100
|
+
<div style="max-height: 300px; overflow-y: auto">
|
|
100
101
|
<zn-panel>
|
|
101
102
|
<zn-form-group label="Sticky"></zn-form-group>
|
|
102
103
|
</zn-panel>
|
|
103
104
|
</div>`);
|
|
104
|
-
el.querySelector('zn-form-group')
|
|
105
|
+
const group = el.querySelector<HTMLElement>('zn-form-group')!;
|
|
106
|
+
group.innerHTML = tallForm;
|
|
105
107
|
await new Promise(resolve => setTimeout(resolve, 400));
|
|
106
108
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
body.style.maxHeight = '120px';
|
|
111
|
-
await new Promise(resolve => setTimeout(resolve, 400));
|
|
109
|
+
const label = group.shadowRoot!.querySelector<HTMLElement>('.form-control__text')!;
|
|
110
|
+
const [animation] = label.getAnimations();
|
|
112
111
|
|
|
113
|
-
expect(
|
|
112
|
+
expect(animation, 'the compositor drives the label, not the scroll handler').to.exist;
|
|
113
|
+
expect(animation.timeline).to.be.instanceOf(window.ScrollTimeline);
|
|
114
114
|
});
|
|
115
115
|
|
|
116
116
|
it('holds the label in view when a panel sits between the form and the scroll container', async () => {
|
package/src/declaration.d.ts
CHANGED
|
@@ -37,3 +37,25 @@ declare interface Window {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/* eslint-enable */
|
|
40
|
+
|
|
41
|
+
/* eslint-disable */
|
|
42
|
+
interface ScrollTimelineOptions {
|
|
43
|
+
source?: Element | null;
|
|
44
|
+
axis?: 'block' | 'inline' | 'x' | 'y';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ScrollTimeline extends AnimationTimeline {
|
|
48
|
+
readonly source: Element | null;
|
|
49
|
+
readonly axis: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
declare const ScrollTimeline: {
|
|
53
|
+
prototype: ScrollTimeline;
|
|
54
|
+
new(options?: ScrollTimelineOptions): ScrollTimeline;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
declare interface Window {
|
|
58
|
+
ScrollTimeline?: typeof ScrollTimeline;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* eslint-enable */
|