noph-ui 0.51.2 → 0.52.0

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.
Files changed (130) hide show
  1. package/dist/animation.d.ts +26 -1
  2. package/dist/animation.js +50 -2
  3. package/dist/app-bar/AppBar.svelte +71 -7
  4. package/dist/app-bar/types.d.ts +6 -0
  5. package/dist/autocomplete/AutoComplete.svelte +6 -31
  6. package/dist/autocomplete/AutoComplete.svelte.d.ts +1 -1
  7. package/dist/badge/Badge.svelte +12 -4
  8. package/dist/badge/description.d.ts +4 -0
  9. package/dist/badge/description.js +4 -0
  10. package/dist/button/Button.svelte +55 -20
  11. package/dist/button/ButtonGroup.svelte +139 -19
  12. package/dist/button/ButtonGroup.svelte.d.ts +1 -1
  13. package/dist/button/IconButton.svelte +49 -18
  14. package/dist/button/SegmentedButton.svelte +44 -4
  15. package/dist/button/SplitButton.svelte +77 -3
  16. package/dist/button/SplitButton.svelte.d.ts +1 -1
  17. package/dist/button/buttonGroup.d.ts +0 -4
  18. package/dist/button/buttonGroup.js +0 -9
  19. package/dist/button/groupContext.d.ts +10 -0
  20. package/dist/button/groupContext.js +4 -0
  21. package/dist/button/types.d.ts +19 -0
  22. package/dist/checkbox/Checkbox.svelte +5 -0
  23. package/dist/chip/ChipSet.svelte +28 -2
  24. package/dist/chip/FilterChip.svelte +32 -4
  25. package/dist/chip/InputChip.svelte +127 -26
  26. package/dist/chip/SuggestionChip.svelte +6 -22
  27. package/dist/chip/SuggestionChip.svelte.d.ts +1 -2
  28. package/dist/chip/types.d.ts +8 -1
  29. package/dist/date-picker/Calendar.svelte +4 -2
  30. package/dist/date-picker/DatePickerDialog.svelte +1 -0
  31. package/dist/date-picker/DatePickerDialog.svelte.d.ts +1 -1
  32. package/dist/date-picker/DateRangePicker.svelte +1 -0
  33. package/dist/date-picker/DateRangePicker.svelte.d.ts +1 -1
  34. package/dist/date-picker/DockedDatePicker.svelte +12 -5
  35. package/dist/date-picker/DockedDatePicker.svelte.d.ts +1 -1
  36. package/dist/date-picker/DockedDateTimePicker.svelte +12 -5
  37. package/dist/date-picker/DockedDateTimePicker.svelte.d.ts +1 -1
  38. package/dist/date-picker/YearGrid.svelte +4 -2
  39. package/dist/dialog/Dialog.svelte +171 -33
  40. package/dist/dialog/Dialog.svelte.d.ts +2 -1
  41. package/dist/dialog/types.d.ts +6 -0
  42. package/dist/divider/Divider.svelte +36 -10
  43. package/dist/divider/types.d.ts +1 -0
  44. package/dist/fab/ExtendedFab.svelte +26 -60
  45. package/dist/fab/Fab.svelte +13 -48
  46. package/dist/fab/FabMenu.svelte +102 -44
  47. package/dist/fab/FabMenu.svelte.d.ts +2 -1
  48. package/dist/fab/FabMenuItem.svelte +8 -0
  49. package/dist/fab/FabMenuItem.svelte.d.ts +4 -0
  50. package/dist/fab/index.d.ts +1 -0
  51. package/dist/fab/index.js +1 -0
  52. package/dist/fab/types.d.ts +6 -4
  53. package/dist/icons/MenuIcon.svelte +13 -0
  54. package/dist/icons/MenuIcon.svelte.d.ts +7 -0
  55. package/dist/icons/MenuOpenIcon.svelte +15 -0
  56. package/dist/icons/MenuOpenIcon.svelte.d.ts +7 -0
  57. package/dist/icons/index.d.ts +2 -0
  58. package/dist/icons/index.js +2 -0
  59. package/dist/internal/exit.css +47 -0
  60. package/dist/internal/fieldTypes.d.ts +6 -0
  61. package/dist/internal/focus-ring.css +4 -0
  62. package/dist/internal/interest.css +3 -1
  63. package/dist/keyboard-nav.d.ts +12 -1
  64. package/dist/keyboard-nav.js +104 -14
  65. package/dist/list/Item.svelte +52 -6
  66. package/dist/list/List.svelte +35 -2
  67. package/dist/list/ListItem.svelte +16 -2
  68. package/dist/list/context.d.ts +6 -0
  69. package/dist/list/context.js +4 -0
  70. package/dist/list/types.d.ts +7 -0
  71. package/dist/media.d.ts +1 -0
  72. package/dist/media.js +1 -0
  73. package/dist/menu/Menu.svelte +70 -18
  74. package/dist/menu/Menu.svelte.d.ts +2 -1
  75. package/dist/menu/MenuItem.svelte +9 -3
  76. package/dist/menu/types.d.ts +7 -1
  77. package/dist/navigation-bar/NavigationBar.svelte +9 -3
  78. package/dist/navigation-bar/NavigationBarItem.svelte +29 -13
  79. package/dist/navigation-drawer/NavigationDrawer.svelte +96 -20
  80. package/dist/navigation-drawer/NavigationDrawer.svelte.d.ts +2 -1
  81. package/dist/navigation-drawer/NavigationDrawerItem.svelte +8 -3
  82. package/dist/navigation-drawer/NavigationDrawerSection.svelte +49 -0
  83. package/dist/navigation-drawer/NavigationDrawerSection.svelte.d.ts +4 -0
  84. package/dist/navigation-drawer/index.d.ts +1 -0
  85. package/dist/navigation-drawer/index.js +1 -0
  86. package/dist/navigation-drawer/types.d.ts +12 -0
  87. package/dist/navigation-rail/NavigationRail.svelte +367 -7
  88. package/dist/navigation-rail/NavigationRail.svelte.d.ts +1 -1
  89. package/dist/navigation-rail/NavigationRailItem.svelte +190 -53
  90. package/dist/navigation-rail/NavigationRailSection.svelte +57 -0
  91. package/dist/navigation-rail/NavigationRailSection.svelte.d.ts +4 -0
  92. package/dist/navigation-rail/context.d.ts +6 -0
  93. package/dist/navigation-rail/context.js +5 -0
  94. package/dist/navigation-rail/index.d.ts +1 -0
  95. package/dist/navigation-rail/index.js +1 -0
  96. package/dist/navigation-rail/morph.d.ts +14 -0
  97. package/dist/navigation-rail/morph.js +78 -0
  98. package/dist/navigation-rail/types.d.ts +21 -0
  99. package/dist/progress/CircularProgress.svelte +2 -1
  100. package/dist/progress/LinearProgress.svelte +48 -20
  101. package/dist/progress/types.d.ts +1 -0
  102. package/dist/radio/Radio.svelte +7 -1
  103. package/dist/search/Search.svelte +245 -58
  104. package/dist/search/types.d.ts +4 -0
  105. package/dist/select/NativeSelect.svelte +20 -12
  106. package/dist/select/Option.svelte +1 -2
  107. package/dist/select/Select.svelte +59 -140
  108. package/dist/select/VirtualList.svelte +16 -1
  109. package/dist/select/VirtualList.svelte.d.ts +6 -2
  110. package/dist/sheet/Sheet.svelte +223 -5
  111. package/dist/sheet/Sheet.svelte.d.ts +3 -1
  112. package/dist/sheet/types.d.ts +10 -0
  113. package/dist/slider/Slider.svelte +1 -27
  114. package/dist/snackbar/Snackbar.svelte +3 -0
  115. package/dist/snackbar/Snackbar.svelte.d.ts +2 -1
  116. package/dist/switch/Switch.svelte +51 -11
  117. package/dist/tabs/Tab.svelte +28 -14
  118. package/dist/tabs/Tabs.svelte +28 -4
  119. package/dist/tabs/types.d.ts +3 -0
  120. package/dist/text-field/TextField.svelte +49 -13
  121. package/dist/time-picker/DockedTimePicker.svelte +6 -1
  122. package/dist/time-picker/DockedTimePicker.svelte.d.ts +1 -1
  123. package/dist/time-picker/TimePickerDialog.svelte +1 -0
  124. package/dist/time-picker/TimePickerDialog.svelte.d.ts +1 -1
  125. package/dist/toolbar/Toolbar.svelte +21 -11
  126. package/dist/toolbar/types.d.ts +1 -0
  127. package/dist/tooltip/RichTooltip.svelte.d.ts +1 -1
  128. package/dist/tooltip/Tooltip.svelte +3 -1
  129. package/dist/tooltip/Tooltip.svelte.d.ts +1 -1
  130. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { on } from 'svelte/events';
