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.
- package/dist/animation.d.ts +26 -1
- package/dist/animation.js +50 -2
- package/dist/app-bar/AppBar.svelte +71 -7
- package/dist/app-bar/types.d.ts +6 -0
- package/dist/autocomplete/AutoComplete.svelte +6 -31
- package/dist/autocomplete/AutoComplete.svelte.d.ts +1 -1
- package/dist/badge/Badge.svelte +12 -4
- package/dist/badge/description.d.ts +4 -0
- package/dist/badge/description.js +4 -0
- package/dist/button/Button.svelte +55 -20
- package/dist/button/ButtonGroup.svelte +139 -19
- package/dist/button/ButtonGroup.svelte.d.ts +1 -1
- package/dist/button/IconButton.svelte +49 -18
- package/dist/button/SegmentedButton.svelte +44 -4
- package/dist/button/SplitButton.svelte +77 -3
- package/dist/button/SplitButton.svelte.d.ts +1 -1
- package/dist/button/buttonGroup.d.ts +0 -4
- package/dist/button/buttonGroup.js +0 -9
- package/dist/button/groupContext.d.ts +10 -0
- package/dist/button/groupContext.js +4 -0
- package/dist/button/types.d.ts +19 -0
- package/dist/checkbox/Checkbox.svelte +5 -0
- package/dist/chip/ChipSet.svelte +28 -2
- package/dist/chip/FilterChip.svelte +32 -4
- package/dist/chip/InputChip.svelte +127 -26
- package/dist/chip/SuggestionChip.svelte +6 -22
- package/dist/chip/SuggestionChip.svelte.d.ts +1 -2
- package/dist/chip/types.d.ts +8 -1
- package/dist/date-picker/Calendar.svelte +4 -2
- package/dist/date-picker/DatePickerDialog.svelte +1 -0
- package/dist/date-picker/DatePickerDialog.svelte.d.ts +1 -1
- package/dist/date-picker/DateRangePicker.svelte +1 -0
- package/dist/date-picker/DateRangePicker.svelte.d.ts +1 -1
- package/dist/date-picker/DockedDatePicker.svelte +12 -5
- package/dist/date-picker/DockedDatePicker.svelte.d.ts +1 -1
- package/dist/date-picker/DockedDateTimePicker.svelte +12 -5
- package/dist/date-picker/DockedDateTimePicker.svelte.d.ts +1 -1
- package/dist/date-picker/YearGrid.svelte +4 -2
- package/dist/dialog/Dialog.svelte +171 -33
- package/dist/dialog/Dialog.svelte.d.ts +2 -1
- package/dist/dialog/types.d.ts +6 -0
- package/dist/divider/Divider.svelte +36 -10
- package/dist/divider/types.d.ts +1 -0
- package/dist/fab/ExtendedFab.svelte +26 -60
- package/dist/fab/Fab.svelte +13 -48
- package/dist/fab/FabMenu.svelte +102 -44
- package/dist/fab/FabMenu.svelte.d.ts +2 -1
- package/dist/fab/FabMenuItem.svelte +8 -0
- package/dist/fab/FabMenuItem.svelte.d.ts +4 -0
- package/dist/fab/index.d.ts +1 -0
- package/dist/fab/index.js +1 -0
- package/dist/fab/types.d.ts +6 -4
- package/dist/icons/MenuIcon.svelte +13 -0
- package/dist/icons/MenuIcon.svelte.d.ts +7 -0
- package/dist/icons/MenuOpenIcon.svelte +15 -0
- package/dist/icons/MenuOpenIcon.svelte.d.ts +7 -0
- package/dist/icons/index.d.ts +2 -0
- package/dist/icons/index.js +2 -0
- package/dist/internal/exit.css +47 -0
- package/dist/internal/fieldTypes.d.ts +6 -0
- package/dist/internal/focus-ring.css +4 -0
- package/dist/internal/interest.css +3 -1
- package/dist/keyboard-nav.d.ts +12 -1
- package/dist/keyboard-nav.js +104 -14
- package/dist/list/Item.svelte +52 -6
- package/dist/list/List.svelte +35 -2
- package/dist/list/ListItem.svelte +16 -2
- package/dist/list/context.d.ts +6 -0
- package/dist/list/context.js +4 -0
- package/dist/list/types.d.ts +7 -0
- package/dist/media.d.ts +1 -0
- package/dist/media.js +1 -0
- package/dist/menu/Menu.svelte +70 -18
- package/dist/menu/Menu.svelte.d.ts +2 -1
- package/dist/menu/MenuItem.svelte +9 -3
- package/dist/menu/types.d.ts +7 -1
- package/dist/navigation-bar/NavigationBar.svelte +9 -3
- package/dist/navigation-bar/NavigationBarItem.svelte +29 -13
- package/dist/navigation-drawer/NavigationDrawer.svelte +96 -20
- package/dist/navigation-drawer/NavigationDrawer.svelte.d.ts +2 -1
- package/dist/navigation-drawer/NavigationDrawerItem.svelte +8 -3
- package/dist/navigation-drawer/NavigationDrawerSection.svelte +49 -0
- package/dist/navigation-drawer/NavigationDrawerSection.svelte.d.ts +4 -0
- package/dist/navigation-drawer/index.d.ts +1 -0
- package/dist/navigation-drawer/index.js +1 -0
- package/dist/navigation-drawer/types.d.ts +12 -0
- package/dist/navigation-rail/NavigationRail.svelte +367 -7
- package/dist/navigation-rail/NavigationRail.svelte.d.ts +1 -1
- package/dist/navigation-rail/NavigationRailItem.svelte +190 -53
- package/dist/navigation-rail/NavigationRailSection.svelte +57 -0
- package/dist/navigation-rail/NavigationRailSection.svelte.d.ts +4 -0
- package/dist/navigation-rail/context.d.ts +6 -0
- package/dist/navigation-rail/context.js +5 -0
- package/dist/navigation-rail/index.d.ts +1 -0
- package/dist/navigation-rail/index.js +1 -0
- package/dist/navigation-rail/morph.d.ts +14 -0
- package/dist/navigation-rail/morph.js +78 -0
- package/dist/navigation-rail/types.d.ts +21 -0
- package/dist/progress/CircularProgress.svelte +2 -1
- package/dist/progress/LinearProgress.svelte +48 -20
- package/dist/progress/types.d.ts +1 -0
- package/dist/radio/Radio.svelte +7 -1
- package/dist/search/Search.svelte +245 -58
- package/dist/search/types.d.ts +4 -0
- package/dist/select/NativeSelect.svelte +20 -12
- package/dist/select/Option.svelte +1 -2
- package/dist/select/Select.svelte +59 -140
- package/dist/select/VirtualList.svelte +16 -1
- package/dist/select/VirtualList.svelte.d.ts +6 -2
- package/dist/sheet/Sheet.svelte +223 -5
- package/dist/sheet/Sheet.svelte.d.ts +3 -1
- package/dist/sheet/types.d.ts +10 -0
- package/dist/slider/Slider.svelte +1 -27
- package/dist/snackbar/Snackbar.svelte +3 -0
- package/dist/snackbar/Snackbar.svelte.d.ts +2 -1
- package/dist/switch/Switch.svelte +51 -11
- package/dist/tabs/Tab.svelte +28 -14
- package/dist/tabs/Tabs.svelte +28 -4
- package/dist/tabs/types.d.ts +3 -0
- package/dist/text-field/TextField.svelte +49 -13
- package/dist/time-picker/DockedTimePicker.svelte +6 -1
- package/dist/time-picker/DockedTimePicker.svelte.d.ts +1 -1
- package/dist/time-picker/TimePickerDialog.svelte +1 -0
- package/dist/time-picker/TimePickerDialog.svelte.d.ts +1 -1
- package/dist/toolbar/Toolbar.svelte +21 -11
- package/dist/toolbar/types.d.ts +1 -0
- package/dist/tooltip/RichTooltip.svelte.d.ts +1 -1
- package/dist/tooltip/Tooltip.svelte +3 -1
- package/dist/tooltip/Tooltip.svelte.d.ts +1 -1
- package/package.json +1 -1
package/dist/keyboard-nav.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { on } from 'svelte/events';
|
|
2
|
-
|
|
3
|
-
const
|
|
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 = () =>
|
|
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.
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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
|
+
};
|
package/dist/list/Item.svelte
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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:
|
|
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;
|
package/dist/list/List.svelte
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/list/types.d.ts
CHANGED
|
@@ -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
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);
|
package/dist/menu/Menu.svelte
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
}, "
|
|
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 {
|
|
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>
|
package/dist/menu/types.d.ts
CHANGED
|
@@ -6,4 +6,10 @@ export interface MenuProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
6
6
|
open?: boolean;
|
|
7
7
|
coverAnchor?: boolean;
|
|
8
8
|
}
|
|
9
|
-
export
|
|
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:
|
|
44
|
-
padding-block: 0.
|
|
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.
|
|
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;
|