noph-ui 0.24.12 → 0.24.14

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,35 +66,39 @@
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
76
77
  }
77
-
78
- if (menuOpen && activeIndex >= 0) {
79
- const id = `${uid}-opt-${activeIndex}`
80
- const optEl = document.getElementById(id)
81
- if (optEl) {
82
- optEl.scrollIntoView({ block: 'nearest' })
83
- } else if (useVirtualList && menuElement) {
84
- const viewport = menuElement.querySelector(
85
- 'svelte-virtual-list-viewport',
86
- ) as HTMLElement | null
87
- if (viewport) {
88
- let rowHeight = 48
89
- const firstRow = viewport.querySelector('[id^="' + uid + '-opt-"]') as HTMLElement | null
90
- if (firstRow) {
91
- rowHeight = firstRow.offsetHeight || rowHeight
92
- }
93
- const top = activeIndex * rowHeight
94
- const bottom = top + rowHeight
95
- const { scrollTop, clientHeight } = viewport
96
- if (top < scrollTop) {
97
- viewport.scrollTop = top
98
- } else if (bottom > scrollTop + clientHeight) {
99
- viewport.scrollTop = bottom - clientHeight
100
- }
101
- }
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
102
  }
103
103
  }
104
104
  })
@@ -111,14 +111,14 @@
111
111
  aria-selected={index === activeIndex}
112
112
  role="option"
113
113
  tabindex={-1}
114
- onmousedown={(e) => {
114
+ onpointerdown={(e) => {
115
115
  e.preventDefault()
116
116
  }}
117
117
  onmouseenter={() => setActive(index)}
118
118
  onclick={(event) => {
119
119
  event.preventDefault()
120
120
  setActive(index)
121
- onoptionselect(option)
121
+ selectOption(option)
122
122
  }}
123
123
  variant="button"
124
124
  >{option.label}
@@ -138,46 +138,57 @@
138
138
  aria-controls="listbox-{uid}"
139
139
  aria-expanded={menuOpen}
140
140
  aria-autocomplete="list"
141
- aria-activedescendant={activeIndex >= 0 ? `${uid}-opt-${activeIndex}` : undefined}
141
+ aria-activedescendant={menuOpen && activeIndex >= 0 ? `${uid}-opt-${activeIndex}` : undefined}
142
142
  aria-haspopup="listbox"
143
143
  onclick={(event) => {
144
144
  finalPopulated = true
145
- showPopover()
145
+ menuElement?.showPopover()
146
146
  onclick?.(event)
147
147
  }}
148
148
  oninput={(event) => {
149
- showPopover()
150
- activeIndex = -1
149
+ menuElement?.showPopover()
150
+ activeIndex = NO_INDEX
151
151
  oninput?.(event)
152
152
  }}
153
153
  onkeydown={(event) => {
154
154
  if (event.key === 'Tab') {
155
+ menuElement?.hidePopover()
155
156
  return
156
157
  }
157
- if (event.key === 'Escape') {
158
- hidePopover()
159
- activeIndex = -1
158
+ if (event.key === 'Escape' && menuOpen) {
159
+ menuElement?.hidePopover()
160
+ activeIndex = NO_INDEX
160
161
  event.preventDefault()
161
162
  return
162
163
  }
163
164
  if (event.key === 'ArrowDown') {
164
165
  finalPopulated = true
165
- showPopover()
166
+ menuElement?.showPopover()
166
167
  moveActive(1)
167
168
  event.preventDefault()
168
169
  return
169
170
  }
171
+ if (event.key === 'Home') {
172
+ setActive(0)
173
+ event.preventDefault()
174
+ return
175
+ }
176
+ if (event.key === 'End') {
177
+ setActive(displayOptions.length - 1)
178
+ event.preventDefault()
179
+ return
180
+ }
170
181
  if (event.key === 'ArrowUp') {
171
182
  finalPopulated = true
172
- showPopover()
183
+ menuElement?.showPopover()
173
184
  moveActive(-1)
174
185
  event.preventDefault()
175
186
  return
176
187
  }
177
- if (event.key === 'Enter' && activeIndex >= 0) {
188
+ if (event.key === 'Enter' && menuOpen && activeIndex >= 0) {
178
189
  const opt = displayOptions[activeIndex]
179
190
  if (opt) {
180
- onoptionselect(opt)
191
+ selectOption(opt)
181
192
  }
182
193
  event.preventDefault()
183
194
  return
@@ -191,9 +202,7 @@
191
202
  </TextField>
192
203
  <Menu
193
204
  id="listbox-{uid}"
194
- style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
195
- ? 'width'
196
- : 'min-width'}:{clientWidth}px"
205
+ style="position-anchor:--{uid};{widthProp}:{clientWidth}px"
197
206
  role="listbox"
198
207
  class={[!displayOptions.length && 'np-auto-complete-empty']}
199
208
  --np-menu-justify-self="none"
@@ -206,15 +215,14 @@
206
215
  ontoggle={(e) => {
207
216
  if (e.newState === 'closed') {
208
217
  menuOpen = false
209
- activeIndex = -1
218
+ activeIndex = NO_INDEX
210
219
  if (!populated && finalPopulated && !value) {
211
220
  finalPopulated = false
212
221
  }
213
222
  } else {
214
223
  menuOpen = true
215
- // Ensure activeIndex valid when opening
216
224
  if (activeIndex >= displayOptions.length) {
217
- activeIndex = -1
225
+ activeIndex = NO_INDEX
218
226
  }
219
227
  }
220
228
  }}
@@ -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,64 +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
- if (el) {
169
- el.focus()
170
- } else if (useVirtualList && menuElement) {
171
- const viewport = menuElement.querySelector(
172
- 'svelte-virtual-list-viewport',
173
- ) as HTMLElement | null
174
- if (viewport) {
175
- let rowHeight = 48
176
- const firstRow = viewport.querySelector('[id^="' + uid + '-opt-"]') as HTMLElement | null
177
- if (firstRow) {
178
- rowHeight = firstRow.offsetHeight || rowHeight
179
- }
180
- const top = focusIndex * rowHeight
181
- const bottom = top + rowHeight
182
- const { scrollTop, clientHeight } = viewport
183
- if (top < scrollTop) {
184
- viewport.scrollTop = top
185
- } else if (bottom > scrollTop + clientHeight) {
186
- viewport.scrollTop = bottom - clientHeight
187
- }
188
- }
189
- }
196
+ if (el) el.focus()
197
+ else scrollOptionIntoView(focusIndex)
190
198
  }
191
199
 
192
200
  const moveFocus = (delta: number) => {
@@ -254,6 +262,23 @@
254
262
  performTypeahead('')
255
263
  }
256
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
+ }
257
282
  </script>
258
283
 
259
284
  {#snippet arrows()}
@@ -345,7 +370,6 @@
345
370
  }
346
371
  return
347
372
  }
348
- // Printable character for typeahead
349
373
  if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
350
374
  performTypeahead(key)
351
375
  return
@@ -400,18 +424,7 @@
400
424
  multiple
401
425
  {onchange}
402
426
  {oninput}
403
- oninvalid={(event) => {
404
- event.preventDefault()
405
- const { currentTarget } = event
406
- errorRaw = true
407
- doValidity = true
408
- if (errorText === '') {
409
- errorTextRaw = currentTarget.validationMessage
410
- }
411
- if (isFirstInvalidControlInForm(currentTarget.form, currentTarget)) {
412
- field?.focus()
413
- }
414
- }}
427
+ oninvalid={handleInvalid}
415
428
  bind:value
416
429
  bind:this={selectElement}
417
430
  >
@@ -430,18 +443,7 @@
430
443
  {form}
431
444
  {onchange}
432
445
  {oninput}
433
- oninvalid={(event) => {
434
- event.preventDefault()
435
- const { currentTarget } = event
436
- errorRaw = true
437
- doValidity = true
438
- if (errorText === '') {
439
- errorTextRaw = currentTarget.validationMessage
440
- }
441
- if (isFirstInvalidControlInForm(currentTarget.form, currentTarget)) {
442
- field?.focus()
443
- }
444
- }}
446
+ oninvalid={handleInvalid}
445
447
  bind:value
446
448
  bind:this={selectElement}
447
449
  >
@@ -565,9 +567,7 @@
565
567
 
566
568
  <Menu
567
569
  id="listbox-{uid}"
568
- style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
569
- ? 'width'
570
- : 'min-width'}:{clientWidth}px"
570
+ style={`position-anchor:--${uid};${widthProp}:${clientWidth}px`}
571
571
  role="listbox"
572
572
  aria-multiselectable={multiple}
573
573
  --np-menu-justify-self="none"
@@ -608,7 +608,8 @@
608
608
  rendered={({ start, end }) => {
609
609
  if (focusIndex >= start && focusIndex < end) {
610
610
  const el = document.getElementById(`${uid}-opt-${focusIndex}`)
611
- el?.focus()
611
+ if (el) el.focus()
612
+ else scrollOptionIntoView(focusIndex)
612
613
  }
613
614
  }}
614
615
  >
@@ -170,6 +170,7 @@
170
170
  bind:this={viewport}
171
171
  bind:offsetHeight={viewport_height}
172
172
  onscroll={handle_scroll}
173
+ tabindex="-1"
173
174
  style="height: {height};"
174
175
  >
175
176
  <div bind:this={contents} style="padding-top: {top}px; padding-bottom: {bottom}px;">
@@ -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.12",
3
+ "version": "0.24.14",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {