noph-ui 0.24.8 → 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[];
@@ -1,11 +1,7 @@
1
1
  <script lang="ts">
2
2
  import Ripple from '../ripple/Ripple.svelte'
3
3
  import Tooltip from '../tooltip/Tooltip.svelte'
4
- import type {
5
- HTMLAnchorAttributes,
6
- HTMLButtonAttributes,
7
- MouseEventHandler,
8
- } from 'svelte/elements'
4
+ import type { HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements'
9
5
  import type { ButtonProps } from './types.ts'
10
6
  import CircularProgress from '../progress/CircularProgress.svelte'
11
7
 
@@ -29,13 +25,6 @@
29
25
  }: ButtonProps = $props()
30
26
 
31
27
  const uid = $props.id()
32
-
33
- const isButton = (obj: unknown): obj is HTMLButtonAttributes => {
34
- return (obj as HTMLAnchorAttributes).href === undefined
35
- }
36
- const isLink = (obj: unknown): obj is HTMLAnchorAttributes => {
37
- return (obj as HTMLAnchorAttributes).href !== undefined
38
- }
39
28
  </script>
40
29
 
41
30
  {#snippet content()}
@@ -64,13 +53,36 @@
64
53
  {/if}
65
54
  {/snippet}
66
55
 
67
- {#if isButton(attributes) || disabled || loading}
56
+ {#if 'href' in attributes && !disabled && !loading}
57
+ <a
58
+ {...attributes}
59
+ onclick={(event) => {
60
+ ;(onclick as MouseEventHandler<HTMLAnchorElement>)?.(event)
61
+ }}
62
+ aria-describedby={title ? uid : attributes['aria-describedby']}
63
+ aria-label={title || attributes['aria-label']}
64
+ bind:this={element}
65
+ class={[
66
+ 'np-button',
67
+ size,
68
+ selected ? 'square' : shape,
69
+ toggle && 'toggle',
70
+ 'enabled',
71
+ variant,
72
+ attributes.class,
73
+ ]}
74
+ >
75
+ {@render content()}
76
+ </a>
77
+ {:else}
68
78
  <button
69
79
  {...attributes as HTMLButtonAttributes}
70
80
  aria-describedby={title ? uid : attributes['aria-describedby']}
71
81
  aria-label={title || attributes['aria-label']}
72
82
  disabled={disabled || loading}
73
- aria-pressed={selected}
83
+ aria-pressed={toggle ? selected : undefined}
84
+ aria-busy={loading}
85
+ type={(attributes['type'] as 'button' | 'submit' | 'reset' | 'button') ?? undefined}
74
86
  bind:this={element}
75
87
  onclick={(event) => {
76
88
  if (toggle) {
@@ -82,39 +94,18 @@
82
94
  'np-button',
83
95
  size,
84
96
  selected || loading ? 'square' : shape,
85
- toggle ? 'toggle' : '',
86
- selected ? 'selected' : '',
87
- loading ? 'np-loading' : '',
97
+ toggle && 'toggle',
98
+ selected && 'selected',
99
+ loading && 'np-loading',
88
100
  disabled || loading ? `${variant}-disabled disabled` : `${variant} enabled`,
89
101
  attributes.class,
90
102
  ]}
91
103
  >
92
104
  {@render content()}
93
105
  </button>
94
- {:else if isLink(attributes)}
95
- <a
96
- {...attributes}
97
- onclick={(event) => {
98
- ;(onclick as MouseEventHandler<HTMLAnchorElement>)?.(event)
99
- }}
100
- aria-describedby={title ? uid : attributes['aria-describedby']}
101
- aria-label={title || attributes['aria-label']}
102
- bind:this={element}
103
- class={[
104
- 'np-button',
105
- size,
106
- selected || loading ? 'square' : shape,
107
- toggle ? 'toggle' : '',
108
- 'enabled',
109
- variant,
110
- attributes.class,
111
- ]}
112
- >
113
- {@render content()}
114
- </a>
115
106
  {/if}
116
107
 
117
- {#if title}
108
+ {#if title && !disabled && !loading}
118
109
  <Tooltip {keepTooltipOnClick} id={uid}>{title}</Tooltip>
119
110
  {/if}
120
111
 
@@ -258,10 +249,8 @@
258
249
  background-color: color-mix(in srgb, var(--np-color-on-surface) 12%, transparent);
259
250
  }
260
251
  .outlined-disabled {
261
- outline-style: solid;
262
- outline-color: color-mix(in srgb, var(--np-color-on-surface) 12%, transparent);
263
- outline-width: 1px;
264
- outline-offset: -1px;
252
+ /* Variant outline now rendered via pseudo-element; keep token for color */
253
+ --_outlined-border-color: color-mix(in srgb, var(--np-color-on-surface) 12%, transparent);
265
254
  }
266
255
  .enabled:focus-visible {
267
256
  outline-style: solid;
@@ -363,19 +352,28 @@
363
352
  }
364
353
  .outlined {
365
354
  background-color: var(--np-outlined-button-container-color, transparent);
366
- outline-style: solid;
367
- outline-color: var(--np-outlined-button-outline-color, var(--np-color-outline-variant));
368
- outline-width: 1px;
369
- outline-offset: -1px;
355
+ --_outlined-border-color: var(
356
+ --np-outlined-button-outline-color,
357
+ var(--np-color-outline-variant)
358
+ );
370
359
  --np-ripple-hover-color: var(--np-outlined-button-label-text-color, var(--np-color-primary));
371
360
  --np-ripple-pressed-color: var(--np-outlined-button-label-text-color, var(--np-color-primary));
372
361
  color: var(--np-outlined-button-label-text-color, var(--np-color-primary));
373
362
  }
374
363
 
364
+ .outlined:not(.selected)::after,
365
+ .outlined-disabled::after {
366
+ content: '';
367
+ position: absolute;
368
+ inset: 0;
369
+ border: 1px solid var(--_outlined-border-color);
370
+ border-radius: inherit;
371
+ pointer-events: none;
372
+ }
373
+
375
374
  .outlined.selected {
376
375
  background-color: var(--np-color-inverse-surface);
377
376
  color: var(--np-color-inverse-on-surface);
378
- outline-style: none;
379
377
  }
380
378
  .button-icon {
381
379
  display: inline-flex;
@@ -3,11 +3,7 @@
3
3
  import Ripple from '../ripple/Ripple.svelte'
4
4
  import Tooltip from '../tooltip/Tooltip.svelte'
5
5
  import type { IconButtonProps } from './types.ts'
6
- import type {
7
- HTMLAnchorAttributes,
8
- HTMLButtonAttributes,
9
- MouseEventHandler,
10
- } from 'svelte/elements'
6
+ import type { HTMLButtonAttributes, MouseEventHandler } from 'svelte/elements'
11
7
 
12
8
  let {
13
9
  variant = 'text',
@@ -15,7 +11,7 @@
15
11
  children,
16
12
  title,
17
13
  element = $bindable(),
18
- disabled,
14
+ disabled = false,
19
15
  loading = false,
20
16
  loadingAriaLabel,
21
17
  selected = $bindable(false),
@@ -30,13 +26,6 @@
30
26
 
31
27
  const uid = $props.id()
32
28
  let touchEl: HTMLSpanElement | undefined = $state()
33
-
34
- const isButton = (obj: unknown): obj is HTMLButtonAttributes => {
35
- return (obj as HTMLAnchorAttributes).href === undefined
36
- }
37
- const isLink = (obj: unknown): obj is HTMLAnchorAttributes => {
38
- return (obj as HTMLAnchorAttributes).href !== undefined
39
- }
40
29
  </script>
41
30
 
42
31
  {#snippet content()}
@@ -56,12 +45,37 @@
56
45
  {/if}
57
46
  {/snippet}
58
47
 
59
- {#if isButton(attributes) || disabled || loading}
48
+ {#if 'href' in attributes && !disabled && !loading}
49
+ <a
50
+ {...attributes}
51
+ onclick={(event) => {
52
+ ;(onclick as MouseEventHandler<HTMLAnchorElement>)?.(event)
53
+ }}
54
+ aria-describedby={title ? uid : undefined}
55
+ aria-label={title}
56
+ bind:this={element}
57
+ class={[
58
+ 'np-icon-button',
59
+ size,
60
+ width,
61
+ variant,
62
+ selected ? 'square' : shape,
63
+ 'enabled',
64
+ toggle && 'toggle',
65
+ selected && 'selected',
66
+ attributes.class,
67
+ ].filter(Boolean)}
68
+ >
69
+ {@render content()}
70
+ </a>
71
+ {:else}
60
72
  <button
73
+ {...attributes as HTMLButtonAttributes}
61
74
  aria-describedby={title ? uid : attributes['aria-describedby']}
62
75
  aria-label={title || attributes['aria-label']}
63
- aria-pressed={selected}
64
- {...attributes as HTMLButtonAttributes}
76
+ aria-pressed={toggle ? selected : undefined}
77
+ aria-busy={loading}
78
+ type={(attributes['type'] as 'button' | 'submit' | 'reset' | 'button') ?? undefined}
65
79
  disabled={disabled || loading}
66
80
  bind:this={element}
67
81
  onclick={(event) => {
@@ -77,38 +91,15 @@
77
91
  selected || loading ? 'square' : shape,
78
92
  disabled || loading ? `${variant}-disabled disabled` : `${variant} enabled`,
79
93
  toggle && 'toggle',
80
- selected ? 'selected' : '',
94
+ selected && 'selected',
81
95
  attributes.class,
82
96
  ]}
83
97
  >
84
98
  {@render content()}
85
99
  </button>
86
- {:else if isLink(attributes)}
87
- <a
88
- {...attributes}
89
- onclick={(event) => {
90
- ;(onclick as MouseEventHandler<HTMLAnchorElement>)?.(event)
91
- }}
92
- aria-describedby={title ? uid : undefined}
93
- aria-label={title}
94
- bind:this={element}
95
- class={[
96
- 'np-icon-button',
97
- size,
98
- width,
99
- variant,
100
- selected || loading ? 'square' : shape,
101
- 'enabled',
102
- toggle && 'toggle',
103
- selected ? 'selected' : '',
104
- attributes.class,
105
- ]}
106
- >
107
- {@render content()}
108
- </a>
109
100
  {/if}
110
101
 
111
- {#if title}
102
+ {#if title && !disabled && !loading}
112
103
  <Tooltip {keepTooltipOnClick} id={uid}>{title}</Tooltip>
113
104
  {/if}
114
105
 
@@ -343,16 +334,26 @@
343
334
  }
344
335
 
345
336
  .outlined {
346
- outline-style: solid;
347
- outline-color: var(--np-outlined-icon-button-outline-color, var(--np-color-outline-variant));
348
- outline-width: 1px;
349
- outline-offset: -1px;
337
+ --_outlined-border-color: var(
338
+ --np-outlined-icon-button-outline-color,
339
+ var(--np-color-outline-variant)
340
+ );
350
341
  --np-ripple-hover-color: var(--np-color-on-surface-variant);
351
342
  --np-ripple-pressed-color: var(--np-color-on-surface-variant);
352
343
  color: var(--np-color-on-surface-variant);
353
344
  }
345
+
346
+ .outlined:not(.selected)::after,
347
+ .outlined-disabled::after {
348
+ content: '';
349
+ position: absolute;
350
+ inset: 0;
351
+ border: 1px solid var(--_outlined-border-color);
352
+ border-radius: inherit;
353
+ pointer-events: none;
354
+ }
355
+
354
356
  .outlined.selected {
355
- outline-style: none;
356
357
  --np-ripple-hover-color: var(--np-color-on-surface-variant);
357
358
  --np-ripple-pressed-color: var(--np-color-on-surface-variant);
358
359
  color: var(--np-color-inverse-on-surface);
@@ -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;
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'
2
+ import type { HTMLButtonAttributes } from 'svelte/elements'
3
3
  import type { NavigationDrawerItemProps } from './types.ts'
4
4
  import Ripple from '../ripple/Ripple.svelte'
5
5
 
@@ -10,13 +10,6 @@
10
10
  icon,
11
11
  ...attributes
12
12
  }: NavigationDrawerItemProps = $props()
13
-
14
- const isButton = (obj: unknown): obj is HTMLButtonAttributes => {
15
- return (obj as HTMLAnchorAttributes).href === undefined
16
- }
17
- const isLink = (obj: unknown): obj is HTMLAnchorAttributes => {
18
- return (obj as HTMLAnchorAttributes).href !== undefined
19
- }
20
13
  </script>
21
14
 
22
15
  {#snippet content()}
@@ -30,9 +23,9 @@
30
23
  <div class="np-navigation-drawer-item-badge">{badgeLabelText}</div>
31
24
  {/snippet}
32
25
 
33
- {#if isButton(attributes)}
34
- <button
35
- {...attributes as HTMLButtonAttributes}
26
+ {#if 'href' in attributes}
27
+ <a
28
+ {...attributes}
36
29
  class={[
37
30
  'np-navigation-drawer-item',
38
31
  selected && 'np-navigation-drawer-item-selected',
@@ -40,10 +33,10 @@
40
33
  ]}
41
34
  >
42
35
  {@render content()}
43
- </button>
44
- {:else if isLink(attributes)}
45
- <a
46
- {...attributes}
36
+ </a>
37
+ {:else}
38
+ <button
39
+ {...attributes as HTMLButtonAttributes}
47
40
  class={[
48
41
  'np-navigation-drawer-item',
49
42
  selected && 'np-navigation-drawer-item-selected',
@@ -51,7 +44,7 @@
51
44
  ]}
52
45
  >
53
46
  {@render content()}
54
- </a>
47
+ </button>
55
48
  {/if}
56
49
 
57
50
  <style>
@@ -1,17 +1,10 @@
1
1
  <script lang="ts">
2
- import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'
2
+ import type { HTMLButtonAttributes } from 'svelte/elements'
3
3
  import type { NavigationRailItemProps } from './types.ts'
4
4
  import Ripple from '../ripple/Ripple.svelte'
5
5
 
6
6
  let { selected, icon, label, ...attributes }: NavigationRailItemProps = $props()
7
7
  let touchEl: HTMLSpanElement | undefined = $state()
8
-
9
- const isButton = (obj: unknown): obj is HTMLButtonAttributes => {
10
- return (obj as HTMLAnchorAttributes).href === undefined
11
- }
12
- const isLink = (obj: unknown): obj is HTMLAnchorAttributes => {
13
- return (obj as HTMLAnchorAttributes).href !== undefined
14
- }
15
8
  </script>
16
9
 
17
10
  {#snippet content()}
@@ -23,20 +16,20 @@
23
16
  <span class="np-touch" bind:this={touchEl}></span>
24
17
  {/snippet}
25
18
 
26
- {#if isButton(attributes)}
27
- <button
28
- {...attributes as HTMLButtonAttributes}
29
- class={['np-navigation-action', selected && 'np-navigation-action-selected', attributes.class]}
30
- >
31
- {@render content()}
32
- </button>
33
- {:else if isLink(attributes)}
19
+ {#if 'href' in attributes}
34
20
  <a
35
21
  {...attributes}
36
22
  class={['np-navigation-action', selected && 'np-navigation-action-selected', attributes.class]}
37
23
  >
38
24
  {@render content()}
39
25
  </a>
26
+ {:else}
27
+ <button
28
+ {...attributes as HTMLButtonAttributes}
29
+ class={['np-navigation-action', selected && 'np-navigation-action-selected', attributes.class]}
30
+ >
31
+ {@render content()}
32
+ </button>
40
33
  {/if}
41
34
 
42
35
  <style>
@@ -11,7 +11,8 @@
11
11
  ...attributes
12
12
  }: RadioProps = $props()
13
13
 
14
- let inputEl: HTMLSpanElement | undefined = $state()
14
+ let inputEl = $state<HTMLInputElement>()
15
+ const uid = $props.id()
15
16
  </script>
16
17
 
17
18
  <label {style} class={['np-host', attributes.class]} bind:this={element}>
@@ -20,11 +21,11 @@
20
21
  <Ripple forElement={inputEl} class="np-radio-ripple" />
21
22
  {/if}
22
23
  <svg class="np-radio-icon" viewBox="0 0 20 20">
23
- <mask id="1">
24
+ <mask id="{uid}-mask">
24
25
  <rect width="100%" height="100%" fill="white" />
25
26
  <circle cx="10" cy="10" r="8" fill="black" />
26
27
  </mask>
27
- <circle class="outer circle" cx="10" cy="10" r="10" mask="url(#1)" />
28
+ <circle class="outer circle" cx="10" cy="10" r="10" mask="url(#{uid}-mask)" />
28
29
  <circle class="inner circle" cx="10" cy="10" r="5" />
29
30
  </svg>
30
31
  {#if group !== undefined}
@@ -45,12 +45,12 @@
45
45
  value = options.find((option) => option.selected)?.value
46
46
  }
47
47
  }
48
- let selectedOption: SelectOption[] = $state(
48
+ let selectedOption: SelectOption[] = $derived(
49
49
  options
50
- .filter((option) =>
51
- option.selected || Array.isArray(value)
52
- ? value.includes(option.value)
53
- : value === option.value || false,
50
+ .filter(
51
+ (option) =>
52
+ option.selected ||
53
+ (Array.isArray(value) ? value.includes(option.value) : value === option.value),
54
54
  )
55
55
  .map((option) => ({ ...option, selected: true })),
56
56
  )
@@ -59,13 +59,27 @@
59
59
 
60
60
  let errorTextRaw: string = $state(errorText)
61
61
  let errorRaw = $state(error)
62
- let selectElement: HTMLSelectElement | undefined = $state()
63
- let menuElement: HTMLDivElement | undefined = $state()
64
- let anchorElement: HTMLDivElement | undefined = $state()
65
- let field: HTMLDivElement | undefined = $state()
62
+ let selectElement = $state<HTMLSelectElement>()
63
+ let menuElement = $state<HTMLDivElement>()
64
+ let anchorElement = $state<HTMLDivElement>()
65
+ let field = $state<HTMLDivElement>()
66
66
  let clientWidth = $state(0)
67
67
  let menuOpen = $state(false)
68
- let selectedLabel = $derived.by<string | string[]>(() => {
68
+ let focusIndex = $state(-1)
69
+ let typeBuffer = ''
70
+ let lastTypeTime = 0
71
+
72
+ let activeDescendantId = $derived.by<string | undefined>(() => {
73
+ if (!menuOpen) return undefined
74
+ if (focusIndex >= 0 && focusIndex < options.length) return `${uid}-opt-${focusIndex}`
75
+ const fallbackIdx = multiple
76
+ ? Array.isArray(value) && value.length
77
+ ? options.findIndex((o) => o.value === value[0])
78
+ : -1
79
+ : options.findIndex((o) => o.value === value)
80
+ return fallbackIdx >= 0 ? `${uid}-opt-${fallbackIdx}` : undefined
81
+ })
82
+ let selectedLabel = $derived.by<string>(() => {
69
83
  if (multiple) {
70
84
  if (value && Array.isArray(value)) {
71
85
  return value
@@ -143,6 +157,82 @@
143
157
  selectElement?.dispatchEvent(new Event('change', { bubbles: true }))
144
158
  })
145
159
  }
160
+
161
+ const openMenuAndFocus = async (index: number) => {
162
+ if (!menuOpen) {
163
+ menuElement?.showPopover()
164
+ }
165
+ focusIndex = Math.min(Math.max(index, 0), options.length - 1)
166
+ await tick()
167
+ const el = document.getElementById(`${uid}-opt-${focusIndex}`)
168
+ ;(el as HTMLElement | null)?.focus?.()
169
+ }
170
+
171
+ const moveFocus = (delta: number) => {
172
+ if (!options.length) return
173
+ let next = focusIndex
174
+ if (next < 0) {
175
+ const selIdx = Array.isArray(value)
176
+ ? options.findIndex((o) => value.includes(o.value) && !o.disabled)
177
+ : options.findIndex((o) => o.value === value && !o.disabled)
178
+ next = selIdx >= 0 ? selIdx : 0
179
+ }
180
+ let attempts = 0
181
+ while (attempts < options.length) {
182
+ next = (next + delta + options.length) % options.length
183
+ if (!options[next].disabled) {
184
+ openMenuAndFocus(next)
185
+ return
186
+ }
187
+ attempts++
188
+ }
189
+ }
190
+
191
+ const focusEdge = (start: boolean) => {
192
+ if (!options.length) {
193
+ return
194
+ }
195
+ if (start) {
196
+ for (let i = 0; i < options.length; i++) {
197
+ if (!options[i].disabled) {
198
+ openMenuAndFocus(i)
199
+ return
200
+ }
201
+ }
202
+ } else {
203
+ for (let i = options.length - 1; i >= 0; i--) {
204
+ if (!options[i].disabled) {
205
+ openMenuAndFocus(i)
206
+ return
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ const performTypeahead = (char: string) => {
213
+ const now = performance.now()
214
+ if (now - lastTypeTime > 700) typeBuffer = ''
215
+ lastTypeTime = now
216
+ typeBuffer += char.toLowerCase()
217
+ if (!options.length) {
218
+ return
219
+ }
220
+ const startIdx = focusIndex >= 0 ? (focusIndex + 1) % options.length : 0
221
+ for (let i = 0; i < options.length; i++) {
222
+ const idx = (startIdx + i) % options.length
223
+ const label = options[idx].label?.toLowerCase?.() || ''
224
+ if (label.startsWith(typeBuffer) && !options[idx].disabled) {
225
+ openMenuAndFocus(idx)
226
+ return
227
+ }
228
+ }
229
+
230
+ if (typeBuffer.length > 1) {
231
+ const last = typeBuffer[typeBuffer.length - 1]
232
+ typeBuffer = last
233
+ performTypeahead('')
234
+ }
235
+ }
146
236
  </script>
147
237
 
148
238
  {#snippet arrows()}
@@ -175,10 +265,11 @@
175
265
  role="combobox"
176
266
  aria-haspopup="listbox"
177
267
  tabindex={disabled ? -1 : tabindex}
178
- aria-controls="listbox"
268
+ aria-controls="listbox-{uid}"
179
269
  aria-expanded={menuOpen}
180
270
  aria-label={attributes['aria-label'] || label}
181
271
  aria-disabled={disabled}
272
+ aria-activedescendant={activeDescendantId}
182
273
  data-testid={attributes['data-testid']}
183
274
  bind:this={field}
184
275
  bind:clientWidth
@@ -192,19 +283,51 @@
192
283
  }
193
284
  }}
194
285
  onkeydown={(event) => {
195
- if (event.key === 'Tab' || event.key === 'Escape') {
286
+ const key = event.key
287
+ if (key === 'Tab') {
196
288
  menuElement?.hidePopover()
197
- } else {
289
+ return
290
+ }
291
+ if (key === 'Escape') {
292
+ menuElement?.hidePopover()
293
+ return
294
+ }
295
+ if (key === 'ArrowDown') {
198
296
  event.preventDefault()
199
- if (
200
- event.key === 'ArrowDown' ||
201
- event.key === 'ArrowUp' ||
202
- event.key === 'Enter' ||
203
- event.key === ' '
204
- ) {
205
- menuElement?.showPopover()
206
- ;(menuElement?.firstElementChild?.firstElementChild as HTMLElement)?.focus()
297
+ moveFocus(1)
298
+ return
299
+ }
300
+ if (key === 'ArrowUp') {
301
+ event.preventDefault()
302
+ moveFocus(-1)
303
+ return
304
+ }
305
+ if (key === 'Home') {
306
+ event.preventDefault()
307
+ focusEdge(true)
308
+ return
309
+ }
310
+ if (key === 'End') {
311
+ event.preventDefault()
312
+ focusEdge(false)
313
+ return
314
+ }
315
+ if (key === 'Enter' || key === ' ') {
316
+ event.preventDefault()
317
+ if (!menuOpen) {
318
+ openMenuAndFocus(focusIndex >= 0 ? focusIndex : 0)
319
+ } else if (focusIndex >= 0) {
320
+ const opt = options[focusIndex]
321
+ if (opt && !opt.disabled) {
322
+ handleOptionSelect(event, opt)
323
+ }
207
324
  }
325
+ return
326
+ }
327
+ // Printable character for typeahead
328
+ if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
329
+ performTypeahead(key)
330
+ return
208
331
  }
209
332
  }}
210
333
  >
@@ -222,7 +345,7 @@
222
345
  <span class={['label', !noAsterisk && required && 'required']}>{label}</span>
223
346
  </div>
224
347
  <div class="outline-notch">
225
- <span class="notch np-hidden" aria-hidden="true"
348
+ <span class="notch" aria-hidden="true"
226
349
  >{label}{noAsterisk || !required ? '' : '*'}</span
227
350
  >
228
351
  </div>
@@ -334,33 +457,44 @@
334
457
  </div>
335
458
  </div>
336
459
 
337
- {#snippet item(option: SelectOption)}
460
+ {#snippet item(option: SelectOption, index: number)}
338
461
  {#if Array.isArray(value) && multiple}
339
462
  <Item
463
+ id="{uid}-opt-{index}"
340
464
  onclick={(event) => {
341
465
  handleOptionSelect(event, option)
342
466
  field?.focus()
343
467
  }}
468
+ tabindex={-1}
344
469
  disabled={option.disabled}
470
+ aria-disabled={option.disabled}
345
471
  role="option"
346
472
  onkeydown={(event) => {
347
- if (event.key === 'ArrowDown') {
348
- ;(event.currentTarget?.nextElementSibling as HTMLElement)?.focus()
473
+ const key = event.key
474
+ if (key === 'ArrowDown') {
349
475
  event.preventDefault()
350
- }
351
- if (event.key === 'ArrowUp') {
352
- ;(event.currentTarget?.previousElementSibling as HTMLElement)?.focus()
476
+ moveFocus(1)
477
+ } else if (key === 'ArrowUp') {
478
+ event.preventDefault()
479
+ moveFocus(-1)
480
+ } else if (key === 'Home') {
481
+ event.preventDefault()
482
+ focusEdge(true)
483
+ } else if (key === 'End') {
484
+ event.preventDefault()
485
+ focusEdge(false)
486
+ } else if (key === 'Enter' || key === ' ') {
353
487
  event.preventDefault()
354
- }
355
- if (event.key === 'Enter') {
356
488
  handleOptionSelect(event, option)
357
- }
358
- if (event.key === 'Tab') {
489
+ } else if (key === 'Tab') {
359
490
  menuElement?.hidePopover()
491
+ } else if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
492
+ performTypeahead(key)
360
493
  }
361
494
  }}
362
495
  variant="button"
363
496
  selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
497
+ aria-selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
364
498
  >{option.label}
365
499
  {#snippet start()}
366
500
  <Check disabled={option.disabled} checked={value.includes(option.value)} />
@@ -368,40 +502,53 @@
368
502
  </Item>
369
503
  {:else}
370
504
  <Item
505
+ id="{uid}-opt-{index}"
371
506
  onclick={(event) => {
372
507
  handleOptionSelect(event, option)
373
508
  field?.focus()
374
509
  }}
510
+ tabindex={-1}
375
511
  disabled={option.disabled}
512
+ aria-disabled={option.disabled}
376
513
  role="option"
377
514
  onkeydown={(event) => {
378
- if (event.key === 'ArrowDown') {
379
- ;(event.currentTarget?.nextElementSibling as HTMLElement)?.focus()
515
+ const key = event.key
516
+ if (key === 'ArrowDown') {
380
517
  event.preventDefault()
381
- }
382
- if (event.key === 'ArrowUp') {
383
- ;(event.currentTarget?.previousElementSibling as HTMLElement)?.focus()
518
+ moveFocus(1)
519
+ } else if (key === 'ArrowUp') {
520
+ event.preventDefault()
521
+ moveFocus(-1)
522
+ } else if (key === 'Home') {
523
+ event.preventDefault()
524
+ focusEdge(true)
525
+ } else if (key === 'End') {
526
+ event.preventDefault()
527
+ focusEdge(false)
528
+ } else if (key === 'Enter' || key === ' ') {
384
529
  event.preventDefault()
385
- }
386
- if (event.key === 'Enter') {
387
530
  handleOptionSelect(event, option)
388
- }
389
- if (event.key === 'Tab') {
531
+ } else if (key === 'Tab') {
390
532
  menuElement?.hidePopover()
533
+ } else if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
534
+ performTypeahead(key)
391
535
  }
392
536
  }}
393
537
  variant="button"
394
538
  selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
539
+ aria-selected={Array.isArray(value) ? value.includes(option.value) : value === option.value}
395
540
  >{option.label}
396
541
  </Item>
397
542
  {/if}
398
543
  {/snippet}
399
544
 
400
545
  <Menu
546
+ id="listbox-{uid}"
401
547
  style="position-anchor:--{uid};{clampMenuWidth || useVirtualList
402
548
  ? 'width'
403
549
  : 'min-width'}:{clientWidth}px"
404
550
  role="listbox"
551
+ aria-multiselectable={multiple}
405
552
  --np-menu-justify-self="none"
406
553
  --np-menu-position-area="bottom span-right"
407
554
  --np-menu-margin="2px 0"
@@ -409,24 +556,41 @@
409
556
  ? 'var(--np-outlined-select-text-field-container-shape)'
410
557
  : 'var(--np-filled-select-text-field-container-shape)'}
411
558
  anchor={anchorElement}
412
- ontoggle={({ newState }) => {
559
+ ontoggle={async ({ newState }) => {
413
560
  if (newState === 'open') {
414
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?.()
415
578
  } else {
416
579
  menuOpen = false
580
+ focusIndex = -1
417
581
  }
418
582
  }}
419
583
  bind:element={menuElement}
420
584
  >
421
585
  {#if useVirtualList}
422
586
  <VirtualList height="250px" itemHeight={48} items={options}>
423
- {#snippet row(option)}
424
- {@render item(option)}
587
+ {#snippet row(option, index)}
588
+ {@render item(option, index)}
425
589
  {/snippet}
426
590
  </VirtualList>
427
591
  {:else}
428
592
  {#each options as option, index (index)}
429
- {@render item(option)}
593
+ {@render item(option, index)}
430
594
  {/each}
431
595
  {/if}
432
596
  </Menu>
@@ -763,12 +927,6 @@
763
927
  .notch {
764
928
  font-size: 0.75rem;
765
929
  line-height: 1rem;
766
- }
767
- .notch.np-hidden {
768
- opacity: 0;
769
- }
770
-
771
- .label.np-hidden {
772
930
  opacity: 0;
773
931
  }
774
932
 
@@ -828,7 +986,7 @@
828
986
  .disabled .label {
829
987
  color: var(--np-color-on-surface);
830
988
  }
831
- .disabled .label:not(.np-hidden) {
989
+ .disabled {
832
990
  opacity: 0.38;
833
991
  }
834
992
  .resizable:not(.disabled) .np-container {
@@ -2,13 +2,14 @@
2
2
  import { onMount, tick, type Snippet } from 'svelte'
3
3
  import type { HTMLAttributes } from 'svelte/elements'
4
4
 
5
- interface VitualListProps extends HTMLAttributes<HTMLDivElement> {
5
+ interface VirtualListProps extends HTMLAttributes<HTMLDivElement> {
6
6
  items: T[]
7
7
  height?: string
8
8
  itemHeight?: number
9
9
  start?: number
10
10
  end?: number
11
- row: Snippet<[T]>
11
+ row: Snippet<[T, number]>
12
+ overscan?: number
12
13
  }
13
14
 
14
15
  let {
@@ -18,7 +19,8 @@
18
19
  start = $bindable(0),
19
20
  end = $bindable(0),
20
21
  row,
21
- }: VitualListProps = $props()
22
+ overscan = 4,
23
+ }: VirtualListProps = $props()
22
24
 
23
25
  let height_map: number[] = []
24
26
  // eslint-disable-next-line no-undef
@@ -30,7 +32,7 @@
30
32
 
31
33
  let top = $state(0)
32
34
  let bottom = $state(0)
33
- let average_height: number = $state(0)
35
+ let average_height: number = $state(itemHeight || 0)
34
36
 
35
37
  $effect(() => {
36
38
  if (mounted) {
@@ -38,9 +40,10 @@
38
40
  }
39
41
  })
40
42
  let visible = $derived(
41
- items.slice(start, end).map((data, i) => {
42
- return { index: i + start, data }
43
- }),
43
+ items.slice(Math.max(0, start - overscan), end).map((data, i) => ({
44
+ index: i + Math.max(0, start - overscan),
45
+ data,
46
+ })),
44
47
  )
45
48
 
46
49
  async function refresh(items: T[], viewport_height: number, itemHeight?: number) {
@@ -68,10 +71,11 @@
68
71
  i += 1
69
72
  }
70
73
 
74
+ i = Math.min(i + overscan, items.length)
71
75
  end = i
72
76
 
73
77
  const remaining = items.length - end
74
- average_height = (top + content_height) / end
78
+ average_height = end ? (top + content_height) / end : average_height || itemHeight || 0
75
79
 
76
80
  bottom = remaining * average_height
77
81
  height_map.length = items.length
@@ -98,14 +102,24 @@
98
102
  if (y + row_height > scrollTop) {
99
103
  start = i
100
104
  top = y
101
-
102
105
  break
103
106
  }
104
-
105
107
  y += row_height
106
108
  i += 1
107
109
  }
108
110
 
111
+ if (start > 0 && overscan > 0) {
112
+ let back = 0
113
+ let s = start
114
+ while (s > 0 && back < overscan) {
115
+ s -= 1
116
+ const h = height_map[s] || average_height
117
+ back += 1
118
+ top -= h
119
+ }
120
+ start = s
121
+ }
122
+
109
123
  while (i < items.length) {
110
124
  y += height_map[i] || average_height
111
125
  i += 1
@@ -116,7 +130,7 @@
116
130
  end = i
117
131
 
118
132
  const remaining = items.length - end
119
- average_height = y / end
133
+ average_height = end ? y / end : average_height || itemHeight || 0
120
134
 
121
135
  height_map.fill(average_height, i, items.length)
122
136
  bottom = remaining * average_height
@@ -137,13 +151,8 @@
137
151
  const d = actual_height - expected_height
138
152
  viewport.scrollTo(0, scrollTop + d)
139
153
  }
140
-
141
- // TODO if we overestimated the space these
142
- // rows would occupy we may need to add some
143
- // more. maybe we can just call handle_scroll again?
144
154
  }
145
155
 
146
- // trigger initial refresh
147
156
  onMount(() => {
148
157
  // eslint-disable-next-line no-undef
149
158
  rows = contents?.children as HTMLCollectionOf<HTMLElement>
@@ -159,7 +168,7 @@
159
168
  >
160
169
  <div bind:this={contents} style="padding-top: {top}px; padding-bottom: {bottom}px;">
161
170
  {#each visible as entry (entry.index)}
162
- {@render row(entry.data)}
171
+ {@render row(entry.data, entry.index)}
163
172
  {/each}
164
173
  </div>
165
174
  </svelte-virtual-list-viewport>
@@ -7,7 +7,8 @@ declare function $$render<T>(): {
7
7
  itemHeight?: number;
8
8
  start?: number;
9
9
  end?: number;
10
- row: Snippet<[T]>;
10
+ row: Snippet<[T, number]>;
11
+ overscan?: number;
11
12
  };
12
13
  exports: {};
13
14
  bindings: "start" | "end";
@@ -22,6 +22,7 @@
22
22
  }: SnackbarProps = $props()
23
23
 
24
24
  let timeoutId: number | undefined = $state()
25
+ const uid = $props.id()
25
26
 
26
27
  showPopover = () => {
27
28
  element?.showPopover()
@@ -38,7 +39,7 @@
38
39
  class={['np-snackbar', attributes.class]}
39
40
  bind:this={element}
40
41
  role="alert"
41
- aria-label={label}
42
+ aria-labelledby="np-snackbar-label-{uid}"
42
43
  onbeforetoggle={(event) => {
43
44
  let { newState } = event
44
45
  if (newState === 'closed') {
@@ -54,7 +55,7 @@
54
55
  >
55
56
  <div class="np-snackbar-inner">
56
57
  <div class="np-snackbar-label-container">
57
- <div class="np-snackbar-label">{label}</div>
58
+ <div id="np-snackbar-label-{uid}" class="np-snackbar-label">{label}</div>
58
59
  {#if supportingText}
59
60
  <div class="np-snackbar-supporting-text">{supportingText}</div>
60
61
  {/if}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noph-ui",
3
- "version": "0.24.8",
3
+ "version": "0.24.11",
4
4
  "license": "MIT",
5
5
  "homepage": "https://noph.dev",
6
6
  "repository": {