noph-ui 0.15.4 → 0.16.0
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/list/List.svelte +3 -3
- package/dist/list/List.svelte.d.ts +2 -2
- package/dist/list/types.d.ts +3 -0
- package/dist/menu/Menu.svelte +17 -9
- package/dist/select/Check.svelte +170 -0
- package/dist/select/Check.svelte.d.ts +7 -0
- package/dist/select/Select.svelte +159 -68
- package/dist/select/VirtualList.svelte +181 -0
- package/dist/select/VirtualList.svelte.d.ts +27 -0
- package/dist/select/types.d.ts +8 -6
- package/package.json +1 -1
package/dist/list/List.svelte
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type { ListProps } from './types.ts'
|
|
3
3
|
|
|
4
|
-
let { children, ...attributes }:
|
|
4
|
+
let { element = $bindable(), children, ...attributes }: ListProps = $props()
|
|
5
5
|
</script>
|
|
6
6
|
|
|
7
|
-
<ul {...attributes}>
|
|
7
|
+
<ul bind:this={element} {...attributes}>
|
|
8
8
|
{#if children}
|
|
9
9
|
{@render children()}
|
|
10
10
|
{/if}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
declare const List: import("svelte").Component<
|
|
1
|
+
import type { ListProps } from './types.ts';
|
|
2
|
+
declare const List: import("svelte").Component<ListProps, {}, "element">;
|
|
3
3
|
type List = ReturnType<typeof List>;
|
|
4
4
|
export default List;
|
package/dist/list/types.d.ts
CHANGED
|
@@ -25,4 +25,7 @@ interface TextProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
25
25
|
}
|
|
26
26
|
export type ItemProps = ButtonProps | AnchorProps | TextProps;
|
|
27
27
|
export type ListItemProps = ButtonProps | AnchorProps | TextProps;
|
|
28
|
+
export interface ListProps extends HTMLAttributes<HTMLUListElement> {
|
|
29
|
+
element?: HTMLUListElement;
|
|
30
|
+
}
|
|
28
31
|
export {};
|
package/dist/menu/Menu.svelte
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
element = $bindable(),
|
|
8
8
|
showPopover = $bindable(),
|
|
9
9
|
hidePopover = $bindable(),
|
|
10
|
+
style,
|
|
10
11
|
...attributes
|
|
11
12
|
}: MenuProps = $props()
|
|
12
13
|
|
|
@@ -57,29 +58,36 @@
|
|
|
57
58
|
$effect(refreshValues)
|
|
58
59
|
|
|
59
60
|
const getScrollableParent = (start: HTMLElement) => {
|
|
60
|
-
let
|
|
61
|
-
while (
|
|
62
|
-
const style = getComputedStyle(
|
|
61
|
+
let el: HTMLElement | null = start
|
|
62
|
+
while (el) {
|
|
63
|
+
const style = getComputedStyle(el)
|
|
63
64
|
const overflowY = style.overflowY
|
|
64
65
|
const overflowX = style.overflowX
|
|
65
66
|
const isScrollableY =
|
|
66
|
-
(overflowY === 'auto' || overflowY === 'scroll') &&
|
|
67
|
-
element.scrollHeight > element.clientHeight
|
|
67
|
+
(overflowY === 'auto' || overflowY === 'scroll') && el.scrollHeight > el.clientHeight
|
|
68
68
|
const isScrollableX =
|
|
69
|
-
(overflowX === 'auto' || overflowX === 'scroll') &&
|
|
70
|
-
element.scrollWidth > element.clientWidth
|
|
69
|
+
(overflowX === 'auto' || overflowX === 'scroll') && el.scrollWidth > el.clientWidth
|
|
71
70
|
|
|
72
71
|
if (isScrollableY || isScrollableX) {
|
|
73
|
-
return
|
|
72
|
+
return el
|
|
74
73
|
}
|
|
75
74
|
|
|
76
|
-
|
|
75
|
+
el = el.parentElement
|
|
77
76
|
}
|
|
78
77
|
return window
|
|
79
78
|
}
|
|
80
79
|
|
|
81
80
|
$effect(() => {
|
|
82
81
|
if (anchor && element) {
|
|
82
|
+
if (style) {
|
|
83
|
+
const styleEntries = style
|
|
84
|
+
.split(';')
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.map((entry) => entry.split(':').map((str) => str.trim()))
|
|
87
|
+
styleEntries.forEach(([key, value]) => {
|
|
88
|
+
element?.style.setProperty(key, value)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
83
91
|
getScrollableParent(element).addEventListener(
|
|
84
92
|
'scroll',
|
|
85
93
|
() => {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
let { checked = $bindable(), disabled = false }: { checked: boolean; disabled?: boolean } =
|
|
3
|
+
$props()
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<div class={['np-host']}>
|
|
7
|
+
<div
|
|
8
|
+
class={['np-container', checked ? 'checked' : 'unchecked', disabled ? 'disabled' : 'enabled']}
|
|
9
|
+
>
|
|
10
|
+
<div class="np-outline"></div>
|
|
11
|
+
<div class="np-background"></div>
|
|
12
|
+
<svg class="np-icon" viewBox="0 0 18 18" aria-hidden="true">
|
|
13
|
+
<rect class="mark short" />
|
|
14
|
+
<rect class="mark long" />
|
|
15
|
+
</svg>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
.np-host {
|
|
21
|
+
border-start-start-radius: var(--np-checkbox-container-shape, 2px);
|
|
22
|
+
border-start-end-radius: var(--np-checkbox-container-shape, 2px);
|
|
23
|
+
border-end-end-radius: var(--np-checkbox-container-shape, 2px);
|
|
24
|
+
border-end-start-radius: var(--np-checkbox-container-shape, 2px);
|
|
25
|
+
display: inline-flex;
|
|
26
|
+
height: 18px;
|
|
27
|
+
position: relative;
|
|
28
|
+
vertical-align: top;
|
|
29
|
+
width: 18px;
|
|
30
|
+
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
}
|
|
33
|
+
.np-container {
|
|
34
|
+
border-radius: inherit;
|
|
35
|
+
display: flex;
|
|
36
|
+
height: 100%;
|
|
37
|
+
place-content: center;
|
|
38
|
+
place-items: center;
|
|
39
|
+
position: relative;
|
|
40
|
+
width: 100%;
|
|
41
|
+
}
|
|
42
|
+
.np-outline,
|
|
43
|
+
.np-background,
|
|
44
|
+
.np-icon {
|
|
45
|
+
inset: 0;
|
|
46
|
+
position: absolute;
|
|
47
|
+
}
|
|
48
|
+
.np-outline,
|
|
49
|
+
.np-background {
|
|
50
|
+
border-radius: inherit;
|
|
51
|
+
}
|
|
52
|
+
.np-outline {
|
|
53
|
+
border-color: var(--np-checkbox-outline-color, var(--np-color-on-surface-variant));
|
|
54
|
+
border-style: solid;
|
|
55
|
+
border-width: 2px;
|
|
56
|
+
box-sizing: border-box;
|
|
57
|
+
}
|
|
58
|
+
:where(:hover) .np-outline {
|
|
59
|
+
border-color: var(--np-color-on-surface);
|
|
60
|
+
border-width: 2px;
|
|
61
|
+
}
|
|
62
|
+
:where(:focus-within) .np-outline {
|
|
63
|
+
border-color: var(--np-color-on-surface);
|
|
64
|
+
border-width: 2px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.np-container.disabled .np-outline {
|
|
68
|
+
border-color: var(--np-color-on-surface);
|
|
69
|
+
border-width: 2px;
|
|
70
|
+
opacity: 0.38;
|
|
71
|
+
}
|
|
72
|
+
.np-container.checked.disabled .np-outline {
|
|
73
|
+
visibility: hidden;
|
|
74
|
+
}
|
|
75
|
+
.np-background {
|
|
76
|
+
background-color: var(--np-checkbox-selected-container-color, var(--np-color-primary));
|
|
77
|
+
}
|
|
78
|
+
.np-background,
|
|
79
|
+
.np-icon {
|
|
80
|
+
opacity: 0;
|
|
81
|
+
transition-duration: 150ms, 50ms;
|
|
82
|
+
transition-property: transform, opacity;
|
|
83
|
+
transition-timing-function: cubic-bezier(0.3, 0, 0.8, 0.15), linear;
|
|
84
|
+
transform: scale(0.6);
|
|
85
|
+
}
|
|
86
|
+
.np-container.checked .np-background,
|
|
87
|
+
.np-container.checked .np-icon {
|
|
88
|
+
opacity: 1;
|
|
89
|
+
transition-duration: 350ms, 50ms;
|
|
90
|
+
transition-timing-function: cubic-bezier(0.05, 0.7, 0.1, 1), linear;
|
|
91
|
+
transform: scale(1);
|
|
92
|
+
}
|
|
93
|
+
.np-container.checked.disabled .np-background {
|
|
94
|
+
background: var(--np-color-on-surface);
|
|
95
|
+
opacity: 0.38;
|
|
96
|
+
}
|
|
97
|
+
.np-icon {
|
|
98
|
+
fill: var(--np-checkbox-selected-icon-color, var(--np-color-on-primary));
|
|
99
|
+
height: 18px;
|
|
100
|
+
width: 18px;
|
|
101
|
+
}
|
|
102
|
+
.np-container.disabled .np-icon {
|
|
103
|
+
fill: var(--np-color-surface);
|
|
104
|
+
}
|
|
105
|
+
.np-container .mark.short {
|
|
106
|
+
height: 5.6568542495px;
|
|
107
|
+
}
|
|
108
|
+
.np-container.checked .mark {
|
|
109
|
+
transition-property: none;
|
|
110
|
+
}
|
|
111
|
+
.np-container .mark {
|
|
112
|
+
transform: scaleY(-1) translate(7px, -14px) rotate(45deg);
|
|
113
|
+
}
|
|
114
|
+
.np-container.checked .mark {
|
|
115
|
+
animation-duration: 350ms;
|
|
116
|
+
animation-timing-function: cubic-bezier(0.05, 0.7, 0.1, 1);
|
|
117
|
+
transition-duration: 350ms;
|
|
118
|
+
transition-timing-function: cubic-bezier(0.05, 0.7, 0.1, 1);
|
|
119
|
+
}
|
|
120
|
+
.mark.short {
|
|
121
|
+
height: 2px;
|
|
122
|
+
transition-property: transform, height;
|
|
123
|
+
width: 2px;
|
|
124
|
+
}
|
|
125
|
+
.mark {
|
|
126
|
+
animation-duration: 150ms;
|
|
127
|
+
animation-timing-function: cubic-bezier(0.3, 0, 0.8, 0.15);
|
|
128
|
+
transition-duration: 150ms;
|
|
129
|
+
transition-timing-function: cubic-bezier(0.3, 0, 0.8, 0.15);
|
|
130
|
+
}
|
|
131
|
+
.np-container.checked .mark.long {
|
|
132
|
+
animation-name: prev-unselected-to-checked;
|
|
133
|
+
}
|
|
134
|
+
.np-container .mark.long {
|
|
135
|
+
width: 11.313708499px;
|
|
136
|
+
}
|
|
137
|
+
.mark.long {
|
|
138
|
+
height: 2px;
|
|
139
|
+
transition-property: transform, width;
|
|
140
|
+
width: 10px;
|
|
141
|
+
}
|
|
142
|
+
@keyframes prev-unselected-to-checked {
|
|
143
|
+
from {
|
|
144
|
+
width: 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
@media (forced-colors: active) {
|
|
148
|
+
.np-background {
|
|
149
|
+
background-color: CanvasText;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.np-container.disabled.checked .np-background {
|
|
153
|
+
background-color: GrayText;
|
|
154
|
+
opacity: 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.np-outline {
|
|
158
|
+
border-color: CanvasText;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.np-container.disabled .np-outline {
|
|
162
|
+
border-color: GrayText;
|
|
163
|
+
opacity: 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.np-icon {
|
|
167
|
+
fill: Canvas;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
</style>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import Menu from '../menu/Menu.svelte'
|
|
3
3
|
import { isFirstInvalidControlInForm } from '../text-field/report-validity.js'
|
|
4
|
-
import type { SelectProps } from './types.ts'
|
|
4
|
+
import type { SelectOption, SelectProps } from './types.ts'
|
|
5
5
|
import Item from '../list/Item.svelte'
|
|
6
6
|
import { tick } from 'svelte'
|
|
7
|
+
import Check from './Check.svelte'
|
|
8
|
+
import VirtualList from './VirtualList.svelte'
|
|
7
9
|
|
|
8
10
|
let {
|
|
9
11
|
options = [],
|
|
@@ -27,10 +29,19 @@
|
|
|
27
29
|
autofocus,
|
|
28
30
|
onchange,
|
|
29
31
|
oninput,
|
|
32
|
+
multiple,
|
|
30
33
|
...attributes
|
|
31
34
|
}: SelectProps = $props()
|
|
32
35
|
|
|
33
36
|
const uid = $props.id()
|
|
37
|
+
if (value === undefined) {
|
|
38
|
+
if (multiple) {
|
|
39
|
+
value = options.filter((option) => option.selected).map((option) => option.value)
|
|
40
|
+
} else {
|
|
41
|
+
value = options.find((option) => option.selected)?.value
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let selectedOption = $state(options.filter((option) => option.selected))
|
|
34
45
|
|
|
35
46
|
let errorTextRaw: string = $state(errorText)
|
|
36
47
|
let errorRaw = $state(error)
|
|
@@ -40,11 +51,24 @@
|
|
|
40
51
|
let field: HTMLDivElement | undefined = $state()
|
|
41
52
|
let clientWidth = $state(0)
|
|
42
53
|
let menuOpen = $state(false)
|
|
43
|
-
let selectedLabel = $derived.by<string>(() => {
|
|
54
|
+
let selectedLabel = $derived.by<string | string[]>(() => {
|
|
55
|
+
if (multiple) {
|
|
56
|
+
if (value && Array.isArray(value)) {
|
|
57
|
+
return value
|
|
58
|
+
.map((v) => options.find((option) => option.value === v)?.label || '')
|
|
59
|
+
.filter((o) => o)
|
|
60
|
+
.join(', ')
|
|
61
|
+
}
|
|
62
|
+
return ''
|
|
63
|
+
}
|
|
44
64
|
return options.find((option) => option.value === value)?.label || ''
|
|
45
65
|
})
|
|
46
66
|
$effect(() => {
|
|
47
|
-
if (
|
|
67
|
+
if (
|
|
68
|
+
value &&
|
|
69
|
+
(Array.isArray(value) ? value.length !== 0 : value !== '') &&
|
|
70
|
+
selectElement?.checkValidity()
|
|
71
|
+
) {
|
|
48
72
|
errorRaw = error
|
|
49
73
|
errorTextRaw = errorText
|
|
50
74
|
}
|
|
@@ -56,6 +80,30 @@
|
|
|
56
80
|
})
|
|
57
81
|
}
|
|
58
82
|
})
|
|
83
|
+
const handleOptionSelect = (event: Event, option: SelectOption) => {
|
|
84
|
+
if (multiple) {
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
if (value.includes(option.value)) {
|
|
87
|
+
selectedOption = selectedOption.filter((v) => v.value !== option.value)
|
|
88
|
+
value = value.filter((v) => v !== option.value)
|
|
89
|
+
} else {
|
|
90
|
+
selectedOption = [...selectedOption, option]
|
|
91
|
+
value = [...value, option.value]
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
selectedOption = [option]
|
|
95
|
+
value = [option.value]
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
selectedOption = [option]
|
|
99
|
+
value = option.value
|
|
100
|
+
menuElement?.hidePopover()
|
|
101
|
+
}
|
|
102
|
+
event.preventDefault()
|
|
103
|
+
tick().then(() => {
|
|
104
|
+
selectElement?.dispatchEvent(new Event('change', { bubbles: true }))
|
|
105
|
+
})
|
|
106
|
+
}
|
|
59
107
|
</script>
|
|
60
108
|
|
|
61
109
|
{#snippet arrows()}
|
|
@@ -152,34 +200,66 @@
|
|
|
152
200
|
</div>
|
|
153
201
|
{/if}
|
|
154
202
|
<div class="content">
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
203
|
+
{#if multiple}
|
|
204
|
+
<select
|
|
205
|
+
tabindex="-1"
|
|
206
|
+
aria-label={attributes['aria-label'] || label}
|
|
207
|
+
{disabled}
|
|
208
|
+
{required}
|
|
209
|
+
{name}
|
|
210
|
+
{form}
|
|
211
|
+
multiple
|
|
212
|
+
{onchange}
|
|
213
|
+
{oninput}
|
|
214
|
+
oninvalid={(event) => {
|
|
215
|
+
event.preventDefault()
|
|
216
|
+
const { currentTarget } = event
|
|
217
|
+
errorRaw = true
|
|
218
|
+
if (errorText === '') {
|
|
219
|
+
errorTextRaw = currentTarget.validationMessage
|
|
220
|
+
}
|
|
221
|
+
if (isFirstInvalidControlInForm(currentTarget.form, currentTarget)) {
|
|
222
|
+
field?.focus()
|
|
223
|
+
menuElement?.showPopover()
|
|
224
|
+
}
|
|
225
|
+
}}
|
|
226
|
+
bind:value
|
|
227
|
+
bind:this={selectElement}
|
|
228
|
+
>
|
|
229
|
+
{#each selectedOption as option, index (index)}
|
|
230
|
+
<option value={option.value} selected={option.selected}>{option.label}</option>
|
|
231
|
+
{/each}
|
|
232
|
+
</select>
|
|
233
|
+
{:else}
|
|
234
|
+
<select
|
|
235
|
+
tabindex="-1"
|
|
236
|
+
aria-label={attributes['aria-label'] || label}
|
|
237
|
+
{disabled}
|
|
238
|
+
{required}
|
|
239
|
+
{name}
|
|
240
|
+
{form}
|
|
241
|
+
{onchange}
|
|
242
|
+
{oninput}
|
|
243
|
+
oninvalid={(event) => {
|
|
244
|
+
event.preventDefault()
|
|
245
|
+
const { currentTarget } = event
|
|
246
|
+
errorRaw = true
|
|
247
|
+
if (errorText === '') {
|
|
248
|
+
errorTextRaw = currentTarget.validationMessage
|
|
249
|
+
}
|
|
250
|
+
if (isFirstInvalidControlInForm(currentTarget.form, currentTarget)) {
|
|
251
|
+
field?.focus()
|
|
252
|
+
menuElement?.showPopover()
|
|
253
|
+
}
|
|
254
|
+
}}
|
|
255
|
+
bind:value
|
|
256
|
+
bind:this={selectElement}
|
|
257
|
+
>
|
|
258
|
+
{#each selectedOption as option, index (index)}
|
|
259
|
+
<option value={option.value} selected={option.selected}>{option.label}</option>
|
|
260
|
+
{/each}
|
|
261
|
+
</select>
|
|
262
|
+
{/if}
|
|
183
263
|
<div class="input">
|
|
184
264
|
{#if selectedLabel}
|
|
185
265
|
{selectedLabel}
|
|
@@ -208,6 +288,41 @@
|
|
|
208
288
|
</div>
|
|
209
289
|
</div>
|
|
210
290
|
|
|
291
|
+
{#snippet item(option: SelectOption, width?: number)}
|
|
292
|
+
<Item
|
|
293
|
+
onclick={(event) => {
|
|
294
|
+
handleOptionSelect(event, option)
|
|
295
|
+
field?.focus()
|
|
296
|
+
}}
|
|
297
|
+
disabled={option.disabled}
|
|
298
|
+
onkeydown={(event) => {
|
|
299
|
+
if (event.key === 'ArrowDown') {
|
|
300
|
+
;(event.currentTarget?.nextElementSibling as HTMLElement)?.focus()
|
|
301
|
+
event.preventDefault()
|
|
302
|
+
}
|
|
303
|
+
if (event.key === 'ArrowUp') {
|
|
304
|
+
;(event.currentTarget?.previousElementSibling as HTMLElement)?.focus()
|
|
305
|
+
event.preventDefault()
|
|
306
|
+
}
|
|
307
|
+
if (event.key === 'Enter') {
|
|
308
|
+
handleOptionSelect(event, option)
|
|
309
|
+
}
|
|
310
|
+
if (event.key === 'Tab') {
|
|
311
|
+
menuElement?.hidePopover()
|
|
312
|
+
}
|
|
313
|
+
}}
|
|
314
|
+
variant="button"
|
|
315
|
+
style={width ? `width:${width}px` : ''}
|
|
316
|
+
selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
|
|
317
|
+
>{option.label}
|
|
318
|
+
{#snippet start()}
|
|
319
|
+
{#if Array.isArray(value) && multiple}
|
|
320
|
+
<Check disabled={option.disabled} checked={value.includes(option.value)} />
|
|
321
|
+
{/if}
|
|
322
|
+
{/snippet}
|
|
323
|
+
</Item>
|
|
324
|
+
{/snippet}
|
|
325
|
+
|
|
211
326
|
<Menu
|
|
212
327
|
style="position-anchor:--{uid};min-width:{clientWidth}px;"
|
|
213
328
|
popover="manual"
|
|
@@ -228,43 +343,17 @@
|
|
|
228
343
|
}}
|
|
229
344
|
bind:element={menuElement}
|
|
230
345
|
>
|
|
231
|
-
{#
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
onkeydown={(event) => {
|
|
243
|
-
if (event.key === 'ArrowDown') {
|
|
244
|
-
;(event.currentTarget?.nextElementSibling as HTMLElement)?.focus()
|
|
245
|
-
event.preventDefault()
|
|
246
|
-
}
|
|
247
|
-
if (event.key === 'ArrowUp') {
|
|
248
|
-
;(event.currentTarget?.previousElementSibling as HTMLElement)?.focus()
|
|
249
|
-
event.preventDefault()
|
|
250
|
-
}
|
|
251
|
-
if (event.key === 'Enter') {
|
|
252
|
-
value = option.value
|
|
253
|
-
menuElement?.hidePopover()
|
|
254
|
-
event.preventDefault()
|
|
255
|
-
tick().then(() => {
|
|
256
|
-
selectElement?.dispatchEvent(new Event('change', { bubbles: true }))
|
|
257
|
-
})
|
|
258
|
-
}
|
|
259
|
-
if (event.key === 'Tab') {
|
|
260
|
-
menuElement?.hidePopover()
|
|
261
|
-
}
|
|
262
|
-
}}
|
|
263
|
-
variant="button"
|
|
264
|
-
selected={value === option.value}
|
|
265
|
-
>{option.label}
|
|
266
|
-
</Item>
|
|
267
|
-
{/each}
|
|
346
|
+
{#if options.length > 100}
|
|
347
|
+
<VirtualList width="{clientWidth}px" height="250px" itemHeight={56} items={options}>
|
|
348
|
+
{#snippet row(option)}
|
|
349
|
+
{@render item(option, clientWidth - 15)}
|
|
350
|
+
{/snippet}
|
|
351
|
+
</VirtualList>
|
|
352
|
+
{:else}
|
|
353
|
+
{#each options as option, index (index)}
|
|
354
|
+
{@render item(option)}
|
|
355
|
+
{/each}
|
|
356
|
+
{/if}
|
|
268
357
|
</Menu>
|
|
269
358
|
|
|
270
359
|
<style>
|
|
@@ -430,6 +519,8 @@
|
|
|
430
519
|
|
|
431
520
|
.content select {
|
|
432
521
|
width: 0;
|
|
522
|
+
height: 0;
|
|
523
|
+
visibility: hidden;
|
|
433
524
|
}
|
|
434
525
|
|
|
435
526
|
.middle {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
import { onMount, tick, type Snippet } from 'svelte'
|
|
3
|
+
import type { HTMLAttributes } from 'svelte/elements'
|
|
4
|
+
|
|
5
|
+
interface VitualListProps extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
items: T[]
|
|
7
|
+
height?: string
|
|
8
|
+
width?: string
|
|
9
|
+
itemHeight?: number
|
|
10
|
+
start?: number
|
|
11
|
+
end?: number
|
|
12
|
+
row: Snippet<[T]>
|
|
13
|
+
}
|
|
14
|
+
// props
|
|
15
|
+
let {
|
|
16
|
+
items,
|
|
17
|
+
height = '100%',
|
|
18
|
+
width,
|
|
19
|
+
itemHeight,
|
|
20
|
+
start = $bindable(0),
|
|
21
|
+
end = $bindable(0),
|
|
22
|
+
row,
|
|
23
|
+
}: VitualListProps = $props()
|
|
24
|
+
|
|
25
|
+
// local state
|
|
26
|
+
let height_map: number[] = []
|
|
27
|
+
// eslint-disable-next-line no-undef
|
|
28
|
+
let rows: HTMLCollectionOf<HTMLElement> | undefined = $state()
|
|
29
|
+
let viewport: HTMLElement | undefined = $state()
|
|
30
|
+
let contents: HTMLDivElement | undefined = $state()
|
|
31
|
+
let viewport_height: number = $state(0)
|
|
32
|
+
let mounted = $state()
|
|
33
|
+
|
|
34
|
+
let top = $state(0)
|
|
35
|
+
let bottom = $state(0)
|
|
36
|
+
let average_height: number = $state(0)
|
|
37
|
+
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (mounted) {
|
|
40
|
+
refresh(items, viewport_height, itemHeight)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
let visible = $derived(
|
|
44
|
+
items.slice(start, end).map((data, i) => {
|
|
45
|
+
return { index: i + start, data }
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
async function refresh(items: T[], viewport_height: number, itemHeight?: number) {
|
|
50
|
+
if (!viewport || !rows) {
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const { scrollTop } = viewport
|
|
54
|
+
|
|
55
|
+
await tick() // wait until the DOM is up to date
|
|
56
|
+
|
|
57
|
+
let content_height = top - scrollTop
|
|
58
|
+
let i = start
|
|
59
|
+
|
|
60
|
+
while (content_height < viewport_height && i < items.length) {
|
|
61
|
+
let row = rows[i - start]
|
|
62
|
+
|
|
63
|
+
if (!row) {
|
|
64
|
+
end = i + 1
|
|
65
|
+
await tick() // render the newly visible row
|
|
66
|
+
row = rows[i - start]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const row_height = (height_map[i] = itemHeight || row.offsetHeight)
|
|
70
|
+
content_height += row_height
|
|
71
|
+
i += 1
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
end = i
|
|
75
|
+
|
|
76
|
+
const remaining = items.length - end
|
|
77
|
+
average_height = (top + content_height) / end
|
|
78
|
+
|
|
79
|
+
bottom = remaining * average_height
|
|
80
|
+
height_map.length = items.length
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function handle_scroll() {
|
|
84
|
+
if (!viewport || !rows) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { scrollTop } = viewport
|
|
89
|
+
|
|
90
|
+
const old_start = start
|
|
91
|
+
|
|
92
|
+
for (let v = 0; v < rows.length; v += 1) {
|
|
93
|
+
height_map[start + v] = itemHeight || rows[v].offsetHeight
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let i = 0
|
|
97
|
+
let y = 0
|
|
98
|
+
|
|
99
|
+
while (i < items.length) {
|
|
100
|
+
const row_height = height_map[i] || average_height
|
|
101
|
+
if (y + row_height > scrollTop) {
|
|
102
|
+
start = i
|
|
103
|
+
top = y
|
|
104
|
+
|
|
105
|
+
break
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
y += row_height
|
|
109
|
+
i += 1
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
while (i < items.length) {
|
|
113
|
+
y += height_map[i] || average_height
|
|
114
|
+
i += 1
|
|
115
|
+
|
|
116
|
+
if (y > scrollTop + viewport_height) break
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
end = i
|
|
120
|
+
|
|
121
|
+
const remaining = items.length - end
|
|
122
|
+
average_height = y / end
|
|
123
|
+
|
|
124
|
+
height_map.fill(average_height, i, items.length)
|
|
125
|
+
bottom = remaining * average_height
|
|
126
|
+
|
|
127
|
+
// prevent jumping if we scrolled up into unknown territory
|
|
128
|
+
if (start < old_start) {
|
|
129
|
+
await tick()
|
|
130
|
+
|
|
131
|
+
let expected_height = 0
|
|
132
|
+
let actual_height = 0
|
|
133
|
+
|
|
134
|
+
for (let i = start; i < old_start; i += 1) {
|
|
135
|
+
if (rows[i - start]) {
|
|
136
|
+
expected_height += height_map[i]
|
|
137
|
+
actual_height += itemHeight || rows[i - start].offsetHeight
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const d = actual_height - expected_height
|
|
142
|
+
viewport.scrollTo(0, scrollTop + d)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// TODO if we overestimated the space these
|
|
146
|
+
// rows would occupy we may need to add some
|
|
147
|
+
// more. maybe we can just call handle_scroll again?
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// trigger initial refresh
|
|
151
|
+
onMount(() => {
|
|
152
|
+
// eslint-disable-next-line no-undef
|
|
153
|
+
rows = contents?.children as HTMLCollectionOf<HTMLElement>
|
|
154
|
+
mounted = true
|
|
155
|
+
})
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<svelte-virtual-list-viewport
|
|
159
|
+
bind:this={viewport}
|
|
160
|
+
bind:offsetHeight={viewport_height}
|
|
161
|
+
onscroll={handle_scroll}
|
|
162
|
+
style="display:flex;height: {height};width: {width};"
|
|
163
|
+
>
|
|
164
|
+
<div
|
|
165
|
+
bind:this={contents}
|
|
166
|
+
style="flex:1;display:block;padding-top: {top}px; padding-bottom: {bottom}px;"
|
|
167
|
+
>
|
|
168
|
+
{#each visible as entry (entry.index)}
|
|
169
|
+
{@render row(entry.data)}
|
|
170
|
+
{/each}
|
|
171
|
+
</div>
|
|
172
|
+
</svelte-virtual-list-viewport>
|
|
173
|
+
|
|
174
|
+
<style>
|
|
175
|
+
svelte-virtual-list-viewport {
|
|
176
|
+
position: relative;
|
|
177
|
+
overflow-y: auto;
|
|
178
|
+
-webkit-overflow-scrolling: touch;
|
|
179
|
+
display: block;
|
|
180
|
+
}
|
|
181
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
declare class __sveltets_Render<T> {
|
|
4
|
+
props(): HTMLAttributes<HTMLDivElement> & {
|
|
5
|
+
items: T[];
|
|
6
|
+
height?: string;
|
|
7
|
+
width?: string;
|
|
8
|
+
itemHeight?: number;
|
|
9
|
+
start?: number;
|
|
10
|
+
end?: number;
|
|
11
|
+
row: Snippet<[T]>;
|
|
12
|
+
};
|
|
13
|
+
events(): {};
|
|
14
|
+
slots(): {};
|
|
15
|
+
bindings(): "start" | "end";
|
|
16
|
+
exports(): {};
|
|
17
|
+
}
|
|
18
|
+
interface $$IsomorphicComponent {
|
|
19
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
20
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
21
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
22
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
23
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
24
|
+
}
|
|
25
|
+
declare const VirtualList: $$IsomorphicComponent;
|
|
26
|
+
type VirtualList<T> = InstanceType<typeof VirtualList<T>>;
|
|
27
|
+
export default VirtualList;
|
package/dist/select/types.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { HTMLSelectAttributes } from 'svelte/elements';
|
|
3
|
-
export interface
|
|
3
|
+
export interface SelectOption {
|
|
4
|
+
value: string | number;
|
|
5
|
+
label: string;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
selected?: boolean | undefined | null;
|
|
8
|
+
}
|
|
9
|
+
export interface SelectProps extends Omit<HTMLSelectAttributes, 'size' | 'autocomplete'> {
|
|
4
10
|
label?: string;
|
|
5
11
|
supportingText?: string;
|
|
6
12
|
error?: boolean;
|
|
@@ -10,9 +16,5 @@ export interface SelectProps extends Omit<HTMLSelectAttributes, 'multiple' | 'si
|
|
|
10
16
|
end?: Snippet;
|
|
11
17
|
noAsterisk?: boolean;
|
|
12
18
|
element?: HTMLSpanElement;
|
|
13
|
-
options:
|
|
14
|
-
value: string | number;
|
|
15
|
-
label: string;
|
|
16
|
-
selected?: boolean | undefined | null;
|
|
17
|
-
}[];
|
|
19
|
+
options: SelectOption[];
|
|
18
20
|
}
|