noph-ui 0.24.7 → 0.24.10

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,11 +1,7 @@
1
1
  <script lang="ts">
2
2
  import Ripple from '../ripple/Ripple.svelte'
3
3
  import Tooltip from '../tooltip/Tooltip.svelte'
4
- import type {
5
- HTMLAnchorAttributes,
6
- HTMLButtonAttributes,
7
- MouseEventHandler,
8
- } from 'svelte/elements'
4
+ import type { HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements'
9
5
  import type { ButtonProps } from './types.ts'
10
6
  import CircularProgress from '../progress/CircularProgress.svelte'
11
7
 
@@ -29,13 +25,6 @@
29
25
  }: ButtonProps = $props()
30
26
 
31
27
  const uid = $props.id()
32
-
33
- const isButton = (obj: unknown): obj is HTMLButtonAttributes => {
34
- return (obj as HTMLAnchorAttributes).href === undefined
35
- }
36
- const isLink = (obj: unknown): obj is HTMLAnchorAttributes => {
37
- return (obj as HTMLAnchorAttributes).href !== undefined
38
- }
39
28
  </script>
40
29
 
41
30
  {#snippet content()}
@@ -64,13 +53,36 @@
64
53
  {/if}
65
54
  {/snippet}
66
55
 
67
- {#if isButton(attributes) || disabled || loading}
56
+ {#if 'href' in attributes && !disabled && !loading}
57
+ <a
58
+ {...attributes}
59
+ onclick={(event) => {
60
+ ;(onclick as MouseEventHandler<HTMLAnchorElement>)?.(event)
61
+ }}
62
+ aria-describedby={title ? uid : attributes['aria-describedby']}
63
+ aria-label={title || attributes['aria-label']}
64
+ bind:this={element}
65
+ class={[
66
+ 'np-button',
67
+ size,
68
+ selected ? 'square' : shape,
69
+ toggle && 'toggle',
70
+ 'enabled',
71
+ variant,
72
+ attributes.class,
73
+ ]}
74
+ >
75
+ {@render content()}
76
+ </a>
77
+ {:else}
68
78
  <button
69
79
  {...attributes as HTMLButtonAttributes}
70
80
  aria-describedby={title ? uid : attributes['aria-describedby']}
71
81
  aria-label={title || attributes['aria-label']}
72
82
  disabled={disabled || loading}
73
- aria-pressed={selected}
83
+ aria-pressed={toggle ? selected : undefined}
84
+ aria-busy={loading}
85
+ type={(attributes['type'] as 'button' | 'submit' | 'reset' | 'button') ?? undefined}
74
86
  bind:this={element}
75
87
  onclick={(event) => {
76
88
  if (toggle) {
@@ -82,39 +94,18 @@
82
94
  'np-button',
83
95
  size,
84
96
  selected || loading ? 'square' : shape,
85
- toggle ? 'toggle' : '',
86
- selected ? 'selected' : '',
87
- loading ? 'np-loading' : '',
97
+ toggle && 'toggle',
98
+ selected && 'selected',
99
+ loading && 'np-loading',
88
100
  disabled || loading ? `${variant}-disabled disabled` : `${variant} enabled`,
89
101
  attributes.class,
90
102
  ]}
91
103
  >
92
104
  {@render content()}
93
105
  </button>
94
- {:else if isLink(attributes)}
95
- <a
96
- {...attributes}
97
- onclick={(event) => {
98
- ;(onclick as MouseEventHandler<HTMLAnchorElement>)?.(event)
99
- }}
100
- aria-describedby={title ? uid : attributes['aria-describedby']}
101
- aria-label={title || attributes['aria-label']}
102
- bind:this={element}
103
- class={[
104
- 'np-button',
105
- size,
106
- selected || loading ? 'square' : shape,
107
- toggle ? 'toggle' : '',
108
- 'enabled',
109
- variant,
110
- attributes.class,
111
- ]}
112
- >
113
- {@render content()}
114
- </a>
115
106
  {/if}
116
107
 
117
- {#if title}
108
+ {#if title && !disabled && !loading}
118
109
  <Tooltip {keepTooltipOnClick} id={uid}>{title}</Tooltip>
119
110
  {/if}
120
111
 
@@ -258,10 +249,8 @@
258
249
  background-color: color-mix(in srgb, var(--np-color-on-surface) 12%, transparent);
259
250
  }
260
251
  .outlined-disabled {
261
- outline-style: solid;
262
- outline-color: color-mix(in srgb, var(--np-color-on-surface) 12%, transparent);
263
- outline-width: 1px;
264
- outline-offset: -1px;
252
+ /* Variant outline now rendered via pseudo-element; keep token for color */
253
+ --_outlined-border-color: color-mix(in srgb, var(--np-color-on-surface) 12%, transparent);
265
254
  }
266
255
  .enabled:focus-visible {
267
256
  outline-style: solid;
@@ -363,19 +352,28 @@
363
352
  }
364
353
  .outlined {
365
354
  background-color: var(--np-outlined-button-container-color, transparent);
366
- outline-style: solid;
367
- outline-color: var(--np-outlined-button-outline-color, var(--np-color-outline-variant));
368
- outline-width: 1px;
369
- outline-offset: -1px;
355
+ --_outlined-border-color: var(
356
+ --np-outlined-button-outline-color,
357
+ var(--np-color-outline-variant)
358
+ );
370
359
  --np-ripple-hover-color: var(--np-outlined-button-label-text-color, var(--np-color-primary));
371
360
  --np-ripple-pressed-color: var(--np-outlined-button-label-text-color, var(--np-color-primary));
372
361
  color: var(--np-outlined-button-label-text-color, var(--np-color-primary));
373
362
  }
374
363
 
364
+ .outlined:not(.selected)::after,
365
+ .outlined-disabled::after {
366
+ content: '';
367
+ position: absolute;
368
+ inset: 0;
369
+ border: 1px solid var(--_outlined-border-color);
370
+ border-radius: inherit;
371
+ pointer-events: none;
372
+ }
373
+
375
374
  .outlined.selected {
376
375
  background-color: var(--np-color-inverse-surface);
377
376
  color: var(--np-color-inverse-on-surface);
378
- outline-style: none;
379
377
  }
380
378
  .button-icon {
381
379
  display: inline-flex;
@@ -3,11 +3,7 @@
3
3
  import Ripple from '../ripple/Ripple.svelte'
4
4
  import Tooltip from '../tooltip/Tooltip.svelte'
5
5
  import type { IconButtonProps } from './types.ts'
6
- import type {
7
- HTMLAnchorAttributes,
8
- HTMLButtonAttributes,
9
- MouseEventHandler,
10
- } from 'svelte/elements'
6
+ import type { HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements'
11
7
 
12
8
  let {
13
9
  variant = 'text',
@@ -15,7 +11,7 @@
15
11
  children,
16
12
  title,
17
13
  element = $bindable(),
18
- disabled,
14
+ disabled = false,
19
15
  loading = false,
20
16
  loadingAriaLabel,
21
17
  selected = $bindable(false),
@@ -30,13 +26,6 @@
30
26
 
31
27
  const uid = $props.id()
32
28
  let touchEl: HTMLSpanElement | undefined = $state()
33
-
34
- const isButton = (obj: unknown): obj is HTMLButtonAttributes => {
35
- return (obj as HTMLAnchorAttributes).href === undefined
36
- }
37
- const isLink = (obj: unknown): obj is HTMLAnchorAttributes => {
38
- return (obj as HTMLAnchorAttributes).href !== undefined
39
- }
40
29
  </script>
41
30
 
42
31
  {#snippet content()}
@@ -56,12 +45,37 @@
56
45
  {/if}
57
46
  {/snippet}
58
47
 
59
- {#if isButton(attributes) || disabled || loading}
48
+ {#if 'href' in attributes && !disabled && !loading}
49
+ <a
50
+ {...attributes}
51
+ onclick={(event) => {
52
+ ;(onclick as MouseEventHandler<HTMLAnchorElement>)?.(event)
53
+ }}
54
+ aria-describedby={title ? uid : undefined}
55
+ aria-label={title}
56
+ bind:this={element}
57
+ class={[
58
+ 'np-icon-button',
59
+ size,
60
+ width,
61
+ variant,
62
+ selected ? 'square' : shape,
63
+ 'enabled',
64
+ toggle && 'toggle',
65
+ selected && 'selected',
66
+ attributes.class,
67
+ ].filter(Boolean)}
68
+ >
69
+ {@render content()}
70
+ </a>
71
+ {:else}
60
72
  <button
73
+ {...attributes as HTMLButtonAttributes}
61
74
  aria-describedby={title ? uid : attributes['aria-describedby']}
62
75
  aria-label={title || attributes['aria-label']}
63
- aria-pressed={selected}
64
- {...attributes as HTMLButtonAttributes}
76
+ aria-pressed={toggle ? selected : undefined}
77
+ aria-busy={loading}
78
+ type={(attributes['type'] as 'button' | 'submit' | 'reset' | 'button') ?? undefined}
65
79
  disabled={disabled || loading}
66
80
  bind:this={element}
67
81
  onclick={(event) => {
@@ -77,38 +91,15 @@
77
91
  selected || loading ? 'square' : shape,
78
92
  disabled || loading ? `${variant}-disabled disabled` : `${variant} enabled`,
79
93
  toggle && 'toggle',
80
- selected ? 'selected' : '',
94
+ selected && 'selected',
81
95
  attributes.class,
82
96
  ]}
83
97
  >
84
98
  {@render content()}
85
99
  </button>
86
- {:else if isLink(attributes)}
87
- <a
88
- {...attributes}
89
- onclick={(event) => {
90
- ;(onclick as MouseEventHandler<HTMLAnchorElement>)?.(event)
91
- }}
92
- aria-describedby={title ? uid : undefined}
93
- aria-label={title}
94
- bind:this={element}
95
- class={[
96
- 'np-icon-button',
97
- size,
98
- width,
99
- variant,
100
- selected || loading ? 'square' : shape,
101
- 'enabled',
102
- toggle && 'toggle',
103
- selected ? 'selected' : '',
104
- attributes.class,
105
- ]}
106
- >
107
- {@render content()}
108
- </a>
109
100
  {/if}
110
101
 
111
- {#if title}
102
+ {#if title && !disabled && !loading}
112
103
  <Tooltip {keepTooltipOnClick} id={uid}>{title}</Tooltip>
113
104
  {/if}
114
105
 
@@ -343,16 +334,26 @@
343
334
  }
344
335
 
345
336
  .outlined {
346
- outline-style: solid;
347
- outline-color: var(--np-outlined-icon-button-outline-color, var(--np-color-outline-variant));
348
- outline-width: 1px;
349
- outline-offset: -1px;
337
+ --_outlined-border-color: var(
338
+ --np-outlined-icon-button-outline-color,
339
+ var(--np-color-outline-variant)
340
+ );
350
341
  --np-ripple-hover-color: var(--np-color-on-surface-variant);
351
342
  --np-ripple-pressed-color: var(--np-color-on-surface-variant);
352
343
  color: var(--np-color-on-surface-variant);
353
344
  }
345
+
346
+ .outlined:not(.selected)::after,
347
+ .outlined-disabled::after {
348
+ content: '';
349
+ position: absolute;
350
+ inset: 0;
351
+ border: 1px solid var(--_outlined-border-color);
352
+ border-radius: inherit;
353
+ pointer-events: none;
354
+ }
355
+
354
356
  .outlined.selected {
355
- outline-style: none;
356
357
  --np-ripple-hover-color: var(--np-color-on-surface-variant);
357
358
  --np-ripple-pressed-color: var(--np-color-on-surface-variant);
358
359
  color: var(--np-color-inverse-on-surface);
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'
2
+ import type { HTMLButtonAttributes } from 'svelte/elements'
3
3
  import type { NavigationDrawerItemProps } from './types.ts'
4
4
  import Ripple from '../ripple/Ripple.svelte'
5
5
 
@@ -10,13 +10,6 @@
10
10
  icon,
11
11
  ...attributes
12
12
  }: NavigationDrawerItemProps = $props()
13
-
14
- const isButton = (obj: unknown): obj is HTMLButtonAttributes => {
15
- return (obj as HTMLAnchorAttributes).href === undefined
16
- }
17
- const isLink = (obj: unknown): obj is HTMLAnchorAttributes => {
18
- return (obj as HTMLAnchorAttributes).href !== undefined
19
- }
20
13
  </script>
21
14
 
22
15
  {#snippet content()}
@@ -30,9 +23,9 @@
30
23
  <div class="np-navigation-drawer-item-badge">{badgeLabelText}</div>
31
24
  {/snippet}
32
25
 
33
- {#if isButton(attributes)}
34
- <button
35
- {...attributes as HTMLButtonAttributes}
26
+ {#if 'href' in attributes}
27
+ <a
28
+ {...attributes}
36
29
  class={[
37
30
  'np-navigation-drawer-item',
38
31
  selected && 'np-navigation-drawer-item-selected',
@@ -40,10 +33,10 @@
40
33
  ]}
41
34
  >
42
35
  {@render content()}
43
- </button>
44
- {:else if isLink(attributes)}
45
- <a
46
- {...attributes}
36
+ </a>
37
+ {:else}
38
+ <button
39
+ {...attributes as HTMLButtonAttributes}
47
40
  class={[
48
41
  'np-navigation-drawer-item',
49
42
  selected && 'np-navigation-drawer-item-selected',
@@ -51,7 +44,7 @@
51
44
  ]}
52
45
  >
53
46
  {@render content()}
54
- </a>
47
+ </button>
55
48
  {/if}
56
49
 
57
50
  <style>
@@ -1,17 +1,10 @@
1
1
  <script lang="ts">
2
- import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'
2
+ import type { HTMLButtonAttributes } from 'svelte/elements'
3
3
  import type { NavigationRailItemProps } from './types.ts'
4
4
  import Ripple from '../ripple/Ripple.svelte'
5
5
 
6
6
  let { selected, icon, label, ...attributes }: NavigationRailItemProps = $props()
7
7
  let touchEl: HTMLSpanElement | undefined = $state()
8
-
9
- const isButton = (obj: unknown): obj is HTMLButtonAttributes => {
10
- return (obj as HTMLAnchorAttributes).href === undefined
11
- }
12
- const isLink = (obj: unknown): obj is HTMLAnchorAttributes => {
13
- return (obj as HTMLAnchorAttributes).href !== undefined
14
- }
15
8
  </script>
16
9
 
17
10
  {#snippet content()}
@@ -23,20 +16,20 @@
23
16
  <span class="np-touch" bind:this={touchEl}></span>
24
17
  {/snippet}
25
18
 
26
- {#if isButton(attributes)}
27
- <button
28
- {...attributes as HTMLButtonAttributes}
29
- class={['np-navigation-action', selected && 'np-navigation-action-selected', attributes.class]}
30
- >
31
- {@render content()}
32
- </button>
33
- {:else if isLink(attributes)}
19
+ {#if 'href' in attributes}
34
20
  <a
35
21
  {...attributes}
36
22
  class={['np-navigation-action', selected && 'np-navigation-action-selected', attributes.class]}
37
23
  >
38
24
  {@render content()}
39
25
  </a>
26
+ {:else}
27
+ <button
28
+ {...attributes as HTMLButtonAttributes}
29
+ class={['np-navigation-action', selected && 'np-navigation-action-selected', attributes.class]}
30
+ >
31
+ {@render content()}
32
+ </button>
40
33
  {/if}
41
34
 
42
35
  <style>
@@ -11,7 +11,8 @@
11
11
  ...attributes
12
12
  }: RadioProps = $props()
13
13
 
14
- let inputEl: HTMLSpanElement | undefined = $state()
14
+ let inputEl = $state<HTMLInputElement>()
15
+ const uid = $props.id()
15
16
  </script>
16
17
 
17
18
  <label {style} class={['np-host', attributes.class]} bind:this={element}>
@@ -20,11 +21,11 @@
20
21
  <Ripple forElement={inputEl} class="np-radio-ripple" />
21
22
  {/if}
22
23
  <svg class="np-radio-icon" viewBox="0 0 20 20">
23
- <mask id="1">
24
+ <mask id="{uid}-mask">
24
25
  <rect width="100%" height="100%" fill="white" />
25
26
  <circle cx="10" cy="10" r="8" fill="black" />
26
27
  </mask>
27
- <circle class="outer circle" cx="10" cy="10" r="10" mask="url(#1)" />
28
+ <circle class="outer circle" cx="10" cy="10" r="10" mask="url(#{uid}-mask)" />
28
29
  <circle class="inner circle" cx="10" cy="10" r="5" />
29
30
  </svg>
30
31
  {#if group !== undefined}
@@ -45,12 +45,12 @@
45
45
  value = options.find((option) => option.selected)?.value
46
46
  }
47
47
  }
48
- let selectedOption: SelectOption[] = $state(
48
+ let selectedOption: SelectOption[] = $derived(
49
49
  options
50
- .filter((option) =>
51
- option.selected || Array.isArray(value)
52
- ? value.includes(option.value)
53
- : value === option.value || false,
50
+ .filter(
51
+ (option) =>
52
+ option.selected ||
53
+ (Array.isArray(value) ? value.includes(option.value) : value === option.value),
54
54
  )
55
55
  .map((option) => ({ ...option, selected: true })),
56
56
  )
@@ -59,13 +59,27 @@
59
59
 
60
60
  let errorTextRaw: string = $state(errorText)
61
61
  let errorRaw = $state(error)
62
- let selectElement: HTMLSelectElement | undefined = $state()
63
- let menuElement: HTMLDivElement | undefined = $state()
64
- let anchorElement: HTMLDivElement | undefined = $state()
65
- let field: HTMLDivElement | undefined = $state()
62
+ let selectElement = $state<HTMLSelectElement>()
63
+ let menuElement = $state<HTMLDivElement>()
64
+ let anchorElement = $state<HTMLDivElement>()
65
+ let field = $state<HTMLDivElement>()
66
66
  let clientWidth = $state(0)
67
67
  let menuOpen = $state(false)
68
- let selectedLabel = $derived.by<string | string[]>(() => {
68
+ let focusIndex = $state(-1)
69
+ let typeBuffer = ''
70
+ let lastTypeTime = 0
71
+
72
+ let activeDescendantId = $derived.by<string | undefined>(() => {
73
+ if (!menuOpen) return undefined
74
+ if (focusIndex >= 0 && focusIndex < options.length) return `${uid}-opt-${focusIndex}`
75
+ const fallbackIdx = multiple
76
+ ? Array.isArray(value) && value.length
77
+ ? options.findIndex((o) => o.value === value[0])
78
+ : -1
79
+ : options.findIndex((o) => o.value === value)
80
+ return fallbackIdx >= 0 ? `${uid}-opt-${fallbackIdx}` : undefined
81
+ })
82
+ let selectedLabel = $derived.by<string>(() => {
69
83
  if (multiple) {
70
84
  if (value && Array.isArray(value)) {
71
85
  return value
@@ -143,6 +157,82 @@
143
157
  selectElement?.dispatchEvent(new Event('change', { bubbles: true }))
144
158
  })
145
159
  }
160
+
161
+ const openMenuAndFocus = async (index: number) => {
162
+ if (!menuOpen) {
163
+ menuElement?.showPopover()
164
+ }
165
+ focusIndex = Math.min(Math.max(index, 0), options.length - 1)
166
+ await tick()
167
+ const el = document.getElementById(`${uid}-opt-${focusIndex}`)
168
+ ;(el as HTMLElement | null)?.focus?.()
169
+ }
170
+
171
+ const moveFocus = (delta: number) => {
172
+ if (!options.length) return
173
+ let next = focusIndex
174
+ if (next < 0) {
175
+ const selIdx = Array.isArray(value)
176
+ ? options.findIndex((o) => value.includes(o.value) && !o.disabled)
177
+ : options.findIndex((o) => o.value === value && !o.disabled)
178
+ next = selIdx >= 0 ? selIdx : 0
179
+ }
180
+ let attempts = 0
181
+ while (attempts < options.length) {
182
+ next = (next + delta + options.length) % options.length
183
+ if (!options[next].disabled) {
184
+ openMenuAndFocus(next)
185
+ return
186
+ }
187
+ attempts++
188
+ }
189
+ }
190
+
191
+ const focusEdge = (start: boolean) => {
192
+ if (!options.length) {
193
+ return
194
+ }
195
+ if (start) {
196
+ for (let i = 0; i < options.length; i++) {
197
+ if (!options[i].disabled) {
198
+ openMenuAndFocus(i)
199
+ return
200
+ }
201
+ }
202
+ } else {
203
+ for (let i = options.length - 1; i >= 0; i--) {
204
+ if (!options[i].disabled) {
205
+ openMenuAndFocus(i)
206
+ return
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ const performTypeahead = (char: string) => {
213
+ const now = performance.now()
214
+ if (now - lastTypeTime > 700) typeBuffer = ''
215
+ lastTypeTime = now
216
+ typeBuffer += char.toLowerCase()
217
+ if (!options.length) {
218
+ return
219
+ }
220
+ const startIdx = focusIndex >= 0 ? (focusIndex + 1) % options.length : 0
221
+ for (let i = 0; i < options.length; i++) {
222
+ const idx = (startIdx + i) % options.length
223
+ const label = options[idx].label?.toLowerCase?.() || ''
224
+ if (label.startsWith(typeBuffer) && !options[idx].disabled) {
225
+ openMenuAndFocus(idx)
226
+ return
227
+ }
228
+ }
229
+
230
+ if (typeBuffer.length > 1) {
231
+ const last = typeBuffer[typeBuffer.length - 1]
232
+ typeBuffer = last
233
+ performTypeahead('')
234
+ }
235
+ }
146
236
  </script>
147
237
 
148
238
  {#snippet arrows()}
@@ -175,10 +265,11 @@
175
265
  role="combobox"
176
266
  aria-haspopup="listbox"
177
267
  tabindex={disabled ? -1 : tabindex}
178
- aria-controls="listbox"
268
+ aria-controls="listbox-{uid}"
179
269
  aria-expanded={menuOpen}
180
270
  aria-label={attributes['aria-label'] || label}
181
271
  aria-disabled={disabled}
272
+ aria-activedescendant={activeDescendantId}
182
273
  data-testid={attributes['data-testid']}
183
274
  bind:this={field}
184
275
  bind:clientWidth
@@ -192,19 +283,51 @@
192
283
  }
193
284
  }}
194
285
  onkeydown={(event) => {
195
- if (event.key === 'Tab' || event.key === 'Escape') {
286
+ const key = event.key
287
+ if (key === 'Tab') {
196
288
  menuElement?.hidePopover()
197
- } else {
289
+ return
290
+ }
291
+ if (key === 'Escape') {
292
+ menuElement?.hidePopover()
293
+ return
294
+ }
295
+ if (key === 'ArrowDown') {
296
+ event.preventDefault()
297
+ moveFocus(1)
298
+ return
299
+ }
300
+ if (key === 'ArrowUp') {
301
+ event.preventDefault()
302
+ moveFocus(-1)
303
+ return
304
+ }
305
+ if (key === 'Home') {
306
+ event.preventDefault()
307
+ focusEdge(true)
308
+ return
309
+ }
310
+ if (key === 'End') {
311
+ event.preventDefault()
312
+ focusEdge(false)
313
+ return
314
+ }
315
+ if (key === 'Enter' || key === ' ') {
198
316
  event.preventDefault()
199
- if (
200
- event.key === 'ArrowDown' ||
201
- event.key === 'ArrowUp' ||
202
- event.key === 'Enter' ||
203
- event.key === ' '
204
- ) {
205
- menuElement?.showPopover()
206
- ;(menuElement?.firstElementChild?.firstElementChild as HTMLElement)?.focus()
317
+ if (!menuOpen) {
318
+ openMenuAndFocus(focusIndex >= 0 ? focusIndex : 0)
319
+ } else if (focusIndex >= 0) {
320
+ const opt = options[focusIndex]
321
+ if (opt && !opt.disabled) {
322
+ handleOptionSelect(event, opt)
323
+ }
207
324
  }
325
+ return
326
+ }
327
+ // Printable character for typeahead
328
+ if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
329
+ performTypeahead(key)
330
+ return
208
331
  }
209
332
  }}
210
333
  >
@@ -222,7 +345,7 @@
222
345
  <span class={['label', !noAsterisk && required && 'required']}>{label}</span>
223
346
  </div>
224
347
  <div class="outline-notch">
225
- <span class="notch np-hidden" aria-hidden="true"
348
+ <span class="notch" aria-hidden="true"
226
349
  >{label}{noAsterisk || !required ? '' : '*'}</span
227
350
  >
228
351
  </div>
@@ -334,33 +457,43 @@
334
457
  </div>
335
458
  </div>
336
459
 
337
- {#snippet item(option: SelectOption)}
460
+ {#snippet item(option: SelectOption, index?: number)}
338
461
  {#if Array.isArray(value) && multiple}
339
462
  <Item
463
+ id={typeof index === 'number' ? `${uid}-opt-${index}` : undefined}
340
464
  onclick={(event) => {
341
465
  handleOptionSelect(event, option)
342
466
  field?.focus()
343
467
  }}
344
468
  disabled={option.disabled}
469
+ aria-disabled={option.disabled}
345
470
  role="option"
346
471
  onkeydown={(event) => {
347
- if (event.key === 'ArrowDown') {
348
- ;(event.currentTarget?.nextElementSibling as HTMLElement)?.focus()
472
+ const key = event.key
473
+ if (key === 'ArrowDown') {
349
474
  event.preventDefault()
350
- }
351
- if (event.key === 'ArrowUp') {
352
- ;(event.currentTarget?.previousElementSibling as HTMLElement)?.focus()
475
+ moveFocus(1)
476
+ } else if (key === 'ArrowUp') {
477
+ event.preventDefault()
478
+ moveFocus(-1)
479
+ } else if (key === 'Home') {
480
+ event.preventDefault()
481
+ focusEdge(true)
482
+ } else if (key === 'End') {
483
+ event.preventDefault()
484
+ focusEdge(false)
485
+ } else if (key === 'Enter' || key === ' ') {
353
486
  event.preventDefault()
354
- }
355
- if (event.key === 'Enter') {
356
487
  handleOptionSelect(event, option)
357
- }
358
- if (event.key === 'Tab') {
488
+ } else if (key === 'Tab') {
359
489
  menuElement?.hidePopover()
490
+ } else if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
491
+ performTypeahead(key)
360
492
  }
361
493
  }}
362
494
  variant="button"
363
495
  selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
496
+ aria-selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
364
497
  >{option.label}
365
498
  {#snippet start()}
366
499
  <Check disabled={option.disabled} checked={value.includes(option.value)} />
@@ -368,40 +501,52 @@
368
501
  </Item>
369
502
  {:else}
370
503
  <Item
504
+ id={typeof index === 'number' ? `${uid}-opt-${index}` : undefined}
371
505
  onclick={(event) => {
372
506
  handleOptionSelect(event, option)
373
507
  field?.focus()
374
508
  }}
375
509
  disabled={option.disabled}
510
+ aria-disabled={option.disabled}
376
511
  role="option"
377
512
  onkeydown={(event) => {
378
- if (event.key === 'ArrowDown') {
379
- ;(event.currentTarget?.nextElementSibling as HTMLElement)?.focus()
513
+ const key = event.key
514
+ if (key === 'ArrowDown') {
380
515
  event.preventDefault()
381
- }
382
- if (event.key === 'ArrowUp') {
383
- ;(event.currentTarget?.previousElementSibling as HTMLElement)?.focus()
516
+ moveFocus(1)
517
+ } else if (key === 'ArrowUp') {
518
+ event.preventDefault()
519
+ moveFocus(-1)
520
+ } else if (key === 'Home') {
521
+ event.preventDefault()
522
+ focusEdge(true)
523
+ } else if (key === 'End') {
524
+ event.preventDefault()
525
+ focusEdge(false)
526
+ } else if (key === 'Enter' || key === ' ') {
384
527
  event.preventDefault()
385
- }
386
- if (event.key === 'Enter') {
387
528
  handleOptionSelect(event, option)
388
- }
389
- if (event.key === 'Tab') {
529
+ } else if (key === 'Tab') {
390
530
  menuElement?.hidePopover()
531
+ } else if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
532
+ performTypeahead(key)
391
533
  }
392
534
  }}
393
535
  variant="button"
394
536
  selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
537
+ aria-selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
395
538
  >{option.label}
396
539
  </Item>
397
540
  {/if}
398
541
  {/snippet}
399
542
 
400
543
  <Menu
544
+ id="listbox-{uid}"
401
545
  style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
402
546
  ? 'width'
403
547
  : 'min-width'}:{clientWidth}px"
404
548
  role="listbox"
549
+ aria-multiselectable={multiple}
405
550
  --np-menu-justify-self="none"
406
551
  --np-menu-position-area="bottom span-right"
407
552
  --np-menu-margin="2px 0"
@@ -420,13 +565,13 @@
420
565
  >
421
566
  {#if useVirtualList}
422
567
  <VirtualList height="250px" itemHeight={48} items={options}>
423
- {#snippet row(option)}
424
- {@render item(option)}
568
+ {#snippet row(option, index)}
569
+ {@render item(option, index)}
425
570
  {/snippet}
426
571
  </VirtualList>
427
572
  {:else}
428
573
  {#each options as option, index (index)}
429
- {@render item(option)}
574
+ {@render item(option, index)}
430
575
  {/each}
431
576
  {/if}
432
577
  </Menu>
@@ -763,12 +908,6 @@
763
908
  .notch {
764
909
  font-size: 0.75rem;
765
910
  line-height: 1rem;
766
- }
767
- .notch.np-hidden {
768
- opacity: 0;
769
- }
770
-
771
- .label.np-hidden {
772
911
  opacity: 0;
773
912
  }
774
913
 
@@ -828,7 +967,7 @@
828
967
  .disabled .label {
829
968
  color: var(--np-color-on-surface);
830
969
  }
831
- .disabled .label:not(.np-hidden) {
970
+ .disabled {
832
971
  opacity: 0.38;
833
972
  }
834
973
  .resizable:not(.disabled) .np-container {
@@ -2,13 +2,14 @@
2
2
  import { onMount, tick, type Snippet } from 'svelte'
3
3
  import type { HTMLAttributes } from 'svelte/elements'
4
4
 
5
- interface VitualListProps extends HTMLAttributes<HTMLDivElement> {
5
+ interface VirtualListProps extends HTMLAttributes<HTMLDivElement> {
6
6
  items: T[]
7
7
  height?: string
8
8
  itemHeight?: number
9
9
  start?: number
10
10
  end?: number
11
- row: Snippet<[T]>
11
+ row: Snippet<[T, number]>
12
+ overscan?: number
12
13
  }
13
14
 
14
15
  let {
@@ -18,7 +19,8 @@
18
19
  start = $bindable(0),
19
20
  end = $bindable(0),
20
21
  row,
21
- }: VitualListProps = $props()
22
+ overscan = 4,
23
+ }: VirtualListProps = $props()
22
24
 
23
25
  let height_map: number[] = []
24
26
  // eslint-disable-next-line no-undef
@@ -30,7 +32,7 @@
30
32
 
31
33
  let top = $state(0)
32
34
  let bottom = $state(0)
33
- let average_height: number = $state(0)
35
+ let average_height: number = $state(itemHeight || 0)
34
36
 
35
37
  $effect(() => {
36
38
  if (mounted) {
@@ -38,9 +40,10 @@
38
40
  }
39
41
  })
40
42
  let visible = $derived(
41
- items.slice(start, end).map((data, i) => {
42
- return { index: i + start, data }
43
- }),
43
+ items.slice(Math.max(0, start - overscan), end).map((data, i) => ({
44
+ index: i + Math.max(0, start - overscan),
45
+ data,
46
+ })),
44
47
  )
45
48
 
46
49
  async function refresh(items: T[], viewport_height: number, itemHeight?: number) {
@@ -68,10 +71,11 @@
68
71
  i += 1
69
72
  }
70
73
 
74
+ i = Math.min(i + overscan, items.length)
71
75
  end = i
72
76
 
73
77
  const remaining = items.length - end
74
- average_height = (top + content_height) / end
78
+ average_height = end ? (top + content_height) / end : average_height || itemHeight || 0
75
79
 
76
80
  bottom = remaining * average_height
77
81
  height_map.length = items.length
@@ -98,14 +102,24 @@
98
102
  if (y + row_height > scrollTop) {
99
103
  start = i
100
104
  top = y
101
-
102
105
  break
103
106
  }
104
-
105
107
  y += row_height
106
108
  i += 1
107
109
  }
108
110
 
111
+ if (start > 0 && overscan > 0) {
112
+ let back = 0
113
+ let s = start
114
+ while (s > 0 && back < overscan) {
115
+ s -= 1
116
+ const h = height_map[s] || average_height
117
+ back += 1
118
+ top -= h
119
+ }
120
+ start = s
121
+ }
122
+
109
123
  while (i < items.length) {
110
124
  y += height_map[i] || average_height
111
125
  i += 1
@@ -116,7 +130,7 @@
116
130
  end = i
117
131
 
118
132
  const remaining = items.length - end
119
- average_height = y / end
133
+ average_height = end ? y / end : average_height || itemHeight || 0
120
134
 
121
135
  height_map.fill(average_height, i, items.length)
122
136
  bottom = remaining * average_height
@@ -137,13 +151,8 @@
137
151
  const d = actual_height - expected_height
138
152
  viewport.scrollTo(0, scrollTop + d)
139
153
  }
140
-
141
- // TODO if we overestimated the space these
142
- // rows would occupy we may need to add some
143
- // more. maybe we can just call handle_scroll again?
144
154
  }
145
155
 
146
- // trigger initial refresh
147
156
  onMount(() => {
148
157
  // eslint-disable-next-line no-undef
149
158
  rows = contents?.children as HTMLCollectionOf<HTMLElement>
@@ -159,7 +168,7 @@
159
168
  >
160
169
  <div bind:this={contents} style="padding-top: {top}px; padding-bottom: {bottom}px;">
161
170
  {#each visible as entry (entry.index)}
162
- {@render row(entry.data)}
171
+ {@render row(entry.data, entry.index)}
163
172
  {/each}
164
173
  </div>
165
174
  </svelte-virtual-list-viewport>
@@ -7,7 +7,8 @@ declare function $$render<T>(): {
7
7
  itemHeight?: number;
8
8
  start?: number;
9
9
  end?: number;
10
- row: Snippet<[T]>;
10
+ row: Snippet<[T, number]>;
11
+ overscan?: number;
11
12
  };
12
13
  exports: {};
13
14
  bindings: "start" | "end";
@@ -22,6 +22,7 @@
22
22
  }: SnackbarProps = $props()
23
23
 
24
24
  let timeoutId: number | undefined = $state()
25
+ const uid = $props.id()
25
26
 
26
27
  showPopover = () => {
27
28
  element?.showPopover()
@@ -37,6 +38,8 @@
37
38
  {popover}
38
39
  class={['np-snackbar', attributes.class]}
39
40
  bind:this={element}
41
+ role="alert"
42
+ aria-labelledby="np-snackbar-label-{uid}"
40
43
  onbeforetoggle={(event) => {
41
44
  let { newState } = event
42
45
  if (newState === 'closed') {
@@ -52,7 +55,7 @@
52
55
  >
53
56
  <div class="np-snackbar-inner">
54
57
  <div class="np-snackbar-label-container">
55
- <div role="alert" class="np-snackbar-label">{label}</div>
58
+ <div id="np-snackbar-label-{uid}" class="np-snackbar-label">{label}</div>
56
59
  {#if supportingText}
57
60
  <div class="np-snackbar-supporting-text">{supportingText}</div>
58
61
  {/if}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.24.7",
3
+ "version": "0.24.10",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {