noph-ui 0.24.10 → 0.24.11

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,81 @@
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
+ })
52
78
  </script>
53
79
 
54
- {#snippet item(option: AutoCompleteOption)}
80
+ {#snippet item(option: AutoCompleteOption, index: number)}
55
81
  <Item
82
+ id="{uid}-opt-{index}"
83
+ softFocus={index === activeIndex}
84
+ aria-selected={index === activeIndex}
85
+ role="option"
86
+ onmousedown={(e) => {
87
+ e.preventDefault()
88
+ }}
89
+ onmouseenter={() => setActive(index)}
56
90
  onclick={(event) => {
57
91
  event.preventDefault()
58
- element?.focus()
92
+ setActive(index)
59
93
  onoptionselect(option)
60
94
  }}
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
95
  variant="button"
84
96
  >{option.label}
85
97
  </Item>
86
98
  {/snippet}
87
99
 
88
100
  <TextField
89
- autocomplete="off"
90
101
  {...attributes}
102
+ autocomplete="off"
91
103
  {variant}
92
104
  type="text"
93
105
  populated={finalPopulated}
94
106
  bind:clientWidth
95
107
  bind:value
96
- bind:focused
97
108
  style="anchor-name:--{uid};"
109
+ role="combobox"
110
+ aria-controls="listbox-{uid}"
111
+ aria-expanded={menuOpen}
112
+ aria-autocomplete="list"
113
+ aria-activedescendant={activeIndex >= 0 ? `${uid}-opt-${activeIndex}` : undefined}
114
+ aria-haspopup="listbox"
98
115
  onclick={(event) => {
99
116
  finalPopulated = true
100
117
  showPopover()
@@ -102,32 +119,42 @@
102
119
  }}
103
120
  oninput={(event) => {
104
121
  showPopover()
122
+ activeIndex = -1
105
123
  oninput?.(event)
106
124
  }}
107
125
  onkeydown={(event) => {
108
- if (event.key === 'Tab' || event.key === 'Escape') {
109
- blockEvent = false
126
+ if (event.key === 'Tab') {
127
+ return
128
+ }
129
+ if (event.key === 'Escape') {
110
130
  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
- }
131
+ activeIndex = -1
132
+ event.preventDefault()
133
+ return
119
134
  }
120
- onkeydown?.(event)
121
- }}
122
- onblur={(event) => {
123
- if (!blockEvent) {
124
- onblur?.(event)
135
+ if (event.key === 'ArrowDown') {
136
+ finalPopulated = true
137
+ showPopover()
138
+ moveActive(1)
139
+ event.preventDefault()
140
+ return
125
141
  }
126
- }}
127
- onfocusout={(event) => {
128
- if (!blockEvent) {
129
- onfocusout?.(event)
142
+ if (event.key === 'ArrowUp') {
143
+ finalPopulated = true
144
+ showPopover()
145
+ moveActive(-1)
146
+ event.preventDefault()
147
+ return
130
148
  }
149
+ if (event.key === 'Enter' && activeIndex >= 0) {
150
+ const opt = displayOptions[activeIndex]
151
+ if (opt) {
152
+ onoptionselect(opt)
153
+ }
154
+ event.preventDefault()
155
+ return
156
+ }
157
+ onkeydown?.(event)
131
158
  }}
132
159
  bind:reportValidity
133
160
  bind:checkValidity
@@ -135,6 +162,7 @@
135
162
  >{@render children?.()}
136
163
  </TextField>
137
164
  <Menu
165
+ id="listbox-{uid}"
138
166
  style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
139
167
  ? 'width'
140
168
  : 'min-width'}:{clientWidth}px"
@@ -147,38 +175,32 @@
147
175
  ? 'var(--np-outlined-select-text-field-container-shape)'
148
176
  : 'var(--np-filled-select-text-field-container-shape)'}
149
177
  anchor={element}
150
- onbeforetoggle={(e) => {
151
- if (e.newState !== 'closed') {
152
- blockEvent = true
153
- }
154
- }}
155
178
  ontoggle={(e) => {
156
179
  if (e.newState === 'closed') {
157
- blockEvent = false
180
+ menuOpen = false
181
+ activeIndex = -1
158
182
  if (!populated && finalPopulated && !value) {
159
183
  finalPopulated = false
160
184
  }
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)
185
+ } else {
186
+ menuOpen = true
187
+ // Ensure activeIndex valid when opening
188
+ if (activeIndex >= displayOptions.length) {
189
+ activeIndex = -1
190
+ }
169
191
  }
170
192
  }}
171
193
  bind:element={menuElement}
172
194
  >
173
195
  {#if useVirtualList}
174
196
  <VirtualList height="250px" itemHeight={48} items={displayOptions}>
175
- {#snippet row(option)}
176
- {@render item(option)}
197
+ {#snippet row(option, index)}
198
+ {@render item(option, index)}
177
199
  {/snippet}
178
200
  </VirtualList>
179
201
  {:else}
180
202
  {#each displayOptions as option, index (index)}
181
- {@render item(option)}
203
+ {@render item(option, index)}
182
204
  {/each}
183
205
  {/if}
184
206
  </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;
@@ -457,14 +457,15 @@
457
457
  </div>
458
458
  </div>
459
459
 
460
- {#snippet item(option: SelectOption, index?: number)}
460
+ {#snippet item(option: SelectOption, index: number)}
461
461
  {#if Array.isArray(value) && multiple}
462
462
  <Item
463
- id={typeof index === 'number' ? `${uid}-opt-${index}` : undefined}
463
+ id="{uid}-opt-{index}"
464
464
  onclick={(event) => {
465
465
  handleOptionSelect(event, option)
466
466
  field?.focus()
467
467
  }}
468
+ tabindex={-1}
468
469
  disabled={option.disabled}
469
470
  aria-disabled={option.disabled}
470
471
  role="option"
@@ -501,11 +502,12 @@
501
502
  </Item>
502
503
  {:else}
503
504
  <Item
504
- id={typeof index === 'number' ? `${uid}-opt-${index}` : undefined}
505
+ id="{uid}-opt-{index}"
505
506
  onclick={(event) => {
506
507
  handleOptionSelect(event, option)
507
508
  field?.focus()
508
509
  }}
510
+ tabindex={-1}
509
511
  disabled={option.disabled}
510
512
  aria-disabled={option.disabled}
511
513
  role="option"
@@ -554,11 +556,28 @@
554
556
  ? 'var(--np-outlined-select-text-field-container-shape)'
555
557
  : 'var(--np-filled-select-text-field-container-shape)'}
556
558
  anchor={anchorElement}
557
- ontoggle={({ newState }) => {
559
+ ontoggle={async ({ newState }) => {
558
560
  if (newState === 'open') {
559
561
  menuOpen = true
562
+ let idx = -1
563
+ if (multiple) {
564
+ if (Array.isArray(value) && value.length) {
565
+ idx = options.findIndex((o) => value.includes(o.value) && !o.disabled)
566
+ }
567
+ } else {
568
+ idx = options.findIndex((o) => o.value === value && !o.disabled)
569
+ }
570
+ if (idx < 0) {
571
+ idx = options.findIndex((o) => !o.disabled)
572
+ }
573
+ if (idx < 0) idx = 0
574
+ focusIndex = idx
575
+ await tick()
576
+ const el = document.getElementById(`${uid}-opt-${focusIndex}`)
577
+ ;(el as HTMLElement | null)?.focus?.()
560
578
  } else {
561
579
  menuOpen = false
580
+ focusIndex = -1
562
581
  }
563
582
  }}
564
583
  bind:element={menuElement}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.24.10",
3
+ "version": "0.24.11",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {