noph-ui 0.24.11 → 0.24.13

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.
@@ -16,9 +16,7 @@
16
16
  clampMenuWidth = false,
17
17
  children,
18
18
  optionsFilter,
19
- showPopover = $bindable(),
20
- hidePopover = $bindable(),
21
- onoptionselect = (option) => {
19
+ onoptionselect = (option: AutoCompleteOption) => {
22
20
  value = option.label
23
21
  finalPopulated = populated
24
22
  menuElement?.hidePopover()
@@ -26,43 +24,41 @@
26
24
  onkeydown,
27
25
  onclick,
28
26
  oninput,
27
+ virtualThreshold = 300,
29
28
  ...attributes
30
29
  }: AutoCompleteProps = $props()
31
30
 
32
- showPopover = () => {
33
- menuElement?.showPopover()
34
- }
35
-
36
- hidePopover = () => {
37
- menuElement?.hidePopover()
38
- }
39
31
  const uid = $props.id()
40
- const defaultOptionsFilter = (option: AutoCompleteOption) => {
41
- return !value || option.label.toLocaleLowerCase().includes(value.toLocaleLowerCase())
42
- }
43
- let displayOptions = $derived(options.filter(optionsFilter || defaultOptionsFilter))
44
- let useVirtualList = $derived(displayOptions.length > 4000)
32
+ const query = $derived(value ? value.toLocaleLowerCase() : '')
33
+ const filterFn = $derived(
34
+ optionsFilter ||
35
+ ((option: AutoCompleteOption) => !query || option.label.toLocaleLowerCase().includes(query)),
36
+ )
37
+ const NO_INDEX = -1
38
+ let displayOptions = $derived(query === '' && !optionsFilter ? options : options.filter(filterFn))
39
+ let useVirtualList = $derived(displayOptions.length > virtualThreshold)
40
+ let widthProp = $derived(clampMenuWidth || useVirtualList ? 'width' : 'min-width')
45
41
  let clientWidth = $state(0)
46
42
  let menuElement = $state<HTMLDivElement>()
47
43
  let menuOpen = $state(false)
48
44
  let finalPopulated = $state(populated)
49
- let activeIndex = $state(-1)
45
+ let activeIndex = $state(NO_INDEX)
50
46
 
51
- function setActive(index: number) {
47
+ const setActive = (index: number) => {
52
48
  if (index < 0 || index >= displayOptions.length) {
53
- activeIndex = -1
49
+ activeIndex = NO_INDEX
54
50
  return
55
51
  }
56
52
  activeIndex = index
57
53
  }
58
54
 
59
- function moveActive(delta: number) {
55
+ const moveActive = (delta: number) => {
60
56
  if (!displayOptions.length) {
61
- activeIndex = -1
57
+ activeIndex = NO_INDEX
62
58
  return
63
59
  }
64
60
  const next =
65
- activeIndex === -1
61
+ activeIndex === NO_INDEX
66
62
  ? delta > 0
67
63
  ? 0
68
64
  : displayOptions.length - 1
@@ -70,9 +66,40 @@
70
66
  setActive(next)
71
67
  }
72
68
 
69
+ const selectOption = (option: AutoCompleteOption) => {
70
+ onoptionselect(option)
71
+ }
72
+
73
73
  $effect(() => {
74
74
  if (activeIndex >= displayOptions.length) {
75
- activeIndex = -1
75
+ activeIndex = NO_INDEX
76
+ return
77
+ }
78
+ if (!menuOpen || activeIndex < 0) return
79
+ const id = `${uid}-opt-${activeIndex}`
80
+ const optEl = document.getElementById(id)
81
+ if (optEl) {
82
+ optEl.scrollIntoView({ block: 'nearest' })
83
+ return
84
+ }
85
+ if (useVirtualList && menuElement) {
86
+ const viewport = menuElement.querySelector(
87
+ 'svelte-virtual-list-viewport',
88
+ ) as HTMLElement | null
89
+ if (!viewport) return
90
+ let rowHeight = 48
91
+ const firstRow = viewport.querySelector('[id^="' + uid + '-opt-"]') as HTMLElement | null
92
+ if (firstRow) {
93
+ rowHeight = firstRow.offsetHeight || rowHeight
94
+ }
95
+ const top = activeIndex * rowHeight
96
+ const bottom = top + rowHeight
97
+ const { scrollTop, clientHeight } = viewport
98
+ if (top < scrollTop) {
99
+ viewport.scrollTop = top
100
+ } else if (bottom > scrollTop + clientHeight) {
101
+ viewport.scrollTop = bottom - clientHeight
102
+ }
76
103
  }
77
104
  })
78
105
  </script>
@@ -83,14 +110,15 @@
83
110
  softFocus={index === activeIndex}
84
111
  aria-selected={index === activeIndex}
85
112
  role="option"
86
- onmousedown={(e) => {
113
+ tabindex={-1}
114
+ onpointerdown={(e) => {
87
115
  e.preventDefault()
88
116
  }}
89
117
  onmouseenter={() => setActive(index)}
90
118
  onclick={(event) => {
91
119
  event.preventDefault()
92
120
  setActive(index)
93
- onoptionselect(option)
121
+ selectOption(option)
94
122
  }}
95
123
  variant="button"
96
124
  >{option.label}
@@ -110,46 +138,56 @@
110
138
  aria-controls="listbox-{uid}"
111
139
  aria-expanded={menuOpen}
112
140
  aria-autocomplete="list"
113
- aria-activedescendant={activeIndex >= 0 ? `${uid}-opt-${activeIndex}` : undefined}
141
+ aria-activedescendant={menuOpen && activeIndex >= 0 ? `${uid}-opt-${activeIndex}` : undefined}
114
142
  aria-haspopup="listbox"
115
143
  onclick={(event) => {
116
144
  finalPopulated = true
117
- showPopover()
145
+ menuElement?.showPopover()
118
146
  onclick?.(event)
119
147
  }}
120
148
  oninput={(event) => {
121
- showPopover()
122
- activeIndex = -1
149
+ menuElement?.showPopover()
150
+ activeIndex = NO_INDEX
123
151
  oninput?.(event)
124
152
  }}
125
153
  onkeydown={(event) => {
126
154
  if (event.key === 'Tab') {
127
155
  return
128
156
  }
129
- if (event.key === 'Escape') {
130
- hidePopover()
131
- activeIndex = -1
157
+ if (event.key === 'Escape' && menuOpen) {
158
+ menuElement?.hidePopover()
159
+ activeIndex = NO_INDEX
132
160
  event.preventDefault()
133
161
  return
134
162
  }
135
163
  if (event.key === 'ArrowDown') {
136
164
  finalPopulated = true
137
- showPopover()
165
+ menuElement?.showPopover()
138
166
  moveActive(1)
139
167
  event.preventDefault()
140
168
  return
141
169
  }
170
+ if (event.key === 'Home') {
171
+ setActive(0)
172
+ event.preventDefault()
173
+ return
174
+ }
175
+ if (event.key === 'End') {
176
+ setActive(displayOptions.length - 1)
177
+ event.preventDefault()
178
+ return
179
+ }
142
180
  if (event.key === 'ArrowUp') {
143
181
  finalPopulated = true
144
- showPopover()
182
+ menuElement?.showPopover()
145
183
  moveActive(-1)
146
184
  event.preventDefault()
147
185
  return
148
186
  }
149
- if (event.key === 'Enter' && activeIndex >= 0) {
187
+ if (event.key === 'Enter' && menuOpen && activeIndex >= 0) {
150
188
  const opt = displayOptions[activeIndex]
151
189
  if (opt) {
152
- onoptionselect(opt)
190
+ selectOption(opt)
153
191
  }
154
192
  event.preventDefault()
155
193
  return
@@ -163,9 +201,7 @@
163
201
  </TextField>
164
202
  <Menu
165
203
  id="listbox-{uid}"
166
- style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
167
- ? 'width'
168
- : 'min-width'}:{clientWidth}px"
204
+ style="position-anchor:--{uid};{widthProp}:{clientWidth}px"
169
205
  role="listbox"
170
206
  class={[!displayOptions.length && 'np-auto-complete-empty']}
171
207
  --np-menu-justify-self="none"
@@ -178,15 +214,14 @@
178
214
  ontoggle={(e) => {
179
215
  if (e.newState === 'closed') {
180
216
  menuOpen = false
181
- activeIndex = -1
217
+ activeIndex = NO_INDEX
182
218
  if (!populated && finalPopulated && !value) {
183
219
  finalPopulated = false
184
220
  }
185
221
  } else {
186
222
  menuOpen = true
187
- // Ensure activeIndex valid when opening
188
223
  if (activeIndex >= displayOptions.length) {
189
- activeIndex = -1
224
+ activeIndex = NO_INDEX
190
225
  }
191
226
  }
192
227
  }}
@@ -1,4 +1,4 @@
1
1
  import type { AutoCompleteProps } from './types.ts';
2
- declare const AutoComplete: import("svelte").Component<AutoCompleteProps, {}, "element" | "value" | "showPopover" | "hidePopover" | "reportValidity" | "checkValidity">;
2
+ declare const AutoComplete: import("svelte").Component<AutoCompleteProps, {}, "element" | "value" | "reportValidity" | "checkValidity">;
3
3
  type AutoComplete = ReturnType<typeof AutoComplete>;
4
4
  export default AutoComplete;
@@ -10,4 +10,5 @@ export interface AutoCompleteProps extends Omit<InputFieldProps, 'clientWidth' |
10
10
  clampMenuWidth?: boolean;
11
11
  showPopover?: () => void;
12
12
  hidePopover?: () => void;
13
+ virtualThreshold?: number;
13
14
  }
@@ -74,7 +74,7 @@
74
74
  size="xs"
75
75
  --np-icon-button-icon-size="1.125rem"
76
76
  aria-label={ariaLabelRemove}
77
- onpointerup={(
77
+ onclick={(
78
78
  event: MouseEvent & {
79
79
  currentTarget: EventTarget & HTMLButtonElement
80
80
  },
@@ -82,7 +82,7 @@
82
82
  }
83
83
 
84
84
  $effect(() => {
85
- if (element) {
85
+ if (element && !('anchorName' in document.documentElement.style)) {
86
86
  getScrollableParent(element).addEventListener('scroll', onScroll, { passive: true })
87
87
  }
88
88
  return () => {
@@ -32,6 +32,7 @@
32
32
  reportValidity = $bindable(),
33
33
  checkValidity = $bindable(),
34
34
  multiple,
35
+ virtualThreshold = 300,
35
36
  clampMenuWidth = false,
36
37
  ...attributes
37
38
  }: SelectProps = $props()
@@ -45,17 +46,18 @@
45
46
  value = options.find((option) => option.selected)?.value
46
47
  }
47
48
  }
49
+
50
+ let valueArray = $derived<unknown[]>(
51
+ Array.isArray(value) ? value : value === undefined || value === null ? [] : [value],
52
+ )
53
+ let selectedSet = $derived.by<Set<unknown>>(() => new Set(valueArray))
48
54
  let selectedOption: SelectOption[] = $derived(
49
- options
50
- .filter(
51
- (option) =>
52
- option.selected ||
53
- (Array.isArray(value) ? value.includes(option.value) : value === option.value),
54
- )
55
- .map((option) => ({ ...option, selected: true })),
55
+ options.filter((o) => selectedSet.has(o.value)).map((o) => ({ ...o, selected: true })),
56
56
  )
57
57
 
58
- let useVirtualList = $derived(options.length > 4000)
58
+ let useVirtualList = $derived(options.length > virtualThreshold)
59
+
60
+ let widthProp = $derived(clampMenuWidth || useVirtualList ? 'width' : 'min-width')
59
61
 
60
62
  let errorTextRaw: string = $state(errorText)
61
63
  let errorRaw = $state(error)
@@ -129,43 +131,70 @@
129
131
  }
130
132
  }
131
133
  })
132
- const handleOptionSelect = (event: Event, option: SelectOption) => {
134
+
135
+ let cachedRowHeight = 0
136
+ const ensureRowHeight = () => {
137
+ if (!cachedRowHeight && menuElement) {
138
+ const viewport = menuElement.querySelector(
139
+ 'svelte-virtual-list-viewport',
140
+ ) as HTMLElement | null
141
+ if (viewport) {
142
+ const firstRow = viewport.querySelector('[id^="' + uid + '-opt-"]') as HTMLElement | null
143
+ cachedRowHeight = firstRow?.offsetHeight || 48
144
+ }
145
+ }
146
+ if (!cachedRowHeight) cachedRowHeight = 48
147
+ return cachedRowHeight
148
+ }
149
+ const scrollOptionIntoView = (index: number) => {
150
+ if (!useVirtualList || !menuElement) return
151
+ const viewport = menuElement.querySelector('svelte-virtual-list-viewport') as HTMLElement | null
152
+ if (!viewport) return
153
+ const rowHeight = ensureRowHeight()
154
+ const top = index * rowHeight
155
+ const bottom = top + rowHeight
156
+ const { scrollTop, clientHeight } = viewport
157
+ if (top < scrollTop) viewport.scrollTop = top
158
+ else if (bottom > scrollTop + clientHeight) viewport.scrollTop = bottom - clientHeight
159
+ }
160
+
161
+ const finalizeSelection = async () => {
162
+ await tick()
163
+ if (doValidity && checkValidity()) {
164
+ errorRaw = error
165
+ errorTextRaw = errorText
166
+ }
167
+ selectElement?.dispatchEvent(new Event('change', { bubbles: true }))
168
+ }
169
+ const toggleValue = (option: SelectOption) => {
133
170
  if (multiple) {
134
- if (Array.isArray(value)) {
135
- if (value.includes(option.value)) {
136
- selectedOption = selectedOption.filter((v) => v.value !== option.value)
137
- value = value.filter((v) => v !== option.value)
138
- } else {
139
- selectedOption = [...selectedOption, option]
140
- value = [...value, option.value]
141
- }
171
+ let arr = Array.isArray(value) ? [...value] : []
172
+ const idx = arr.indexOf(option.value)
173
+ if (idx !== -1) {
174
+ arr.splice(idx, 1)
142
175
  } else {
143
- selectedOption = [option]
144
- value = [option.value]
176
+ arr.push(option.value)
145
177
  }
178
+ value = arr
146
179
  } else {
147
- selectedOption = [option]
148
180
  value = option.value
149
- menuElement?.hidePopover()
150
181
  }
182
+ }
183
+ const handleOptionSelect = async (event: Event, option: SelectOption) => {
184
+ if (option.disabled) return
185
+ toggleValue(option)
186
+ if (!multiple) menuElement?.hidePopover()
151
187
  event.preventDefault()
152
- tick().then(() => {
153
- if (doValidity && checkValidity()) {
154
- errorRaw = error
155
- errorTextRaw = errorText
156
- }
157
- selectElement?.dispatchEvent(new Event('change', { bubbles: true }))
158
- })
188
+ await finalizeSelection()
159
189
  }
160
190
 
161
191
  const openMenuAndFocus = async (index: number) => {
162
- if (!menuOpen) {
163
- menuElement?.showPopover()
164
- }
192
+ if (!menuOpen) menuElement?.showPopover()
165
193
  focusIndex = Math.min(Math.max(index, 0), options.length - 1)
166
194
  await tick()
167
195
  const el = document.getElementById(`${uid}-opt-${focusIndex}`)
168
- ;(el as HTMLElement | null)?.focus?.()
196
+ if (el) el.focus()
197
+ else scrollOptionIntoView(focusIndex)
169
198
  }
170
199
 
171
200
  const moveFocus = (delta: number) => {
@@ -233,6 +262,23 @@
233
262
  performTypeahead('')
234
263
  }
235
264
  }
265
+
266
+ const handleInvalid = (
267
+ event: Event & {
268
+ currentTarget: EventTarget & HTMLSelectElement
269
+ },
270
+ ) => {
271
+ event.preventDefault()
272
+ const { currentTarget } = event
273
+ errorRaw = true
274
+ doValidity = true
275
+ if (errorText === '') {
276
+ errorTextRaw = currentTarget.validationMessage
277
+ }
278
+ if (isFirstInvalidControlInForm(currentTarget.form, currentTarget)) {
279
+ field?.focus()
280
+ }
281
+ }
236
282
  </script>
237
283
 
238
284
  {#snippet arrows()}
@@ -324,7 +370,6 @@
324
370
  }
325
371
  return
326
372
  }
327
- // Printable character for typeahead
328
373
  if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
329
374
  performTypeahead(key)
330
375
  return
@@ -379,18 +424,7 @@
379
424
  multiple
380
425
  {onchange}
381
426
  {oninput}
382
- oninvalid={(event) => {
383
- event.preventDefault()
384
- const { currentTarget } = event
385
- errorRaw = true
386
- doValidity = true
387
- if (errorText === '') {
388
- errorTextRaw = currentTarget.validationMessage
389
- }
390
- if (isFirstInvalidControlInForm(currentTarget.form, currentTarget)) {
391
- field?.focus()
392
- }
393
- }}
427
+ oninvalid={handleInvalid}
394
428
  bind:value
395
429
  bind:this={selectElement}
396
430
  >
@@ -409,18 +443,7 @@
409
443
  {form}
410
444
  {onchange}
411
445
  {oninput}
412
- oninvalid={(event) => {
413
- event.preventDefault()
414
- const { currentTarget } = event
415
- errorRaw = true
416
- doValidity = true
417
- if (errorText === '') {
418
- errorTextRaw = currentTarget.validationMessage
419
- }
420
- if (isFirstInvalidControlInForm(currentTarget.form, currentTarget)) {
421
- field?.focus()
422
- }
423
- }}
446
+ oninvalid={handleInvalid}
424
447
  bind:value
425
448
  bind:this={selectElement}
426
449
  >
@@ -544,9 +567,7 @@
544
567
 
545
568
  <Menu
546
569
  id="listbox-{uid}"
547
- style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
548
- ? 'width'
549
- : 'min-width'}:{clientWidth}px"
570
+ style={`position-anchor:--${uid};${widthProp}:${clientWidth}px`}
550
571
  role="listbox"
551
572
  aria-multiselectable={multiple}
552
573
  --np-menu-justify-self="none"
@@ -572,9 +593,6 @@
572
593
  }
573
594
  if (idx < 0) idx = 0
574
595
  focusIndex = idx
575
- await tick()
576
- const el = document.getElementById(`${uid}-opt-${focusIndex}`)
577
- ;(el as HTMLElement | null)?.focus?.()
578
596
  } else {
579
597
  menuOpen = false
580
598
  focusIndex = -1
@@ -583,7 +601,18 @@
583
601
  bind:element={menuElement}
584
602
  >
585
603
  {#if useVirtualList}
586
- <VirtualList height="250px" itemHeight={48} items={options}>
604
+ <VirtualList
605
+ height="250px"
606
+ itemHeight={48}
607
+ items={options}
608
+ rendered={({ start, end }) => {
609
+ if (focusIndex >= start && focusIndex < end) {
610
+ const el = document.getElementById(`${uid}-opt-${focusIndex}`)
611
+ if (el) el.focus()
612
+ else scrollOptionIntoView(focusIndex)
613
+ }
614
+ }}
615
+ >
587
616
  {#snippet row(option, index)}
588
617
  {@render item(option, index)}
589
618
  {/snippet}
@@ -10,6 +10,7 @@
10
10
  end?: number
11
11
  row: Snippet<[T, number]>
12
12
  overscan?: number
13
+ rendered?: (event: { start: number; end: number }) => void
13
14
  }
14
15
 
15
16
  let {
@@ -19,7 +20,8 @@
19
20
  start = $bindable(0),
20
21
  end = $bindable(0),
21
22
  row,
22
- overscan = 4,
23
+ overscan = 0,
24
+ rendered,
23
25
  }: VirtualListProps = $props()
24
26
 
25
27
  let height_map: number[] = []
@@ -79,6 +81,8 @@
79
81
 
80
82
  bottom = remaining * average_height
81
83
  height_map.length = items.length
84
+
85
+ rendered?.({ start, end })
82
86
  }
83
87
 
84
88
  async function handle_scroll() {
@@ -151,6 +155,8 @@
151
155
  const d = actual_height - expected_height
152
156
  viewport.scrollTo(0, scrollTop + d)
153
157
  }
158
+ await tick()
159
+ rendered?.({ start, end })
154
160
  }
155
161
 
156
162
  onMount(() => {
@@ -9,6 +9,10 @@ declare function $$render<T>(): {
9
9
  end?: number;
10
10
  row: Snippet<[T, number]>;
11
11
  overscan?: number;
12
+ rendered?: (event: {
13
+ start: number;
14
+ end: number;
15
+ }) => void;
12
16
  };
13
17
  exports: {};
14
18
  bindings: "start" | "end";
@@ -20,4 +20,5 @@ export interface SelectProps extends Omit<HTMLSelectAttributes, 'size' | 'autoco
20
20
  clampMenuWidth?: boolean;
21
21
  reportValidity?: () => boolean;
22
22
  checkValidity?: () => boolean;
23
+ virtualThreshold?: number;
23
24
  }
@@ -222,12 +222,11 @@
222
222
  ></textarea>
223
223
  {:else}
224
224
  <div class="input-wrapper">
225
- {#if prefixText}
226
- <span class="prefix">
227
- {prefixText}
225
+ {#if suffixText}
226
+ <span class="suffix">
227
+ {suffixText}
228
228
  </span>
229
229
  {/if}
230
- {@render children?.()}
231
230
  <input
232
231
  aria-describedby={supportingText || (errorTextRaw && errorRaw)
233
232
  ? `supporting-text-${uid}`
@@ -242,9 +241,10 @@
242
241
  class="input"
243
242
  aria-invalid={errorRaw}
244
243
  />
245
- {#if suffixText}
246
- <span class="suffix">
247
- {suffixText}
244
+ {@render children?.()}
245
+ {#if prefixText}
246
+ <span class="prefix">
247
+ {prefixText}
248
248
  </span>
249
249
  {/if}
250
250
  </div>
@@ -444,6 +444,7 @@
444
444
  flex-wrap: wrap;
445
445
  align-items: baseline;
446
446
  min-width: 0;
447
+ flex-direction: row-reverse;
447
448
  }
448
449
 
449
450
  .input-wrapper > * {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.24.11",
3
+ "version": "0.24.13",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {