noph-ui 0.24.8 → 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.
- package/dist/button/Button.svelte +46 -48
- package/dist/button/IconButton.svelte +47 -46
- package/dist/navigation-drawer/NavigationDrawerItem.svelte +9 -16
- package/dist/navigation-rail/NavigationRailItem.svelte +9 -16
- package/dist/radio/Radio.svelte +4 -3
- package/dist/select/Select.svelte +190 -51
- package/dist/select/VirtualList.svelte +26 -17
- package/dist/select/VirtualList.svelte.d.ts +2 -1
- package/dist/snackbar/Snackbar.svelte +3 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
86
|
-
selected
|
|
87
|
-
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-
|
|
262
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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 {
|
|
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
|
|
34
|
-
<
|
|
35
|
-
{...attributes
|
|
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
|
-
</
|
|
44
|
-
{:else
|
|
45
|
-
<
|
|
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
|
-
</
|
|
47
|
+
</button>
|
|
55
48
|
{/if}
|
|
56
49
|
|
|
57
50
|
<style>
|
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type {
|
|
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
|
|
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>
|
package/dist/radio/Radio.svelte
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
...attributes
|
|
12
12
|
}: RadioProps = $props()
|
|
13
13
|
|
|
14
|
-
let inputEl
|
|
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="
|
|
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(#
|
|
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[] = $
|
|
48
|
+
let selectedOption: SelectOption[] = $derived(
|
|
49
49
|
options
|
|
50
|
-
.filter(
|
|
51
|
-
option
|
|
52
|
-
|
|
53
|
-
: value === option.value
|
|
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
|
|
63
|
-
let menuElement
|
|
64
|
-
let anchorElement
|
|
65
|
-
let field
|
|
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
|
|
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
|
-
|
|
286
|
+
const key = event.key
|
|
287
|
+
if (key === 'Tab') {
|
|
196
288
|
menuElement?.hidePopover()
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
348
|
-
|
|
472
|
+
const key = event.key
|
|
473
|
+
if (key === 'ArrowDown') {
|
|
349
474
|
event.preventDefault()
|
|
350
|
-
|
|
351
|
-
if (
|
|
352
|
-
|
|
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
|
-
|
|
379
|
-
|
|
513
|
+
const key = event.key
|
|
514
|
+
if (key === 'ArrowDown') {
|
|
380
515
|
event.preventDefault()
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -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()
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
class={['np-snackbar', attributes.class]}
|
|
39
40
|
bind:this={element}
|
|
40
41
|
role="alert"
|
|
41
|
-
aria-label
|
|
42
|
+
aria-labelledby="np-snackbar-label-{uid}"
|
|
42
43
|
onbeforetoggle={(event) => {
|
|
43
44
|
let { newState } = event
|
|
44
45
|
if (newState === 'closed') {
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
>
|
|
55
56
|
<div class="np-snackbar-inner">
|
|
56
57
|
<div class="np-snackbar-label-container">
|
|
57
|
-
<div class="np-snackbar-label">{label}</div>
|
|
58
|
+
<div id="np-snackbar-label-{uid}" class="np-snackbar-label">{label}</div>
|
|
58
59
|
{#if supportingText}
|
|
59
60
|
<div class="np-snackbar-supporting-text">{supportingText}</div>
|
|
60
61
|
{/if}
|