@kubex/zinc 1.0.107 → 1.0.109

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.
@@ -7,6 +7,7 @@
7
7
  <li><a href="/getting-started/table-examples">Table Examples</a></li>
8
8
  <li><a href="/getting-started/example-layout">Example Layout</a></li>
9
9
  <li><a href="/getting-started/example-page">Example Page</a></li>
10
+ <li><a href="/getting-started/shimmer-text">Shimmer Text</a></li>
10
11
  </ul>
11
12
  </li>
12
13
  <li>
@@ -333,6 +333,8 @@ Explicit library specification:
333
333
  <zn-icon src="test1@example.com" library="gravatar" size="48"></zn-icon>
334
334
  ```
335
335
 
336
+ Email addresses are trimmed, lowercased, and hashed with SHA-256 before the Gravatar request is made.
337
+
336
338
  Using shorthand notation:
337
339
 
338
340
  ```html:preview
@@ -348,6 +350,8 @@ Libravatar is an open-source alternative to Gravatar. Use it for federated avata
348
350
  <zn-icon src="user@example.com" library="libravatar" size="48" round></zn-icon>
349
351
  ```
350
352
 
353
+ Email addresses are trimmed, lowercased, and hashed with SHA-256 before the Libravatar request is made.
354
+
351
355
  Similar to Gravatar, you can specify fallback options:
352
356
 
353
357
  ```html:preview
@@ -727,4 +731,4 @@ your HTML like this:
727
731
  grid-template-columns: repeat(4, 1fr);
728
732
  }
729
733
  }
730
- </style>
734
+ </style>
@@ -0,0 +1,81 @@
1
+ ---
2
+ meta:
3
+ title: Shimmer Text
4
+ description: A global utility class that applies an animated shimmer sweep to text, suitable for loading, processing, or "thinking" states.
5
+ ---
6
+
7
+ # Shimmer Text
8
+
9
+ `shimmer-text` is a global utility class that animates a horizontal highlight sweep across text colour. Use it to convey an in-progress state — AI generation, background processing, or any "working on it" indicator.
10
+
11
+ The class works on any element with text content, including inside Zinc components. Colour variants mirror the standard Zinc palette: `zn-primary`, `zn-accent`, `zn-info`, `zn-success`, `zn-warning`, `zn-error`.
12
+
13
+ When `prefers-reduced-motion: reduce` is set, the animation is suppressed and the text falls back to a solid colour matching the chosen variant.
14
+
15
+ ## Examples
16
+
17
+ ### Default
18
+
19
+ ```html:preview
20
+ <span class="shimmer-text">Thinking…</span>
21
+ ```
22
+
23
+ ### Colour Variants
24
+
25
+ ```html:preview
26
+ <div style="display: flex; flex-direction: column; gap: 8px;">
27
+ <span class="shimmer-text">Default</span>
28
+ <span class="shimmer-text zn-primary">Primary</span>
29
+ <span class="shimmer-text zn-accent">Accent</span>
30
+ <span class="shimmer-text zn-info">Info</span>
31
+ <span class="shimmer-text zn-success">Success</span>
32
+ <span class="shimmer-text zn-warning">Warning</span>
33
+ <span class="shimmer-text zn-error">Error</span>
34
+ </div>
35
+ ```
36
+
37
+ ### Headings and Larger Text
38
+
39
+ The shimmer scales with whatever `font-size` is on the element, so it can be applied to headings or inline within prose.
40
+
41
+ ```html:preview
42
+ <h2 class="shimmer-text zn-primary" style="margin: 0;">Generating summary…</h2>
43
+ <p style="margin: 8px 0 0;">
44
+ Please wait while we <span class="shimmer-text zn-info">analyse your data</span>.
45
+ </p>
46
+ ```
47
+
48
+ ### Inside Zinc Components
49
+
50
+ Because `shimmer-text` is a plain class, it composes with any Zinc component that renders slotted text.
51
+
52
+ ```html:preview
53
+ <zn-chip class="shimmer-text zn-accent">Processing</zn-chip>
54
+ ```
55
+
56
+ ### Tuning the Sweep
57
+
58
+ The effect mirrors the Claude CLI's character-level shimmer: a narrow, sharper highlight slides across text rather than a soft gradient wash. The class exposes these CSS custom properties — all sized in `ch` so they scale with the current font size.
59
+
60
+ | Variable | Default | Purpose |
61
+ |---|---|---|
62
+ | `--shimmer-base` | `rgb(var(--zn-text))` | Resting text colour. |
63
+ | `--shimmer-highlight` | `rgb(var(--zn-text-heading))` | Colour of the spotlight. |
64
+ | `--shimmer-core` | `0.5ch` | Half-width of the solid-colour core of the spotlight. |
65
+ | `--shimmer-spread` | `1.5ch` | Half-width of the full highlight including its fade-out edges. |
66
+ | `--shimmer-duration` | `2s` | Time for one full sweep cycle. |
67
+
68
+ ```html:preview
69
+ <div style="display: flex; flex-direction: column; gap: 6px;">
70
+ <span class="shimmer-text">Default sweep</span>
71
+ <span class="shimmer-text" style="--shimmer-duration: 1s;">Faster sweep</span>
72
+ <span class="shimmer-text" style="--shimmer-duration: 4s;">Slower, gentler sweep</span>
73
+ <span class="shimmer-text" style="--shimmer-core: 1ch; --shimmer-spread: 3ch;">Wider spotlight</span>
74
+ </div>
75
+ ```
76
+
77
+ The Claude CLI scales its sweep with message length (50ms per character plus 10 characters of offscreen pause on each side). To match that feel in CSS, set `--shimmer-duration` to roughly `(messageCharacters + 20) × 50ms`.
78
+
79
+ ### Reduced Motion
80
+
81
+ The animation is wrapped in `@media (prefers-reduced-motion: no-preference)`. Users who have requested reduced motion see the static colour variant instead — no configuration required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "1.0.107",
3
+ "version": "1.0.109",
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",
@@ -2,6 +2,7 @@
2
2
  @forward "layout";
