@kubex/zinc 2.0.1 → 2.0.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubex/zinc",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
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",
@@ -12,8 +12,8 @@ import ZnDropdown from "../dropdown";
12
12
  import ZnIcon from "../icon";
13
13
  import ZnTooltip from "../tooltip";
14
14
  import type {IconColor, IconLibrary} from "../icon";
15
- import type ZnMenu from "../menu";
16
15
  import type {ZincFormControl} from '../../internal/zinc-element';
16
+ import type ZnMenu from "../menu";
17
17
 
18
18
  import sharedStyles from '../../../scss/shared/index.scss';
19
19
  import styles from './button.scss';
@@ -641,7 +641,7 @@ export default class ZnButton extends ZincElement implements ZincFormControl {
641
641
  'button--icon-button-small': this.iconButton === 'small',
642
642
  'button--icon-button-round': this.iconButton === 'round',
643
643
  'button--grow': this.grow,
644
- 'button--standard': !this.outline && !this.text,
644
+ 'button--standard': !this.outline && (!this.text || this.solid),
645
645
  'button--with-icon': this.icon,
646
646
  'button--icon-left': this.iconPosition === 'left',
647
647
  'button--icon-right': this.iconPosition === 'right',
@@ -66,6 +66,13 @@ describe('<zn-button>', () => {
66
66
  expect(base.classList.contains('button--large')).to.be.true;
67
67
  });
68
68
 
69
+ it('keeps the filled style when solid overrides text', async () => {
70
+ const el = await fixture<ZnButton>(html`<zn-button text solid color="primary">Create</zn-button>`);
71
+ const base = el.shadowRoot!.querySelector('[part~="base"]')!;
72
+ expect(base.classList.contains('button--standard')).to.be.true;
73
+ expect(base.classList.contains('button--text')).to.be.false;
74
+ });
75
+
69
76
  it('applies the ai color', async () => {
70
77
  const el = await fixture<ZnButton>(html`<zn-button color="ai">Summarise</zn-button>`);
71
78
  const base = el.shadowRoot!.querySelector('[part~="base"]')!;
@@ -16,8 +16,8 @@ import {
16
16
  type RadarIndicator,
17
17
  type SeriesItem,
18
18
  } from './builders';
19
- import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
20
19
  import { classMap } from 'lit/directives/class-map.js';
20
+ import { type CSSResultGroup, html, type PropertyValues, unsafeCSS } from 'lit';
21
21
  import { type EChartsModule, loadECharts } from './echarts-loader';
22
22
  import { LiveTransport, LiveWindow } from './live';
23
23
  import { property, state } from 'lit/decorators.js';
@@ -4,10 +4,11 @@ import {HasSlotController} from "../../internal/slot";
4
4
  import {ifDefined} from "lit/directives/if-defined.js";
5
5
  import {property, state} from 'lit/decorators.js';
6
6
  import {ref} from "lit/directives/ref.js";
7
- import {styleMap} from "lit/directives/style-map.js";
8
7
  import {renderSortIcon, sortKeyHandler} from "../../internal/table-sort";
9
8
  import {ReorderController} from "../../internal/reorder";
10
9
  import {ResizeController} from '@lit-labs/observers/resize-controller.js';
10
+ import {slideTabIndicator, squishTabIndicator} from "../../internal/tab-indicator";
11
+ import {styleMap} from "lit/directives/style-map.js";
11
12
  import {type TableTab, TabRowController} from "../../internal/table-tabs";
12
13
  import {Task} from "@lit/task";
13
14
  import {unsafeHTML} from "lit/directives/unsafe-html.js";
@@ -24,18 +25,18 @@ import ZnDataTableSearch from "../data-table-search";
24
25
  import ZnDropdown from "../dropdown";
25
26
  import ZnEmptyState from "../empty-state";
26
27
  import ZnHoverContainer from "../hover-container";
28
+ import ZnIcon from "../icon";
29
+ import ZnInput from "../input";
27
30
  import ZnMenu from "../menu";
28
31
  import ZnMenuItem from "../menu-item";
32
+ import ZnOptGroup from "../opt-group";
33
+ import ZnOption from "../option";
29
34
  import ZnPanel from "../panel";
35
+ import ZnSelect from "../select";
30
36
  import ZnSkeleton from "../skeleton";
31
37
  import ZnStyle from "../style";
32
38
  import type ZnDataSelect from "../data-select";
33
- import ZnIcon from "../icon";
34
- import ZnInput from "../input";
35
- import ZnOptGroup from "../opt-group";
36
- import ZnOption from "../option";
37
39
  import type ZnQueryBuilder from "../query-builder";
38
- import ZnSelect from "../select";
39
40
 
40
41
  // Tables load in such a way that the global sheet has to be re-applied inside the shadow root.
41
42
  import bootStyles from '../../../scss/boot.scss';
@@ -920,6 +921,34 @@ export default class ZnDataTable extends ZincElement {
920
921
  protected updated(changed: PropertyValues) {
921
922
  super.updated(changed);
922
923
  this.publishFilterSuggestions();
924
+ void this.syncPageIndicator();
925
+ }
926
+
927
+ // Slides the shared active-page pill to the current page the way navbar pill tabs do
928
+ private async syncPageIndicator() {
929
+ const indicator = this.shadowRoot?.querySelector<HTMLElement>('.table__footer__indicator');
930
+ const active = indicator?.parentElement?.querySelector<ZnButton>('.table__footer__page--active');
931
+ if (!indicator || !active) return;
932
+
933
+ // The button has no size until it has rendered
934
+ await active.updateComplete;
935
+ if (!active.isConnected || !active.classList.contains('table__footer__page--active')) return;
936
+
937
+ const origin = indicator.parentElement!.getBoundingClientRect().left;
938
+ const tab = active.getBoundingClientRect();
939
+ const target = {left: tab.left - origin, right: tab.right - origin};
940
+ const left = `${target.left}px`; const width = `${target.right - target.left}px`;
941
+ if (indicator.style.left === left && indicator.style.width === width) return;
942
+
943
+ // A freshly rendered indicator has no width yet and jumps straight into place
944
+ const placed = indicator.style.width !== '';
945
+ const drawn = indicator.getBoundingClientRect();
946
+ indicator.style.left = left;
947
+ indicator.style.width = width;
948
+ if (placed) {
949
+ squishTabIndicator(indicator);
950
+ slideTabIndicator(indicator, {left: drawn.left - origin, right: drawn.right - origin}, target);
951
+ }
923
952
  }
924
953
 
925
954
  // The filter bar can't see the data, so hand it the values already on screen to type against
@@ -1459,28 +1488,26 @@ export default class ZnDataTable extends ZincElement {
1459
1488
  </div>`;
1460
1489
  }
1461
1490
 
1462
- // A window of PAGE_WINDOW pages around the current one, plus the first and last pages;
1463
- // null marks a gap, except a gap of one page, which shows that page instead
1491
+ // A window of PAGE_WINDOW pages around the current one, plus the first and last pages; null marks a gap.
1492
+ // Always PAGE_WINDOW + 4 slots once there are that many pages, so the prev/next buttons never move.
1464
1493
  private getPageRange(): (number | null)[] {
1465
- const size = Math.min(PAGE_WINDOW, this.totalPages);
1466
- const start = Math.min(
1467
- Math.max(1, this.page - Math.floor(size / 2)),
1468
- this.totalPages - size + 1
1469
- );
1470
- const pages = [...new Set([1, ...Array.from({length: size}, (_, i) => start + i), this.totalPages])];
1471
-
1472
- return pages.flatMap((p, i) => {
1473
- const gap = i > 0 ? p - pages[i - 1] : 1;
1474
- if (gap === 2) return [p - 1, p];
1475
- return gap > 2 ? [null, p] : [p];
1476
- });
1494
+ const n = this.totalPages;
1495
+ const slots = PAGE_WINDOW + 4;
1496
+ const range = (from: number, count: number) => Array.from({length: count}, (_, i) => from + i);
1497
+ if (n <= slots) return range(1, n);
1498
+
1499
+ const half = Math.floor(PAGE_WINDOW / 2);
1500
+ if (this.page - half <= 3) return [...range(1, slots - 2), null, n];
1501
+ if (this.page + half >= n - 2) return [1, null, ...range(n - slots + 3, slots - 2)];
1502
+ return [1, null, ...range(this.page - half, PAGE_WINDOW), null, n];
1477
1503
  }
1478
1504
 
1479
1505
  getPagination() {
1480
1506
  if (this.hidePagination || this.totalPages <= 1) return null;
1481
1507
 
1482
1508
  return html`
1483
- <div class="table__footer__pages">
1509
+ <div class="table__footer__pages" style="--page-digits: ${String(this.totalPages).length}">
1510
+ <span class="table__footer__indicator"></span>
1484
1511
  <zn-button class="table__footer__page"
1485
1512
  color="transparent"
1486
1513
  size="small"
@@ -311,7 +311,9 @@ table {
311
311
  padding: 0;
312
312
  }
313
313
 
314
+ // Equal shares either side keep the page buttons centred however wide the range label gets
314
315
  &__left, &__rows-per-page {
316
+ flex: 1 1 0;
315
317
  display: flex;
316
318
  flex-direction: row;
317
319
  align-items: center;
@@ -320,14 +322,33 @@ table {
320
322
  }
321
323
 
322
324
  &__pages {
325
+ // Every slot sized for the widest page number, so buttons don't resize as the window slides
326
+ --page-slot: max(var(--zn-button-height-small, 28px), calc(var(--page-digits, 1) * 1ch + 12px));
327
+
328
+ position: relative;
329
+ isolation: isolate;
323
330
  display: flex;
324
331
  align-items: center;
325
332
  gap: 2px;
326
333
  margin-inline: auto;
327
334
  }
328
335
 
336
+ // Placed by left and width, slid with transform only
337
+ &__indicator {
338
+ @include wc.pill-tab-indicator-motion;
339
+
340
+ position: absolute;
341
+ inset-block: 0;
342
+ left: 0;
343
+ z-index: -1;
344
+ transform-origin: left center;
345
+ border-radius: var(--zn-border-radius);
346
+ background-color: var(--zn-table-page-active-background, var(--zn-color-neutral-300));
347
+ pointer-events: none;
348
+ }
349
+
329
350
  &__rows-per-page {
330
- margin-inline-start: auto;
351
+ justify-content: flex-end;
331
352
 
332
353
  zn-select[name="rowPerPage"] {
333
354
  --zn-input-background-color: rgb(var(--zn-panel));
@@ -346,13 +367,10 @@ table {
346
367
  }
347
368
  }
348
369
 
349
- &__pages + &__rows-per-page {
350
- margin-inline-start: 0;
351
- }
352
-
353
370
  &__page::part(base) {
354
- min-width: var(--zn-button-height-small, 28px);
371
+ min-width: var(--page-slot);
355
372
  padding: 0 5px;
373
+ font-variant-numeric: tabular-nums;
356
374
  justify-content: center;
357
375
  color: rgb(var(--zn-color-muted-text, var(--zn-text)));
358
376
  font-size: 12px;
@@ -360,14 +378,14 @@ table {
360
378
  }
361
379
 
362
380
  &__page--active::part(base) {
363
- background-color: var(--zn-table-page-active-background, var(--zn-color-neutral-300));
381
+ background-color: transparent;
364
382
  color: var(--zn-table-page-active-color, rgb(var(--zn-text)));
365
383
  }
366
384
 
367
385
  &__gap {
368
386
  display: inline-flex;
369
387
  justify-content: center;
370
- min-width: var(--zn-button-height-small, 28px);
388
+ min-width: var(--page-slot);
371
389
  font-weight: var(--zn-font-weight-semibold, 600);
372
390
  }
373
391
  }
@@ -1,9 +1,17 @@
1
1
  import '../../../dist/zn.min.js';
2
+ import { centre, dragTo, drop, startDrag } from '../../internal/test-drag';
2
3
  import { expect, fixture, html, waitUntil } from '@open-wc/testing';
3
- import { centre, drop, dragTo, startDrag } from '../../internal/test-drag';
4
4
  import { render } from 'lit';
5
5
  import type ZnDataTable from './data-table.component';
6
6
 
7
+ // WebKit reports a benign "ResizeObserver loop" error when paging re-lays out the footer's zn-select
8
+ window.addEventListener('error', (e: ErrorEvent) => {
9
+ if (typeof e.message === 'string' && e.message.includes('ResizeObserver loop')) {
10
+ e.stopImmediatePropagation();
11
+ e.preventDefault();
12
+ }
13
+ }, true);
14
+
7
15
  describe('<zn-data-table>', () => {
8
16
  it('should render a component', async () => {
9
17
  const el = await fixture(html` <zn-data-table></zn-data-table> `);
@@ -67,6 +75,51 @@ describe('<zn-data-table>', () => {
67
75
  }
68
76
  });
69
77
 
78
+ it('renders the same number of page slots on every page', async () => {
79
+ const originalFetch = window.fetch;
80
+ window.fetch = (_url, init) => {
81
+ const {page} = JSON.parse(String(init!.body)) as {page: number};
82
+ return Promise.resolve(new Response(JSON.stringify({
83
+ rows: [{id: '1', cells: [{text: 'Row', column: 'name'}]}],
84
+ page,
85
+ perPage: 10,
86
+ total: 1234,
87
+ }), {status: 200, headers: {'Content-Type': 'application/json'}}));
88
+ };
89
+
90
+ try {
91
+ const el = await fixture<ZnDataTable>(html`
92
+ <zn-data-table data-uri="/test-data" method="POST" headers='{"name": {"key": "name", "label": "Name"}}'></zn-data-table>`);
93
+ const slots = () => [...el.shadowRoot!.querySelectorAll('.table__footer__page, .table__footer__gap')]
94
+ .slice(1, -1).map(s => s.textContent!.trim()).join(',');
95
+
96
+ const seen: Record<number, string> = {};
97
+ for (const page of [1, 4, 5, 60, 120, 121, 124]) {
98
+ (el as unknown as {goToPage: (p: number) => void}).goToPage(page);
99
+ await waitUntil(() => el.shadowRoot!.querySelector('[aria-current="page"]')?.textContent?.trim() === String(page));
100
+ seen[page] = slots();
101
+ }
102
+
103
+ expect(seen).to.deep.equal({
104
+ 1: '1,2,3,4,5,…,124',
105
+ 4: '1,2,3,4,5,…,124',
106
+ 5: '1,…,4,5,6,…,124',
107
+ 60: '1,…,59,60,61,…,124',
108
+ 120: '1,…,119,120,121,…,124',
109
+ 121: '1,…,120,121,122,123,124',
110
+ 124: '1,…,120,121,122,123,124',
111
+ });
112
+
113
+ const indicator = el.shadowRoot!.querySelector<HTMLElement>('.table__footer__indicator')!;
114
+ const active = el.shadowRoot!.querySelector<HTMLElement>('.table__footer__page--active')!;
115
+ await waitUntil(() => indicator.getAnimations().length === 0);
116
+ expect(Math.round(indicator.getBoundingClientRect().left)).to.equal(Math.round(active.getBoundingClientRect().left));
117
+ expect(Math.round(indicator.getBoundingClientRect().width)).to.equal(Math.round(active.getBoundingClientRect().width));
118
+ } finally {
119
+ window.fetch = originalFetch;
120
+ }
121
+ });
122
+
70
123
  it('hides the rows-per-page selector when the dataset fits the smallest page size', async () => {
71
124
  const originalFetch = window.fetch;
72
125
  window.fetch = () => Promise.resolve(new Response(JSON.stringify({
@@ -1,7 +1,7 @@
1
1
  import '../../../../../dist/zn.min.js';
2
2
  import {expect, fixture, html, oneEvent} from '@open-wc/testing';
3
- import type DialogComponent from './dialog.component';
4
3
  import type {ZnEditorInsertEvent} from '../events/zn-editor-insert';
4
+ import type DialogComponent from './dialog.component';
5
5
 
6
6
  describe('<zn-editor-dialog> content inserts', () => {
7
7
  const dialog = () => fixture<DialogComponent>(html`
@@ -3,10 +3,10 @@ import {type CSSResultGroup, html, nothing, type PropertyValues, unsafeCSS} from
3
3
  import {deepQuerySelectorAll} from "../../utilities/query";
4
4
  import {md5} from "../../utilities/md5";
5
5
  import {MutationController} from '@lit-labs/observers/mutation-controller.js';
6
+ import {onHoverIntent} from '../../internal/hover-intent';
6
7
  import {property} from 'lit/decorators.js';
7
8
  import {styleMap} from "lit/directives/style-map.js";
8
9
  import ZincElement from '../../internal/zinc-element';
9
- import {onHoverIntent} from '../../internal/hover-intent';
10
10
 
11
11
  import styles from './expanding-action.scss';
12
12
 
@@ -3,8 +3,8 @@ import {property, state} from "lit/decorators.js";
3
3
  import {trimControlValue} from '../../internal/form';
4
4
  import ZincElement from '../../internal/zinc-element';
5
5
 
6
- import type ZnDataSelect from "../data-select";
7
6
  import type ZnButton from "../button";
7
+ import type ZnDataSelect from "../data-select";
8
8
  import type ZnDataTable from "../data-table";
9
9
  import type ZnInput from "../input";
10
10
  import type ZnQueryBuilder from "../query-builder";
@@ -86,7 +86,7 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
86
86
 
87
87
  @property({ type: Boolean }) inline: boolean
88
88
 
89
- @property({ type: Boolean }) padded: boolean
89
+ @property({ type: Boolean, reflect: true }) padded: boolean
90
90
 
91
91
  @property({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium';
92
92
 
@@ -436,7 +436,6 @@ export default class ZnInlineEdit extends ZincElement implements ZincFormControl
436
436
  'ai--focused': this.hasFocus,
437
437
  'ai--disabled': this.disabled,
438
438
  'ai--inline': this.inline,
439
- 'ai--padded': this.padded,
440
439
  'ai--masked': !!this.displayValue,
441
440
  })}" dir="ltr">
442
441
 
@@ -1,30 +1,34 @@
1
1
  @use "../../wc";
2
2
 
3
3
  :host {
4
+ --ai-input-height: var(--zn-input-height-medium);
5
+
4
6
  display: block;
7
+ box-sizing: border-box;
5
8
  container-type: inline-size;
6
9
  width: 100%;
7
10
  }
8
11
 
12
+ // On the host rather than the row, so the hover fill and edit actions stay hugging the field
13
+ :host([padded]) {
14
+ padding: var(--zn-spacing-large);
15
+ }
16
+
9
17
  .ai {
10
18
  --bleed: var(--zn-inline-edit-bleed, 10px);
11
19
  display: flex;
12
- width: 100%;
20
+ box-sizing: border-box;
21
+ // The hover fill hangs out past the value's left edge, so the text stays aligned with the column
22
+ width: calc(100% + var(--bleed));
23
+ margin-inline-start: calc(-1 * var(--bleed));
24
+ padding-inline-start: var(--bleed);
13
25
  flex-direction: row;
14
26
  gap: 10px;
15
27
  border-radius: var(--zn-border-radius-medium);
16
28
  transition: background-color var(--zn-transition-fast) ease;
17
29
 
18
- // The hover fill hangs out past the value's left edge, so the text stays aligned with the column
19
- &:not(.ai--padded) {
20
- box-sizing: border-box;
21
- width: calc(100% + var(--bleed));
22
- margin-inline-start: calc(-1 * var(--bleed));
23
- padding-inline-start: var(--bleed);
24
- }
25
-
26
30
  // The input's own padding takes over from the bleed, keeping its text roughly where the value was
27
- &--editing:not(.ai--padded) {
31
+ &--editing {
28
32
  padding-inline-start: 0;
29
33
  }
30
34
 
@@ -116,10 +120,6 @@
116
120
  }
117
121
  }
118
122
 
119
- &--padded {
120
- padding: var(--zn-spacing-large);
121
- }
122
-
123
123
  &--inline {
124
124
  display: flex;
125
125
  flex-direction: row;
@@ -160,7 +160,7 @@
160
160
  position: absolute;
161
161
  inset-block-start: 0;
162
162
  inset-inline-end: var(--action-inset);
163
- height: var(--zn-input-height-medium);
163
+ height: var(--ai-input-height);
164
164
  gap: 2px;
165
165
  }
166
166
 
@@ -238,7 +238,7 @@
238
238
 
239
239
  &__reveal {
240
240
  // Matches the height of the input it stands in for, so entering edit mode doesn't resize the row
241
- line-height: var(--zn-input-height-medium);
241
+ line-height: var(--ai-input-height);
242
242
  color: rgb(var(--zn-text));
243
243
 
244
244
  // Fill the row so hovering anywhere over the field reveals the value
@@ -312,10 +312,10 @@ zn-textarea::part(textarea) {
312
312
  resize: none;
313
313
  }
314
314
 
315
- :host([size="small"]) .ai__reveal {
316
- line-height: var(--zn-input-height-small);
315
+ :host([size="small"]) {
316
+ --ai-input-height: var(--zn-input-height-small);
317
317
  }
318
318
 
319
- :host([size="large"]) .ai__reveal {
320
- line-height: var(--zn-input-height-large);
319
+ :host([size="large"]) {
320
+ --ai-input-height: var(--zn-input-height-large);
321
321
  }
@@ -192,7 +192,7 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
192
192
  /** Determines whether or no the password is currently visible. Only applies to password types **/
193
193
  @property({attribute: 'password-visible', type: Boolean}) passwordVisible: boolean = false;
194
194
 
195
- /** Hides the browsers built-in increment/decrement spin buttons for number inputs **/
195
+ /** Hides the increment/decrement buttons on number inputs **/
196
196
  @property({attribute: 'no-spin-buttons', type: Boolean}) noSpinButtons: boolean = false;
197
197
 
198
198
  /**
@@ -505,6 +505,27 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
505
505
  }
506
506
  }
507
507
 
508
+ /** Whether the value has reached `limit`; `direction` 1 checks a maximum, -1 a minimum. */
509
+ private isAtLimit(limit: number | string | undefined, direction: 1 | -1): boolean {
510
+ const value = parseFloat(String(this.value));
511
+ const bound = parseFloat(String(limit));
512
+ return !isNaN(value) && !isNaN(bound) && (value - bound) * direction >= 0;
513
+ }
514
+
515
+ private handleStep(direction: 'up' | 'down') {
516
+ const previous: unknown = this.value;
517
+ if (direction === 'up') {
518
+ this.stepUp();
519
+ } else {
520
+ this.stepDown();
521
+ }
522
+ if (this.value === previous) return;
523
+ // stepUp()/stepDown() don't fire events, unlike the native spinner they replace
524
+ this.formControlController.updateValidity();
525
+ this.emit('zn-input');
526
+ this.emit('zn-change');
527
+ }
528
+
508
529
  private handlePasswordToggle() {
509
530
  this.passwordVisible = !this.passwordVisible;
510
531
  }
@@ -861,7 +882,6 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
861
882
  'input--focused': this.hasFocus,
862
883
  'input--empty': !this.value,
863
884
  'input--range': isRange,
864
- 'input--no-spin-buttons': this.noSpinButtons && this.type !== 'currency',
865
885
  'input--color': this.type === 'color',
866
886
  'input--color-invalid': this.colorInvalid,
867
887
  })}>
@@ -973,6 +993,22 @@ export default class ZnInput extends ZincElement implements ZincFormControl {
973
993
  </button>`
974
994
  : ''}
975
995
 
996
+ ${this.type === 'currency' || (this.type === 'number' && !this.noSpinButtons)
997
+ ? html`
998
+ <span part="stepper" class="input__stepper">
999
+ <button class="input__stepper-button" type="button" tabindex="-1" aria-label="Decrement"
1000
+ ?disabled=${this.disabled || this.readonly || this.isAtLimit(this.min, -1)}
1001
+ @click=${() => this.handleStep('down')}>
1002
+ <zn-icon src="minus@lu" size="16"></zn-icon>
1003
+ </button>
1004
+ <button class="input__stepper-button" type="button" tabindex="-1" aria-label="Increment"
1005
+ ?disabled=${this.disabled || this.readonly || this.isAtLimit(this.max, 1)}
1006
+ @click=${() => this.handleStep('up')}>
1007
+ <zn-icon src="plus@lu" size="16"></zn-icon>
1008
+ </button>
1009
+ </span>`
1010
+ : ''}
1011
+
976
1012
  <span part="suffix" class="input__suffix">
977
1013
  <slot name="suffix"></slot>
978
1014
  </span>
@@ -386,17 +386,81 @@ input[type='text'].input__control[data-color-input] {
386
386
  display: none;
387
387
  }
388
388
 
389
- /* Hide the built-in number spinner */
390
- .input--no-spin-buttons input[type='number']::-webkit-outer-spin-button,
391
- .input--no-spin-buttons input[type='number']::-webkit-inner-spin-button {
389
+ /* The built-in spinner is replaced by .input__stepper */
390
+ input[type='number']::-webkit-outer-spin-button,
391
+ input[type='number']::-webkit-inner-spin-button {
392
392
  -webkit-appearance: none;
393
393
  display: none;
394
394
  }
395
395
 
396
- .input--no-spin-buttons input[type='number'] {
396
+ input[type='number'] {
397
397
  -moz-appearance: textfield;
398
398
  }
399
399
 
400
+ .input__stepper {
401
+ --stepper-button-size: calc(var(--zn-input-height-medium) - 10px);
402
+ display: flex;
403
+ flex: 0 0 auto;
404
+ align-items: center;
405
+ gap: 4px;
406
+ padding-inline: 4px 5px;
407
+ }
408
+
409
+ .input--x-small .input__stepper {
410
+ --stepper-button-size: calc(var(--zn-input-height-x-small) - 6px);
411
+ gap: 2px;
412
+ padding-inline: 2px 3px;
413
+ }
414
+
415
+ .input--small .input__stepper {
416
+ --stepper-button-size: calc(var(--zn-input-height-small) - 8px);
417
+ }
418
+
419
+ .input--large .input__stepper {
420
+ --stepper-button-size: calc(var(--zn-input-height-large) - 10px);
421
+ }
422
+
423
+ .input__stepper-button {
424
+ display: grid;
425
+ place-content: center;
426
+ flex: 0 0 auto;
427
+ width: var(--stepper-button-size);
428
+ height: var(--stepper-button-size);
429
+ padding: 0;
430
+ border: none;
431
+ border-radius: var(--zn-border-radius-small);
432
+ background: none;
433
+ cursor: pointer;
434
+ transition: var(--zn-transition-fast) background-color,
435
+ transform 160ms var(--zn-ease-out, cubic-bezier(0.23, 1, 0.32, 1));
436
+
437
+ zn-icon {
438
+ --icon-color: var(--zn-input-icon-color);
439
+ }
440
+
441
+ &:hover:not(:disabled) {
442
+ background-color: var(--zn-color-neutral-100);
443
+
444
+ zn-icon {
445
+ --icon-color: var(--zn-input-icon-color-hover);
446
+ }
447
+ }
448
+
449
+ // Deeper than zn-button's 0.97, which is imperceptible at this size
450
+ &:active:not(:disabled) {
451
+ background-color: var(--zn-color-neutral-200);
452
+ transform: scale(0.9);
453
+ }
454
+
455
+ &:disabled {
456
+ cursor: not-allowed;
457
+
458
+ zn-icon {
459
+ --icon-color: var(--zn-input-border-color);
460
+ }
461
+ }
462
+ }
463
+
400
464
  /* Replace the -webkit-calendar-picker icon */
401
465
  input[type='date']::-webkit-calendar-picker-indicator {
402
466
  background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="14" width="12.25" viewBox="0 0 448 512"><path fill="%236d7176" d="M128 0c13.3 0 24 10.7 24 24V64H296V24c0-13.3 10.7-24 24-24s24 10.7 24 24V64h40c35.3 0 64 28.7 64 64v16 48V448c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V192 144 128C0 92.7 28.7 64 64 64h40V24c0-13.3 10.7-24 24-24zM400 192H48V448c0 8.8 7.2 16 16 16H384c8.8 0 16-7.2 16-16V192zM112 256h96c8.8 0 16 7.2 16 16v96c0 8.8-7.2 16-16 16H112c-8.8 0-16-7.2-16-16V272c0-8.8 7.2-16 16-16z"/></svg>');
@@ -1,6 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
- import {expect, fixture, html, waitUntil} from '@open-wc/testing';
3
2
  import {clearFormStoreValues} from '../../utilities/form';
3
+ import {expect, fixture, html, waitUntil} from '@open-wc/testing';
4
4
  import type ZnInput from './input.component';
5
5
  import type ZnSlashMenu from '../slash-menu/slash-menu.component';
6
6
 
@@ -1,6 +1,6 @@
1
1
  import '../../../dist/zn.min.js';
2
2
  import {aTimeout, expect, fixture, html} from '@open-wc/testing';
3
- import {centre, drop, dragTo, startDrag} from '../../internal/test-drag';
3
+ import {centre, dragTo, drop, startDrag} from '../../internal/test-drag';
4
4
  import type ZnNavItem from '../nav-item';
5
5
  import type ZnNavList from './nav-list.component';
6
6