@kubex/zinc 1.1.117 → 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">
@@ -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.117",
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,18 +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;
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 }>();
50
50
  private frame: number = 0;
51
- private rebind: boolean = false;
52
- private offset: number = 0;
53
51
  private resizeObserver: ResizeObserver | null = null;
54
52
 
55
53
  connectedCallback() {
56
54
  super.connectedCallback();
57
55
 
58
56
  // Whether an ancestor scrolls depends on how tall this form has grown.
59
- this.resizeObserver ??= new ResizeObserver(() => this.schedule(true));
57
+ this.resizeObserver ??= new ResizeObserver(() => this.schedule());
60
58
  this.resizeObserver.observe(this);
61
59
  window.addEventListener('resize', this.onViewportResize);
62
60
  }
@@ -65,119 +63,95 @@ export default class ZnFormGroup extends ZincElement {
65
63
  super.disconnectedCallback();
66
64
  this.resizeObserver?.disconnect();
67
65
  window.removeEventListener('resize', this.onViewportResize);
68
- this.trackScroller(null);
66
+ this.release([...this.clipped.keys()]);
69
67
  cancelAnimationFrame(this.frame);
70
68
  this.frame = 0;
71
69
  }
72
70
 
73
71
  protected firstUpdated(changedProperties: PropertyValues) {
74
72
  super.firstUpdated(changedProperties);
75
- this.schedule(true);
73
+ this.schedule();
76
74
  }
77
75
 
78
76
  private get labelColumn(): HTMLElement | null {
79
77
  return this.shadowRoot?.querySelector('.form-control__text') ?? null;
80
78
  }
81
79
 
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;
80
+ /** Coalesces resize work into one frame, and out of the ResizeObserver callback. */
81
+ private schedule() {
85
82
  if (this.frame) return;
86
83
 
87
84
  this.frame = requestAnimationFrame(() => {
88
85
  this.frame = 0;
89
- if (this.rebind) {
90
- this.rebind = false;
91
- this.findScroller();
92
- }
93
- this.positionLabel();
86
+ this.freeSticky();
94
87
  });
95
88
  }
96
89
 
90
+ // A shorter viewport can make an ancestor scroll without changing this form's size.
91
+ private readonly onViewportResize = () => this.schedule();
92
+
97
93
  /**
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.
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.
101
98
  */
102
- private findScroller() {
99
+ private freeSticky() {
103
100
  const column = this.labelColumn;
104
101
  if (!column) return;
105
102
 
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();
103
+ const dead: HTMLElement[] = [];
126
104
 
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;
105
+ for (const element of this.ancestors(column)) {
106
+ if (!this.clipped.has(element) && !this.isStickyAnchor(element)) continue;
134
107
 
135
- let offset = 0;
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;
136
111
 
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);
112
+ dead.push(element);
147
113
  }
148
114
 
149
- if (Math.round(offset) === Math.round(this.offset)) return;
150
-
151
- this.offset = offset;
152
- 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));
153
117
  }
154
118
 
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;
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);
161
129
  }
162
130
 
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;
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);
169
138
  });
139
+ }
170
140
 
171
- if (scroller) return scroller;
172
-
173
- const root = document.scrollingElement as HTMLElement | null;
174
- 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);
175
145
  }
176
146
 
177
147
  private isScrollContainer(overflow: string) {
178
148
  return overflow === 'auto' || overflow === 'scroll' || overflow === 'hidden' || overflow === 'overlay';
179
149
  }
180
150
 
151
+ private overflows(element: HTMLElement) {
152
+ return element.scrollHeight > element.clientHeight + 1 || element.scrollWidth > element.clientWidth + 1;
153
+ }
154
+
181
155
  /** Walks the flattened tree, so slots and shadow boundaries are crossed the way layout does. */
182
156
  private ancestors(from: HTMLElement): HTMLElement[] {
183
157
  const out: HTMLElement[] = [];
@@ -77,6 +77,42 @@ describe('<zn-form-group>', () => {
77
77
  expect(position(narrow)).to.equal('static');
78
78
  });
79
79
 
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
+ const el = await fixture<HTMLElement>(html`
83
+ <div style="width: 900px; padding-top: 60px">
84
+ <zn-panel>
85
+ <zn-form-group label="Sticky"><zn-input label="Name"></zn-input></zn-form-group>
86
+ </zn-panel>
87
+ </div>`);
88
+ await new Promise(resolve => setTimeout(resolve, 100));
89
+
90
+ const root = el.querySelector('zn-form-group')!.shadowRoot!;
91
+ const label = root.querySelector('.form-control__text')!.getBoundingClientRect().top;
92
+ const inputs = root.querySelector('.form-control-input')!.getBoundingClientRect().top;
93
+
94
+ expect(label).to.be.closeTo(inputs, 2);
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
+
80
116
  it('holds the label in view when a panel sits between the form and the scroll container', async () => {
81
117
  const el = await fixture<HTMLElement>(html`
82
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
  ];