@kubex/zinc 1.1.50 → 1.1.52

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.
@@ -54,6 +54,47 @@ Use the `edit-text` attribute to customize the edit button text instead of showi
54
54
  </div>
55
55
  ```
56
56
 
57
+ ### Masked Values
58
+
59
+ Use the `display-value` attribute to mask sensitive data at rest. The masked value is shown in place of the input and
60
+ the real value is revealed on hover (via [`zn-reveal`](/components/reveal)). Clicking the field or the edit button
61
+ switches to the normal inline edit flow, and the real `value` — never the mask — is what gets edited and submitted.
62
+
63
+ ```html:preview
64
+ <div class="form-spacing">
65
+ <zn-inline-edit name="email"
66
+ value="john.doe@example.com"
67
+ display-value="j***@example.com">
68
+ </zn-inline-edit>
69
+
70
+ <zn-inline-edit name="phone"
71
+ value="555-123-1234"
72
+ display-value="•••-•••-1234">
73
+ </zn-inline-edit>
74
+ </div>
75
+ ```
76
+
77
+ :::tip
78
+ Show enough context in the mask (e.g. the email domain or last 4 digits) so users know what they're looking at before
79
+ revealing. The component doesn't re-mask automatically after a save — update `display-value` in your `zn-submit`
80
+ handler once the server confirms the change.
81
+ :::
82
+
83
+ ### Clear on Edit
84
+
85
+ Add the `clear-on-edit` attribute to start editing with an empty input instead of the current value — useful for
86
+ secrets where the real value shouldn't be pre-filled. Cancelling (or clicking outside without typing) restores the
87
+ original value.
88
+
89
+ ```html:preview
90
+ <zn-inline-edit name="api-key"
91
+ value="sk_live_1234567890abcdef"
92
+ display-value="sk_live_••••••••••••••••"
93
+ clear-on-edit
94
+ style="font-family: monospace;">
95
+ </zn-inline-edit>
96
+ ```
97
+
57
98
  ### Textarea Input Type
58
99
 
59
100
  Use `input-type="textarea"` for multi-line text content.
@@ -149,6 +149,22 @@ Without a duration or with hover, the content is revealed only while hovering an
149
149
  </p>
150
150
  ```
151
151
 
152
+ ### Disabling Click Toggle
153
+
154
+ Add the `no-toggle` attribute to disable click-to-toggle entirely — the value is only revealed on hover. Clicks still
155
+ bubble, so a parent component can handle them (this is how [`zn-inline-edit`](/components/inline-edit#masked-values)
156
+ uses click to enter edit mode).
157
+
158
+ ```html:preview
159
+ <zn-reveal no-toggle
160
+ initial="j***@example.com"
161
+ revealed="john.doe@example.com">
162
+ </zn-reveal>
163
+ <p style="font-size: 0.875rem; color: var(--zn-color-neutral-600); margin-top: 0.5rem;">
164
+ Hover to reveal — clicking does nothing
165
+ </p>
166
+ ```
167
+
152
168
  ### Click and Hover Combined
153
169
 
154
170
  When duration is set, clicking toggles temporarily while hovering still works. After clicking, the content remains revealed for the duration, and hovering won't hide it until the duration expires.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.1.50",
3
+ "version": "1.1.52",
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",
@@ -58,6 +58,18 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
58
58
 
59
59
  @property({ attribute: 'edit-text' }) editText: string;
60
60
 
61
+ /**
62
+ * A masked value (e.g. `j***@example.com`) shown in place of the input when not editing. Hovering reveals the
63
+ * real value via an embedded `zn-reveal`. The real `value` is still what gets edited and submitted.
64
+ */
65
+ @property({ attribute: 'display-value' }) displayValue: string = '';
66
+
67
+ /**
68
+ * When set, the edit input starts empty instead of pre-filled with the current value. Cancelling restores the
69
+ * original value. Useful with `display-value` when the real value shouldn't be pre-filled into the input.
70
+ */
71
+ @property({ type: Boolean, attribute: 'clear-on-edit' }) clearOnEdit: boolean = false;
72
+
61
73
  @property() conditional = '';
62
74
 
63
75
  @property({ type: Boolean }) disabled: boolean
@@ -131,6 +143,7 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
131
143
  @state() private isEditing: boolean;
132
144
 
133
145
  private _valueBeforeEdit: string | string[];
146
+ private _editStartValue: string | string[];
134
147
 
135
148
  @query('.ai__input') input: ZnInput | ZnSelect;
136
149
 
@@ -212,11 +225,12 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
212
225
  mouseEventHandler = (e: MouseEvent) => {
213
226
  if (this.isEditing && !this.contains(e.target as Node)) {
214
227
  const hasChanged = Array.isArray(this.value)
215
- ? JSON.stringify(this.value) !== JSON.stringify(this._valueBeforeEdit)
216
- : this.value !== this._valueBeforeEdit;
228
+ ? JSON.stringify(this.value) !== JSON.stringify(this._editStartValue)
229
+ : this.value !== this._editStartValue;
217
230
 
218
231
  if (!hasChanged) {
219
232
  this.isEditing = false;
233
+ this.value = this._valueBeforeEdit;
220
234
  this.input.blur();
221
235
  }
222
236
  }
@@ -260,6 +274,10 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
260
274
  }
261
275
  if (!this.isEditing) {
262
276
  this._valueBeforeEdit = this.value;
277
+ if (this.clearOnEdit) {
278
+ this.value = this.multiple ? [] : '';
279
+ }
280
+ this._editStartValue = this.value;
263
281
  }
264
282
  this.isEditing = true;
265
283
  }
@@ -352,9 +370,15 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
352
370
  'ai--disabled': this.disabled,
353
371
  'ai--inline': this.inline,
354
372
  'ai--padded': this.padded,
373
+ 'ai--masked': !!this.displayValue,
355
374
  })}" dir="ltr">
356
375
 
357
376
  <div class="ai__left" @click="${this.disabled ? undefined : this.handleEditClick}">
377
+ ${this.displayValue && !this.isEditing ? html`
378
+ <zn-reveal class="ai__reveal"
379
+ initial="${this.displayValue}"
380
+ revealed="${Array.isArray(this.value) ? this.value.join(' ') : this.value}"
381
+ no-toggle></zn-reveal>` : ''}
358
382
  ${input}
359
383
  </div>
360
384
 
@@ -127,6 +127,26 @@
127
127
  width: 100%;
128
128
  }
129
129
 
130
+ &--masked:not(.ai--editing) {
131
+ .ai__input {
132
+ display: none;
133
+ }
134
+ }
135
+
136
+ &__reveal {
137
+ line-height: var(--zn-line-height-looser);
138
+ color: rgb(var(--zn-text));
139
+
140
+ // Fill the row so hovering anywhere over the field reveals the value
141
+ &::part(base) {
142
+ width: 100%;
143
+ text-decoration: underline;
144
+ text-decoration-style: dashed;
145
+ text-decoration-color: rgb(var(--zn-primary), 0.5);
146
+ text-underline-offset: 3px;
147
+ }
148
+ }
149
+
130
150
  &:not(.ai--disabled) {
131
151
  .ai__left {
132
152
  cursor: pointer;
@@ -162,6 +182,10 @@
162
182
  border-radius: 0;
163
183
  }
164
184
 
185
+ .ai__reveal::part(base) {
186
+ text-decoration: none;
187
+ }
188
+
165
189
  .ai__input::part(display-input) {
166
190
  cursor: text;
167
191
  }
@@ -370,4 +370,117 @@ describe('<zn-inline-edit>', () => {
370
370
 
371
371
  expect(el.value).to.equal('a b');
372
372
  });
373
+
374
+ // -- Masked display value --
375
+
376
+ it('should render a zn-reveal with the masked and real values when display-value is set', async () => {
377
+ const el = await fixture<ZnInlineEdit>(html`
378
+ <zn-inline-edit value="real@example.com" display-value="*****@example.com"></zn-inline-edit>
379
+ `);
380
+ await el.updateComplete;
381
+
382
+ const reveal = el.shadowRoot!.querySelector('zn-reveal')!;
383
+ expect(reveal).to.exist;
384
+ expect(reveal.getAttribute('initial')).to.equal('*****@example.com');
385
+ expect(reveal.getAttribute('revealed')).to.equal('real@example.com');
386
+ });
387
+
388
+ it('should not render a zn-reveal when display-value is not set', async () => {
389
+ const el = await fixture<ZnInlineEdit>(
390
+ html`<zn-inline-edit value="hello"></zn-inline-edit>`
391
+ );
392
+ await el.updateComplete;
393
+
394
+ expect(el.shadowRoot!.querySelector('zn-reveal')).to.not.exist;
395
+ });
396
+
397
+ it('should enter edit mode and hide the zn-reveal when the masked display is clicked', async () => {
398
+ const el = await fixture<ZnInlineEdit>(html`
399
+ <zn-inline-edit value="real@example.com" display-value="*****@example.com"></zn-inline-edit>
400
+ `);
401
+ await el.updateComplete;
402
+
403
+ const reveal = el.shadowRoot!.querySelector('zn-reveal')!;
404
+ reveal.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
405
+ await el.updateComplete;
406
+
407
+ expect(el.shadowRoot!.querySelector('.ai--editing')).to.exist;
408
+ expect(el.shadowRoot!.querySelector('zn-reveal')).to.not.exist;
409
+ });
410
+
411
+ it('should not enter edit mode from the masked display when disabled', async () => {
412
+ const el = await fixture<ZnInlineEdit>(html`
413
+ <zn-inline-edit value="real@example.com" display-value="*****@example.com" disabled></zn-inline-edit>
414
+ `);
415
+ await el.updateComplete;
416
+
417
+ const reveal = el.shadowRoot!.querySelector('zn-reveal')!;
418
+ reveal.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
419
+ await el.updateComplete;
420
+
421
+ expect(el.shadowRoot!.querySelector('.ai--editing')).to.not.exist;
422
+ });
423
+
424
+ it('should submit the real value, not the display-value, in form data', async () => {
425
+ const form = await fixture<HTMLFormElement>(html`
426
+ <form>
427
+ <zn-inline-edit name="email" value="real@example.com" display-value="*****@example.com"></zn-inline-edit>
428
+ </form>
429
+ `);
430
+ const el = form.querySelector<ZnInlineEdit>('zn-inline-edit')!;
431
+ await el.updateComplete;
432
+
433
+ const formData = new FormData(form);
434
+ expect(formData.get('email')).to.equal('real@example.com');
435
+ });
436
+
437
+ // -- clear-on-edit --
438
+
439
+ it('should start with an empty input when clear-on-edit is set', async () => {
440
+ const el = await fixture<ZnInlineEdit>(html`
441
+ <zn-inline-edit value="real@example.com" clear-on-edit></zn-inline-edit>
442
+ `);
443
+ await el.updateComplete;
444
+
445
+ const editBtn = el.shadowRoot!.querySelector<HTMLElement>('.button--edit')!;
446
+ editBtn.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
447
+ await el.updateComplete;
448
+
449
+ expect(el.value).to.equal('');
450
+ });
451
+
452
+ it('should restore the original value on cancel when clear-on-edit is set', async () => {
453
+ const el = await fixture<ZnInlineEdit>(html`
454
+ <zn-inline-edit value="real@example.com" clear-on-edit></zn-inline-edit>
455
+ `);
456
+ await el.updateComplete;
457
+
458
+ const editBtn = el.shadowRoot!.querySelector<HTMLElement>('.button--edit')!;
459
+ editBtn.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
460
+ await el.updateComplete;
461
+
462
+ const cancelBtn = el.shadowRoot!.querySelector<HTMLElement>('zn-button[icon="close"]')!;
463
+ cancelBtn.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
464
+ await el.updateComplete;
465
+
466
+ expect(el.shadowRoot!.querySelector('.ai--editing')).to.not.exist;
467
+ expect(el.value).to.equal('real@example.com');
468
+ });
469
+
470
+ it('should auto-cancel and restore the value on outside click when clear-on-edit is set and nothing was typed', async () => {
471
+ const el = await fixture<ZnInlineEdit>(html`
472
+ <zn-inline-edit value="real@example.com" clear-on-edit></zn-inline-edit>
473
+ `);
474
+ await el.updateComplete;
475
+
476
+ const editBtn = el.shadowRoot!.querySelector<HTMLElement>('.button--edit')!;
477
+ editBtn.dispatchEvent(new MouseEvent('click', {bubbles: true, composed: true}));
478
+ await el.updateComplete;
479
+
480
+ document.dispatchEvent(new MouseEvent('click', {bubbles: true}));
481
+ await el.updateComplete;
482
+
483
+ expect(el.shadowRoot!.querySelector('.ai--editing')).to.not.exist;
484
+ expect(el.value).to.equal('real@example.com');
485
+ });
373
486
  });
@@ -41,6 +41,9 @@ export default class ZnReveal extends ZincElement {
41
41
 
42
42
  @property({type: Number, attribute: 'hide-delay'}) hideDelay: number = 150;
43
43
 
44
+ /** Disables click-to-toggle so the value is only revealed on hover. Clicks still bubble to the parent. */
45
+ @property({type: Boolean, attribute: 'no-toggle'}) noToggle: boolean = false;
46
+
44
47
  private _isRevealed: boolean = false;
45
48
  private _isToggled: boolean = false;
46
49
  private _hideTimer?: ReturnType<typeof setTimeout>;
@@ -58,6 +61,8 @@ export default class ZnReveal extends ZincElement {
58
61
  }
59
62
 
60
63
  protected handleToggleReveal() {
64
+ if (this.noToggle) return;
65
+
61
66
  this._clearHideTimer();
62
67
 
63
68
  if (this.duration) {
@@ -98,7 +103,8 @@ export default class ZnReveal extends ZincElement {
98
103
 
99
104
  render() {
100
105
  return html`
101
- <div class=${classMap({
106
+ <div part="base"
107
+ class=${classMap({
102
108
  'reveal': true,
103
109
  'reveal--revealed': this._isRevealed,
104
110
  'reveal--toggled': this._isToggled,
@@ -1,5 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import { expect, fixture, html } from '@open-wc/testing';
3
+ import type ZnReveal from './reveal.component';
3
4
 
4
5
  describe('<zn-reveal>', () => {
5
6
  it('should render a component', async () => {
@@ -7,4 +8,28 @@ describe('<zn-reveal>', () => {
7
8
 
8
9
  expect(el).to.exist;
9
10
  });
11
+
12
+ it('should toggle the revealed state on click by default', async () => {
13
+ const el = await fixture<ZnReveal>(html`
14
+ <zn-reveal initial="****" revealed="real"></zn-reveal>
15
+ `);
16
+ const container = el.shadowRoot!.querySelector<HTMLElement>('.reveal')!;
17
+
18
+ container.click();
19
+ await el.updateComplete;
20
+
21
+ expect(container.classList.contains('reveal--revealed')).to.be.true;
22
+ });
23
+
24
+ it('should not toggle on click when no-toggle is set', async () => {
25
+ const el = await fixture<ZnReveal>(html`
26
+ <zn-reveal initial="****" revealed="real" no-toggle></zn-reveal>
27
+ `);
28
+ const container = el.shadowRoot!.querySelector<HTMLElement>('.reveal')!;
29
+
30
+ container.click();
31
+ await el.updateComplete;
32
+
33
+ expect(container.classList.contains('reveal--revealed')).to.be.false;
34
+ });
10
35
  });
@@ -102,13 +102,15 @@ export default class ZnTile extends ZincElement {
102
102
  <div
103
103
  class="tile__link">
104
104
  <div class="tile__left">
105
- ${hasImage ? html`<slot name="image" part="image" class="tile__image"></slot>` : html``}
105
+ ${hasImage ? html`
106
+ <slot name="image" part="image" class="tile__image"></slot>` : html``}
106
107
  <div class="tile__content">
107
108
  <p part="caption" class="tile__caption">
108
109
  <slot name="caption">${this.caption}</slot>
109
110
  </p>
110
111
  ${hasDescription ? html`
111
- <p part="description" class="tile__description">${this.description}</p>` : ''}
112
+ <p part="description" class="tile__description">
113
+ ${this.description}</p>` : html`<slot name="description" class="tile__description"></slot>`}
112
114
  </div>
113
115
  </div>
114
116
  </div>
@@ -117,7 +119,8 @@ export default class ZnTile extends ZincElement {
117
119
  ${hasProperties ? html`
118
120
  <slot name="properties" part="properties" class="tile__properties"></slot>` : ''}
119
121
  ${hasActions ? html`
120
- <slot name="actions" part="actions" class="tile__actions" @click=${this._handleActionsClick}></slot>` : ''}
122
+ <slot name="actions" part="actions" class="tile__actions"
123
+ @click=${this._handleActionsClick}></slot>` : ''}
121
124
  </div>`}
122
125
  </${tag}>`;
123
126
  }