noph-ui 0.24.10 → 0.24.12

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/README.md CHANGED
@@ -71,6 +71,7 @@ Beta (No breaking changes expected)
71
71
 
72
72
  In progress (Breaking changes expected)
73
73
 
74
+ - Auto complete
74
75
  - Chips (Docs missing)
75
76
  - Dialogs (Fullscreen + Docs missing)
76
77
  - Menus (Positioning missing + Docs missing)
@@ -26,9 +26,6 @@
26
26
  onkeydown,
27
27
  onclick,
28
28
  oninput,
29
- onblur,
30
- onfocusout,
31
- focused = $bindable(false),
32
29
  ...attributes
33
30
  }: AutoCompleteProps = $props()
34
31
 
@@ -40,61 +37,109 @@
40
37
  menuElement?.hidePopover()
41
38
  }
42
39
  const uid = $props.id()
43
- let defaultOptionsFilter = (option: AutoCompleteOption) => {
40
+ const defaultOptionsFilter = (option: AutoCompleteOption) => {
44
41
  return !value || option.label.toLocaleLowerCase().includes(value.toLocaleLowerCase())
45
42
  }
46
43
  let displayOptions = $derived(options.filter(optionsFilter || defaultOptionsFilter))
47
44
  let useVirtualList = $derived(displayOptions.length > 4000)
48
45
  let clientWidth = $state(0)
49
- let menuElement: HTMLDivElement | undefined = $state()
46
+ let menuElement = $state<HTMLDivElement>()
47
+ let menuOpen = $state(false)
50
48
  let finalPopulated = $state(populated)
51
- let blockEvent = $state(false)
49
+ let activeIndex = $state(-1)
50
+
51
+ function setActive(index: number) {
52
+ if (index < 0 || index >= displayOptions.length) {
53
+ activeIndex = -1
54
+ return
55
+ }
56
+ activeIndex = index
57
+ }
58
+
59
+ function moveActive(delta: number) {
60
+ if (!displayOptions.length) {
61
+ activeIndex = -1
62
+ return
63
+ }
64
+ const next =
65
+ activeIndex === -1
66
+ ? delta > 0
67
+ ? 0
68
+ : displayOptions.length - 1
69
+ : (activeIndex + delta + displayOptions.length) % displayOptions.length
70
+ setActive(next)
71
+ }
72
+
73
+ $effect(() => {
74
+ if (activeIndex >= displayOptions.length) {
75
+ activeIndex = -1
76
+ }
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
+ }
102
+ }
103
+ }
104
+ })
52
105
  </script>
53
106
 
54
- {#snippet item(option: AutoCompleteOption)}
107
+ {#snippet item(option: AutoCompleteOption, index: number)}
55
108
  <Item
109
+ id="{uid}-opt-{index}"
110
+ softFocus={index === activeIndex}
111
+ aria-selected={index === activeIndex}
112
+ role="option"
113
+ tabindex={-1}
114
+ onmousedown={(e) => {
115
+ e.preventDefault()
116
+ }}
117
+ onmouseenter={() => setActive(index)}
56
118
  onclick={(event) => {
57
119
  event.preventDefault()
58
- element?.focus()
120
+ setActive(index)
59
121
  onoptionselect(option)
60
122
  }}
61
- role="option"
62
- disabled={option.disabled}
63
- onkeydown={(event) => {
64
- if (event.key === 'ArrowDown') {
65
- blockEvent = true
66
- ;(event.currentTarget?.nextElementSibling as HTMLElement)?.focus()
67
- event.preventDefault()
68
- }
69
- if (event.key === 'ArrowUp') {
70
- blockEvent = true
71
- ;(event.currentTarget?.previousElementSibling as HTMLElement)?.focus()
72
- event.preventDefault()
73
- }
74
- if (event.key === 'Enter') {
75
- onoptionselect(option)
76
- }
77
- if (event.key === 'Tab') {
78
- finalPopulated = populated
79
- blockEvent = false
80
- hidePopover?.()
81
- }
82
- }}
83
123
  variant="button"
84
124
  >{option.label}
85
125
  </Item>
86
126
  {/snippet}
87
127
 
88
128
  <TextField
89
- autocomplete="off"
90
129
  {...attributes}
130
+ autocomplete="off"
91
131
  {variant}
92
132
  type="text"
93
133
  populated={finalPopulated}
94
134
  bind:clientWidth
95
135
  bind:value
96
- bind:focused
97
136
  style="anchor-name:--{uid};"
137
+ role="combobox"
138
+ aria-controls="listbox-{uid}"
139
+ aria-expanded={menuOpen}
140
+ aria-autocomplete="list"
141
+ aria-activedescendant={activeIndex >= 0 ? `${uid}-opt-${activeIndex}` : undefined}
142
+ aria-haspopup="listbox"
98
143
  onclick={(event) => {
99
144
  finalPopulated = true
100
145
  showPopover()
@@ -102,32 +147,42 @@
102
147
  }}
103
148
  oninput={(event) => {
104
149
  showPopover()
150
+ activeIndex = -1
105
151
  oninput?.(event)
106
152
  }}
107
153
  onkeydown={(event) => {
108
- if (event.key === 'Tab' || event.key === 'Escape') {
109
- blockEvent = false
154
+ if (event.key === 'Tab') {
155
+ return
156
+ }
157
+ if (event.key === 'Escape') {
110
158
  hidePopover()
111
- } else {
112
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
113
- event.preventDefault()
114
- finalPopulated = true
115
- blockEvent = true
116
- showPopover()
117
- ;(menuElement?.firstElementChild?.firstElementChild as HTMLElement)?.focus()
118
- }
159
+ activeIndex = -1
160
+ event.preventDefault()
161
+ return
119
162
  }
120
- onkeydown?.(event)
121
- }}
122
- onblur={(event) => {
123
- if (!blockEvent) {
124
- onblur?.(event)
163
+ if (event.key === 'ArrowDown') {
164
+ finalPopulated = true
165
+ showPopover()
166
+ moveActive(1)
167
+ event.preventDefault()
168
+ return
125
169
  }
126
- }}
127
- onfocusout={(event) => {
128
- if (!blockEvent) {
129
- onfocusout?.(event)
170
+ if (event.key === 'ArrowUp') {
171
+ finalPopulated = true
172
+ showPopover()
173
+ moveActive(-1)
174
+ event.preventDefault()
175
+ return
130
176
  }
177
+ if (event.key === 'Enter' && activeIndex >= 0) {
178
+ const opt = displayOptions[activeIndex]
179
+ if (opt) {
180
+ onoptionselect(opt)
181
+ }
182
+ event.preventDefault()
183
+ return
184
+ }
185
+ onkeydown?.(event)
131
186
  }}
132
187
  bind:reportValidity
133
188
  bind:checkValidity
@@ -135,6 +190,7 @@
135
190
  >{@render children?.()}
136
191
  </TextField>
137
192
  <Menu
193
+ id="listbox-{uid}"
138
194
  style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
139
195
  ? 'width'
140
196
  : 'min-width'}:{clientWidth}px"
@@ -147,38 +203,32 @@
147
203
  ? 'var(--np-outlined-select-text-field-container-shape)'
148
204
  : 'var(--np-filled-select-text-field-container-shape)'}
149
205
  anchor={element}
150
- onbeforetoggle={(e) => {
151
- if (e.newState !== 'closed') {
152
- blockEvent = true
153
- }
154
- }}
155
206
  ontoggle={(e) => {
156
207
  if (e.newState === 'closed') {
157
- blockEvent = false
208
+ menuOpen = false
209
+ activeIndex = -1
158
210
  if (!populated && finalPopulated && !value) {
159
211
  finalPopulated = false
160
212
  }
161
- }
162
- if (!focused) {
163
- const event = {
164
- ...new FocusEvent('blur', { relatedTarget: element }),
165
- currentTarget: element as EventTarget & HTMLInputElement,
166
- } as FocusEvent & { currentTarget: EventTarget & HTMLInputElement }
167
- onblur?.(event)
168
- onfocusout?.(event)
213
+ } else {
214
+ menuOpen = true
215
+ // Ensure activeIndex valid when opening
216
+ if (activeIndex >= displayOptions.length) {
217
+ activeIndex = -1
218
+ }
169
219
  }
170
220
  }}
171
221
  bind:element={menuElement}
172
222
  >
173
223
  {#if useVirtualList}
174
224
  <VirtualList height="250px" itemHeight={48} items={displayOptions}>
175
- {#snippet row(option)}
176
- {@render item(option)}
225
+ {#snippet row(option, index)}
226
+ {@render item(option, index)}
177
227
  {/snippet}
178
228
  </VirtualList>
179
229
  {:else}
180
230
  {#each displayOptions as option, index (index)}
181
- {@render item(option)}
231
+ {@render item(option, index)}
182
232
  {/each}
183
233
  {/if}
184
234
  </Menu>
@@ -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" | "focused">;
2
+ declare const AutoComplete: import("svelte").Component<AutoCompleteProps, {}, "element" | "value" | "showPopover" | "hidePopover" | "reportValidity" | "checkValidity">;
3
3
  type AutoComplete = ReturnType<typeof AutoComplete>;
4
4
  export default AutoComplete;
@@ -2,8 +2,6 @@ import type { InputFieldProps } from '../types.ts';
2
2
  export interface AutoCompleteOption {
3
3
  value?: string | number;
4
4
  label: string;
5
- disabled?: boolean;
6
- selected?: boolean | undefined | null;
7
5
  }
8
6
  export interface AutoCompleteProps extends Omit<InputFieldProps, 'clientWidth' | 'clientHeight'> {
9
7
  options: AutoCompleteOption[];
@@ -13,10 +13,11 @@
13
13
  disabled = false,
14
14
  onfocus,
15
15
  onblur,
16
+ softFocus = false,
16
17
  ...attributes
17
18
  }: ItemProps = $props()
18
19
 
19
- let focused = $state(false)
20
+ let focused = $derived(softFocus)
20
21
  let visible = $state(false)
21
22
  let element: HTMLButtonElement | HTMLAnchorElement | HTMLDivElement | undefined = $state()
22
23
  let observer: IntersectionObserver | undefined
@@ -6,6 +6,7 @@ interface ButtonProps extends HTMLButtonAttributes {
6
6
  end?: Snippet;
7
7
  variant: 'button';
8
8
  supportingText?: Snippet;
9
+ softFocus?: boolean;
9
10
  }
10
11
  interface AnchorProps extends HTMLAnchorAttributes {
11
12
  selected?: boolean;
@@ -14,6 +15,7 @@ interface AnchorProps extends HTMLAnchorAttributes {
14
15
  disabled?: boolean;
15
16
  variant: 'link';
16
17
  supportingText?: Snippet;
18
+ softFocus?: boolean;
17
19
  }
18
20
  interface TextProps extends HTMLAttributes<HTMLDivElement> {
19
21
  selected?: boolean;
@@ -22,6 +24,7 @@ interface TextProps extends HTMLAttributes<HTMLDivElement> {
22
24
  disabled?: boolean;
23
25
  variant?: 'text';
24
26
  supportingText?: Snippet;
27
+ softFocus?: boolean;
25
28
  }
26
29
  export type ItemProps = ButtonProps | AnchorProps | TextProps;
27
30
  export type ListItemProps = ButtonProps | AnchorProps | TextProps;
@@ -165,7 +165,28 @@
165
165
  focusIndex = Math.min(Math.max(index, 0), options.length - 1)
166
166
  await tick()
167
167
  const el = document.getElementById(`${uid}-opt-${focusIndex}`)
168
- ;(el as HTMLElement | null)?.focus?.()
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
+ }
169
190
  }
170
191
 
171
192
  const moveFocus = (delta: number) => {
@@ -457,14 +478,15 @@
457
478
  </div>
458
479
  </div>
459
480
 
460
- {#snippet item(option: SelectOption, index?: number)}
481
+ {#snippet item(option: SelectOption, index: number)}
461
482
  {#if Array.isArray(value) && multiple}
462
483
  <Item
463
- id={typeof index === 'number' ? `${uid}-opt-${index}` : undefined}
484
+ id="{uid}-opt-{index}"
464
485
  onclick={(event) => {
465
486
  handleOptionSelect(event, option)
466
487
  field?.focus()
467
488
  }}
489
+ tabindex={-1}
468
490
  disabled={option.disabled}
469
491
  aria-disabled={option.disabled}
470
492
  role="option"
@@ -501,11 +523,12 @@
501
523
  </Item>
502
524
  {:else}
503
525
  <Item
504
- id={typeof index === 'number' ? `${uid}-opt-${index}` : undefined}
526
+ id="{uid}-opt-{index}"
505
527
  onclick={(event) => {
506
528
  handleOptionSelect(event, option)
507
529
  field?.focus()
508
530
  }}
531
+ tabindex={-1}
509
532
  disabled={option.disabled}
510
533
  aria-disabled={option.disabled}
511
534
  role="option"
@@ -554,17 +577,41 @@
554
577
  ? 'var(--np-outlined-select-text-field-container-shape)'
555
578
  : 'var(--np-filled-select-text-field-container-shape)'}
556
579
  anchor={anchorElement}
557
- ontoggle={({ newState }) => {
580
+ ontoggle={async ({ newState }) => {
558
581
  if (newState === 'open') {
559
582
  menuOpen = true
583
+ let idx = -1
584
+ if (multiple) {
585
+ if (Array.isArray(value) && value.length) {
586
+ idx = options.findIndex((o) => value.includes(o.value) && !o.disabled)
587
+ }
588
+ } else {
589
+ idx = options.findIndex((o) => o.value === value && !o.disabled)
590
+ }
591
+ if (idx < 0) {
592
+ idx = options.findIndex((o) => !o.disabled)
593
+ }
594
+ if (idx < 0) idx = 0
595
+ focusIndex = idx
560
596
  } else {
561
597
  menuOpen = false
598
+ focusIndex = -1
562
599
  }
563
600
  }}
564
601
  bind:element={menuElement}
565
602
  >
566
603
  {#if useVirtualList}
567
- <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
+ el?.focus()
612
+ }
613
+ }}
614
+ >
568
615
  {#snippet row(option, index)}
569
616
  {@render item(option, index)}
570
617
  {/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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.24.10",
3
+ "version": "0.24.12",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {