noph-ui 0.2.7 → 0.3.1

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.
@@ -1,43 +1,32 @@
1
1
  <script lang="ts">
2
2
  import Ripple from '../ripple/Ripple.svelte'
3
- import type { HTMLInputAttributes } from 'svelte/elements'
3
+ import type { CheckboxProps } from './types.ts'
4
4
 
5
5
  let {
6
6
  indeterminate = $bindable(),
7
7
  checked = $bindable(),
8
8
  ...attributes
9
- }: HTMLInputAttributes = $props()
10
- let selected = $derived(checked || indeterminate)
9
+ }: CheckboxProps = $props()
11
10
  </script>
12
11
 
13
12
  <div class="np-host">
14
- <div
15
- class:selected
16
- class="np-container"
17
- class:checked
18
- class:unselected={!selected}
19
- class:prev-checked={!checked}
20
- class:prev-unselected={selected}
21
- class:indeterminate
22
- >
13
+ <div class="np-container">
23
14
  <label class="np-input-wrapper">
24
15
  <input
25
- {...attributes}
26
16
  class="np-input"
27
17
  type="checkbox"
28
- {indeterminate}
18
+ bind:indeterminate
29
19
  bind:checked
30
20
  aria-checked={indeterminate ? 'mixed' : undefined}
31
- onclick={() => {
32
- indeterminate = false
33
- }}
21
+ {...attributes}
34
22
  />
35
- <Ripple />
23
+ {#if !attributes.disabled}
24
+ <Ripple />
25
+ {/if}
36
26
  </label>
37
27
 
38
28
  <div class="np-outline"></div>
39
29
  <div class="np-background"></div>
40
- {#if !attributes.disabled}{/if}
41
30
  <svg class="np-icon" viewBox="0 0 18 18" aria-hidden="true">
42
31
  <rect class="mark short" />
43
32
  <rect class="mark long" />
@@ -60,6 +49,9 @@
60
49
  cursor: pointer;
61
50
  margin: max(0px, (48px - 18px)/2);
62
51
  }
52
+ .np-host:has(input:disabled) {
53
+ cursor: default;
54
+ }
63
55
  .np-container {
64
56
  border-radius: inherit;
65
57
  display: flex;
@@ -114,6 +106,12 @@
114
106
  .np-background {
115
107
  border-radius: inherit;
116
108
  }
109
+ .np-outline {
110
+ border-color: var(--np-checkbox-outline-color, var(--np-color-on-surface-variant));
111
+ border-style: solid;
112
+ border-width: 2px;
113
+ box-sizing: border-box;
114
+ }
117
115
  :where(:hover) .np-outline {
118
116
  border-color: var(--np-color-on-surface);
119
117
  border-width: 2px;
@@ -122,11 +120,14 @@
122
120
  border-color: var(--np-color-on-surface);
123
121
  border-width: 2px;
124
122
  }
125
- .np-outline {
126
- border-color: var(--np-checkbox-outline-color, var(--np-color-on-surface-variant));
127
- border-style: solid;
123
+ .np-container:has(input:disabled) .np-outline {
124
+ border-color: var(--np-color-on-surface);
128
125
  border-width: 2px;
129
- box-sizing: border-box;
126
+ opacity: 0.38;
127
+ }
128
+ .np-container:has(input:disabled:checked) .np-outline,
129
+ .np-container:has(input:disabled:indeterminate) .np-outline {
130
+ visibility: hidden;
130
131
  }
131
132
  .np-background {
132
133
  background-color: var(--np-color-primary);
@@ -139,29 +140,42 @@
139
140
  transition-timing-function: cubic-bezier(0.3, 0, 0.8, 0.15), linear;
140
141
  transform: scale(0.6);
141
142
  }
142
- :where(.selected) :is(.np-background, .np-icon) {
143
+ .np-container:has(input:indeterminate) .np-background,
144
+ .np-container:has(input:checked) .np-background,
145
+ .np-container:has(input:indeterminate) .np-icon,
146
+ .np-container:has(input:checked) .np-icon {
143
147
  opacity: 1;
144
148
  transition-duration: 350ms, 50ms;
145
149
  transition-timing-function: cubic-bezier(0.05, 0.7, 0.1, 1), linear;
146
150
  transform: scale(1);
147
151
  }
152
+ .np-container:has(input:disabled:checked) .np-background,
153
+ .np-container:has(input:disabled:indeterminate) .np-background {
154
+ background: var(--np-color-on-surface);
155
+ opacity: 0.38;
156
+ }
148
157
  .np-icon {
149
158
  fill: var(--np-checkbox-selected-icon-color, var(--np-color-on-primary));
150
159
  height: 18px;
151
160
  width: 18px;
152
161
  }
153
- .checked .mark.short,
154
- .prev-checked.unselected .mark.short {
162
+ .np-container:has(input:disabled) .np-icon {
163
+ fill: var(--np-color-surface);
164
+ }
165
+ .np-container:has(input:checked) .mark.short,
166
+ .np-container:has(input:not(:checked):not(:indeterminate)) .mark.short {
155
167
  height: 5.6568542495px;
156
168
  }
157
- .prev-unselected .mark {
169
+ .np-container:has(input:indeterminate) .mark,
170
+ .np-container:has(input:checked) .mark {
158
171
  transition-property: none;
159
172
  }
160
- .checked .mark,
161
- .prev-checked.unselected .mark {
173
+ .np-container:has(input:checked) .mark,
174
+ .np-container:has(input:not(:checked):not(:indeterminate)) .mark {
162
175
  transform: scaleY(-1) translate(7px, -14px) rotate(45deg);
163
176
  }
164
- .selected .mark {
177
+ .np-container:has(input:indeterminate) .mark,
178
+ .np-container:has(input:checked) .mark {
165
179
  animation-duration: 350ms;
166
180
  animation-timing-function: cubic-bezier(0.05, 0.7, 0.1, 1);
167
181
  transition-duration: 350ms;
@@ -178,11 +192,11 @@
178
192
  transition-duration: 150ms;
179
193
  transition-timing-function: cubic-bezier(0.3, 0, 0.8, 0.15);
180
194
  }
181
- .prev-unselected.checked .mark.long {
195
+ .np-container:has(input:checked) .mark.long {
182
196
  animation-name: prev-unselected-to-checked;
183
197
  }
184
- .checked .mark.long,
185
- .prev-checked.unselected .mark.long {
198
+ .np-container:has(input:checked) .mark.long,
199
+ .np-container:has(input:not(:checked):not(:indeterminate)) .mark.long {
186
200
  width: 11.313708499px;
187
201
  }
188
202
  .mark.long {
@@ -190,7 +204,7 @@
190
204
  transition-property: transform, width;
191
205
  width: 10px;
192
206
  }
193
- .indeterminate .mark {
207
+ .np-container:has(input:indeterminate) .mark {
194
208
  transform: scaleY(-1) translate(4px, -10px) rotate(0deg);
195
209
  }
196
210
  @keyframes prev-unselected-to-checked {
@@ -1,4 +1,4 @@
1
- import type { HTMLInputAttributes } from 'svelte/elements';
2
- declare const Checkbox: import("svelte").Component<HTMLInputAttributes, {}, "indeterminate" | "checked">;
1
+ import type { CheckboxProps } from './types.ts';
2
+ declare const Checkbox: import("svelte").Component<CheckboxProps, {}, "checked" | "indeterminate">;
3
3
  type Checkbox = ReturnType<typeof Checkbox>;
4
4
  export default Checkbox;
@@ -1 +1,2 @@
1
1
  export { default as Checkbox } from './Checkbox.svelte';
2
+ export type { CheckboxProps } from './types.ts';
@@ -0,0 +1,2 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ export type CheckboxProps = Omit<HTMLInputAttributes, 'class' | 'type'>;
@@ -0,0 +1 @@
1
+ export {};
@@ -21,11 +21,10 @@
21
21
  -webkit-font-smoothing: antialiased;
22
22
  }
23
23
  .np-icon {
24
- font-variation-settings:
25
- 'FILL' 0,
26
- 'wght' 400,
27
- 'GRAD' 0,
28
- 'opsz' 24;
24
+ transition-property: font-variation-settings;
25
+ transition-timing-function: ease-in;
26
+ transition: font-variation-settings 0.3s;
27
+ font-variation-settings: var(--np-icon-settings, 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24);
29
28
  }
30
29
  :where(.button-icon) .np-icon {
31
30
  display: inline-flex;
@@ -1,30 +1,98 @@
1
1
  <script lang="ts">
2
2
  import Ripple from '../ripple/Ripple.svelte'
3
- import type { HTMLInputAttributes } from 'svelte/elements'
4
- let { ...attributes }: HTMLInputAttributes = $props()
3
+ import type { RadioProps } from './types.ts'
5
4
 
6
- const maskId = '1'
5
+ let { ...attributes }: RadioProps = $props()
6
+
7
+ let touchEl: HTMLSpanElement | undefined = $state()
7
8
  </script>
8
9
 
9
- <div class="host">
10
+ <label class="np-host">
11
+ <input {...attributes} type="radio" class="np-input" />
10
12
  <div class="np-container" aria-hidden="true">
11
13
  {#if !attributes.disabled}
12
- <Ripple />
14
+ <Ripple forElement={touchEl} class="np-radio-ripple" />
13
15
  {/if}
14
- <svg class="icon" viewBox="0 0 20 20">
15
- <mask id={maskId}>
16
+ <svg class="np-radio-icon" viewBox="0 0 20 20">
17
+ <mask id="1">
16
18
  <rect width="100%" height="100%" fill="white" />
17
19
  <circle cx="10" cy="10" r="8" fill="black" />
18
20
  </mask>
19
- <circle class="outer circle" cx="10" cy="10" r="10" mask="url(#{maskId})" />
21
+ <circle class="outer circle" cx="10" cy="10" r="10" mask="url(#1)" />
20
22
  <circle class="inner circle" cx="10" cy="10" r="5" />
21
23
  </svg>
22
- <div class="touch-target"></div>
24
+ <span class="np-touch" bind:this={touchEl}></span>
23
25
  </div>
24
- </div>
26
+ </label>
25
27
 
26
28
  <style>
27
- .np-container {
29
+ :global(.np-radio-ripple) {
30
+ border-radius: 50% !important;
31
+ height: 40px;
32
+ inset: unset !important;
33
+ width: 40px;
34
+ }
35
+ .np-input {
36
+ opacity: 0;
37
+ inset: 0;
38
+ position: absolute;
39
+ cursor: inherit;
40
+ }
41
+ .np-host {
42
+ margin: max(0px, (48px - var(--np-radio-icon-size, 20px))/2);
28
43
  position: relative;
44
+ display: inline-flex;
45
+ vertical-align: top;
46
+ width: 20px;
47
+ height: 20px;
48
+ cursor: pointer;
49
+ -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
50
+ outline: none;
51
+ }
52
+
53
+ .np-host:has(input:disabled) {
54
+ cursor: default;
55
+ }
56
+
57
+ .np-container {
58
+ display: flex;
59
+ height: 100%;
60
+ place-content: center;
61
+ place-items: center;
62
+ width: 100%;
63
+ }
64
+ .np-touch {
65
+ height: 48px;
66
+ position: absolute;
67
+ width: 48px;
68
+ }
69
+ .np-radio-icon {
70
+ fill: var(--np-radio-icon-color, var(--np-color-on-surface-variant));
71
+ inset: 0px;
72
+ position: absolute;
73
+ }
74
+ .np-host:has(input:checked) .np-radio-icon {
75
+ fill: var(--np-radio-selected-icon-color, var(--np-color-primary));
76
+ }
77
+ .np-host:has(input:disabled) .np-radio-icon {
78
+ fill: var(--np-color-on-surface);
79
+ opacity: 0.38;
80
+ }
81
+ .inner.circle {
82
+ opacity: 0;
83
+ transform-origin: center center;
84
+ transition: opacity 50ms linear;
85
+ }
86
+ .np-host:has(input:checked) .inner.circle {
87
+ animation: 300ms cubic-bezier(0.05, 0.7, 0.1, 1) 0s 1 normal none running inner-circle-grow;
88
+ opacity: 1;
89
+ }
90
+ @keyframes inner-circle-grow {
91
+ from {
92
+ transform: scale(0);
93
+ }
94
+ to {
95
+ transform: scale(1);
96
+ }
29
97
  }
30
98
  </style>
@@ -1,4 +1,4 @@
1
- import type { HTMLInputAttributes } from 'svelte/elements';
2
- declare const Radio: import("svelte").Component<HTMLInputAttributes, {}, "">;
1
+ import type { RadioProps } from './types.ts';
2
+ declare const Radio: import("svelte").Component<RadioProps, {}, "">;
3
3
  type Radio = ReturnType<typeof Radio>;
4
4
  export default Radio;
@@ -1 +1,2 @@
1
1
  export { default as Radio } from './Radio.svelte';
2
+ export type { RadioProps } from './types.ts';
@@ -0,0 +1,2 @@
1
+ import type { HTMLInputAttributes } from 'svelte/elements';
2
+ export type RadioProps = Omit<HTMLInputAttributes, 'class' | 'type'>;
@@ -0,0 +1 @@
1
+ export {};
@@ -12,11 +12,12 @@
12
12
  start,
13
13
  end,
14
14
  label,
15
+ style,
15
16
  noAsterisk = false,
16
17
  variant = 'filled',
18
+ placeholder = ' ',
17
19
  ...attributes
18
20
  }: TextFieldProps | TextAreaFieldProps = $props()
19
- let focused = $state(false)
20
21
 
21
22
  let contentEl: HTMLInputElement | HTMLTextAreaElement | undefined = $state()
22
23
  let errorTextRaw = $state(errorText)
@@ -40,15 +41,6 @@
40
41
  currentTarget.focus()
41
42
  }
42
43
  })
43
- contentEl.addEventListener('focus', () => {
44
- if (!attributes.disabled) {
45
- focused = true
46
- }
47
- })
48
-
49
- contentEl.addEventListener('blur', () => {
50
- focused = false
51
- })
52
44
 
53
45
  contentEl.addEventListener('change', (event) => {
54
46
  const { currentTarget } = event as Event & {
@@ -61,31 +53,27 @@
61
53
  })
62
54
  }
63
55
  })
64
- let emptyValue = $derived(value === undefined || value === null || value === '')
65
56
  </script>
66
57
 
67
58
  <!-- svelte-ignore a11y_click_events_have_key_events -->
68
59
  <!-- svelte-ignore a11y_no_static_element_interactions -->
69
60
  <span
70
- style={variant === 'outlined'
61
+ style={(variant === 'outlined'
71
62
  ? '--top-space:1rem;--bottom-space:1rem;--floating-label-top:-0.5rem;--floating-label-left:-2.25rem;--_focus-outline-width:3px'
72
63
  : !label?.length
73
64
  ? '--top-space:1rem;--bottom-space:1rem'
74
- : ''}
65
+ : '') + style}
75
66
  class="text-field"
76
67
  onclick={() => {
77
68
  if (attributes.disabled) {
78
69
  return
79
70
  }
80
- focused = true
81
71
  contentEl?.focus()
82
72
  }}
83
73
  >
84
74
  <div
85
75
  class="field"
86
- class:populated={!emptyValue}
87
76
  class:error
88
- class:focused
89
77
  class:resizable={attributes.type === 'textarea'}
90
78
  class:no-label={!label?.length}
91
79
  class:with-start={start}
@@ -121,12 +109,7 @@
121
109
  <div class="middle">
122
110
  <div class="label-wrapper">
123
111
  {#if label?.length}
124
- <span
125
- class="label"
126
- class:resting={!focused && emptyValue}
127
- class:floating={focused || !emptyValue}
128
- >{label}{noAsterisk || !attributes.required ? '' : '*'}
129
- </span>
112
+ <span class="label">{label}{noAsterisk || !attributes.required ? '' : '*'} </span>
130
113
  {/if}
131
114
  </div>
132
115
  <div class="content">
@@ -137,6 +120,7 @@
137
120
  bind:this={contentEl}
138
121
  class="input"
139
122
  aria-label={label}
123
+ {placeholder}
140
124
  rows={attributes.rows || 2}
141
125
  ></textarea>
142
126
  {:else}
@@ -151,6 +135,7 @@
151
135
  bind:value
152
136
  bind:this={contentEl}
153
137
  class="input"
138
+ {placeholder}
154
139
  aria-label={label}
155
140
  aria-invalid={error}
156
141
  />
@@ -191,7 +176,8 @@
191
176
  width: 100%;
192
177
  z-index: 1;
193
178
  }
194
- .field:has(input:focus-visible) .active-indicator::after {
179
+ .field:has(input:focus-visible) .active-indicator::after,
180
+ .field:has(textarea:focus-visible) .active-indicator::after {
195
181
  opacity: 1;
196
182
  }
197
183
  .active-indicator::after {
@@ -356,7 +342,9 @@
356
342
 
357
343
  .no-label .content,
358
344
  .field:has(input:focus-visible) .content,
359
- .populated .content {
345
+ .field:has(textarea:focus-visible) .content,
346
+ .field:has(input:not(:placeholder-shown)) .content,
347
+ .field:has(textarea:not(:placeholder-shown)) .content {
360
348
  opacity: 1;
361
349
  }
362
350
 
@@ -481,22 +469,31 @@
481
469
  .label.np-hidden {
482
470
  opacity: 0;
483
471
  }
484
- .label.resting {
472
+
473
+ .field:has(input:not(:focus-visible):placeholder-shown) .label,
474
+ .field:has(textarea:not(:focus-visible):placeholder-shown) .label {
485
475
  position: absolute;
486
476
  top: 1rem;
487
477
  left: 0rem;
488
478
  }
489
-
490
- .label.floating {
479
+ .field:has(input:focus-visible:not(:placeholder-shown)) .label,
480
+ .field:has(input:focus-visible) .label,
481
+ .field:has(input:not(:placeholder-shown)) .label,
482
+ .field:has(textarea:focus-visible:not(:placeholder-shown)) .label,
483
+ .field:has(textarea:focus-visible) .label,
484
+ .field:has(textarea:not(:placeholder-shown)) .label {
491
485
  font-size: 0.75rem;
492
486
  line-height: 1rem;
493
487
  transform-origin: top left;
494
- }
495
- .label.floating {
496
488
  position: absolute;
497
489
  top: var(--floating-label-top, 0.5rem);
498
490
  }
499
- .with-start .label.floating {
491
+ .with-start:has(input:focus-visible:not(:placeholder-shown)) .label,
492
+ .with-start:has(input:focus-visible) .label,
493
+ .with-start:has(input:not(:placeholder-shown)) .label,
494
+ .with-start:has(textarea:focus-visible:not(:placeholder-shown)) .label,
495
+ .with-start:has(textarea:focus-visible) .label,
496
+ .with-start:has(textarea:not(:placeholder-shown)) .label {
500
497
  left: var(--floating-label-left, 0);
501
498
  }
502
499
  .label {
@@ -514,11 +511,13 @@
514
511
  line-height: 1.5rem;
515
512
  width: min-content;
516
513
  }
517
- .field:has(input:focus-visible) .label {
514
+ .field:has(input:focus-visible) .label,
515
+ .field:has(textarea:focus-visible) .label {
518
516
  color: var(--np-color-primary);
519
517
  }
520
518
  .error .label,
521
- .error:has(input:focus-visible) .label {
519
+ .error:has(input:focus-visible) .label,
520
+ .error:has(textarea:focus-visible) .label {
522
521
  color: var(--np-color-error);
523
522
  }
524
523
  .disabled .label {
@@ -532,7 +531,8 @@
532
531
  overflow: hidden;
533
532
  }
534
533
  .disabled.no-label .content,
535
- .disabled.populated .content {
534
+ .disabled:has(input:not(:placeholder-shown)) .content,
535
+ .disabled:has(textarea:not(:placeholder-shown)) .content {
536
536
  opacity: 0.38;
537
537
  }
538
538
  .field,
@@ -598,14 +598,19 @@
598
598
  border-bottom-style: solid;
599
599
  border-top-style: solid;
600
600
  }
601
- .outline-notch::before,
602
601
  .outline-notch::after {
603
602
  border-bottom-style: solid;
604
603
  border-top-style: none;
605
604
  }
606
- .field:not(:has(input:focus-visible)):not(.populated) .outline-notch::before {
605
+ .outline-notch::before {
606
+ border-bottom-style: solid;
607
607
  border-top-style: solid;
608
608
  }
609
+ .field:has(input:focus-visible) .outline-notch::before,
610
+ .field:has(textarea:focus-visible) .outline-notch::before,
611
+ .field:has(input:not(:placeholder-shown)) .outline-notch::before {
612
+ border-top-style: none;
613
+ }
609
614
 
610
615
  .outline-notch::before,
611
616
  .outline-notch::after {
@@ -643,7 +648,10 @@
643
648
  }
644
649
  .field:has(input:focus-visible) .outline-start::after,
645
650
  .field:has(input:focus-visible) .outline-end::after,
646
- .field:has(input:focus-visible) .outline-notch::after {
651
+ .field:has(input:focus-visible) .outline-notch::after,
652
+ .field:has(textarea:focus-visible) .outline-start::after,
653
+ .field:has(textarea:focus-visible) .outline-end::after,
654
+ .field:has(textarea:focus-visible) .outline-notch::after {
647
655
  opacity: 1;
648
656
  }
649
657
  .np-outline {
@@ -657,12 +665,14 @@
657
665
  z-index: 1;
658
666
  }
659
667
 
660
- .field:has(input:focus-visible) .np-outline {
668
+ .field:has(input:focus-visible) .np-outline,
669
+ .field:has(textarea:focus-visible) .np-outline {
661
670
  border-color: var(--np-color-primary);
662
671
  color: var(--np-color-primary);
663
672
  }
664
673
  .error .np-outline,
665
- .error:has(input:focus-visible) .np-outline {
674
+ .error:has(input:focus-visible) .np-outline,
675
+ .error:has(textarea:focus-visible) .np-outline {
666
676
  border-color: var(--np-color-error);
667
677
  }
668
678
  .disabled .np-outline {
@@ -1 +1,2 @@
1
1
  export { default as TextField } from './TextField.svelte';
2
+ export type { TextAreaFieldProps, TextFieldProps } from './types.ts';
@@ -1,6 +1,6 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements';
3
- export interface TextFieldProps extends HTMLInputAttributes {
3
+ export interface TextFieldProps extends Omit<HTMLInputAttributes, 'class'> {
4
4
  label?: string;
5
5
  type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
6
6
  supportingText?: string;
@@ -13,7 +13,7 @@ export interface TextFieldProps extends HTMLInputAttributes {
13
13
  end?: Snippet;
14
14
  noAsterisk?: boolean;
15
15
  }
16
- export interface TextAreaFieldProps extends HTMLTextareaAttributes {
16
+ export interface TextAreaFieldProps extends Omit<HTMLTextareaAttributes, 'class'> {
17
17
  label?: string;
18
18
  type: 'textarea';
19
19
  supportingText?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.2.7",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {
@@ -66,7 +66,7 @@
66
66
  "@material/material-color-utilities": "^0.3.0",
67
67
  "@playwright/test": "^1.49.1",
68
68
  "@sveltejs/adapter-vercel": "^5.5.2",
69
- "@sveltejs/kit": "^2.11.1",
69
+ "@sveltejs/kit": "^2.12.1",
70
70
  "@sveltejs/package": "^2.3.7",
71
71
  "@sveltejs/vite-plugin-svelte": "^5.0.2",
72
72
  "@types/eslint": "^9.6.1",
@@ -77,10 +77,10 @@
77
77
  "prettier": "^3.4.2",
78
78
  "prettier-plugin-svelte": "^3.3.2",
79
79
  "publint": "^0.2.12",
80
- "svelte": "^5.14.0",
80
+ "svelte": "^5.14.1",
81
81
  "svelte-check": "^4.1.1",
82
82
  "typescript": "^5.7.2",
83
- "typescript-eslint": "^8.18.0",
83
+ "typescript-eslint": "^8.18.1",
84
84
  "vite": "^6.0.3",
85
85
  "vitest": "^2.1.8"
86
86
  },