@kubex/zinc 1.1.117 → 1.1.118

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.
@@ -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.118",
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",
@@ -47,6 +47,7 @@ export default class ZnFormGroup extends ZincElement {
47
47
 
48
48
  /** The scroller the label is tracked against by hand; null while native sticky is enough. */
49
49
  private tracked: HTMLElement | null = null;
50
+ private stickyTop: number = 0;
50
51
  private frame: number = 0;
51
52
  private rebind: boolean = false;
52
53
  private offset: number = 0;
@@ -103,9 +104,18 @@ export default class ZnFormGroup extends ZincElement {
103
104
  const column = this.labelColumn;
104
105
  if (!column) return;
105
106
 
107
+ column.style.top = '';
108
+ this.stickyTop = parseFloat(getComputedStyle(column).top) || 0;
109
+
106
110
  const anchor = this.nearestScrollContainer(column);
107
111
  const scroller = anchor ? this.scrollingAncestor(column) : null;
108
- this.trackScroller(scroller === anchor ? null : scroller);
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';
109
119
  }
110
120
 
111
121
  private trackScroller(scroller: HTMLElement | null) {
@@ -139,11 +149,10 @@ export default class ZnFormGroup extends ZincElement {
139
149
  const visibleTop = this.tracked === document.scrollingElement
140
150
  ? 0
141
151
  : this.tracked.getBoundingClientRect().top;
142
- const stickyTop = parseFloat(getComputedStyle(column).top) || 0;
143
152
  const restingTop = column.getBoundingClientRect().top - this.offset;
144
153
  const travel = Math.max(0, fieldset.clientHeight - column.offsetHeight);
145
154
 
146
- offset = Math.min(Math.max(visibleTop + stickyTop - restingTop, 0), travel);
155
+ offset = Math.min(Math.max(visibleTop + this.stickyTop - restingTop, 0), travel);
147
156
  }
148
157
 
149
158
  if (Math.round(offset) === Math.round(this.offset)) return;
@@ -77,6 +77,22 @@ 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
+ const el = await fixture<HTMLElement>(html`
82
+ <div style="width: 900px">
83
+ <zn-panel>
84
+ <zn-form-group label="Sticky"><zn-input label="Name"></zn-input></zn-form-group>
85
+ </zn-panel>
86
+ </div>`);
87
+ await new Promise(resolve => setTimeout(resolve, 100));
88
+
89
+ const root = el.querySelector('zn-form-group')!.shadowRoot!;
90
+ const label = root.querySelector('.form-control__text')!.getBoundingClientRect().top;
91
+ const inputs = root.querySelector('.form-control-input')!.getBoundingClientRect().top;
92
+
93
+ expect(label).to.be.closeTo(inputs, 2);
94
+ });
95
+
80
96
  it('holds the label in view when a panel sits between the form and the scroll container', async () => {
81
97
  const el = await fixture<HTMLElement>(html`
82
98
  <div style="max-height: 300px; overflow-y: auto">
@@ -1,10 +1,9 @@
1
1
  import {classMap} from 'lit/directives/class-map.js';
2
2
  import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from 'lit';
3
3
  import {HasSlotController} from '../../internal/slot';
4
- import {ifDefined} from 'lit/directives/if-defined.js';
5
4
  import {property, state} from 'lit/decorators.js';
6
5
  import ZnChip from '../chip';
7
- import ZnHeader from '../header';
6
+ import ZnFormGroup from '../form-group';
8
7
  import ZnOption from '../option';
9
8
  import ZnPanel from '../panel/panel.component';
10
9
  import ZnSelect from '../select';
@@ -19,8 +18,12 @@ import styles from './translation-group.scss';
19
18
  * @status experimental
20
19
  * @since 1.0
21
20
  *
22
- * The select sits at the top right of the header, opposite the caption. Choosing a language switches every child at
23
- * once, and each child hides its own select while it is in a group — `grouped` is set on them here.
21
+ * The fields sit in a `zn-form-group`, so the caption, help text and the language select share its label column and
22
+ * the fields line up with every other form group around them. Choosing a language switches every child at once, and
23
+ * each child hides its own select while it is in a group — `grouped` is set on them here.
24
+ *
25
+ * The select is searchable. Each option holds its language code as its value, so typing `de` finds German without the
26
+ * code being on show.
24
27
  *
25
28
  * Closed, the select carries how many target languages are done — `1/5`. Its options each carry a chip aggregated
26
29
  * across the children:
@@ -35,11 +38,11 @@ import styles from './translation-group.scss';
35
38
  * The children own their values; this component only chooses which language is shown and reports on what they hold.
36
39
  * It reads them back on every child `zn-change`, so the chips and the count follow an edit as it is typed.
37
40
  *
38
- * Extends `zn-panel`, so `caption`, `icon`, `flush`, `transparent` and the `footer` slot behave as they do there.
39
- * Nested inside another panel, add `inline` to drop the chrome and keep the fields aligned with the surrounding form.
41
+ * Extends `zn-panel`, so `caption`, `flush`, `transparent` and the `footer` slot behave as they do there. Nested
42
+ * inside another panel, add `inline` to drop the chrome and keep the fields aligned with the surrounding form.
40
43
  *
41
44
  * @dependency zn-chip
42
- * @dependency zn-header
45
+ * @dependency zn-form-group
43
46
  * @dependency zn-option
44
47
  * @dependency zn-select
45
48
  *
@@ -49,30 +52,33 @@ import styles from './translation-group.scss';
49
52
  * @slot actions - Buttons for the bottom of the panel, on the white body rather than the grey footer. They sit on
50
53
  * the right, as zinc's form action rows do; `align="start"` moves one to the left. Write them in the order they
51
54
  * should be read — the sides are set by CSS ordering, so markup order is what a keyboard follows.
52
- * @slot footer - Content displayed in the grey panel footer. The header belongs to the language select; nothing
53
- * else is slotted into it.
55
+ * @slot footer - Content displayed in the grey panel footer.
54
56
  *
55
57
  * @csspart base - The component's base wrapper.
58
+ * @csspart form-group - The form group holding the caption, the language select and the fields.
56
59
  * @csspart actions - The row of buttons at the bottom of the body.
57
- * @csspart language-field - The label and select that choose the language every child is editing.
60
+ * @csspart language-field - The select that chooses the language every child is editing, in the group's chip slot.
58
61
  * @csspart language-select - The select itself.
59
62
  */
60
63
  export default class ZnTranslationGroup extends ZnPanel {
61
64
  static styles: CSSResultGroup = [ZnPanel.styles, unsafeCSS(styles)];
62
65
  static dependencies = {
63
66
  'zn-chip': ZnChip,
64
- 'zn-header': ZnHeader,
67
+ 'zn-form-group': ZnFormGroup,
65
68
  'zn-option': ZnOption,
66
69
  'zn-select': ZnSelect
67
70
  };
68
71
 
69
72
  private readonly _slotController = new HasSlotController(this, 'actions', 'footer');
70
73
 
71
- /** The caption shown in the panel header. An alias for the inherited `caption`, which wins where both are set. */
74
+ /** The form group's label. An alias for the inherited `caption`, which wins where both are set. */
72
75
  @property() label = '';
73
76
 
77
+ /** Sits under the label, above the language select, as help text does in any other form group. */
78
+ @property({attribute: 'help-text'}) helpText = '';
79
+
74
80
  /**
75
- * Drops the panel chrome — border, background and padding — so the group reads as a section of the form around it
81
+ * Drops the panel chrome — border, background and padding — so the group reads as a section of the surrounding form
76
82
  * rather than a panel of its own. For groups nested inside another panel, where the fields would otherwise sit
77
83
  * indented behind a second border.
78
84
  */
@@ -149,11 +155,9 @@ export default class ZnTranslationGroup extends ZnPanel {
149
155
  return {type: 'error', label: language === 'en' ? 'Empty' : 'English'};
150
156
  }
151
157
 
152
- /** `English (EN)`, or the code alone where the configured name already is the code. */
158
+ /** The configured name, or the code where `languages` does not name the language. */
153
159
  private displayName(language: string): string {
154
- const name = this.languages[language] ?? language.toUpperCase();
155
- const code = language.toUpperCase();
156
- return name.toUpperCase() === code ? name : `${name} (${code})`;
160
+ return this.languages[language] ?? language.toUpperCase();
157
161
  }
158
162
 
159
163
  /** Children take their language list from the group, so a change to `languages` has to reach them. */
@@ -206,7 +210,7 @@ export default class ZnTranslationGroup extends ZnPanel {
206
210
  render() {
207
211
  const hasActionsSlot = this._slotController.test('actions');
208
212
  const hasFooterSlot = this._slotController.test('footer');
209
- const headerCaption = this.caption || this.label;
213
+ const caption = this.caption || this.label;
210
214
 
211
215
  // A child's value can carry a language `languages` does not list — server-rendered content outliving a config
212
216
  // change. Offer those too, or the translation is stranded in the value with no way to reach it.
@@ -216,7 +220,6 @@ export default class ZnTranslationGroup extends ZnPanel {
216
220
  .forEach(code => extra.add(code)));
217
221
  const languageCodes = [...Object.keys(this.languages), ...extra];
218
222
  const hasMultipleLanguages = languageCodes.length > 1;
219
- const hasHeader = Boolean(headerCaption) || hasMultipleLanguages;
220
223
 
221
224
  // English is the source every other language falls back to, so it is not itself one of the translations counted.
222
225
  const targets = languageCodes.filter(code => code !== 'en');
@@ -229,49 +232,50 @@ export default class ZnTranslationGroup extends ZnPanel {
229
232
  };
230
233
 
231
234
  return html`
232
- <div class="${classMap({
235
+ <div part="base" class="${classMap({
233
236
  panel: true,
234
237
  'panel--flush': this.flush || this.inline,
235
238
  'panel--transparent': this.transparent || this.inline,
236
239
  'translation-group--inline': this.inline,
237
- 'panel--has-header': hasHeader,
238
240
  'panel--has-actions': hasActionsSlot,
239
241
  'panel--has-footer': hasFooterSlot,
240
242
  })}">
