noph-ui 0.24.12 → 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,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,56 @@
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
155
  return
156
156
  }
157
- if (event.key === 'Escape') {
158
- hidePopover()
159
- activeIndex = -1
157
+ if (event.key === 'Escape' && menuOpen) {
158
+ menuElement?.hidePopover()
159
+ activeIndex = NO_INDEX
160
160
  event.preventDefault()
161
161
  return
162
162
  }
163
163
  if (event.key === 'ArrowDown') {
164
164
  finalPopulated = true
165
- showPopover()
165
+ menuElement?.showPopover()
166
166
  moveActive(1)
167
167
  event.preventDefault()
168
168
  return
169
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
+ }
170
180
  if (event.key === 'ArrowUp') {
171
181
  finalPopulated = true
172
- showPopover()
182
+ menuElement?.showPopover()
173
183
  moveActive(-1)
174
184
  event.preventDefault()
175
185
  return
176
186
  }
177
- if (event.key === 'Enter' && activeIndex >= 0) {
187
+ if (event.key === 'Enter' && menuOpen && activeIndex >= 0) {
178
188
  const opt = displayOptions[activeIndex]
179
189
  if (opt) {
180
- onoptionselect(opt)
190
+ selectOption(opt)
181
191
  }
182
192
  event.preventDefault()
183
193
  return
@@ -191,9 +201,7 @@
191
201
  </TextField>
192
202
  <Menu
193
203
  id="listbox-{uid}"
194
- style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
195
- ? 'width'
196
- : 'min-width'}:{clientWidth}px"
204
+ style="position-anchor:--{uid};{widthProp}:{clientWidth}px"
197
205
  role="listbox"
198
206
  class={[!displayOptions.length && 'np-auto-complete-empty']}
199
207
  --np-menu-justify-self="none"
@@ -206,15 +214,14 @@
206
214
  ontoggle={(e) => {
207
215
  if (e.newState === 'closed') {
208
216
  menuOpen = false
209
- activeIndex = -1
217
+ activeIndex = NO_INDEX
210
218
  if (!populated && finalPopulated && !value) {
211
219
  finalPopulated = false
212
220
  }
213
221
  } else {
214
222
  menuOpen = true
215
- // Ensure activeIndex valid when opening
216
223
  if (activeIndex >= displayOptions.length) {
217
- activeIndex = -1
224
+ activeIndex = NO_INDEX
218
225
  }
219
226
  }
220
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,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
  >
@@ -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.13",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {