@kubex/zinc 1.1.45 → 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.45",
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) => {
@@ -206,6 +226,10 @@ export default class ContentBlock extends ZincElement {
206
226
  <slot name="caption">${this.sender}</slot>
207
227
  </span>
208
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>` : ''}
209
233
  </div>
210
234
 
211
235
  <div class="content-block-header__actions">
@@ -246,6 +270,13 @@ export default class ContentBlock extends ZincElement {
246
270
  </div>
247
271
  </zn-sp>
248
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
+
249
280
  ${hasFooter ? html`
250
281
  <slot slot="footer" name="footer" @slotchange="${this._handleSlotChange}"></slot>` : ''}
251
282
  </div>
@@ -69,6 +69,16 @@
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;
@@ -77,8 +87,25 @@
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
 
@@ -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>