3
3
  @forward "divide";
4
4
  @forward "nav";
5
+ @forward "shimmer";
5
6
 
6
7
 
7
8
  // Need to remove the below from zinc, or have nice defaults that don't mess everything up
@@ -0,0 +1,98 @@
1
+
2
+ @keyframes shimmer-text-sweep {
3
+ 0% {
4
+ background-position: 100% center;
5
+ }
6
+ 100% {
7
+ background-position: 0% center;
8
+ }
9
+ }
10
+
11
+ @media (prefers-reduced-motion: no-preference) {
12
+ .shimmer-text {
13
+ --shimmer-base: rgb(var(--zn-text));
14
+ --shimmer-highlight: rgb(var(--zn-text-heading));
15
+ --shimmer-core: 0.5ch;
16
+ --shimmer-spread: 1.5ch;
17
+ --shimmer-duration: 1.5s;
18
+
19
+ display: inline-block;
20
+ width: fit-content;
21
+ max-width: 100%;
22
+ background-image:
23
+ linear-gradient(
24
+ 90deg,
25
+ transparent calc(50% - var(--shimmer-spread)),
26
+ var(--shimmer-highlight) calc(50% - var(--shimmer-core)),
27
+ var(--shimmer-highlight) calc(50% + var(--shimmer-core)),
28
+ transparent calc(50% + var(--shimmer-spread))
29
+ ),
30
+ linear-gradient(var(--shimmer-base), var(--shimmer-base));
31
+ background-size: 250% 100%, auto;
32
+ background-clip: text;
33
+ -webkit-background-clip: text;
34
+ -webkit-text-fill-color: transparent !important;
35
+ color: transparent !important;
36
+ animation: var(--shimmer-duration) linear infinite shimmer-text-sweep;
37
+
38
+ &.zn-error {
39
+ --shimmer-base: rgb(var(--zn-color-error));
40
+ --shimmer-highlight: color-mix(in srgb, rgb(var(--zn-color-error)) 55%, white);
41
+ }
42
+
43
+ &.zn-success {
44
+ --shimmer-base: rgb(var(--zn-color-success));
45
+ --shimmer-highlight: color-mix(in srgb, rgb(var(--zn-color-success)) 55%, white);
46
+ }
47
+
48
+ &.zn-info {
49
+ --shimmer-base: rgb(var(--zn-color-info));
50
+ --shimmer-highlight: color-mix(in srgb, rgb(var(--zn-color-info)) 55%, white);
51
+ }
52
+
53
+ &.zn-warning {
54
+ --shimmer-base: rgb(var(--zn-color-warning));
55
+ --shimmer-highlight: color-mix(in srgb, rgb(var(--zn-color-warning)) 55%, white);
56
+ }
57
+
58
+ &.zn-primary {
59
+ --shimmer-base: rgb(var(--zn-primary));
60
+ --shimmer-highlight: color-mix(in srgb, rgb(var(--zn-primary)) 55%, white);
61
+ }
62
+
63
+ &.zn-accent {
64
+ --shimmer-base: rgb(var(--zn-accent));
65
+ --shimmer-highlight: color-mix(in srgb, rgb(var(--zn-accent)) 55%, white);
66
+ }
67
+ }
68
+ }
69
+
70
+ @media (prefers-reduced-motion: reduce) {
71
+ .shimmer-text {
72
+ color: rgb(var(--zn-text));
73
+
74
+ &.zn-error {
75
+ color: rgb(var(--zn-color-error)) !important;
76
+ }
77
+
78
+ &.zn-success {
79
+ color: rgb(var(--zn-color-success)) !important;
80
+ }
81
+
82
+ &.zn-info {
83
+ color: rgb(var(--zn-color-info)) !important;
84
+ }
85
+
86
+ &.zn-warning {
87
+ color: rgb(var(--zn-color-warning)) !important;
88
+ }
89
+
90
+ &.zn-primary {
91
+ color: rgb(var(--zn-primary)) !important;
92
+ }
93
+
94
+ &.zn-accent {
95
+ color: rgb(var(--zn-accent)) !important;
96
+ }
97
+ }
98
+ }
@@ -321,6 +321,12 @@ export default class ZnDatepicker extends ZincElement implements ZincFormControl
321
321
  return;