241
243
 
242
244
  <div class="panel__inner">
243
- ${hasHeader ? html`
244
- <zn-header class="panel__header"
245
- caption="${ifDefined(headerCaption || undefined)}"
246
- transparent>
247
- ${hasMultipleLanguages ? html`
248
- <div slot="actions" class="translation-group__language-field" part="language-field">
249
- <zn-select
250
- label="${this.languageLabel}"
251
- class="translation-group__language-select"
252
- part="language-select"
253
- hoist
254
- .value="${this._activeLanguage}"
255
- @zn-change="${this.handleLanguageSelect}"
256
- @zn-input="${this.handleLanguageInput}">
257
- <zn-chip slot="suffix" type="${summary.type}">${summary.label}</zn-chip>
258
- ${languageCodes.map(code => {
259
- const optionState = this.languageState(code);
260
- return html`
261
- <zn-option value="${code}">
262
- ${this.displayName(code)}
263
- <zn-chip slot="suffix" type="${optionState.type}">${optionState.label}</zn-chip>
264
- </zn-option>`;
265
- })}
266
- </zn-select>
267
- </div>` : nothing}
268
- </zn-header>` : null}
269
-
270
245
  <div class="panel__content">
271
246
  <div class="panel__body">
272
- <slot
273
- @slotchange="${this.handleSlotChange}"
274
- @zn-change="${this.handleChildChange}"></slot>
247
+ <zn-form-group
248
+ part="form-group"
249
+ label="${caption}"
250
+ help-text="${this.helpText}">
251
+
252
+ ${hasMultipleLanguages ? html`
253
+ <div slot="chip" class="translation-group__language-field" part="language-field">
254
+ <zn-select
255
+ label="${this.languageLabel}"
256
+ class="translation-group__language-select"
257
+ part="language-select"
258
+ hoist
259
+ search
260
+ .value="${this._activeLanguage}"
261
+ @zn-change="${this.handleLanguageSelect}"
262
+ @zn-input="${this.handleLanguageInput}">
263
+ <zn-chip slot="suffix" type="${summary.type}">${summary.label}</zn-chip>
264
+ ${languageCodes.map(code => {
265
+ const optionState = this.languageState(code);
266
+ return html`
267
+ <zn-option value="${code}">
268
+ ${this.displayName(code)}
269
+ <zn-chip slot="suffix" type="${optionState.type}">${optionState.label}</zn-chip>
270
+ </zn-option>`;
271
+ })}
272
+ </zn-select>
273
+ </div>` : nothing}
274
+
275
+ <slot
276
+ @slotchange="${this.handleSlotChange}"
277
+ @zn-change="${this.handleChildChange}"></slot>
278
+ </zn-form-group>
275
279
 
