@kubex/zinc 1.1.115 → 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 +619 -176
- package/dist/vscode.html-custom-data.json +72 -27
- package/dist/web-types.json +172 -52
- package/dist/zn.d.ts +218 -49
- package/dist/zn.min.js +480 -487
- package/docs/pages/components/dialog.md +45 -16
- package/docs/pages/components/form-group.md +42 -9
- package/docs/pages/components/translation-group.md +102 -93
- package/docs/pages/components/translations.md +64 -114
- package/package.json +1 -1
- package/src/components/dialog/dialog.component.ts +2 -1
- package/src/components/dialog/dialog.scss +8 -0
- 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
- package/src/components/header/header.component.ts +2 -1
- package/src/components/select/select.component.ts +5 -0
- package/src/components/select/select.scss +11 -0
- package/src/components/translation-group/translation-group.component.ts +171 -198
- package/src/components/translation-group/translation-group.scss +124 -1
- package/src/components/translation-group/translation-group.test.ts +371 -2
- package/src/components/translations/translations.component.ts +249 -209
- package/src/components/translations/translations.scss +3 -25
- package/src/components/translations/translations.test.ts +214 -10
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
---
|
|
2
2
|
meta:
|
|
3
3
|
title: Translations
|
|
4
|
-
description: A component for managing multi-language text input
|
|
4
|
+
description: A component for managing multi-language text input behind a language select.
|
|
5
5
|
layout: component
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
The Translations component provides a user-friendly interface for managing multi-language text input.
|
|
8
|
+
The Translations component provides a user-friendly interface for managing multi-language text input. A select above
|
|
9
|
+
the field chooses the language being edited, and each language it offers carries a chip saying whether it has a
|
|
10
|
+
translation of its own or falls back to English; closed, it carries how many languages are done as a `1/5` chip.
|
|
11
|
+
The field itself is a plain input, or a textarea with `input-type="textarea"`.
|
|
12
|
+
|
|
13
|
+
Several fields that should share one language picker belong in a
|
|
14
|
+
[`zn-translation-group`](/components/translation-group) instead.
|
|
9
15
|
|
|
10
16
|
```html:preview
|
|
11
17
|
<zn-translations
|
|
@@ -48,58 +54,25 @@ Use the `label` slot for rich HTML content in the label.
|
|
|
48
54
|
</zn-translations>
|
|
49
55
|
```
|
|
50
56
|
|
|
51
|
-
###
|
|
57
|
+
### Setting Values
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
`values` takes an object keyed by language code. `value` is the same thing as a JSON string, for setting it from
|
|
60
|
+
markup or from a server-rendered template — set one or the other, not both.
|
|
54
61
|
|
|
55
62
|
```html:preview
|
|
56
63
|
<zn-translations
|
|
57
|
-
label="
|
|
58
|
-
languages='{"en":"English","fr":"French","de":"German","es":"Spanish","it":"Italian","pt":"Portuguese"}'
|
|
59
|
-
></zn-translations>
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
### Pre-filled Values
|
|
63
|
-
|
|
64
|
-
Set initial translations using the `values` property.
|
|
65
|
-
|
|
66
|
-
```html:preview
|
|
67
|
-
<zn-translations
|
|
68
|
-
label="Welcome Message"
|
|
64
|
+
label="Set with values"
|
|
69
65
|
languages='{"en":"English","fr":"French","es":"Spanish"}'
|
|
70
66
|
values='{"en":"Hello World","fr":"Bonjour le monde","es":"Hola Mundo"}'
|
|
71
67
|
></zn-translations>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
### JSON Value Attribute
|
|
75
|
-
|
|
76
|
-
Alternatively, use the `value` attribute with a JSON string.
|
|
77
|
-
|
|
78
|
-
```html:preview
|
|
68
|
+
<br />
|
|
79
69
|
<zn-translations
|
|
80
|
-
label="
|
|
70
|
+
label="Set with value"
|
|
81
71
|
languages='{"en":"English","fr":"French"}'
|
|
82
72
|
value='{"en":"This is a product description","fr":"Ceci est une description de produit"}'
|
|
83
73
|
></zn-translations>
|
|
84
74
|
```
|
|
85
75
|
|
|
86
|
-
### Required Field
|
|
87
|
-
|
|
88
|
-
Mark the translations as required for form validation.
|
|
89
|
-
|
|
90
|
-
```html:preview
|
|
91
|
-
<form>
|
|
92
|
-
<zn-translations
|
|
93
|
-
name="description"
|
|
94
|
-
label="Product Description"
|
|
95
|
-
required
|
|
96
|
-
languages='{"en":"English","fr":"French"}'
|
|
97
|
-
></zn-translations>
|
|
98
|
-
<br />
|
|
99
|
-
<zn-button type="submit" color="success">Submit</zn-button>
|
|
100
|
-
</form>
|
|
101
|
-
```
|
|
102
|
-
|
|
103
76
|
### Disabled State
|
|
104
77
|
|
|
105
78
|
Disable editing of translations.
|
|
@@ -125,24 +98,38 @@ Remove padding for a more compact appearance.
|
|
|
125
98
|
></zn-translations>
|
|
126
99
|
```
|
|
127
100
|
|
|
128
|
-
###
|
|
101
|
+
### Textarea
|
|
129
102
|
|
|
130
|
-
Use
|
|
103
|
+
Use `input-type="textarea"` for longer copy, and `textarea-rows` to set its height.
|
|
131
104
|
|
|
132
105
|
```html:preview
|
|
133
106
|
<zn-translations
|
|
134
|
-
label="
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
107
|
+
label="Confirmation Message"
|
|
108
|
+
input-type="textarea"
|
|
109
|
+
textarea-rows="3"
|
|
110
|
+
languages='{"en":"English","de":"German"}'
|
|
111
|
+
values='{"en":"Thanks — your order is on its way.","de":"Danke — Ihre Bestellung ist unterwegs."}'
|
|
112
|
+
></zn-translations>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Inline Editing
|
|
116
|
+
|
|
117
|
+
Add `inline-edit` to read the translation as text until it is clicked, through
|
|
118
|
+
[`zn-inline-edit`](/components/inline-edit), rather than showing an input outright.
|
|
119
|
+
|
|
120
|
+
```html:preview
|
|
121
|
+
<zn-translations
|
|
122
|
+
inline-edit
|
|
123
|
+
label="Welcome Message"
|
|
124
|
+
languages='{"en":"English","fr":"French"}'
|
|
125
|
+
values='{"en":"Hello World","fr":"Bonjour le monde"}'
|
|
126
|
+
></zn-translations>
|
|
140
127
|
```
|
|
141
128
|
|
|
142
129
|
### Many Languages
|
|
143
130
|
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
146
133
|
|
|
147
134
|
```html:preview
|
|
148
135
|
<zn-translations
|
|
@@ -152,19 +139,17 @@ single line. Resizing the container re-measures and expands the buttons back out
|
|
|
152
139
|
></zn-translations>
|
|
153
140
|
```
|
|
154
141
|
|
|
155
|
-
###
|
|
142
|
+
### Blank Languages Fall Back to English
|
|
156
143
|
|
|
157
|
-
|
|
158
|
-
|
|
144
|
+
A language you have not translated yet is marked `English` in the select rather than hidden, and its field shows the
|
|
145
|
+
English text as a placeholder. Leave it blank and the English text is what gets used.
|
|
159
146
|
|
|
160
147
|
```html:preview
|
|
161
|
-
<
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
></zn-translations>
|
|
167
|
-
</div>
|
|
148
|
+
<zn-translations
|
|
149
|
+
label="Product Name"
|
|
150
|
+
languages='{"en":"English","fr":"French","de":"German","pl":"Polish"}'
|
|
151
|
+
values='{"en":"Premium Wireless Headphones","de":"Premium kabellose Kopfhörer"}'
|
|
152
|
+
></zn-translations>
|
|
168
153
|
```
|
|
169
154
|
|
|
170
155
|
### RTL Language Support
|
|
@@ -209,7 +194,8 @@ The component emits `zn-change` events when translation values change.
|
|
|
209
194
|
|
|
210
195
|
### Form Integration
|
|
211
196
|
|
|
212
|
-
|
|
197
|
+
The component submits its translations as a JSON object under `name`. `required` marks the label, though validity is
|
|
198
|
+
not enforced per language.
|
|
213
199
|
|
|
214
200
|
```html:preview
|
|
215
201
|
<form class="translations-form">
|
|
@@ -275,54 +261,6 @@ Access and modify translation values via JavaScript.
|
|
|
275
261
|
</script>
|
|
276
262
|
```
|
|
277
263
|
|
|
278
|
-
### Real-World Use Case: Content Management
|
|
279
|
-
|
|
280
|
-
A complete example showing product content management with translations.
|
|
281
|
-
|
|
282
|
-
```html:preview
|
|
283
|
-
<div style="max-width: 800px;">
|
|
284
|
-
<zn-panel caption="Product Details" icon="inventory_2">
|
|
285
|
-
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
|
286
|
-
<zn-input
|
|
287
|
-
label="Product SKU"
|
|
288
|
-
value="PROD-12345"
|
|
289
|
-
readonly
|
|
290
|
-
></zn-input>
|
|
291
|
-
|
|
292
|
-
<zn-translations
|
|
293
|
-
name="name"
|
|
294
|
-
label="Product Name"
|
|
295
|
-
required
|
|
296
|
-
languages='{"en":"English","fr":"French","de":"German","es":"Spanish"}'
|
|
297
|
-
values='{"en":"Premium Wireless Headphones","fr":"Écouteurs sans fil premium","de":"Premium kabellose Kopfhörer","es":"Auriculares inalámbricos premium"}'
|
|
298
|
-
></zn-translations>
|
|
299
|
-
|
|
300
|
-
<zn-translations
|
|
301
|
-
name="description"
|
|
302
|
-
label="Product Description"
|
|
303
|
-
languages='{"en":"English","fr":"French","de":"German","es":"Spanish"}'
|
|
304
|
-
values='{"en":"High-quality wireless headphones with active noise cancellation","fr":"Écouteurs sans fil de haute qualité avec suppression active du bruit","de":"Hochwertige kabellose Kopfhörer mit aktiver Geräuschunterdrückung","es":"Auriculares inalámbricos de alta calidad con cancelación activa de ruido"}'
|
|
305
|
-
>
|
|
306
|
-
<zn-button slot="expand" color="transparent" icon="smart_toy">
|
|
307
|
-
AI Translate
|
|
308
|
-
</zn-button>
|
|
309
|
-
</zn-translations>
|
|
310
|
-
|
|
311
|
-
<zn-input
|
|
312
|
-
label="Price"
|
|
313
|
-
type="currency"
|
|
314
|
-
value="299.99"
|
|
315
|
-
></zn-input>
|
|
316
|
-
</div>
|
|
317
|
-
|
|
318
|
-
<div slot="footer" style="display: flex; gap: 0.5rem; justify-content: flex-end;">
|
|
319
|
-
<zn-button color="secondary">Cancel</zn-button>
|
|
320
|
-
<zn-button color="success">Save Product</zn-button>
|
|
321
|
-
</div>
|
|
322
|
-
</zn-panel>
|
|
323
|
-
</div>
|
|
324
|
-
```
|
|
325
|
-
|
|
326
264
|
### Help Text
|
|
327
265
|
|
|
328
266
|
Use the `help-text` attribute to describe how the field should be filled in. It sits below the field and applies to
|
|
@@ -382,6 +320,10 @@ way `Enter` otherwise would.
|
|
|
382
320
|
| `flush` | `boolean` | `false` | Removes padding for compact layout |
|
|
383
321
|
| `languages` | `Record<string, string>` | `{en: "EN"}` | Object mapping language codes to display names |
|
|
384
322
|
| `values` | `Record<string, string>` | `{}` | Object mapping language codes to translation text |
|
|
323
|
+
| `grouped` | `boolean` | `false` | Hides the language select; a parent `zn-translation-group` drives it |
|
|
324
|
+
| `input-type` | `'text' \| 'number' \| 'textarea'` | `'text'` | The control each translation is edited through |
|
|
325
|
+
| `textarea-rows` | `number` | — | Rows of the textarea, when `input-type` is `textarea` |
|
|
326
|
+
| `inline-edit` | `boolean` | `false` | Edits through `zn-inline-edit` instead of a plain input or textarea |
|
|
385
327
|
| `slash-items` | `SlashMenuItem[]` | `[]` | Quick insertions offered by the slash menu |
|
|
386
328
|
| `slash-preset` | `string` | `''` | Registered item sets to offer, comma separated |
|
|
387
329
|
| `slash-trigger` | `string` | `'/'` | The characters that open the slash menu |
|
|
@@ -399,7 +341,6 @@ way `Enter` otherwise would.
|
|
|
399
341
|
| Slot | Description |
|
|
400
342
|
|-------------|------------------------------------------------------------------|
|
|
401
343
|
| `label` | Alternative to the `label` attribute for rich HTML content |
|
|
402
|
-
| `expand` | Action button displayed in the navbar (e.g., translate button) |
|
|
403
344
|
| `help-text` | Alternative to the `help-text` attribute for rich HTML content |
|
|
404
345
|
|
|
405
346
|
## Methods
|
|
@@ -413,10 +354,19 @@ way `Enter` otherwise would.
|
|
|
413
354
|
|
|
414
355
|
## CSS Parts
|
|
415
356
|
|
|
416
|
-
|
|
357
|
+
| Part | Description |
|
|
358
|
+
|----------------------|---------------------------------------------------|
|
|
359
|
+
| `form-control` | The component's base wrapper |
|
|
360
|
+
| `form-control-label` | The label's wrapper |
|
|
361
|
+
| `form-control-input` | The wrapper around the field being edited |
|
|
362
|
+
| `language-select` | The select that chooses the language being edited |
|
|
363
|
+
|
|
364
|
+
The component uses `zn-select` for the language and `zn-input`, `zn-textarea` or `zn-inline-edit` for the field, each
|
|
365
|
+
of which exposes its own CSS parts for advanced styling.
|
|
417
366
|
|
|
418
367
|
## Accessibility
|
|
419
368
|
|
|
420
369
|
- The component automatically detects RTL languages (Arabic, Hebrew) and applies proper text direction
|
|
421
|
-
-
|
|
422
|
-
|
|
370
|
+
- The language select is a standard combobox: it opens on `Enter` or `Space` and moves through the languages with the
|
|
371
|
+
arrow keys
|
|
372
|
+
- `Enter` submits the form from a single-line field, and inserts a newline in a textarea
|
package/package.json
CHANGED
|
@@ -235,7 +235,8 @@ export default class ZnDialog extends ZincElement {
|
|
|
235
235
|
<h2 part="title" class="dialog__title" id="title">
|
|
236
236
|
<slot name="header-icon"></slot>
|
|
237
237
|
<slot name="announcement-intro"></slot>
|
|
238
|
-
<slot name="label"
|
|
238
|
+
<slot name="label" class="dialog__label">
|
|
239
|
+
${this.label && this.label.length > 0 ? this.label : String.fromCharCode(65279)}
|
|
239
240
|
</slot>
|
|
240
241
|
</h2>
|
|
241
242
|
<div part="header-actions" class="dialog__header-actions">
|
|
@@ -55,6 +55,8 @@
|
|
|
55
55
|
align-items: center;
|
|
56
56
|
font: inherit;
|
|
57
57
|
margin: 0;
|
|
58
|
+
width: 100%;
|
|
59
|
+
overflow: hidden;
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
&__header-actions {
|
|
@@ -75,6 +77,12 @@
|
|
|
75
77
|
}
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
&__label {
|
|
81
|
+
overflow: hidden;
|
|
82
|
+
word-break: break-word;
|
|
83
|
+
display: inline-block;
|
|
84
|
+
}
|
|
85
|
+
|
|
78
86
|
&__body {
|
|
79
87
|
flex: 1 1 auto;
|
|
80
88
|
min-height: 0;
|
|
@@ -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
|
});
|
|
@@ -22,6 +22,7 @@ import styles from './header.scss';
|
|
|
22
22
|
* @slot example - An example slot.
|
|
23
23
|
*
|
|
24
24
|
* @csspart base - The component's base wrapper.
|
|
25
|
+
* @csspart header-right - The container that wraps the actions slot.
|
|
25
26
|
*
|
|
26
27
|
* @cssproperty --example - An example CSS custom property.
|
|
27
28
|
*/
|
|
@@ -159,7 +160,7 @@ export default class ZnHeader extends ZincElement {
|
|
|
159
160
|
</span>
|
|
160
161
|
</div>
|
|
161
162
|
|
|
162
|
-
<div class="header__right">
|
|
163
|
+
<div part="header-right" class="header__right">
|
|
163
164
|
<slot name="actions"></slot>
|
|
164
165
|
</div>
|
|
165
166
|
</div>
|
|
@@ -41,6 +41,8 @@ import styles from './select.scss';
|
|
|
41
41
|
* @slot label-tooltip - Used to add text that is displayed in a tooltip next to the label. Alternatively, you can use the `label-tooltip` attribute.
|
|
42
42
|
* @slot context-note - Used to add contextual text that is displayed above the select, on the right. Alternatively, you can use the `context-note` attribute.
|
|
43
43
|
* @slot prefix - Used to prepend a presentational icon or similar element to the combobox.
|
|
44
|
+
* @slot suffix - Used to append a presentational element — a chip marking the selected option's state, for
|
|
45
|
+
* instance — to the combobox, between the value and the clear and expand icons.
|
|
44
46
|
* @slot clear-icon - An icon to use in lieu of the default clear icon.
|
|
45
47
|
* @slot expand-icon - The icon to show when the control is expanded and collapsed. Rotates on open and close.
|
|
46
48
|
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
|
|
@@ -66,6 +68,7 @@ import styles from './select.scss';
|
|
|
66
68
|
* @csspart form-control-help-text - The help text's wrapper.
|
|
67
69
|
* @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button.
|
|
68
70
|
* @csspart prefix - The container that wraps the prefix slot.
|
|
71
|
+
* @csspart suffix - The container that wraps the suffix slot.
|
|
69
72
|
* @csspart display-input - The element that displays the selected option's label, an `<input>` element.
|
|
70
73
|
* @csspart listbox - The listbox container where options are slotted.
|
|
71
74
|
* @csspart tags - The container that houses option tags when `multiselect` is used.
|
|
@@ -1918,6 +1921,8 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
|
|
|
1918
1921
|
@invalid=${this.handleInvalid}
|
|
1919
1922
|
/>
|
|
1920
1923
|
|
|
1924
|
+
<slot part="suffix" name="suffix" class="select__suffix"></slot>
|
|
1925
|
+
|
|
1921
1926
|
${hasClearIcon
|
|
1922
1927
|
? html`
|
|
1923
1928
|
<button
|
|
@@ -314,6 +314,17 @@
|
|
|
314
314
|
padding-inline-start: 0;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
/* Suffix */
|
|
318
|
+
.select__suffix {
|
|
319
|
+
flex: 0 0 auto;
|
|
320
|
+
display: inline-flex;
|
|
321
|
+
align-items: center;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.select__suffix::slotted(*) {
|
|
325
|
+
margin-inline-start: var(--zn-spacing-x-small);
|
|
326
|
+
}
|
|
327
|
+
|
|
317
328
|
/* Clear button */
|
|
318
329
|
.select__clear {
|
|
319
330
|
display: inline-flex;
|