322
322
  }
323
323
 
324
+ if (event.key === 'Escape') {
325
+ this.input.blur();
326
+ event.stopPropagation();
327
+ return;
328
+ }
329
+
324
330
  // Allow navigation and control keys
325
331
  const allowedKeys = [
326
332
  'Backspace', 'Delete', 'Tab', 'Escape', 'Enter',
@@ -1,5 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
- import { expect, fixture, html } from '@open-wc/testing';
2
+ import {expect, fixture, html} from '@open-wc/testing';
3
+ import type ZnDatepicker from './datepicker.component';
3
4
 
4
5
  describe('<zn-datepicker>', () => {
5
6
  it('should render a component', async () => {
@@ -7,4 +8,34 @@ describe('<zn-datepicker>', () => {
7
8
 
8
9
  expect(el).to.exist;
9
10
  });
11
+
12
+ describe('Escape key', () => {
13
+ it('blurs the inner input when focused and the calendar is closed', async () => {
14
+ const el = await fixture<ZnDatepicker>(html`<zn-datepicker></zn-datepicker>`);
15
+ await el.updateComplete;
16
+
17
+ // Focus shows AirDatepicker's calendar (showEvent: 'focus'). Per spec, Escape
18
+ // while calendar is open is handled by AirDatepicker; our handler engages once
19
+ // the calendar is closed. Hide it explicitly to simulate the calendar-closed
20
+ // state, then press Escape.
21
+ el.focus();
22
+ await el.updateComplete;
23
+ const dp = (el as unknown as { _instance?: { hide: () => void; visible: boolean } })._instance;
24
+ dp?.hide();
25
+ await el.updateComplete;
26
+ el.input.focus();
27
+ await el.updateComplete;
28
+ expect(el.shadowRoot!.activeElement).to.equal(el.input);
29
+
30
+ el.input.dispatchEvent(new KeyboardEvent('keydown', {
31
+ key: 'Escape',
32
+ bubbles: true,
33
+ composed: true,
34
+ cancelable: true
35
+ }));
36
+ await el.updateComplete;
37
+
38
+ expect(el.shadowRoot!.activeElement, 'inner input should be blurred').to.not.equal(el.input);
39
+ });
40
+ });
10
41
  });
@@ -74,14 +74,14 @@ export default class ZnFormGroup extends ZincElement {
74
74
  </zn-tooltip>`
75
75
  : ''}
76
76
  </label>
77
+ </div>` : html``}
77
78
 
78
- ${hasHelpText ? html`
79
- <div
80
- part="form-control-help-text"
81
- id="help-text"
82
- class="form-control__help-text">
83
- <slot name="help-text">${this.helpText}</slot>
84
- </div>` : html``}
79
+ ${hasHelpText ? html`
80
+ <div
81
+ part="form-control-help-text"
82
+ id="help-text"
83
+ class="form-control__help-text">
84
+ <slot name="help-text">${this.helpText}</slot>
85
85
  </div>` : html``}
86
86
 
87
87
  <div part="form-control-input" class="form-control-input">
@@ -1,8 +1,8 @@
1
1
  import {choose} from 'lit/directives/choose.js';
2
2
  import {classMap} from "lit/directives/class-map.js";
3
3
  import {type CSSResultGroup, html, render, unsafeCSS} from 'lit';
4
- import {md5} from '../../utilities/md5';
5
4
  import {property} from 'lit/decorators.js';
5
+ import {sha256} from '../../utilities/sha256';
6
6
  import {styleMap} from "lit/directives/style-map.js";
7
7
  import ZincElement from '../../internal/zinc-element';
8
8
 
@@ -30,7 +30,7 @@ export type IconColor =
30
30
  | "violet"
31
31
  | "pink"
32
32
  | "grey"
33
- | (string & {});
33
+ | (string & Record<never, never>);
34
34
 
35
35
  /**
36
36
  * @summary Short summary of the component's intended use.
@@ -127,40 +127,37 @@ export default class ZnIcon extends ZincElement {
127
127
  connectedCallback() {
128
128
  super.connectedCallback();
129
129
 
130
+ let hashFragment = '';
130
131
 
131
132
  if (this.src && this.src.includes('#')) {
132
- const split = this.src.split('#');
133
+ const split = this.src.split('#', 2);
133
134
  this.src = split[0];
134
- const attributes = split[1].split(',');
135
- attributes.forEach(attr => {
136
- if (attr === "round") {
137
- this.round = true;
138
- }
139
- if (attr === "tile") {
140
- this.tile = true;
141
- }
142
- if (attr === "depth") {
143
- this.depth = true;
144
- }
145
- });
135
+ hashFragment = split[1] ?? '';
146
136
  }
147
137
 
148
138
  if (this.src && this.src.includes('@')) {
149
- const split = this.src.split('@');
150
- if (split[1].includes('.')) {
151
- this.library = "gravatar";
152
- } else if ((this.library === undefined) && split[1] !== "") {
139
+ const atIndex = this.src.lastIndexOf('@');
140
+ const libraryOrDomain = this.src.slice(atIndex + 1);
141
+
142
+ if (libraryOrDomain.includes('.')) {
143
+ this.library = this.library ?? "gravatar";
144
+ } else if ((this.library === undefined) && libraryOrDomain !== "") {
153
145
  // if split[1] is a valid library name, set it
154
- this.library = this.convertToLibrary(split[1]);
155
- this.src = split[0];
146
+ this.library = this.convertToLibrary(libraryOrDomain);
147
+ this.src = this.src.slice(0, atIndex);
156
148
  }
157
149
 
158
150
  if (this.library === "gravatar" || this.library === "libravatar") {
159
- this.ravatarOptions();
160
- this.src = md5(this.src);
151
+ this.applyHashFragment(hashFragment);
152
+ this.src = sha256(this.normalizeRavatarEmail(this.src));
153
+ } else {
154
+ this.applyHashFragment(hashFragment);
161
155
  }
162
156
  } else if (!this.library && this.src && !this.src.includes('/')) {
157
+ this.applyHashFragment(hashFragment);
163
158
  this.library = this.defaultLibrary;
159
+ } else {
160
+ this.applyHashFragment(hashFragment);
164
161
  }
165
162
 
166
163
  // load the material icons font if the library is set to material
@@ -174,18 +171,42 @@ export default class ZnIcon extends ZincElement {
174
171
  <link
175
172
  href="https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined|Material+Icons|Material+Icons+Round|Material+Icons+Sharp|Material+Icons+Two+Tone|Material+Icons+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
176
173
  rel="stylesheet">`, document.head);
177
-
178
- this.ravatarOptions();
179
174
  }
180
175
 
181
- ravatarOptions() {
182
- if ((this.library === "gravatar" || this.library === "libravatar") && this.src.includes('#')) {
183
- const split = this.src.split('#');
184
- this.gravatarOptions = "&d=" + split[1];
185
- this.src = split[0];
176
+ private applyHashFragment(hashFragment: string) {
177
+ if (!hashFragment) {
178
+ return;
179
+ }
180
+
181
+ const attributes = hashFragment.split(',');
182
+
183
+ attributes.forEach(attr => {
184
+ if (attr === 'round') {
185
+ this.round = true;
186
+ }
187
+
188
+ if (attr === 'tile') {
189
+ this.tile = true;
190
+ }
191
+
192
+ if (attr === 'depth') {
193
+ this.depth = true;
194
+ }
195
+ });
196
+
197
+ if ((this.library === 'gravatar' || this.library === 'libravatar')) {
198
+ const defaultOption = attributes.find(attr => !['round', 'tile', 'depth'].includes(attr));
199
+
200
+ if (defaultOption) {
201
+ this.gravatarOptions = `&d=${defaultOption}`;
202
+ }
186
203
  }
187
204
  }
188
205
 
206
+ private normalizeRavatarEmail(email: string) {
207
+ return email.trim().toLowerCase();
208
+ }
209
+
189
210
  render() {
190
211
  return html`
191
212
  <div class="icon-wrapper">
@@ -1,5 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import { expect, fixture, html } from '@open-wc/testing';
3
+ import type ZnIcon from './icon.component';
3
4
 
4
5
  describe('<zn-icon>', () => {
5
6
  it('should render a component', async () => {
@@ -7,4 +8,26 @@ describe('<zn-icon>', () => {
7
8
 
8
9
  expect(el).to.exist;
9
10
  });
11
+
12
+ it('should build Gravatar URLs with a normalized SHA-256 hash', async () => {
13
+ const el = await fixture<ZnIcon>(html`
14
+ <zn-icon src="MyEmailAddress@example.com " library="gravatar" size="48"></zn-icon>
15
+ `);
16
+
17
+ const image = el.shadowRoot?.querySelector('img');
18
+ expect(image?.getAttribute('src')).to.equal(
19
+ 'https://www.gravatar.com/avatar/84059b07d4be67b806386c0aad8070a23f18836bbaae342275dc0a83414c32ee?s=48'
20
+ );
21
+ });
22
+
23
+ it('should pass fallback options through to Libravatar', async () => {
24
+ const el = await fixture<ZnIcon>(html`
25
+ <zn-icon src="nonexistent@example.com#identicon" library="libravatar" size="48"></zn-icon>
26
+ `);
27
+
28
+ const image = el.shadowRoot?.querySelector('img');
29
+ expect(image?.getAttribute('src')).to.equal(
30
+ 'https://seccdn.libravatar.org/avatar/04e8703fcfb60849fb5c596abc8f3d7547c7e0099c3806a3ffec77a95dc02e25?s=48&d=identicon'
31
+ );
32
+ });
10
33
  });
@@ -547,6 +547,12 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
547
547
  }
548
548
 
549
549
  private handleKeyDown(event: KeyboardEvent) {
550
+ if (event.key === 'Escape') {
551
+ this.input.blur();
552
+ event.stopPropagation();
553
+ return;
554
+ }
555
+
550
556
  const hasModifier = event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
551
557
 
552
558
  // Pressing enter when focused on the input should submit the form like a native input, be we wait a tick before
@@ -1,5 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
- import { expect, fixture, html } from '@open-wc/testing';
2
+ import {expect, fixture, html} from '@open-wc/testing';
3
+ import type ZnInput from './input.component';
3
4
 
4
5
  describe('<zn-input>', () => {
5
6
  it('should render a component', async () => {
@@ -7,4 +8,36 @@ describe('<zn-input>', () => {
7
8
 
8
9
  expect(el).to.exist;
9
10
  });
11
+
12
+ describe('Escape key', () => {
13
+ it('blurs the inner input and stops propagation when focused', async () => {
14
+ const wrapper = await fixture<HTMLDivElement>(html`
15
+ <div>
16
+ <zn-input></zn-input>
17
+ </div>
18
+ `);
19
+ const el = wrapper.querySelector('zn-input') as ZnInput;
20
+ await el.updateComplete;
21
+
22
+ let wrapperEscapes = 0;
23
+ wrapper.addEventListener('keydown', (e) => {
24
+ if ((e as KeyboardEvent).key === 'Escape') wrapperEscapes++;
25
+ });
26
+
27
+ el.focus();
28
+ await el.updateComplete;
29
+ expect(el.shadowRoot!.activeElement).to.equal(el.input);
30
+
31
+ el.input.dispatchEvent(new KeyboardEvent('keydown', {
32
+ key: 'Escape',
33
+ bubbles: true,
34
+ composed: true,
35
+ cancelable: true
36
+ }));
37
+ await el.updateComplete;
38
+
39
+ expect(el.shadowRoot!.activeElement, 'inner input should be blurred').to.not.equal(el.input);
40
+ expect(wrapperEscapes, 'wrapper should not see Escape on press 1').to.equal(0);
41
+ });
42
+ });
10
43
  });
@@ -269,6 +269,8 @@ export default class ZnMarkdownEditor extends ZnPanel implements ZincFormControl
269
269
  'panel--cosmic': this.cosmic,
270
270
  'panel--shadow': this.shadow,
271
271
  })}>
272
+
273
+
272
274
  <div class="panel__inner">
273
275
  ${hasHeader ? html`
274
276
  <zn-header class=${classMap({
@@ -297,6 +299,7 @@ export default class ZnMarkdownEditor extends ZnPanel implements ZincFormControl
297
299
  </label>` : ''}
298
300
 
299
301
  <div part="toolbar" class="markdown-editor__toolbar">
302
+ ${this.markdownHelperBtn()}
300
303
  <zn-button-group class="markdown-editor__view-toggle" @click=${this.handleViewToggle}>
301
304
  ${VIEW_MODES.map(v => this.renderViewButton(v.mode, v.icon, v.label))}
302
305
  </zn-button-group>
@@ -351,6 +354,21 @@ export default class ZnMarkdownEditor extends ZnPanel implements ZincFormControl
351
354
  `;
352
355
  }
353
356
 
357
+ markdownHelperBtn() {
358
+ return html`
359
+ <a
360
+ class="markdown-editor__markdown-link"
361
+ href="https://www.markdownguide.org/cheat-sheet"
362
+ target="_blank"
363
+ rel="noopener noreferrer"
364
+ title="Markdown is supported"
365
+ >
366
+ <zn-icon src="markdown"></zn-icon>
367
+ <span>Markdown is supported</span>
368
+ </a>
369
+ `;
370
+ }
371
+
354
372
  private renderViewButton(mode: ViewMode, icon: string, label: string) {
355
373
  const isActive = this.viewMode === mode;
356
374
  return html`
@@ -372,4 +390,7 @@ export default class ZnMarkdownEditor extends ZnPanel implements ZincFormControl
372
390
  ></zn-button>
373
391
  `;
374
392
  }
393
+
394
+
395
+
375
396
  }
@@ -44,9 +44,32 @@
44
44
 
45
45
  .markdown-editor__toolbar {
46
46
  display: flex;
47
- align-items: center;
47
+ align-items: end;
48
48
  gap: var(--zn-spacing-small);
49
49
  justify-content: flex-end;
50
+ flex-wrap: wrap;
51
+ }
52
+
53
+ .markdown-editor__markdown-link {
54
+ display: inline-flex;
55
+ align-items: center;
56
+ gap: var(--zn-spacing-x-small);
57
+ margin-right: auto;
58
+ font-size: var(--zn-input-label-font-size-small);
59
+ line-height: 1;
60
+ color: var(--zn-color-neutral-500, #656d76);
61
+ text-decoration: none;
62
+ cursor: pointer;
63
+ transition: color 0.15s ease;
64
+ padding-left: var(--zn-spacing-x-small);
65
+
66
+ &:hover {
67
+ color: var(--zn-color-primary-600, #0969da);
68
+ }
69
+
70
+ span {
71
+ white-space: nowrap;
72
+ }
50
73
  }
51
74
 
52
75
  .markdown-editor__view-toggle {
@@ -484,6 +484,13 @@ export default class ZnSelect extends ZincElement implements ZincFormControl {
484
484
  this.displayInput.focus({preventScroll: true});
485
485
  }
486
486
 
487
+ // Blur the display input when Escape is pressed and the dropdown is closed,
488
+ // so a second Escape press can bubble (e.g. close a containing dialog).
489
+ if (event.key === 'Escape' && !this.open) {
490
+ event.stopPropagation();
491
+ this.displayInput.blur();
492
+ }
493
+
487
494
  // Handle enter and space. When pressing space, we allow for type to select behaviors so if there's anything in the
488
495
  // buffer we _don't_ close it. When search is enabled, space should type into the input, not toggle.
489
496
  if (event.key === 'Enter' || (event.key === ' ' && !this.search && this.typeToSelectString === '')) {
@@ -152,4 +152,28 @@ describe('<zn-select>', () => {
152
152
  expect(visibleOptions[0].value).to.equal('apple');
153
153
  });
154
154
  });
155
+
156
+ describe('Escape key', () => {
157
+ it('blurs the display input when dropdown is closed and Escape is pressed', async () => {
158
+ const el = await fixture<ZnSelect>(html`<zn-select></zn-select>`);
159
+ await el.updateComplete;
160
+
161
+ const displayInput = el.shadowRoot!.querySelector<HTMLInputElement>('.select__display-input')!;
162
+ el.focus();
163
+ await el.updateComplete;
164
+ expect(el.shadowRoot!.activeElement).to.equal(displayInput);
165
+ expect(el.open).to.be.false;
166
+
167
+ displayInput.dispatchEvent(new KeyboardEvent('keydown', {
168
+ key: 'Escape',
169
+ bubbles: true,
170
+ composed: true,
171
+ cancelable: true
172
+ }));
173
+ await el.updateComplete;
174
+
175
+ expect(el.shadowRoot!.activeElement, 'display input should be blurred').to.not.equal(displayInput);
176
+ });
177
+
178
+ });
155
179
  });