276
280
  ${hasActionsSlot ? html`
277
281
  <div class="translation-group__actions" part="actions">
@@ -2,81 +2,15 @@
2
2
  display: block;
3
3
  }
4
4
 
5
- // The body stacks the fields the select drives, so they take the row gap zn-form-group sets between stacked
6
- // controls. The slotted children are `display: contents` through the slot, putting them in this same flex flow.
5
+ // The body holds the form group and the actions row; the gap is what separates them.
7
6
  .panel__body {
8
7
  gap: var(--zn-spacing-medium);
9
8
  }
10
9
 
11
- // The header sets its own gap down to the first field; the panel's body padding on top of it would make that one gap
12
- // the odd one out.
13
- .panel--has-header .panel__body {
14
- padding-top: 0;
15
- }
16
-
17
- .panel__header {
18
- padding-bottom: var(--zn-spacing-medium);
19
- }
20
-
21
- .translation-group--inline .panel__header {
22
- padding: 0 0 var(--zn-spacing-medium);
23
- }
24
-
25
- // zn-header holds its content row at 36px so a caption sits level with action buttons. The select is taller than
26
- // that and sets the height on its own, so the row hugs whichever of the two is bigger.
27
- .panel__header::part(content) {
28
- min-height: 0;
29
- }
30
-
31
- // The actions container is a plain block that clips horizontally: its content would sit at the top rather than level
32
- // with the caption, and a select flush against its right edge would lose the focus ring.
33
- .panel__header::part(header-right) {
34
- display: flex;
35
- align-items: center;
36
- justify-content: flex-end;
37
- overflow: visible;
38
- }
39
-
40
- .translation-group__language-field {
41
- display: flex;
42
- align-items: center;
43
- }
44
-
45
- // The trigger shows one language, so it sizes to that.
46
- .translation-group__language-select {
47
- width: max-content;
48
- max-width: 100%;
49
- }
50
-
51
- // Left alone the display input hands the trigger its default 20-character intrinsic width, however short the language
52
- // name is. Where field-sizing is unsupported that width is what is left, which only makes the trigger a little wider.
53
- .translation-group__language-select::part(display-input) {
54
- width: auto;
55
- min-width: 0;
56
- field-sizing: content;
57
- }
58
-
59
- // The popup syncs its width to the trigger, which is now sized to the current language rather than the longest one in
60
- // the list. zn-select runs the popup with auto-size="vertical", so --auto-size-available-width is never set and the
61
- // listbox's own max-width is inert — this is the only thing keeping a long list on screen.
62
- .translation-group__language-select::part(listbox) {
63
- min-width: 320px;
64
- max-width: calc(100vw - var(--zn-spacing-large));
65
- }
66
-
67
- // The select is chrome in the panel header rather than a field to fill in, so it drops the input's fill and shadow.
68
- // The border stays, marking it out as something to open.
69
- .translation-group__language-select::part(combobox) {
70
- background-color: transparent;
71
- box-shadow: none;
72
- }
73
-
74
- .translation-group__language-select:hover::part(combobox) {
75
- background-color: var(--zn-input-background-color-hover);
76
- }
77
-
78
- .translation-group__language-select:focus-within::part(combobox) {
79
- box-shadow: 0 0 0 var(--zn-focus-ring-width) var(--zn-input-focus-ring-color);
10
+ // The fields land in the form group's 6-column input grid, but they are assigned to this slot rather than the
11
+ // group's, so its span rules cannot reach them — each takes the full row from here.
12
+ ::slotted(:not([slot])) {
13
+ grid-column: 1 / -1;
80
14
  }
81
15
 
82
16
  // The select's label is its accessible name only; the caption names the section on screen.
@@ -92,11 +26,13 @@
92
26
  border: 0;
93
27
  }
94
28
 
95
- // Matches zinc's form action rows (.form-actions, zn-form-actions): right-aligned, 16px apart.
29
+ // Matches zinc's form action rows (.form-actions, zn-form-actions): right-aligned, 16px apart. The cap holds the
30
+ // buttons level with the fields' right edge, which zn-form-group sizes to the same container.
96
31
  .translation-group__actions {
97
32
  display: flex;
98
33
  align-items: center;
99
34
  gap: var(--zn-spacing-small);
35
+ max-width: var(--zn-container-lg);
100
36
  }
101
37
 
102
38
  // The spacer, not an auto margin, is what splits the two sides: an auto margin on every right-hand button would open
@@ -114,8 +50,8 @@
114
50
  order: 0;
115
51
  }
116
52
 
117
- // The panel draws a border between any two body children, and the actions row is the first real element to follow
118
- // the fields' slot. Matched on .panel--has-actions to out-specify that rule.
53
+ // The panel draws a border between any two body children, and the actions row follows the form group. Matched on
54
+ // .panel--has-actions to out-specify that rule.
119
55
  .panel--has-actions .panel__body > .translation-group__actions {
120
56
  border: 0;
121
57
  }