contain-css-svelte 1.1.11 → 1.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">import DropdownMenu from "../dropdowns/DropdownMenu.svelte";
|
|
2
2
|
import { onMount, tick } from "svelte";
|
|
3
|
-
let { value = $bindable(), children, "data-audit-action": dropdownAuditAction = null, ...restProps } = $props();
|
|
3
|
+
let { value = $bindable(), children, "data-audit-action": dropdownAuditAction = null, matchMode = "prefix", typeaheadMode = "focus", ...restProps } = $props();
|
|
4
4
|
let selectElement = $state();
|
|
5
5
|
let observer;
|
|
6
6
|
let resizeObserver;
|
|
@@ -95,7 +95,11 @@ $effect(() => {
|
|
|
95
95
|
{@render children?.()}
|
|
96
96
|
</select>
|
|
97
97
|
<div class="dropdown-wrapper" style:--target-width={targetWidth}>
|
|
98
|
-
<DropdownMenu
|
|
98
|
+
<DropdownMenu
|
|
99
|
+
triggerAuditAction={dropdownAuditAction}
|
|
100
|
+
{matchMode}
|
|
101
|
+
{typeaheadMode}
|
|
102
|
+
>
|
|
99
103
|
{#snippet label()}
|
|
100
104
|
<span
|
|
101
105
|
class="select-dropdown"
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { HTMLSelectAttributes } from "svelte/elements";
|
|
2
|
+
import type { MatchMode, TypeaheadMode } from "../dropdowns/DropdownMenu.svelte";
|
|
2
3
|
type Props = {
|
|
3
4
|
value?: any;
|
|
4
5
|
children?: import("svelte").Snippet;
|
|
5
6
|
"data-audit-action"?: string | null;
|
|
7
|
+
/** See {@link MatchMode} on DropdownMenu -- how type-ahead text is compared. */
|
|
8
|
+
matchMode?: MatchMode;
|
|
9
|
+
/** See {@link TypeaheadMode} on DropdownMenu -- focus a match or filter the list. */
|
|
10
|
+
typeaheadMode?: TypeaheadMode;
|
|
6
11
|
} & HTMLSelectAttributes;
|
|
7
12
|
declare const Select: import("svelte").Component<Props, {}, "value">;
|
|
8
13
|
type Select = ReturnType<typeof Select>;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
<script module lang="ts">
|
|
2
|
-
|
|
1
|
+
<script module lang="ts">var idPostfix = 1;
|
|
2
|
+
export {};
|
|
3
3
|
</script>
|
|
4
4
|
|
|
5
5
|
<script lang="ts">import { cssProperties } from "../cssprops";
|
|
6
6
|
import MenuList from "../layout/MenuList.svelte";
|
|
7
7
|
import { injectVars } from "../util";
|
|
8
8
|
import { onMount } from "svelte";
|
|
9
|
-
let { label, children, triggerAuditAction = null, ...props } = $props();
|
|
9
|
+
let { label, children, triggerAuditAction = null, matchMode = "prefix", typeaheadMode = "focus", ...props } = $props();
|
|
10
10
|
idPostfix++;
|
|
11
11
|
let id = "contain-dropdown-menu-" + idPostfix;
|
|
12
12
|
let buttonElement = $state();
|
|
@@ -65,48 +65,133 @@ function clearSearch() {
|
|
|
65
65
|
clearTimeout(clearTimer);
|
|
66
66
|
clearTimer = undefined;
|
|
67
67
|
searchString = "";
|
|
68
|
+
clearFilter();
|
|
68
69
|
}
|
|
69
70
|
const timeoutAfterMS = 2500; // 2.5 seconds seems more humane
|
|
70
71
|
function scheduleSearchClear() {
|
|
72
|
+
// In filter mode the buffer is a live filter, not a transient jump target --
|
|
73
|
+
// auto-clearing it mid-scroll would make the list flicker back to full
|
|
74
|
+
// length. It persists until Escape, close, or Backspace to empty.
|
|
75
|
+
if (typeaheadMode === "filter")
|
|
76
|
+
return;
|
|
71
77
|
clearTimeout(clearTimer);
|
|
72
78
|
clearTimer = setTimeout(clearSearch, timeoutAfterMS);
|
|
73
79
|
}
|
|
74
80
|
function handleKeystroke(event) {
|
|
75
81
|
if (event.key == "Backspace" && searchString) {
|
|
76
82
|
searchString = searchString.slice(0, -1);
|
|
83
|
+
applySearch();
|
|
77
84
|
scheduleSearchClear();
|
|
78
85
|
}
|
|
79
86
|
else if (event.key.length == 1) {
|
|
80
87
|
if (searchString || event.key != " ") {
|
|
81
88
|
searchString += event.key;
|
|
82
|
-
|
|
89
|
+
applySearch();
|
|
83
90
|
scheduleSearchClear();
|
|
84
91
|
}
|
|
85
92
|
}
|
|
86
|
-
else {
|
|
93
|
+
else if (event.key === "Escape") {
|
|
94
|
+
clearSearch();
|
|
95
|
+
popoverDiv?.hidePopover();
|
|
96
|
+
}
|
|
97
|
+
else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
98
|
+
event.preventDefault(); // Prevent default to stop scrolling the page
|
|
99
|
+
// In filter mode the buffer stays live so arrow keys (and Tab) cycle
|
|
100
|
+
// through the *filtered* rows -- type a few letters, then arrow down to
|
|
101
|
+
// the one you saw. In focus mode a navigation key ends the type-ahead.
|
|
102
|
+
if (typeaheadMode !== "filter")
|
|
103
|
+
clearSearch();
|
|
104
|
+
navigateMenu(event.key);
|
|
105
|
+
}
|
|
106
|
+
else if (typeaheadMode !== "filter") {
|
|
107
|
+
// Tab, Enter, Home/End, etc. -- end a focus-mode type-ahead session.
|
|
108
|
+
// Filter mode keeps the filter until Escape, close, or Backspace-to-empty.
|
|
87
109
|
clearSearch();
|
|
88
|
-
|
|
89
|
-
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Route the current buffer to whichever behavior this menu is configured for. */
|
|
113
|
+
function applySearch() {
|
|
114
|
+
if (typeaheadMode === "filter") {
|
|
115
|
+
applyFilter(searchString);
|
|
116
|
+
}
|
|
117
|
+
if (!searchString)
|
|
118
|
+
return;
|
|
119
|
+
const matched = maybeFocusMatch(searchString);
|
|
120
|
+
// In filter mode a filtered-out item may have been holding focus; if nothing
|
|
121
|
+
// matched, park focus on the trigger so keystrokes still reach this <nav>
|
|
122
|
+
// (Backspace to recover, Escape to close).
|
|
123
|
+
if (!matched && typeaheadMode === "filter")
|
|
124
|
+
buttonElement?.focus();
|
|
125
|
+
}
|
|
126
|
+
/** Does `text` satisfy `query` under the active {@link matchMode}? */
|
|
127
|
+
function textMatches(text, query) {
|
|
128
|
+
if (!query)
|
|
129
|
+
return true;
|
|
130
|
+
const haystack = text.toLowerCase();
|
|
131
|
+
const needle = query.toLowerCase();
|
|
132
|
+
if (matchMode === "substring")
|
|
133
|
+
return haystack.includes(needle);
|
|
134
|
+
if (matchMode === "word")
|
|
135
|
+
return haystack.split(/\s+/).some((word) => word.startsWith(needle));
|
|
136
|
+
return haystack.startsWith(needle); // "prefix"
|
|
137
|
+
}
|
|
138
|
+
function getFocusableItems(visibleOnly = false) {
|
|
139
|
+
if (!dropdownContentElement)
|
|
140
|
+
return [];
|
|
141
|
+
let items = Array.from(dropdownContentElement.querySelectorAll("button, a, [tabindex]:not([tabindex='-1'])"));
|
|
142
|
+
if (visibleOnly)
|
|
143
|
+
items = items.filter((el) => el.closest("[hidden]") === null);
|
|
144
|
+
return items;
|
|
145
|
+
}
|
|
146
|
+
/** The row we hide/show for a given item -- its wrapping <li>, or the item. */
|
|
147
|
+
function itemRow(el) {
|
|
148
|
+
return el.closest("li") ?? el;
|
|
149
|
+
}
|
|
150
|
+
function applyFilter(query) {
|
|
151
|
+
if (!dropdownContentElement)
|
|
152
|
+
return;
|
|
153
|
+
if (!query) {
|
|
154
|
+
clearFilter();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
for (const el of getFocusableItems()) {
|
|
158
|
+
const row = itemRow(el);
|
|
159
|
+
if (textMatches(el.textContent ?? "", query)) {
|
|
160
|
+
if (row.dataset.typeaheadFiltered) {
|
|
161
|
+
delete row.dataset.typeaheadFiltered;
|
|
162
|
+
row.hidden = false;
|
|
163
|
+
}
|
|
90
164
|
}
|
|
91
|
-
else if (
|
|
92
|
-
|
|
93
|
-
|
|
165
|
+
else if (!row.hidden) {
|
|
166
|
+
row.hidden = true;
|
|
167
|
+
row.dataset.typeaheadFiltered = "true";
|
|
94
168
|
}
|
|
95
169
|
}
|
|
170
|
+
// The popover just changed height; keep it anchored under the trigger.
|
|
171
|
+
computePosition();
|
|
96
172
|
}
|
|
97
|
-
|
|
173
|
+
/** Undo {@link applyFilter}, leaving any consumer-set `hidden` rows alone. */
|
|
174
|
+
function clearFilter() {
|
|
98
175
|
if (!dropdownContentElement)
|
|
99
176
|
return;
|
|
100
|
-
|
|
101
|
-
for (
|
|
102
|
-
|
|
103
|
-
|
|
177
|
+
const hiddenRows = dropdownContentElement.querySelectorAll("[data-typeahead-filtered]");
|
|
178
|
+
for (const row of hiddenRows) {
|
|
179
|
+
delete row.dataset.typeaheadFiltered;
|
|
180
|
+
row.hidden = false;
|
|
181
|
+
}
|
|
182
|
+
if (hiddenRows.length)
|
|
183
|
+
computePosition();
|
|
184
|
+
}
|
|
185
|
+
function maybeFocusMatch(searchString) {
|
|
186
|
+
for (const element of getFocusableItems(true)) {
|
|
187
|
+
if (element.textContent && textMatches(element.textContent, searchString)) {
|
|
104
188
|
if (element.focus) {
|
|
105
189
|
element.focus();
|
|
106
|
-
return;
|
|
190
|
+
return true;
|
|
107
191
|
}
|
|
108
192
|
}
|
|
109
193
|
}
|
|
194
|
+
return false;
|
|
110
195
|
}
|
|
111
196
|
function navigateMenu(direction) {
|
|
112
197
|
if (!popoverDiv?.matches(":popover-open") && buttonElement) {
|
|
@@ -115,7 +200,7 @@ function navigateMenu(direction) {
|
|
|
115
200
|
}
|
|
116
201
|
if (!dropdownContentElement)
|
|
117
202
|
return;
|
|
118
|
-
const focusableItems =
|
|
203
|
+
const focusableItems = getFocusableItems(true);
|
|
119
204
|
let currentIndex = focusableItems.findIndex((item) => item === document.activeElement);
|
|
120
205
|
if (direction === "ArrowDown") {
|
|
121
206
|
currentIndex = (currentIndex + 1) % focusableItems.length;
|
|
@@ -172,7 +257,9 @@ let popoverDiv = $state();
|
|
|
172
257
|
style:max-height="{dropdownMaxHeight}px"
|
|
173
258
|
>
|
|
174
259
|
{#if searchString}
|
|
175
|
-
<div class="search-hint
|
|
260
|
+
<div class="search-hint-wrapper">
|
|
261
|
+
<div class="search-hint" aria-hidden="true">{searchString}</div>
|
|
262
|
+
</div>
|
|
176
263
|
{/if}
|
|
177
264
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
178
265
|
<div
|
|
@@ -434,12 +521,19 @@ button {
|
|
|
434
521
|
overflow: hidden;
|
|
435
522
|
}
|
|
436
523
|
|
|
524
|
+
.search-hint-wrapper {
|
|
525
|
+
position: sticky;
|
|
526
|
+
top: 0;
|
|
527
|
+
right: 0;
|
|
528
|
+
height: 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
437
531
|
.search-hint {
|
|
438
|
-
position: absolute;
|
|
439
|
-
top: var(--search-hint-offset, 4px);
|
|
440
|
-
right: var(--search-hint-offset, 4px);
|
|
441
532
|
z-index: 2;
|
|
533
|
+
position: absolute;
|
|
534
|
+
right: 0;
|
|
442
535
|
pointer-events: none;
|
|
536
|
+
width: fit-content;
|
|
443
537
|
max-width: calc(100% - 2 * var(--search-hint-offset, 4px));
|
|
444
538
|
--link-bg: var(--search-hint-link-bg, var(--tag-link-bg, var(--secondary-link-bg, inherit)));
|
|
445
539
|
--link-fg: var(--search-hint-link-fg, var(--tag-link-fg, var(--secondary-link-fg, inherit)));
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the type-ahead search buffer is compared against each item's text.
|
|
3
|
+
* - "prefix" : item text must start with the query (default, classic
|
|
4
|
+
* <select> behavior)
|
|
5
|
+
* - "word" : any whitespace-delimited word in the item must start with
|
|
6
|
+
* the query -- typing "Boo" matches "Foo Boo Baz"
|
|
7
|
+
* - "substring" : query may appear anywhere in the item text
|
|
8
|
+
*/
|
|
9
|
+
export type MatchMode = "prefix" | "word" | "substring";
|
|
10
|
+
/**
|
|
11
|
+
* What a type-ahead match does:
|
|
12
|
+
* - "focus" : move focus to the first matching item (default)
|
|
13
|
+
* - "filter" : hide non-matching items so the list shrinks as you type;
|
|
14
|
+
* focus follows the first remaining match
|
|
15
|
+
*/
|
|
16
|
+
export type TypeaheadMode = "focus" | "filter";
|
|
1
17
|
import type { Snippet } from "svelte";
|
|
2
18
|
import type { DropdownMenuStyleProps } from "../types";
|
|
3
19
|
import type { HTMLAttributes } from "svelte/elements";
|
|
@@ -5,6 +21,8 @@ type Props = {
|
|
|
5
21
|
label?: Snippet;
|
|
6
22
|
children?: Snippet;
|
|
7
23
|
triggerAuditAction?: string | null;
|
|
24
|
+
matchMode?: MatchMode;
|
|
25
|
+
typeaheadMode?: TypeaheadMode;
|
|
8
26
|
} & DropdownMenuStyleProps & HTMLAttributes<HTMLDivElement>;
|
|
9
27
|
declare const DropdownMenu: import("svelte").Component<Props, {}, "">;
|
|
10
28
|
type DropdownMenu = ReturnType<typeof DropdownMenu>;
|
|
@@ -154,7 +154,7 @@ const style = $derived(injectVars(restProps, "menu", [
|
|
|
154
154
|
|
|
155
155
|
.menu :global(a), .menu :global(button), .menu :global(input[type="submit"]), .menu :global(.button) {
|
|
156
156
|
display: flex;
|
|
157
|
-
justify-content: var(--menu-item-justify,
|
|
157
|
+
justify-content: var(--menu-item-justify, start);
|
|
158
158
|
align-items: var(--menu-item-align, center);
|
|
159
159
|
width: var(--menu-item-width, 100%);
|
|
160
160
|
height: var(--menu-item-height);
|