@kubex/zinc 1.1.118 → 1.1.119

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.
@@ -362,6 +362,8 @@ 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
+ A container that clips its overflow but never scrolls — a `zn-panel` body sized to its content, say — would otherwise catch the label and hold it still for the whole scroll. The group clips such a container instead (`overflow: clip`, which clips without being a scroll container), so the label follows the box that is actually scrolled. Scrolling goes straight back to the container the moment its content outgrows it.
366
+
365
367
  ```html:preview
366
368
  <div style="max-height: 300px; overflow-y: auto;">
367
369
  <zn-form-group label="Delivery Details" help-text="This label follows the inputs as you scroll">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.118",
3
+ "version": "1.1.119",
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",
@@ -45,19 +45,16 @@ export default class ZnFormGroup extends ZincElement {
45
45
 
46
46
  @property({ attribute: 'pad', type: Boolean }) pad: boolean = false;
47
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;
48
+ /** The scroll containers we have clipped, against the inline overflow each carried before. */
49
+ private readonly clipped = new Map<HTMLElement, { x: string; y: string }>();
51
50
  private frame: number = 0;
52
- private rebind: boolean = false;
53
- private offset: number = 0;
54
51
  private resizeObserver: ResizeObserver | null = null;
55
52
 
56
53
  connectedCallback() {
57
54
  super.connectedCallback();
58
55
 
59
56
  // Whether an ancestor scrolls depends on how tall this form has grown.
60
- this.resizeObserver ??= new ResizeObserver(() => this.schedule(true));
57
+ this.resizeObserver ??= new ResizeObserver(() => this.schedule());
61
58
  this.resizeObserver.observe(this);
62
59
  window.addEventListener('resize', this.onViewportResize);
63
60
  }
@@ -66,127 +63,95 @@ export default class ZnFormGroup extends ZincElement {
66
63
  super.disconnectedCallback();
67
64
  this.resizeObserver?.disconnect();
68
65
  window.removeEventListener('resize', this.onViewportResize);
69
- this.trackScroller(null);
66
+ this.release([...this.clipped.keys()]);
70
67
  cancelAnimationFrame(this.frame);
71
68
  this.frame = 0;
72
69
  }
73
70
 
74
71
  protected firstUpdated(changedProperties: PropertyValues) {
75
72
  super.firstUpdated(changedProperties);
76
- this.schedule(true);
73
+ this.schedule();
77
74
  }
78
75
 
79
76
  private get labelColumn(): HTMLElement | null {
80
77
  return this.shadowRoot?.querySelector('.form-control__text') ?? null;
81
78
  }
82
79
 
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;
80
+ /** Coalesces resize work into one frame, and out of the ResizeObserver callback. */
81
+ private schedule() {
86
82
  if (this.frame) return;
87
83
 
88
84
  this.frame = requestAnimationFrame(() => {
89
85
  this.frame = 0;
90
- if (this.rebind) {
91
- this.rebind = false;
92
- this.findScroller();
93
- }
94
- this.positionLabel();
86
+ this.freeSticky();
95
87
  });
96
88
  }
97
89
 
90
+ // A shorter viewport can make an ancestor scroll without changing this form's size.
91
+ private readonly onViewportResize = () => this.schedule();
92
+
98
93
  /**
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.
94
+ * Native sticky anchors to the nearest scroll container, even one that cannot scroll — a `zn-panel` body sized to
95
+ * its content, say — where it then holds the label still for the whole scroll. `overflow: clip` clips without
96
+ * making a scroll container, so clipping those takes them out of sticky's search and the label follows the box the
97
+ * user actually scrolls, moved by the compositor rather than by hand.
102
98
  */
103
- private findScroller() {
99
+ private freeSticky() {
104
100
  const column = this.labelColumn;
105
101
  if (!column) return;
106
102
 
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();
103
+ const dead: HTMLElement[] = [];
136
104
 
137
- // A shorter viewport can make an ancestor scrollable without changing this form's size.
138
- private readonly onViewportResize = () => this.schedule(true);
105
+ for (const element of this.ancestors(column)) {
106
+ if (!this.clipped.has(element) && !this.isStickyAnchor(element)) continue;
139
107
 
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);
108
+ // Scroll size reports the overflow through a clip, so a box that has grown into needing to scroll is handed
109
+ // straight back — and sticky anchors to it, which is now the right answer.
110
+ if (this.overflows(element)) break;
154
111
 
155
- offset = Math.min(Math.max(visibleTop + this.stickyTop - restingTop, 0), travel);
112
+ dead.push(element);
156
113
  }
157
114
 
158
- if (Math.round(offset) === Math.round(this.offset)) return;
159
-
160
- this.offset = offset;
161
- column.style.transform = offset ? `translateY(${offset}px)` : '';
115
+ this.release([...this.clipped.keys()].filter(element => !dead.includes(element)));
116
+ dead.filter(element => !this.clipped.has(element)).forEach(element => this.clip(element));
162
117
  }
163
118
 
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;
119
+ private clip(element: HTMLElement) {
120
+ // A second form group in the same container finds the first one's clip already inline. Recording it as what was
121
+ // there before would leave the box clipped for good, so it counts as nothing to put back.
122
+ const kept = (overflow: string) => overflow === 'clip' ? '' : overflow;
123
+ this.clipped.set(element, { x: kept(element.style.overflowX), y: kept(element.style.overflowY) });
124
+ element.style.overflowX = 'clip';
125
+ element.style.overflowY = 'clip';
126
+
127
+ // Scrolling has to go back the moment the box is short enough to need it.
128
+ this.resizeObserver?.observe(element);
170
129
  }
171
130
 
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;
131
+ private release(elements: HTMLElement[]) {
132
+ elements.forEach(element => {
133
+ const inline = this.clipped.get(element);
134
+ element.style.overflowX = inline?.x ?? '';
135
+ element.style.overflowY = inline?.y ?? '';
136
+ this.resizeObserver?.unobserve(element);
137
+ this.clipped.delete(element);
178
138
  });
139
+ }
179
140
 
180
- if (scroller) return scroller;
181
-
182
- const root = document.scrollingElement as HTMLElement | null;
183
- return root && root.scrollHeight > root.clientHeight + 1 ? root : null;
141
+ /** The box native sticky would anchor to, whether or not it can be scrolled. */
142
+ private isStickyAnchor(element: HTMLElement) {
143
+ const style = getComputedStyle(element);
144
+ return this.isScrollContainer(style.overflowY) || this.isScrollContainer(style.overflowX);
184
145
  }
185
146
 
186
147
  private isScrollContainer(overflow: string) {
187
148
  return overflow === 'auto' || overflow === 'scroll' || overflow === 'hidden' || overflow === 'overlay';
188
149
  }
189
150
 
151
+ private overflows(element: HTMLElement) {
152
+ return element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1;
153
+ }
154
+
190
155
  /** Walks the flattened tree, so slots and shadow boundaries are crossed the way layout does. */
191
156
  private ancestors(from: HTMLElement): HTMLElement[] {
192
157
  const out: HTMLElement[] = [];
@@ -78,8 +78,9 @@ 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.
81
82
  const el = await fixture<HTMLElement>(html`
