@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.
- package/dist/custom-elements.json +1733 -1567
- package/dist/vscode.html-custom-data.json +146 -136
- package/dist/web-types.json +329 -309
- package/dist/zn.d.ts +74 -37
- package/dist/zn.min.js +278 -258
- package/docs/pages/components/form-group.md +2 -0
- package/docs/pages/components/translation-group.md +20 -18
- package/docs/pages/components/translations.md +3 -2
- package/package.json +1 -1
- package/src/components/form-group/form-group.component.ts +51 -77
- package/src/components/form-group/form-group.test.ts +36 -0
- package/src/components/remarkd-editor/actions.ts +2 -1
- package/src/components/remarkd-editor/remarkd-editor.component.ts +251 -9
- package/src/components/remarkd-editor/remarkd-editor.scss +49 -0
- package/src/components/remarkd-editor/remarkd-editor.test.ts +329 -5
- package/src/components/translation-group/translation-group.component.ts +55 -51
- package/src/components/translation-group/translation-group.scss +10 -74
- package/src/components/translation-group/translation-group.test.ts +76 -38
- package/src/components/translations/translations.component.ts +3 -7
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
###
|
|
46
|
+
### Label and Help Text
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
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` | `''` |
|
|
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
|
-
| `
|
|
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
|
|
132
|
-
|
|
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
|
@@ -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
|
|
49
|
-
private
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
|
83
|
-
private schedule(
|
|
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
|
-
|
|
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
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
99
|
+
private freeSticky() {
|
|
103
100
|
const column = this.labelColumn;
|
|
104
101
|
if (!column) return;
|
|
105
102
|
|
|
106
|
-
const
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
return
|
|
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
|
];
|