@kubex/zinc 1.1.44 → 1.1.46

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.
@@ -52,6 +52,26 @@ layout: component
52
52
  </zn-content-block>
53
53
  ```
54
54
 
55
+ ### Attachments
56
+
57
+ Attachments render in a row at the bottom of the content and are only visible while the block
58
+ is expanded. Use `zn-chat-message-attachment`, which assigns itself to the `attachments` slot.
59
+ When a block has attachments, a paperclip icon and count are shown in the header after the
60
+ preview text.
61
+
62
+ ```html:preview
63
+
64
+ <zn-content-block
65
+ time="16 Jun 2025, 13:00"
66
+ sender="John Smith"
67
+ avatar="JS"
68
+ >
69
+ <div slot="text">Text formatted message goes here.</div>
70
+ <zn-chat-message-attachment href="/" name="filename1.txt" download></zn-chat-message-attachment>
71
+ <zn-chat-message-attachment href="/" name="filename2.png" download></zn-chat-message-attachment>
72
+ </zn-content-block>
73
+ ```
74
+
55
75
  ### Content Footer
56
76
 
57
77
  Basic Footer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.44",
3
+ "version": "1.1.46",
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",
@@ -6,10 +6,10 @@ import ZnIcon from "../icon";
6
6
  import styles from './chat-message-attachment.scss';
7
7
 
8
8
  /**
9
- * @summary A single file or link attachment for a `zn-chat-message`. Renders an
10
- * icon and a label as a link, styled to match the message's attachments row. It is
11
- * intended to be used only inside a `zn-chat-message` and is automatically placed in
12
- * that component's `attachments` slot.
9
+ * @summary A single file or link attachment for a `zn-chat-message` or `zn-content-block`.
10
+ * Renders an icon and a label as a link, styled to match the message's attachments row. It is
11
+ * intended to be used only inside a `zn-chat-message` or `zn-content-block` and is automatically
12
+ * placed in that component's `attachments` slot.
13
13
  * @documentation https://zinc.style/components/chat-message-attachment
14
14
  * @status experimental
15
15
  * @since 1.0
@@ -51,12 +51,12 @@ export default class ZnChatMessageAttachment extends ZincElement {
51
51
  connectedCallback() {
52
52
  super.connectedCallback();
53
53
 
54
- if (this.closest('zn-chat-message')) {
54
+ if (this.closest('zn-chat-message, zn-content-block')) {
55
55
  if (!this.slot) {
56
56
  this.slot = 'attachments';
57
57
  }
58
58
  } else {
59
- console.warn('<zn-chat-message-attachment> can only be used inside a <zn-chat-message>.', this);
59
+ console.warn('<zn-chat-message-attachment> can only be used inside a <zn-chat-message> or <zn-content-block>.', this);
60
60
  }
61
61
  }
62
62
 
@@ -3,7 +3,7 @@ import {deepQuerySelectorAll} from "../../utilities/query";
3
3
  import {HasSlotController} from "../../internal/slot";
4
4
  import {html, unsafeCSS} from 'lit';
5
5
  import {MutationController} from '@lit-labs/observers/mutation-controller.js';
6
- import {property, queryAssignedNodes, queryAsync} from 'lit/decorators.js';
6
+ import {property, queryAssignedNodes, queryAsync, state} from 'lit/decorators.js';
7
7
  import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
8
8
  import ZincElement from "../../internal/zinc-element";
9
9
  import type {PropertyValues} from 'lit';
@@ -21,6 +21,12 @@ interface TextRow {
21
21
  * @documentation https://zinc.style/components/content-block
22
22
  * @status experimental
23
23
  * @since 1.0
24
+ *
25
+ * @slot attachments - Attachments displayed at the bottom of the content when the block is
26
+ * expanded. Use `zn-chat-message-attachment`, which auto-assigns itself to this slot.
27
+ *
28
+ * @csspart attachments - The attachments row at the bottom of the content.
29
+ * @csspart header-attachments - The attachment count indicator in the header.
24
30
  */
25
31
  export default class ContentBlock extends ZincElement {
26
32
  static styles = unsafeCSS(styles);
@@ -43,6 +49,8 @@ export default class ContentBlock extends ZincElement {
43
49
 
44
50
  private readonly hasSlotController = new HasSlotController(this, 'text', 'html');
45
51
 
52
+ @state() private attachmentCount = 0;
53
+
46
54
  private _textRows: TextRow[] = [];
47
55
 
48
56
  private readonly _footerObserver = new MutationController(this, {
@@ -163,9 +171,21 @@ export default class ContentBlock extends ZincElement {
163
171
  }
164
172
  }
165
173
 
174
+ private handleAttachmentsSlotChange() {
175
+ this.syncAttachmentCount();
176
+ }
177
+
178
+ private syncAttachmentCount() {
179
+ const slot = this.shadowRoot?.querySelector<HTMLSlotElement>('slot[name="attachments"]');
180
+ this.attachmentCount = slot?.assignedElements().length ?? 0;
181
+ }
182
+
166
183
  protected firstUpdated(_changedProperties: PropertyValues) {
167
184
  super.firstUpdated(_changedProperties);
168
185
 
186
+ this.syncAttachmentCount();
187
+ Promise.resolve().then(() => this.syncAttachmentCount());
188
+
169
189
  const textContent = this.shadowRoot?.querySelectorAll('.text-section');
170
190
  if (textContent && textContent.length > 0) {
171
191
  textContent.forEach((content) => {
@@ -196,6 +216,7 @@ export default class ContentBlock extends ZincElement {
196
216
  <slot name="icon">
197
217
  <zn-icon src="${this.avatar}"
198
218
  library="avatar"
219
+ size="36"
199
220
  round></zn-icon>
200
221
  </slot>
201
222
  </div>
@@ -205,6 +226,10 @@ export default class ContentBlock extends ZincElement {
205
226
  <slot name="caption">${this.sender}</slot>
206
227
  </span>
207
228
  <span class="content-block-header__description">${this.truncateText()}</span>
229
+ ${this.attachmentCount > 0 ? html`
230
+ <span class="content-block-header__attachments" part="header-attachments">
231
+ <zn-icon src="paperclip@lu" size="14"></zn-icon>${this.attachmentCount}
232
+ </span>` : ''}
208
233
  </div>
209
234
 
210
235
  <div class="content-block-header__actions">
@@ -245,6 +270,13 @@ export default class ContentBlock extends ZincElement {
245
270
  </div>
246
271
  </zn-sp>
247
272
 
273
+ <slot
274
+ name="attachments"
275
+ part="attachments"
276
+ class="content-block__attachments ${this.attachmentCount > 0 ? 'content-block__attachments--visible' : ''}"
277
+ @slotchange=${this.handleAttachmentsSlotChange}
278
+ ></slot>
279
+
248
280
  ${hasFooter ? html`
249
281
  <slot slot="footer" name="footer" @slotchange="${this._handleSlotChange}"></slot>` : ''}
250
282
  </div>
@@ -16,10 +16,10 @@
16
16
  .content-block-header {
17
17
  display: flex;
18
18
  align-items: center;
19
- gap: 10px;
19
+ gap: var(--zn-spacing-small);
20
20
  width: 100%;
21
21
  max-width: 100%;
22
- padding: var(--zn-base-gap) 0;
22
+ padding-block: var(--zn-spacing-small);
23
23
  position: relative;
24
24
  }
25
25
 
@@ -37,7 +37,7 @@
37
37
  display: flex;
38
38
  flex-direction: row;
39
39
  align-items: center;
40
- gap: 10px;
40
+ gap: var(--zn-spacing-small);
41
41
  min-width: 0;
42
42
  flex-grow: 1;
43
43
  overflow: hidden;
@@ -51,7 +51,7 @@
51
51
  margin: 0;
52
52
  display: flex;
53
53
  align-items: center;
54
- gap: var(--zn-spacing-x-small);
54
+ gap: var(--zn-spacing-small);
55
55
  flex-shrink: 0;
56
56
  overflow: hidden;
57
57
  white-space: nowrap;
@@ -69,16 +69,43 @@
69
69
  display: none;
70
70
  }
71
71
 
72
+ .content-block-header__attachments {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ gap: var(--zn-spacing-2x-small);
76
+ font-size: var(--zn-font-size-small);
77
+ color: rgb(var(--zn-text-muted));
78
+ flex-shrink: 0;
79
+ margin-left: auto;
80
+ }
81
+
72
82
  .content-block-header__actions {
73
83
  display: flex;
74
84
  align-items: center;
75
- gap: 10px;
85
+ gap: var(--zn-spacing-small);
76
86
  margin-left: auto;
77
87
  flex-shrink: 0;
78
88
  }
79
89
 
90
+ .content-block__attachments {
91
+ display: none;
92
+ flex-direction: row;
93
+ flex-wrap: wrap;
94
+ align-items: center;
95
+ gap: var(--zn-spacing-small);
96
+ padding-block-end: var(--zn-spacing-small);
97
+
98
+ &--visible {
99
+ display: flex;
100
+ }
101
+
102
+ ::slotted(zn-chat-message-attachment) {
103
+ min-width: 0;
104
+ }
105
+ }
106
+
80
107
  .content-block--short {
81
- zn-sp {
108
+ zn-sp, .content-block__attachments {
82
109
  display: none;
83
110
  }
84
111
 
@@ -92,11 +119,10 @@
92
119
  }
93
120
 
94
121
  .text-section {
95
- font-size: 13px;
96
- line-height: 19px;
122
+ @include wc.text-style(paragraph);
97
123
 
98
124
  a {
99
- font-size: 10px;
125
+ font-size: var(--zn-font-size-small);
100
126
  opacity: 0.8;
101
127
  }
102
128
  }
@@ -245,6 +245,7 @@ export default class ZnDataTable extends ZincElement {
245
245
 
246
246
  // Data Table Properties
247
247
  private _initialLoad = true;
248
+ private _hasLoadedData = false;
248
249
  private _lastTableContent: TemplateResult = html``;
249
250
 
250
251
  private readonly resizeObserver = new ResizeController(this, {
@@ -363,7 +364,9 @@ export default class ZnDataTable extends ZincElement {
363
364
  } else if (this.dataUri) {
364
365
  tableBody = this._dataTask.render({
365
366
  pending: () => {
366
- if (this._initialLoad) {
367
+ // Show the skeleton until data has rendered at least once, so the
368
+ // first fetch on a no-initial-load table doesn't render blank space
369
+ if (this._initialLoad || !this._hasLoadedData) {
367
370
  return html`
368
371
  <div>${this.loadingTable()}</div>`;
369
372
  }
@@ -372,6 +375,7 @@ export default class ZnDataTable extends ZincElement {
372
375
  },
373
376
  complete: (data) => {
374
377
  this._initialLoad = false;
378
+ this._hasLoadedData = true;
375
379
  this._lastTableContent = html`
376
380
  <div>${this.renderTable(data as Response)}</div>`;
377
381
  return this._lastTableContent;
@@ -401,6 +405,7 @@ export default class ZnDataTable extends ZincElement {
401
405
  page: 1,
402
406
  };
403
407
  this._initialLoad = false;
408
+ this._hasLoadedData = true;
404
409
  this._lastTableContent = html`
405
410
  <div>${this.renderTable(response)}</div>`;
406
411
  tableBody = this._lastTableContent;
@@ -1,6 +1,6 @@
1
- import {type CSSResultGroup, html, unsafeCSS} from 'lit';
2
- import {ifDefined} from "lit/directives/if-defined.js";
3
- import {property, query} from 'lit/decorators.js';
1
+ import { type CSSResultGroup, html, unsafeCSS } from 'lit';
2
+ import { ifDefined } from "lit/directives/if-defined.js";
3
+ import { property, query } from 'lit/decorators.js';
4
4
  import ZincElement from '../../internal/zinc-element';
5
5
  import ZnConfirm from "../confirm";
6
6
  import ZnDropdown from "../dropdown";
@@ -9,6 +9,7 @@ import ZnMenuItem from "../menu-item";
9
9
  import ZnTooltip from "../tooltip";
10
10
 
11
11
  import styles from './menu.scss';
12
+ import { classMap } from "lit/directives/class-map.js";
12
13
 
13
14
  interface NavItem {
14
15
  title: string;
@@ -56,12 +57,12 @@ export default class ZnMenu extends ZincElement {
56
57
 
57
58
  @query('slot') defaultSlot: HTMLSlotElement;
58
59
 
59
- @property({attribute: 'actions', type: Array}) actions = [];
60
+ @property({ attribute: 'actions', type: Array }) actions = [];
60
61
 
61
62
  /** The menu's visual style. `shell` renders the app-shell header dropdown
62
63
  * look: a padded panel with floating rounded items. Propagated to the
63
64
  * menu's items. */
64
- @property({reflect: true}) variant: 'default' | 'shell' = 'default';
65
+ @property({ reflect: true }) variant: 'default' | 'shell' = 'default';
65
66
 
66
67
  connectedCallback() {
67
68
  super.connectedCallback();
@@ -70,7 +71,7 @@ export default class ZnMenu extends ZincElement {
70
71
 
71
72
  /** @internal Gets all slotted menu items, ignoring dividers, headers, and other elements. */
72
73
  getAllItems() {
73
- return [...this.defaultSlot.assignedElements({flatten: true})].filter((el: HTMLElement) => {
74
+ return [...this.defaultSlot.assignedElements({ flatten: true })].filter((el: HTMLElement) => {
74
75
  return !(el.inert || !this.isMenuItem(el));
75
76
 
76
77
  }) as ZnMenuItem[];
@@ -100,15 +101,19 @@ export default class ZnMenu extends ZincElement {
100
101
  render() {
101
102
  return html`
102
103
  <div
104
+ class="${classMap({
105
+ 'menu': true,
106
+ 'menu--shell': this.variant === 'shell',
107
+ })}"
103
108
  @slotchange=${this.handleSlotChange}
104
109
  @keydown=${this.handleKeyDown}
105
110
  @mousedown=${this.handleMouseDown}>
106
111
  <slot></slot>
107
112
  ${this.actions.map((item: NavItem) => {
108
- if( item === null || item === undefined) {
113
+ if (item === null || item === undefined) {
109
114
  return html``; // Skip null or undefined items
110
115
  }
111
- if ( item?.confirm) {
116
+ if (item?.confirm) {
112
117
  return html`
113
118
  <zn-confirm trigger="${ifDefined(item.confirm?.trigger)}"
114
119
  type="${ifDefined(item.confirm?.type)}"
@@ -177,7 +182,7 @@ export default class ZnMenu extends ZincElement {
177
182
  // get the parent dropdown and close it
178
183
  (closestMenu?.closest('zn-dropdown') as ZnDropdown | null)?.hide();
179
184
 
180
- this.emit('zn-select', {detail: {item}});
185
+ this.emit('zn-select', { detail: { item } });
181
186
  }
182
187
 
183
188
  private handleKeyDown(event: KeyboardEvent) {
@@ -1,10 +1,10 @@
1
1
  @use "../../wc";
2
2
 
3
- :host {
3
+ .menu {
4
4
  display: block;
5
5
  position: relative;
6
6
  padding: 1px;
7
- border: 1px solid rgb(var(--zn-border-color));
7
+ border: 1px solid rgb(var(--zn-border-color)) !important;
8
8
  border-radius: var(--zn-border-radius);
9
9
  width: fit-content;
10
10
  min-width: var(--zn-size-spanel);
@@ -19,7 +19,7 @@
19
19
 
20
20
  // Shell variant — the app-shell header dropdown look (as used by Rubix):
21
21
  // padded panel with floating rounded items, translucent blurred background
22
- :host([variant="shell"]) {
22
+ .menu--shell {
23
23
  display: flex;
24
24
  flex-direction: column;
25
25
  gap: var(--zn-spacing-2x-small);
@@ -35,3 +35,8 @@
35
35
  background-color: rgba(var(--zn-color-navigation, 255, 255, 255), 0.95);
36
36
  backdrop-filter: blur(3px);
37
37
  }
38
+
39
+
40
+ ::slotted(zn-menu-item + zn-menu-item) {
41
+ border-top: 1px solid rgb(var(--zn-border-color));
42
+ }
@@ -62,8 +62,8 @@ export default class ZnNavbar extends ZincElement {
62
62
  @property({attribute: 'store-ttl', type: Number, reflect: true}) storeTtl = 0;
63
63
  @property({attribute: 'local-storage', type: Boolean, reflect: true}) localStorage: boolean;
64
64
 
65
- private _preItems: NodeListOf<Element>;
66
- private _postItems: NodeListOf<Element>;
65
+ private _preItems: HTMLElement[] = [];
66
+ private _postItems: HTMLElement[] = [];
67
67
  @property()
68
68
  private _appended: Element[];
69
69
  private _expanding: Element[] = [];
@@ -79,6 +79,7 @@ export default class ZnNavbar extends ZincElement {
79
79
  private _expandable: HTMLElement | null = null;
80
80
  private _extendedMenu: HTMLElement | null = null;
81
81
  private readonly _cloneSources = new WeakMap<HTMLElement, HTMLElement>();
82
+ private readonly _lightDomClones = new WeakMap<HTMLElement, HTMLElement>();
82
83
  private _navItemsGap: number = 0;
83
84
  private _expandableMargin: number = 0;
84
85
  private _totalItemWidth: number = 0;
@@ -91,6 +92,40 @@ export default class ZnNavbar extends ZincElement {
91
92
  this._appended = [...(this._appended || []), item];
92
93
  }
93
94
 
95
+ private _cloneLightItem(item: HTMLElement): HTMLElement {
96
+ const existing = this._lightDomClones.get(item);
97
+ if (existing) {
98
+ return existing;
99
+ }
100
+
101
+ const clone = item.cloneNode(true) as HTMLElement;
102
+ this._cloneSources.set(clone, item);
103
+ this._lightDomClones.set(item, clone);
104
+ clone.addEventListener('click', () => item.click());
105
+ return clone;
106
+ }
107
+
108
+ private _syncLightDomItems() {
109
+ const preItems: HTMLElement[] = [];
110
+ const postItems: HTMLElement[] = [];
111
+
112
+ Array.from(this.children).forEach(child => {
113
+ if (!(child instanceof HTMLElement) || child.tagName !== 'LI') {
114
+ return;
115
+ }
116
+
117
+ const clone = this._cloneLightItem(child);
118
+ if (child.hasAttribute('suffix')) {
119
+ postItems.push(clone);
120
+ } else {
121
+ preItems.push(clone);
122
+ }
123
+ });
124
+
125
+ this._preItems = preItems;
126
+ this._postItems = postItems;
127
+ }
128
+
94
129
  addExpandingAction(action: Element) {
95
130
  if (!this._expanding.includes(action)) {
96
131
  this._expanding = [...this._expanding, action];
@@ -141,31 +176,26 @@ export default class ZnNavbar extends ZincElement {
141
176
  }
142
177
 
143
178
  private _adoptNewLightItems(mutations: MutationRecord[]) {
144
- const ul = this.shadowRoot?.querySelector('ul');
145
- if (!ul) return;
146
- const moreItem = ul.querySelector('li.more');
147
179
  for (const m of mutations) {
148
180
  for (const node of Array.from(m.addedNodes)) {
149
181
  if (node instanceof Element && node.tagName === 'ZN-EXPANDING-ACTION') {
150
182
  this.addExpandingAction(node);
151
- continue;
152
- }
153
-
154
- if (!(node instanceof HTMLLIElement)) continue;
155
- if (node.hasAttribute('suffix')) continue;
156
- if (moreItem) {
157
- ul.insertBefore(node, moreItem);
158
- } else {
159
- ul.appendChild(node);
160
183
  }
161
184
  }
162
185
  }
186
+
187
+ if (mutations.some(mutation =>
188
+ Array.from(mutation.addedNodes).some(node => node instanceof HTMLLIElement) ||
189
+ Array.from(mutation.removedNodes).some(node => node instanceof HTMLLIElement)
190
+ )) {
191
+ this._syncLightDomItems();
192
+ this.requestUpdate();
193
+ }
163
194
  }
164
195
 
165
196
  connectedCallback() {
166
197
  super.connectedCallback();
167
- this._preItems = this.querySelectorAll('li:not([suffix])');
168
- this._postItems = this.querySelectorAll('li[suffix]');
198
+ this._syncLightDomItems();
169
199
  this._expanding = Array.from(this.querySelectorAll('zn-expanding-action'));
170
200
 
171
201
  if (!this.masterId) {
@@ -242,7 +242,7 @@ ul.navbar.navbar--stacked.navbar--icon-bar {
242
242
 
243
243
  :host(:not([stacked]):not([icon-bar]):not([icon-only])) {
244
244
  ul.navbar {
245
- min-width: 180px;
245
+ min-width: 200px;
246
246
  }
247
247
  }
248
248
 
@@ -279,7 +279,7 @@ ul.navbar.navbar--stacked.navbar--icon-bar {
279
279
  }
280
280
  }
281
281
 
282
- &:not(.has-hidden) > li.last-visible {
282
+ > li.last-visible {
283
283
  border-top-right-radius: 6px;
284
284
  }
285
285
 
@@ -1,5 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import {aTimeout, expect, fixture, html, waitUntil} from '@open-wc/testing';
3
+ import {render} from 'lit';
3
4
  import type ZnNavbar from './navbar.component';
4
5
 
5
6
  describe('<zn-navbar>', () => {
@@ -9,6 +10,40 @@ describe('<zn-navbar>', () => {
9
10
  expect(el).to.exist;
10
11
  });
11
12
 
13
+ it('does not move Lit-owned light DOM items when the parent rerenders', async () => {
14
+ const host = document.createElement('div');
15
+ document.body.append(host);
16
+
17
+ try {
18
+ render(html`
19
+ <zn-navbar>
20
+ <li tab="one">One</li>
21
+ </zn-navbar>
22
+ `, host);
23
+ await aTimeout(20);
24
+
25
+ render(html`
26
+ <zn-navbar>
27
+ <li tab="one">One</li>
28
+ <li tab="two">Two</li>
29
+ </zn-navbar>
30
+ `, host);
31
+ await aTimeout(20);
32
+
33
+ const navbar = host.querySelector<ZnNavbar>('zn-navbar')!;
34
+ const shadowItems = navbar.shadowRoot!.querySelectorAll('li[tab]');
35
+ const lightItems = navbar.querySelectorAll('li[tab]');
36
+
37
+ expect(lightItems.length).to.equal(2);
38
+ expect(shadowItems.length).to.equal(2);
39
+ expect(shadowItems[0]).to.not.equal(lightItems[0]);
40
+ expect(shadowItems[1].textContent?.trim()).to.equal('Two');
41
+ } finally {
42
+ render(html``, host);
43
+ host.remove();
44
+ }
45
+ });
46
+
12
47
  it('reserves at least 200px for visible nav items when expandables need space', async () => {
13
48
  const el = await fixture<ZnNavbar>(html`
14
49
  <zn-navbar>