82
- <div style="width: 900px">
83
+ <div style="width: 900px; padding-top: 60px">
83
84
  <zn-panel>
84
85
  <zn-form-group label="Sticky"><zn-input label="Name"></zn-input></zn-form-group>
85
86
  </zn-panel>
@@ -93,6 +94,25 @@ describe('<zn-form-group>', () => {
93
94
  expect(label).to.be.closeTo(inputs, 2);
94
95
  });
95
96
 
97
+ it('clips a scroll container that cannot scroll, and hands it back when it can', async () => {
98
+ const el = await fixture<HTMLElement>(html`
99
+ <div style="width: 900px; max-height: 300px; overflow-y: auto">
100
+ <zn-panel>
101
+ <zn-form-group label="Sticky"></zn-form-group>
102
+ </zn-panel>
103
+ </div>`);
104
+ el.querySelector('zn-form-group')!.innerHTML = tallForm;
105
+ await new Promise(resolve => setTimeout(resolve, 400));
106
+
107
+ const body = el.querySelector('zn-panel')!.shadowRoot!.querySelector<HTMLElement>('.panel__body')!;
108
+ expect(getComputedStyle(body).overflowY, 'sticky has to skip a body that cannot scroll').to.equal('clip');
109
+
110
+ body.style.maxHeight = '120px';
111
+ await new Promise(resolve => setTimeout(resolve, 400));
112
+
113
+ expect(getComputedStyle(body).overflowY, 'and it scrolls again once it must').to.equal('auto');
114
+ });
115
+
96
116
  it('holds the label in view when a panel sits between the form and the scroll container', async () => {
97
117
  const el = await fixture<HTMLElement>(html`
98
118
  <div style="max-height: 300px; overflow-y: auto">
@@ -25,7 +25,7 @@ export interface EditorAction {
25
25
  /** Where the caret lands within `prefix`. Defaults to the end. */
26
26
  caretOffset?: number;
27
27
  /** Actions that open their own picker instead of inserting text. */
28
- opens?: 'image' | 'include';
28
+ opens?: 'image' | 'include' | 'link';
29
29
  }
30
30
 
31
31
  /** Toolbar order, most-used first — the last groups are the first to collapse. */
@@ -128,6 +128,7 @@ export const EDITOR_ACTIONS: EditorAction[] = [
128
128
  {key: 'tooltip', label: 'Tooltip', icon: 'message-circle-question-mark@lu', group: 'inline', keywords: ['term'], inline: {before: '{', after: '}(Explanation)', placeholder: 'Term'}},
129
129
  {key: 'cross-reference', label: 'Cross reference', icon: 'link-2@lu', group: 'inline', keywords: ['xref'], inline: {before: '<<', after: '>>', placeholder: 'section,Label'}},
130
130
  {key: 'links-and-images', label: 'Link', icon: 'link@lu', group: 'inline', inline: {before: '[', after: '](https://)', placeholder: 'Label'}},
131
+ {key: 'document-link', label: 'Link to article', icon: 'file-symlink@lu', group: 'inline', keywords: ['article', 'document', 'kb'], opens: 'link'},
131
132
  {key: 'passthrough', label: 'Passthrough', icon: 'shield@lu', group: 'inline', keywords: ['raw', 'literal'], inline: {before: 'pass:[', after: ']', placeholder: 'raw'}},
132
133
  {key: 'curly-bang-passthrough', label: 'Literal braces', icon: 'braces@lu', group: 'inline', inline: {before: '{!', after: '!}', placeholder: 'raw'}},
133
134
  ];