2
- const focusable = (items) => items.filter((item) => !item.matches(':disabled'));
3
- const focusedItem = (node, itemSelector, items) => {
2
+ // Items inside a hidden part, such as a section shown only when expanded, cannot take focus.
3
+ export const focusableItems = (node, itemSelector) => Array.from(node.querySelectorAll(itemSelector)).filter((item) => !item.matches(':disabled') && !item.closest('[hidden]'));
4
+ export const focusedItem = (node, itemSelector, items) => {
4
5
  const active = document.activeElement;
5
6
  if (!(active instanceof HTMLElement) || !node.contains(active))
6
7
  return null;
@@ -8,13 +9,13 @@ const focusedItem = (node, itemSelector, items) => {
8
9
  return item && items.includes(item) ? item : null;
9
10
  };
10
11
  export const rovingTabindex = (itemSelector, options = {}) => {
11
- const { currentAttr = 'aria-current', currentValue = 'page' } = options;
12
+ const { currentAttr = 'aria-current', currentValue = 'page', current: currentSelector } = options;
12
13
  return (node) => {
13
- const getItems = () => focusable(Array.from(node.querySelectorAll(itemSelector)));
14
+ const getItems = () => focusableItems(node, itemSelector);
14
15
  const setTabstop = (target) => {
15
16
  for (const i of getItems()) {
16
17
  const wanted = i === target ? 0 : -1;
17
- if (i.tabIndex !== wanted)
18
+ if (i.getAttribute('tabindex') !== String(wanted))
18
19
  i.tabIndex = wanted;
19
20
  }
20
21
  };
@@ -23,7 +24,7 @@ export const rovingTabindex = (itemSelector, options = {}) => {
23
24
  if (items.length === 0)
24
25
  return;
25
26
  const focused = focusedItem(node, itemSelector, items);
26
- const current = items.find((i) => i.getAttribute(currentAttr) === currentValue);
27
+ const current = items.find((i) => currentSelector ? i.matches(currentSelector) : i.getAttribute(currentAttr) === currentValue);
27
28
  setTabstop(focused ?? current ?? items[0]);
28
29
  };
29
30
  const onFocusIn = (event) => {
@@ -47,14 +48,58 @@ export const rovingTabindex = (itemSelector, options = {}) => {
47
48
  };
48
49
  };
49
50
  };
50
- export const arrowKeyNav = (itemSelector, orientation = 'vertical', { wrap = true } = {}) => (event) => {
51
- const [prev, next] = orientation === 'vertical'
52
- ? ['ArrowUp', 'ArrowDown']
53
- : ['ArrowLeft', 'ArrowRight'];
51
+ // Inputs without text to edit, whose arrow keys are free to move between items.
52
+ const NOT_EDITED = ['button', 'checkbox', 'color', 'file', 'image', 'radio', 'reset', 'submit'];
53
+ /**
54
+ * Whether a field keeps an arrow, Home or End key for itself, so it moves the caret or changes the
55
+ * value instead of moving to the next item. A caret leaves its field once it reaches the end it
56
+ * moves towards.
57
+ */
58
+ const fieldKeeps = (target, key, isNext) => {
59
+ const upDown = key === 'ArrowUp' || key === 'ArrowDown';
60
+ if (target instanceof HTMLSelectElement)
61
+ return upDown;
62
+ if (target instanceof HTMLInputElement && NOT_EDITED.includes(target.type))
63
+ return false;
64
+ const textArea = target instanceof HTMLTextAreaElement;
65
+ if (!textArea && !(target instanceof HTMLInputElement)) {
66
+ return target instanceof HTMLElement && target.isContentEditable;
67
+ }
68
+ const field = target;
69
+ // Email, number, date and range fields have no caret to read, so they keep every key.
70
+ if (field.selectionStart === null)
71
+ return true;
72
+ if (key === 'Home' || key === 'End')
73
+ return true;
74
+ if (upDown)
75
+ return textArea;
76
+ const { selectionStart: start, selectionEnd: end, value } = field;
77
+ if (start !== end)
78
+ return true;
79
+ return isNext ? end < value.length : start > 0;
80
+ };
81
+ export const arrowKeyNav = (itemSelector,
82
+ // `both`: ↓ and → move on, ↑ and ← move back, as in a list whose items hold several actions.
83
+ orientation = 'vertical', { wrap = true } = {}) => (event) => {
54
84
  const { key } = event;
55
- if (key !== prev && key !== next && key !== 'Home' && key !== 'End')
85
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key))
86
+ return;
87
+ const rtl = orientation !== 'vertical' && getComputedStyle(event.currentTarget).direction === 'rtl';
88
+ const [left, right] = rtl ? ['ArrowRight', 'ArrowLeft'] : ['ArrowLeft', 'ArrowRight'];
89
+ const [prev, next] = orientation === 'vertical'
90
+ ? [['ArrowUp'], ['ArrowDown']]
91
+ : orientation === 'horizontal'
92
+ ? [[left], [right]]
93
+ : [
94
+ ['ArrowUp', left],
95
+ ['ArrowDown', right],
96
+ ];
97
+ const isNext = next.includes(key);
98
+ if (!isNext && !prev.includes(key) && key !== 'Home' && key !== 'End')
99
+ return;
100
+ if (fieldKeeps(event.target, key, isNext))
56
101
  return;
57
- const items = focusable(Array.from(event.currentTarget.querySelectorAll(itemSelector)));
102
+ const items = focusableItems(event.currentTarget, itemSelector);
58
103
  if (items.length === 0)
59
104
  return;
60
105
  const focused = focusedItem(event.currentTarget, itemSelector, items);
@@ -69,9 +114,8 @@ export const arrowKeyNav = (itemSelector, orientation = 'vertical', { wrap = tru
69
114
  target = items[items.length - 1];
70
115
  }
71
116
  else {
72
- const delta = key === next ? 1 : -1;
117
+ const delta = isNext ? 1 : -1;
73
118
  const index = currentIndex + delta;
74
- // `delta` is only ever ±1, so wrapping is one modulo and clamping is one clamp.
75
119
  target =
76
120
  items[wrap
77
121
  ? (index + items.length) % items.length
@@ -80,3 +124,49 @@ export const arrowKeyNav = (itemSelector, orientation = 'vertical', { wrap = tru
80
124
  target.focus();
81
125
  event.preventDefault();
82
126
  };
127
+ const typesLetter = ({ key, ctrlKey, metaKey, altKey }) => key.length === 1 && key !== ' ' && !ctrlKey && !metaKey && !altKey;
128
+ export const typeaheadBuffer = (timeout = 500) => {
129
+ let buffer = '';
130
+ let lastTime = 0;
131
+ return (event) => {
132
+ if (!typesLetter(event))
133
+ return undefined;
134
+ buffer = event.timeStamp - lastTime > timeout ? event.key : buffer + event.key;
135
+ lastTime = event.timeStamp;
136
+ return buffer;
137
+ };
138
+ };
139
+ export const typeaheadMatch = (labels, current, query) => {
140
+ const text = query.toLocaleLowerCase();
141
+ const find = (prefix, offset) => {
142
+ for (let i = 0; i < labels.length; i += 1) {
143
+ const index = (current + offset + i + labels.length) % labels.length;
144
+ if (labels[index].trim().toLocaleLowerCase().startsWith(prefix))
145
+ return index;
146
+ }
147
+ return -1;
148
+ };
149
+ const match = find(text, text.length === 1 ? 1 : 0);
150
+ if (match < 0 && [...text].every((char) => char === text[0]))
151
+ return find(text[0], 1);
152
+ return match;
153
+ };
154
+ export const typeahead = (itemSelector, labelOf, { timeout = 500 } = {}) => {
155
+ const collect = typeaheadBuffer(timeout);
156
+ return (event) => {
157
+ if (!typesLetter(event))
158
+ return;
159
+ const items = focusableItems(event.currentTarget, itemSelector);
160
+ const focused = focusedItem(event.currentTarget, itemSelector, items);
161
+ if (!focused)
162
+ return;
163
+ const query = collect(event);
164
+ if (!query)
165
+ return;
166
+ const target = items[typeaheadMatch(items.map(labelOf), items.indexOf(focused), query)];
167
+ if (!target)
168
+ return;
169
+ target.focus();
170
+ event.preventDefault();
171
+ };
172
+ };
@@ -7,6 +7,7 @@
7
7
  let {
8
8
  selected = false,
9
9
  start,
10
+ avatar,
10
11
  end,
11
12
  children,
12
13
  supportingText,
@@ -23,7 +24,15 @@
23
24
  </script>
24
25
 
25
26
  {#snippet content()}
26
- {#if start}
27
+ {#if avatar}
28
+ <div class="np-item-avatar" aria-hidden={typeof avatar === 'string' ? 'true' : undefined}>
29
+ {#if typeof avatar === 'string'}
30
+ {avatar}
31
+ {:else}
32
+ {@render avatar()}
33
+ {/if}
34
+ </div>
35
+ {:else if start}
27
36
  <div class={['np-item-start', selected && 'selected', disabled && 'disabled']}>
28
37
  {@render start()}
29
38
  </div>
@@ -46,7 +55,14 @@
46
55
  </div>
47
56
 
48
57
  {#if end}
49
- <div class={['np-item-end', selected && 'selected', disabled && 'disabled']}>
58
+ <div
59
+ class={[
60
+ 'np-item-end',
61
+ typeof end === 'string' && 'np-item-end-text',
62
+ selected && 'selected',
63
+ disabled && 'disabled',
64
+ ]}
65
+ >
50
66
  {#if typeof end === 'string'}
51
67
  {end}
52
68
  {:else}
@@ -135,18 +151,47 @@
135
151
  text-align: start;
136
152
  overflow: hidden;
137
153
  width: 100%;
138
- padding-block: 0.625rem;
139
- padding-inline: 1rem;
154
+ padding-block: 0.5rem;
155
+ padding-inline: var(--np-item-padding-inline, 1rem);
140
156
  min-height: var(--np-item-container-height, 3.5rem);
141
- gap: 0.75rem;
157
+ gap: var(--np-item-gap, 1rem);
142
158
  text-decoration: none;
143
159
  -webkit-tap-highlight-color: transparent;
144
160
  color: var(--np-item-label-text-color, var(--np-color-on-surface));
145
161
  box-sizing: border-box;
146
162
  }
147
163
 
164
+ /* 12px above and below text with supporting text, so three lines come to 88px. */
148
165
  .np-item:has(.np-item-supporting-text) {
149
166
  min-height: var(--np-item-container-height, 4.5rem);
167
+ padding-block: 0.75rem;
168
+ }
169
+ .np-item-avatar {
170
+ display: flex;
171
+ flex: none;
172
+ align-items: center;
173
+ justify-content: center;
174
+ width: 2.5rem;
175
+ height: 2.5rem;
176
+ border-radius: 50%;
177
+ overflow: hidden;
178
+ background-color: var(--np-item-avatar-container-color, var(--np-color-primary-container));
179
+ color: var(--np-item-avatar-label-text-color, var(--np-color-on-primary-container));
180
+ font-size: 1rem;
181
+ font-weight: 500;
182
+ letter-spacing: 0.009rem;
183
+ }
184
+ .np-item-avatar > :global(*) {
185
+ width: 100%;
186
+ height: 100%;
187
+ object-fit: cover;
188
+ }
189
+ /* Trailing text, such as a count, is label small. */
190
+ .np-item-end-text {
191
+ font-size: 0.6875rem;
192
+ line-height: 1rem;
193
+ font-weight: 500;
194
+ letter-spacing: 0.031rem;
150
195
  }
151
196
 
152
197
  .np-item-lazy {
@@ -197,17 +242,18 @@
197
242
  flex: 1;
198
243
  font-size: 1rem;
199
244
  line-height: 1.5rem;
245
+ letter-spacing: 0.031rem;
200
246
  }
201
247
  .np-item-supporting-text {
202
248
  color: var(--np-item-supporting-text-color, var(--np-color-on-surface-variant));
203
249
  font-size: 0.875rem;
204
250
  line-height: 1.25rem;
251
+ letter-spacing: 0.016rem;
205
252
  }
206
253
 
207
254
  .np-item.disabled {
208
255
  pointer-events: none;
209
256
  color: color-mix(in srgb, var(--np-color-on-surface) 38%, transparent);
210
- background-color: color-mix(in srgb, var(--np-color-on-surface) 10%, transparent);
211
257
  }
212
258
  .np-item.disabled .np-item-supporting-text {
213
259
  color: inherit;
@@ -1,10 +1,43 @@
1
1
  <script lang="ts">
2
+ import { arrowKeyNav, rovingTabindex } from '../keyboard-nav.js'
3
+ import { setListContext } from './context.js'
2
4
  import type { ListProps } from './types.js'
3
5
 
4
- let { element = $bindable(), children, ...attributes }: ListProps = $props()
6
+ let { element = $bindable(), selection, children, ...attributes }: ListProps = $props()
7
+
8
+ setListContext({
9
+ get selection() {
10
+ return selection
11
+ },
12
+ })
13
+
14
+ // The actions in a list share one tab stop, on the selected item or else the first. ↓ and → move
15
+ // to the next action, ↑ and ← back, wrapping at the ends.
16
+ const ACTIONS = "a[href], button, input:not([type='hidden']), select, textarea"
17
+ const roving = rovingTabindex(ACTIONS, {
18
+ current: "[aria-selected='true'], [aria-current]:not([aria-current='false']), .selected",
19
+ })
20
+ const arrows = arrowKeyNav(ACTIONS, 'both')
5
21
  </script>
6
22
 
7
- <ul bind:this={element} {...attributes}>
23
+ <ul
24
+ bind:this={element}
25
+ role={selection ? 'listbox' : undefined}
26
+ aria-multiselectable={selection === 'multiple' ? 'true' : undefined}
27
+ {...attributes}
28
+ {@attach roving}
29
+ onkeydown={(event) => {
30
+ attributes.onkeydown?.(event)
31
+ if (event.defaultPrevented) return
32
+ arrows(event)
33
+ // Every action follows Space and Enter, links included.
34
+ const target = event.target
35
+ if (event.key === ' ' && target instanceof HTMLAnchorElement && target.matches(ACTIONS)) {
36
+ event.preventDefault()
37
+ target.click()
38
+ }
39
+ }}
40
+ >
8
41
  {#if children}
9
42
  {@render children()}
10
43
  {/if}
@@ -1,10 +1,24 @@
1
1
  <script lang="ts">
2
2
  import Item from './Item.svelte'
3
+ import { getListContext } from './context.js'
3
4
  import type { ListItemProps } from './types.js'
4
5
 
5
6
  let { element = $bindable(), ...attributes }: ListItemProps = $props()
7
+
8
+ // In a listbox, the item is the option and the <li> around it steps aside.
9
+ const list = getListContext()
10
+ let option = $derived(!!list?.selection)
6
11
  </script>
7
12
 
8
- <li>
9
- <Item bind:element {...attributes} />
13
+ <li role={option ? 'none' : undefined}>
14
+ {#if option}
15
+ <Item
16
+ bind:element
17
+ {...attributes}
18
+ role="option"
19
+ aria-selected={attributes.selected ? 'true' : 'false'}
20
+ />
21
+ {:else}
22
+ <Item bind:element {...attributes} />
23
+ {/if}
10
24
  </li>
@@ -0,0 +1,6 @@
1
+ /** Tells the items of a `List` with `selection` to be options of a listbox. */
2
+ export interface ListContext {
3
+ readonly selection: 'single' | 'multiple' | undefined;
4
+ }
5
+ export declare const setListContext: (context: ListContext) => ListContext;
6
+ export declare const getListContext: () => ListContext | undefined;
@@ -0,0 +1,4 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ const key = Symbol('np-list');
3
+ export const setListContext = (context) => setContext(key, context);
4
+ export const getListContext = () => getContext(key);
@@ -5,6 +5,8 @@ export type ItemElement = HTMLButtonElement | HTMLAnchorElement | HTMLDivElement
5
5
  export interface ItemProps extends ElementKindProps<ItemElement> {
6
6
  selected?: boolean;
7
7
  start?: Snippet;
8
+ /** A 40px round avatar in place of `start`: initials as text, or a snippet holding an image. */
9
+ avatar?: string | Snippet;
8
10
  /** Trailing content, such as an icon or short supporting text like a count. */
9
11
  end?: string | Snippet;
10
12
  supportingText?: string | Snippet;
@@ -16,5 +18,10 @@ export interface ItemProps extends ElementKindProps<ItemElement> {
16
18
  }
17
19
  export type ListItemProps = ItemProps;
18
20
  export interface ListProps extends HTMLAttributes<HTMLUListElement> {
21
+ /**
22
+ * Makes the list a listbox whose items are options, announcing each item's `selected` state.
23
+ * The selection itself stays with you, through `selected` and `onclick` on the items.
24
+ */
25
+ selection?: 'single' | 'multiple';
19
26
  element?: HTMLUListElement;
20
27
  }
package/dist/media.d.ts CHANGED
@@ -2,3 +2,4 @@ import { MediaQuery } from 'svelte/reactivity';
2
2
  export declare const reducedMotion: MediaQuery;
3
3
  export declare const forcedColors: MediaQuery;
4
4
  export declare const coarsePointer: MediaQuery;
5
+ export declare const compactWindow: MediaQuery;
package/dist/media.js CHANGED
@@ -2,3 +2,4 @@ import { MediaQuery } from 'svelte/reactivity';
2
2
  export const reducedMotion = new MediaQuery('(prefers-reduced-motion: reduce)', false);
3
3
  export const forcedColors = new MediaQuery('(forced-colors: active)', false);
4
4
  export const coarsePointer = new MediaQuery('(hover: none) and (pointer: coarse)', false);
5
+ export const compactWindow = new MediaQuery('(max-width: 599.98px)', false);
@@ -1,11 +1,19 @@
1
1
  <script lang="ts">
2
- import { arrowKeyNav, rovingTabindex } from '../keyboard-nav.js'
2
+ import '../internal/exit.css'
3
+ import { exitAnimation, motionOf } from '../animation.js'
4
+ import { reducedMotion } from '../media.js'
5
+ import type { Attachment } from 'svelte/attachments'
6
+ import { arrowKeyNav, rovingTabindex, typeahead } from '../keyboard-nav.js'
3
7
  import { popoverController, syncOpenEffect } from '../popover.svelte.js'
4
8
  import type { MenuProps } from './types.js'
5
9
 
6
- const MENU_ITEM_SELECTOR = '[role="menuitem"]'
10
+ const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]'
7
11
  const attach = rovingTabindex(MENU_ITEM_SELECTOR)
8
12
  const arrowHandler = arrowKeyNav(MENU_ITEM_SELECTOR)
13
+ const typeaheadHandler = typeahead(
14
+ MENU_ITEM_SELECTOR,
15
+ (item) => (item.querySelector('.np-item-headline') ?? item).textContent ?? '',
16
+ )
9
17
 
10
18
  let {
11
19
  children,
@@ -25,6 +33,24 @@
25
33
 
26
34
  const controller = popoverController(() => element)
27
35
 
36
+ // Opens with a fade and a scale from the anchor, started just before the menu shows. Driven by
37
+ // script rather than @starting-style, which Safari does not apply again when a popover reopens.
38
+ const openAnimation: Attachment<HTMLElement> = (menu) => {
39
+ const onBeforeToggle = (event: Event) => {
40
+ if ((event as ToggleEvent).newState !== 'open' || reducedMotion.current) return
41
+ menu.animate(
42
+ [{ opacity: 0 }, { opacity: 1 }],
43
+ motionOf(menu, '--np-motion-expressive-fast-effects'),
44
+ )
45
+ menu.animate(
46
+ [{ scale: 0.8 }, { scale: 1 }],
47
+ motionOf(menu, '--np-motion-expressive-fast-spatial'),
48
+ )
49
+ }
50
+ menu.addEventListener('beforetoggle', onBeforeToggle)
51
+ return () => menu.removeEventListener('beforetoggle', onBeforeToggle)
52
+ }
53
+
28
54
  export const show = () => controller.show()
29
55
  export const close = () => controller.close()
30
56
 
@@ -74,9 +100,14 @@
74
100
  {...attributes}
75
101
  {role}
76
102
  bind:this={element}
103
+ {@attach exitAnimation}
104
+ {@attach openAnimation}
77
105
  ontoggle={(event) => {
78
106
  let { newState } = event
79
107
  open = newState === 'open'
108
+ if (open && role === 'menu') {
109
+ element?.querySelector<HTMLElement>(MENU_ITEM_SELECTOR)?.focus()
110
+ }
80
111
  ontoggle?.(event)
81
112
  }}
82
113
  {popover}
@@ -85,6 +116,18 @@
85
116
  onkeydown={(event) => {
86
117
  attributes.onkeydown?.(event)
87
118
  if (!event.defaultPrevented) arrowHandler(event)
119
+ if (!event.defaultPrevented) typeaheadHandler(event)
120
+ // Links only follow Enter, a menu item follows Space as well.
121
+ const item = event.target
122
+ if (
123
+ !event.defaultPrevented &&
124
+ event.key === ' ' &&
125
+ item instanceof HTMLAnchorElement &&
126
+ item.matches(MENU_ITEM_SELECTOR)
127
+ ) {
128
+ event.preventDefault()
129
+ item.click()
130
+ }
88
131
  }}
89
132
  >
90
133
  <div {@attach attach} bind:clientHeight={contentHeight} class="np-menu" role="none">
@@ -99,6 +142,17 @@
99
142
  flex: 1;
100
143
  padding: 0.5rem 0;
101
144
  }
145
+ /* Baseline menu items: 48dp high with 12dp at the sides, in action menus and listboxes alike. */
146
+ .np-menu-container:is([role='menu'], [role='listbox']) .np-menu {
147
+ --np-item-container-height: var(--np-menu-item-container-height, 3rem);
148
+ --np-item-padding-inline: var(--np-menu-item-padding-inline, 0.75rem);
149
+ --np-item-gap: var(--np-menu-item-gap, 0.75rem);
150
+ }
151
+ /* Select and AutoComplete match their field instead. */
152
+ .np-menu-container[role='menu'][popover] {
153
+ min-width: var(--np-menu-min-width, 7rem);
154
+ max-width: var(--np-menu-max-width, 17.5rem);
155
+ }
102
156
  :global(.np-menu .np-divider) {
103
157
  margin-block: 0.5rem;
104
158
  }
@@ -135,25 +189,23 @@
135
189
  align-self: center;
136
190
  }
137
191
 
138
- .np-menu-container:popover-open {
139
- opacity: 1;
140
- scale: 1;
141
- }
142
192
  @media (prefers-reduced-motion: no-preference) {
143
- .np-menu-container:popover-open {
144
- animation:
145
- fadeIn var(--np-motion-expressive-fast-effects),
146
- scaleIn var(--np-motion-expressive-fast-spatial);
147
- }
148
- }
149
- @keyframes fadeIn {
150
- from {
193
+ /* Closes with a fade and a slight shrink towards the anchor, on the default effects curve,
194
+ which is gentler at the start than the fast one. It opens through openAnimation.
195
+ NativeSelect's list closes the same way. */
196
+ .np-menu-container[popover] {
197
+ --_exit: var(--np-motion-expressive-default-effects);
151
198
  opacity: 0;
199
+ scale: 0.95;
200
+ transition:
201
+ opacity var(--_exit),
202
+ scale var(--_exit),
203
+ display var(--_exit) allow-discrete,
204
+ overlay var(--_exit) allow-discrete;
152
205
  }
153
- }
154
- @keyframes scaleIn {
155
- from {
156
- scale: 0.8;
206
+ .np-menu-container:popover-open {
207
+ opacity: 1;
208
+ scale: 1;
157
209
  }
158
210
  }
159
211
  </style>
@@ -1,7 +1,8 @@
1
+ import '../internal/exit.css';
1
2
  import type { MenuProps } from './types.js';
2
3
  declare const Menu: import("svelte").Component<MenuProps, {
3
4
  show: () => void;
4
5
  close: () => void;
5
- }, "element" | "open">;
6
+ }, "open" | "element">;
6
7
  type Menu = ReturnType<typeof Menu>;
7
8
  export default Menu;
@@ -2,15 +2,21 @@
2
2
  import Item from '../list/Item.svelte'
3
3
  import type { MenuItemProps } from './types.js'
4
4
 
5
- let { element = $bindable(), ...attributes }: MenuItemProps = $props()
5
+ let {
6
+ element = $bindable(),
7
+ role = 'menuitem',
8
+ selected,
9
+ ...attributes
10
+ }: MenuItemProps = $props()
6
11
  </script>
7
12
 
8
13
  <div class="np-menu-item" role="none">
9
- <!-- Without a link to follow, a menu item is still a button. -->
10
14
  <Item
11
- role="menuitem"
12
15
  bind:element
13
16
  {...attributes}
17
+ {role}
18
+ {selected}
19
+ aria-checked={role === 'menuitem' ? undefined : !!selected}
14
20
  type={attributes.type ?? (attributes.href == null ? 'button' : undefined)}
15
21
  />
16
22
  </div>
@@ -6,4 +6,10 @@ export interface MenuProps extends HTMLAttributes<HTMLDivElement> {
6
6
  open?: boolean;
7
7
  coverAnchor?: boolean;
8
8
  }
9
- export type MenuItemProps = Omit<ItemProps, 'variant' | 'softFocus'>;
9
+ export interface MenuItemProps extends Omit<ItemProps, 'variant' | 'softFocus' | 'role'> {
10
+ /**
11
+ * `menuitemradio` for one choice out of several, `menuitemcheckbox` for a choice that can be
12
+ * toggled on its own. Both announce `selected` as checked, and a checkbox keeps the menu open.
13
+ */
14
+ role?: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox';
15
+ }
@@ -40,11 +40,11 @@
40
40
  align-items: stretch;
41
41
  box-sizing: border-box;
42
42
  width: 100%;
43
- min-height: 5rem;
44
- padding-block: 0.75rem;
43
+ min-height: 4rem;
44
+ padding-block: 0.375rem;
45
45
  padding-inline: 0.5rem;
46
46
  background-color: var(--np-navigation-bar-container-color, var(--np-color-surface-container));
47
- padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
47
+ padding-bottom: max(0.375rem, env(safe-area-inset-bottom));
48
48
  z-index: 8;
49
49
  }
50
50
 
@@ -52,6 +52,12 @@
52
52
  justify-content: center;
53
53
  gap: 0.5rem;
54
54
  }
55
+ /* Horizontal items keep their own width and sit centred as a group, so extra space goes to
56
+ the ends of the bar. */
57
+ .np-navigation-bar:has(> :global(.np-navigation-bar-item-horizontal)) {
58
+ justify-content: center;
59
+ gap: 2rem;
60
+ }
55
61
  :global(.np-navigation-bar-centered .np-navigation-bar-item.np-navigation-bar-item) {
56
62
  flex: 0 0 auto;
57
63
  min-width: 0;