contain-css-svelte 1.1.10 → 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.
- package/dist/controls/Option.svelte +31 -3
- package/dist/controls/Select.svelte +34 -11
- package/dist/controls/Select.svelte.d.ts +5 -0
- package/dist/controls/TabItem.svelte +1 -2
- package/dist/cssprops.js +1 -0
- package/dist/dropdowns/DropdownMenu.svelte +190 -21
- package/dist/dropdowns/DropdownMenu.svelte.d.ts +18 -0
- package/dist/layout/MenuList.svelte +11 -5
- package/dist/layout/Table.svelte +20 -4
- package/dist/sass/_affordances.scss +27 -0
- package/dist/vars/affordances.css +8 -2
- package/dist/vars/colors.css +4 -1
- package/dist/vars/defaults.css +12 -3
- package/package.json +1 -1
|
@@ -1,8 +1,36 @@
|
|
|
1
1
|
<script lang="ts">let { children, value, ...restProps } = $props();
|
|
2
2
|
let template = $state();
|
|
3
|
-
//
|
|
4
|
-
let textContent = $
|
|
5
|
-
let htmlContent = $
|
|
3
|
+
// Extracted from the rendered children, in the DOM.
|
|
4
|
+
let textContent = $state("");
|
|
5
|
+
let htmlContent = $state("");
|
|
6
|
+
function syncFromTemplate() {
|
|
7
|
+
if (!template)
|
|
8
|
+
return;
|
|
9
|
+
textContent = template.textContent ?? "";
|
|
10
|
+
htmlContent = template.innerHTML;
|
|
11
|
+
}
|
|
12
|
+
/*
|
|
13
|
+
These have to be read back out of the DOM, and a $derived would only
|
|
14
|
+
recompute when `template` itself changed -- which it never does once the
|
|
15
|
+
element is bound. So rendering different content into the snippet (renaming
|
|
16
|
+
a label, say) left the <option> showing whatever it was first given.
|
|
17
|
+
|
|
18
|
+
Watching the template covers that: any change to the rendered children
|
|
19
|
+
re-extracts the html and text.
|
|
20
|
+
*/
|
|
21
|
+
$effect(() => {
|
|
22
|
+
if (!template)
|
|
23
|
+
return;
|
|
24
|
+
syncFromTemplate();
|
|
25
|
+
const observer = new MutationObserver(syncFromTemplate);
|
|
26
|
+
observer.observe(template, {
|
|
27
|
+
childList: true,
|
|
28
|
+
subtree: true,
|
|
29
|
+
characterData: true,
|
|
30
|
+
attributes: true,
|
|
31
|
+
});
|
|
32
|
+
return () => observer.disconnect();
|
|
33
|
+
});
|
|
6
34
|
export {};
|
|
7
35
|
</script>
|
|
8
36
|
|
|
@@ -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;
|
|
@@ -8,16 +8,25 @@ let targetWidth = $state("");
|
|
|
8
8
|
let optionButtons = $state([]);
|
|
9
9
|
onMount(() => {
|
|
10
10
|
tick().then(() => updateOptions());
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
11
|
+
/*
|
|
12
|
+
Watch the options for any change, not just being added or removed. An
|
|
13
|
+
<Option> whose content is rewritten in place -- a label being renamed --
|
|
14
|
+
updates its own data-html without touching the child list, and a
|
|
15
|
+
childList-only observer never hears about it, so the dropdown kept
|
|
16
|
+
rendering the snapshot it took on mount.
|
|
17
|
+
|
|
18
|
+
Coalesced into one pass per microtask: a single re-render can produce a
|
|
19
|
+
burst of mutations, and updateOptions() measures layout.
|
|
20
|
+
*/
|
|
21
|
+
observer = new MutationObserver(() => scheduleUpdateOptions());
|
|
19
22
|
if (selectElement) {
|
|
20
|
-
observer.observe(selectElement, {
|
|
23
|
+
observer.observe(selectElement, {
|
|
24
|
+
childList: true,
|
|
25
|
+
subtree: true,
|
|
26
|
+
characterData: true,
|
|
27
|
+
attributes: true,
|
|
28
|
+
attributeFilter: ["data-html", "value", "label", "selected"],
|
|
29
|
+
});
|
|
21
30
|
}
|
|
22
31
|
// Observe size changes in option buttons
|
|
23
32
|
resizeObserver = new ResizeObserver(() => updateTargetWidth());
|
|
@@ -29,6 +38,16 @@ onMount(() => {
|
|
|
29
38
|
});
|
|
30
39
|
let options = $state([]);
|
|
31
40
|
let activeOption = $state(null);
|
|
41
|
+
let updateQueued = false;
|
|
42
|
+
function scheduleUpdateOptions() {
|
|
43
|
+
if (updateQueued)
|
|
44
|
+
return;
|
|
45
|
+
updateQueued = true;
|
|
46
|
+
queueMicrotask(() => {
|
|
47
|
+
updateQueued = false;
|
|
48
|
+
updateOptions();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
32
51
|
function updateOptions() {
|
|
33
52
|
if (!selectElement) {
|
|
34
53
|
return;
|
|
@@ -76,7 +95,11 @@ $effect(() => {
|
|
|
76
95
|
{@render children?.()}
|
|
77
96
|
</select>
|
|
78
97
|
<div class="dropdown-wrapper" style:--target-width={targetWidth}>
|
|
79
|
-
<DropdownMenu
|
|
98
|
+
<DropdownMenu
|
|
99
|
+
triggerAuditAction={dropdownAuditAction}
|
|
100
|
+
{matchMode}
|
|
101
|
+
{typeaheadMode}
|
|
102
|
+
>
|
|
80
103
|
{#snippet label()}
|
|
81
104
|
<span
|
|
82
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>;
|
|
@@ -73,8 +73,7 @@ const style = $derived(injectVars(restProps, "tab", ["bg", "fg", "padding", "wid
|
|
|
73
73
|
.tab > :global(button):focus-visible,
|
|
74
74
|
.tab > :global(div > button):focus-visible {
|
|
75
75
|
outline: var(--focus-color, -webkit-focus-ring-color) auto 1px;
|
|
76
|
-
outline-offset: var(--focus-outline-offset,
|
|
77
|
-
box-shadow: var(--focus-ring-box-shadow, 0 0 0 3px var(--focus-shadow-color, rgba(100, 150, 250, 0.5)));
|
|
76
|
+
outline-offset: var(--focus-inset-outline-offset, -3px);
|
|
78
77
|
}
|
|
79
78
|
|
|
80
79
|
.tab > :global(button),
|
package/dist/cssprops.js
CHANGED
|
@@ -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();
|
|
@@ -56,44 +56,142 @@ function dismissPopover(_e) {
|
|
|
56
56
|
}
|
|
57
57
|
function handleToggle(event) {
|
|
58
58
|
isOpen = event.newState === "open";
|
|
59
|
+
if (!isOpen)
|
|
60
|
+
clearSearch();
|
|
61
|
+
}
|
|
62
|
+
let searchString = $state("");
|
|
63
|
+
let clearTimer;
|
|
64
|
+
function clearSearch() {
|
|
65
|
+
clearTimeout(clearTimer);
|
|
66
|
+
clearTimer = undefined;
|
|
67
|
+
searchString = "";
|
|
68
|
+
clearFilter();
|
|
69
|
+
}
|
|
70
|
+
const timeoutAfterMS = 2500; // 2.5 seconds seems more humane
|
|
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;
|
|
77
|
+
clearTimeout(clearTimer);
|
|
78
|
+
clearTimer = setTimeout(clearSearch, timeoutAfterMS);
|
|
59
79
|
}
|
|
60
|
-
let searchString = "";
|
|
61
|
-
let lastPress;
|
|
62
80
|
function handleKeystroke(event) {
|
|
63
81
|
if (event.key == "Backspace" && searchString) {
|
|
64
82
|
searchString = searchString.slice(0, -1);
|
|
83
|
+
applySearch();
|
|
84
|
+
scheduleSearchClear();
|
|
65
85
|
}
|
|
66
86
|
else if (event.key.length == 1) {
|
|
67
87
|
if (searchString || event.key != " ") {
|
|
68
88
|
searchString += event.key;
|
|
69
|
-
|
|
89
|
+
applySearch();
|
|
90
|
+
scheduleSearchClear();
|
|
70
91
|
}
|
|
71
92
|
}
|
|
72
|
-
else {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
109
|
+
clearSearch();
|
|
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
|
+
}
|
|
76
164
|
}
|
|
77
|
-
else if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
searchString = "";
|
|
165
|
+
else if (!row.hidden) {
|
|
166
|
+
row.hidden = true;
|
|
167
|
+
row.dataset.typeaheadFiltered = "true";
|
|
81
168
|
}
|
|
82
169
|
}
|
|
170
|
+
// The popover just changed height; keep it anchored under the trigger.
|
|
171
|
+
computePosition();
|
|
83
172
|
}
|
|
84
|
-
|
|
173
|
+
/** Undo {@link applyFilter}, leaving any consumer-set `hidden` rows alone. */
|
|
174
|
+
function clearFilter() {
|
|
85
175
|
if (!dropdownContentElement)
|
|
86
176
|
return;
|
|
87
|
-
|
|
88
|
-
for (
|
|
89
|
-
|
|
90
|
-
|
|
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)) {
|
|
91
188
|
if (element.focus) {
|
|
92
189
|
element.focus();
|
|
93
|
-
return;
|
|
190
|
+
return true;
|
|
94
191
|
}
|
|
95
192
|
}
|
|
96
193
|
}
|
|
194
|
+
return false;
|
|
97
195
|
}
|
|
98
196
|
function navigateMenu(direction) {
|
|
99
197
|
if (!popoverDiv?.matches(":popover-open") && buttonElement) {
|
|
@@ -102,7 +200,7 @@ function navigateMenu(direction) {
|
|
|
102
200
|
}
|
|
103
201
|
if (!dropdownContentElement)
|
|
104
202
|
return;
|
|
105
|
-
const focusableItems =
|
|
203
|
+
const focusableItems = getFocusableItems(true);
|
|
106
204
|
let currentIndex = focusableItems.findIndex((item) => item === document.activeElement);
|
|
107
205
|
if (direction === "ArrowDown") {
|
|
108
206
|
currentIndex = (currentIndex + 1) % focusableItems.length;
|
|
@@ -158,6 +256,11 @@ let popoverDiv = $state();
|
|
|
158
256
|
style:left="{dropdownLeft}px"
|
|
159
257
|
style:max-height="{dropdownMaxHeight}px"
|
|
160
258
|
>
|
|
259
|
+
{#if searchString}
|
|
260
|
+
<div class="search-hint-wrapper">
|
|
261
|
+
<div class="search-hint" aria-hidden="true">{searchString}</div>
|
|
262
|
+
</div>
|
|
263
|
+
{/if}
|
|
161
264
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
162
265
|
<div
|
|
163
266
|
class="dropdown-content"
|
|
@@ -418,6 +521,72 @@ button {
|
|
|
418
521
|
overflow: hidden;
|
|
419
522
|
}
|
|
420
523
|
|
|
524
|
+
.search-hint-wrapper {
|
|
525
|
+
position: sticky;
|
|
526
|
+
top: 0;
|
|
527
|
+
right: 0;
|
|
528
|
+
height: 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.search-hint {
|
|
532
|
+
z-index: 2;
|
|
533
|
+
position: absolute;
|
|
534
|
+
right: 0;
|
|
535
|
+
pointer-events: none;
|
|
536
|
+
width: fit-content;
|
|
537
|
+
max-width: calc(100% - 2 * var(--search-hint-offset, 4px));
|
|
538
|
+
--link-bg: var(--search-hint-link-bg, var(--tag-link-bg, var(--secondary-link-bg, inherit)));
|
|
539
|
+
--link-fg: var(--search-hint-link-fg, var(--tag-link-fg, var(--secondary-link-fg, inherit)));
|
|
540
|
+
--_bg-mix-color: var(--search-hint-bg-mix-color, var(--tag-bg-mix-color, var(--secondary-bg-mix-color, var(--bg-mix-color, transparent))));
|
|
541
|
+
--_bg-mix-amount: var(--search-hint-bg-mix-amount, var(--tag-bg-mix-amount, var(--secondary-bg-mix-amount, var(--bg-mix-amount, 0%))));
|
|
542
|
+
--_fg-mix-color: var(--search-hint-fg-mix-color, var(--tag-fg-mix-color, var(--secondary-fg-mix-color, var(--fg-mix-color, transparent))));
|
|
543
|
+
--_fg-mix-amount: var(--search-hint-fg-mix-amount, var(--tag-fg-mix-amount, var(--secondary-fg-mix-amount, var(--fg-mix-amount, 0%))));
|
|
544
|
+
--_bg-base: var(--search-hint-bg, var(--tag-bg, var(--secondary-bg, var(--bg, unset))));
|
|
545
|
+
--_fg-base: var(--search-hint-fg, var(--tag-fg, var(--secondary-fg, var(--fg, unset))));
|
|
546
|
+
--_background-color: color-mix(
|
|
547
|
+
in srgb,
|
|
548
|
+
var(--_bg-base),
|
|
549
|
+
var(--_bg-mix-color) var(--_bg-mix-amount)
|
|
550
|
+
);
|
|
551
|
+
--_color: color-mix(in srgb, var(--_fg-base), var(--_fg-mix-color) var(--_fg-mix-amount));
|
|
552
|
+
background-color: var(--_background-color);
|
|
553
|
+
color: var(--_color);
|
|
554
|
+
font-family: var(--search-hint-font-family, var(--tag-font-family, inherit));
|
|
555
|
+
text-transform: var(--search-hint-text-transform, var(--tag-text-transform, inherit));
|
|
556
|
+
text-decoration: var(--search-hint-text-decoration, var(--tag-text-decoration, inherit));
|
|
557
|
+
--_font-size: var(--search-hint-font-size, var(--tag-font-size, inherit));
|
|
558
|
+
font-size: var(--_font-size);
|
|
559
|
+
font-weight: var(--search-hint-font-weight, var(--tag-font-weight, inherit));
|
|
560
|
+
line-height: var(--search-hint-line-height, var(--tag-line-height, inherit));
|
|
561
|
+
letter-spacing: var(--search-hint-letter-spacing, var(--tag-letter-spacing, inherit));
|
|
562
|
+
text-indent: var(--search-hint-indent, var(--tag-indent, inherit));
|
|
563
|
+
font-variant: var(--search-hint-font-variant, var(--tag-font-variant, inherit));
|
|
564
|
+
text-align: var(--search-hint-text-align, var(--tag-text-align, inherit));
|
|
565
|
+
box-sizing: border-box;
|
|
566
|
+
--_padding: var(--search-hint-padding, var(--tag-padding, var(--padding, 4px)));
|
|
567
|
+
padding: var(--_padding);
|
|
568
|
+
border: var(--search-hint-border, var(--tag-border, var(--border, inherit)));
|
|
569
|
+
border-width: var(--search-hint-border-width, var(--tag-border-width, var(--__missing-border-width)));
|
|
570
|
+
border-style: var(--search-hint-border-style, var(--tag-border-style, var(--__missing-border-style)));
|
|
571
|
+
border-color: var(--search-hint-border-color, var(--tag-border-color, var(--__missing-border-color)));
|
|
572
|
+
border-top: var(--search-hint-border-top, var(--tag-border-top, var(--border-top, var(--search-hint-border, var(--tag-border, var(--border, none))))));
|
|
573
|
+
border-right: var(--search-hint-border-right, var(--tag-border-right, var(--border-right, var(--search-hint-border, var(--tag-border, var(--border, none))))));
|
|
574
|
+
border-bottom: var(--search-hint-border-bottom, var(--tag-border-bottom, var(--border-bottom, var(--search-hint-border, var(--tag-border, var(--border, none))))));
|
|
575
|
+
border-left: var(--search-hint-border-left, var(--tag-border-left, var(--border-left, var(--search-hint-border, var(--tag-border, var(--border, none))))));
|
|
576
|
+
border-radius: var(--search-hint-border-radius, var(--tag-border-radius, var(--border-radius, none)));
|
|
577
|
+
font-size: var(--search-hint-font-size, var(--font-size-small, 0.75rem));
|
|
578
|
+
padding: var(--search-hint-padding, 0.2em 0.55em);
|
|
579
|
+
font-variant-numeric: tabular-nums;
|
|
580
|
+
white-space: nowrap;
|
|
581
|
+
overflow: hidden;
|
|
582
|
+
text-overflow: ellipsis;
|
|
583
|
+
border-radius: var(--search-hint-radius, var(--border-radius, 4px));
|
|
584
|
+
--_box-shadow:
|
|
585
|
+
var(--search-hint-shadow-distance, var(--dropdown-shadow-distance, var(--shadow-distance, var(--space)))) var(--search-hint-shadow-distance, var(--dropdown-shadow-distance, var(--shadow-distance, var(--space)))) var(--search-hint-shadow-blur, var(--dropdown-shadow-blur, var(--shadow-blur, var(--space)))) var(--search-hint-shadow-color, var(--dropdown-shadow-color, var(--shadow-color, rgba(127, 127, 127, 0.4))));
|
|
586
|
+
box-shadow: var(--_box-shadow);
|
|
587
|
+
opacity: var(--search-hint-opacity, 0.95);
|
|
588
|
+
}
|
|
589
|
+
|
|
421
590
|
.dropdown-content :global(button),
|
|
422
591
|
.dropdown-content :global(a) {
|
|
423
592
|
white-space: var(--dropdown-wrap-mode, wrap);
|
|
@@ -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>;
|
|
@@ -139,9 +139,12 @@ const style = $derived(injectVars(restProps, "menu", [
|
|
|
139
139
|
.menu :global(li.interactive):focus-visible,
|
|
140
140
|
.menu :global(li[role="button"]):focus-visible,
|
|
141
141
|
.menu :global(li[tabindex]:not([tabindex="-1"])):focus-visible {
|
|
142
|
+
filter: var(--menu-item-hover-filter, var(--hover-filter, brightness(1.05)));
|
|
143
|
+
transform: var(--menu-item-hover-transform, var(--hover-transform, none));
|
|
144
|
+
background-color: color-mix(in oklch, var(--_background-color) var(--hover-base-color-percentage, 90%), var(--hover-color-mix, white) calc(100% - var(--hover-base-color-percentage, 90%)));
|
|
145
|
+
box-shadow: var(--menu-item-hover-box-shadow, var(--hover-box-shadow, var(--_box-shadow, none)));
|
|
142
146
|
outline: var(--focus-color, -webkit-focus-ring-color) auto 1px;
|
|
143
|
-
outline-offset: var(--focus-outline-offset,
|
|
144
|
-
box-shadow: var(--focus-ring-box-shadow, 0 0 0 3px var(--focus-shadow-color, rgba(100, 150, 250, 0.5)));
|
|
147
|
+
outline-offset: var(--focus-inset-outline-offset, -3px);
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
.menu :global(li > .subheader) {
|
|
@@ -151,7 +154,7 @@ const style = $derived(injectVars(restProps, "menu", [
|
|
|
151
154
|
|
|
152
155
|
.menu :global(a), .menu :global(button), .menu :global(input[type="submit"]), .menu :global(.button) {
|
|
153
156
|
display: flex;
|
|
154
|
-
justify-content: var(--menu-item-justify,
|
|
157
|
+
justify-content: var(--menu-item-justify, start);
|
|
155
158
|
align-items: var(--menu-item-align, center);
|
|
156
159
|
width: var(--menu-item-width, 100%);
|
|
157
160
|
height: var(--menu-item-height);
|
|
@@ -205,9 +208,12 @@ const style = $derived(injectVars(restProps, "menu", [
|
|
|
205
208
|
}
|
|
206
209
|
|
|
207
210
|
.menu :global(a):focus-visible, .menu :global(button):focus-visible, .menu :global(input[type="submit"]):focus-visible, .menu :global(.button):focus-visible {
|
|
211
|
+
filter: var(--menu-item-hover-filter, var(--hover-filter, brightness(1.05)));
|
|
212
|
+
transform: var(--menu-item-hover-transform, var(--hover-transform, none));
|
|
213
|
+
background-color: color-mix(in oklch, var(--_background-color) var(--hover-base-color-percentage, 90%), var(--hover-color-mix, white) calc(100% - var(--hover-base-color-percentage, 90%)));
|
|
214
|
+
box-shadow: var(--menu-item-hover-box-shadow, var(--hover-box-shadow, var(--_box-shadow, none)));
|
|
208
215
|
outline: var(--focus-color, -webkit-focus-ring-color) auto 1px;
|
|
209
|
-
outline-offset: var(--focus-outline-offset,
|
|
210
|
-
box-shadow: var(--focus-ring-box-shadow, 0 0 0 3px var(--focus-shadow-color, rgba(100, 150, 250, 0.5)));
|
|
216
|
+
outline-offset: var(--focus-inset-outline-offset, -3px);
|
|
211
217
|
}
|
|
212
218
|
|
|
213
219
|
.menu :global(a), .menu :global(button), .menu :global(input[type="submit"]), .menu :global(.button) {
|
package/dist/layout/Table.svelte
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
let { sticky = false, column_widths = null, thead, tbody, children, } = $props();
|
|
1
|
+
<script lang="ts">let { sticky = false, column_widths = null, thead, tbody, children, } = $props();
|
|
3
2
|
// svelte-ignore state_referenced_locally
|
|
4
3
|
let columns = $state(column_widths || []);
|
|
5
4
|
/* Code for syncing column widths for scrolling table solution */
|
|
@@ -205,6 +204,10 @@ function setupWidths() {
|
|
|
205
204
|
container queries don't get their styles overridden by large media queries.
|
|
206
205
|
*/
|
|
207
206
|
/* Convenience groupings */
|
|
207
|
+
table:has(colgroup) {
|
|
208
|
+
table-layout: fixed;
|
|
209
|
+
}
|
|
210
|
+
|
|
208
211
|
table {
|
|
209
212
|
--link-bg: var(--table-link-bg, var(--surface-link-bg, inherit));
|
|
210
213
|
--link-fg: var(--table-link-fg, var(--surface-link-fg, inherit));
|
|
@@ -621,7 +624,7 @@ table :global(tr > th:first-child:has(~ td)) {
|
|
|
621
624
|
position: sticky;
|
|
622
625
|
top: 0;
|
|
623
626
|
z-index: 1;
|
|
624
|
-
background: var(--white);
|
|
627
|
+
background: var(--_background-color, var(--white, #fff));
|
|
625
628
|
margin-inline-start: auto;
|
|
626
629
|
margin-inline-end: auto;
|
|
627
630
|
}
|
|
@@ -637,7 +640,7 @@ table :global(tr > th:first-child:has(~ td)) {
|
|
|
637
640
|
}
|
|
638
641
|
|
|
639
642
|
.veil {
|
|
640
|
-
background-color: var(--white, #fff);
|
|
643
|
+
background-color: var(--_background-color, var(--white, #fff));
|
|
641
644
|
position: sticky;
|
|
642
645
|
top: -2em;
|
|
643
646
|
height: 3em;
|
|
@@ -808,4 +811,17 @@ table :global(td[tabindex]):focus-visible {
|
|
|
808
811
|
outline: var(--focus-color, -webkit-focus-ring-color) auto 1px;
|
|
809
812
|
outline-offset: var(--focus-outline-offset, 2px);
|
|
810
813
|
box-shadow: var(--focus-ring-box-shadow, 0 0 0 3px var(--focus-shadow-color, rgba(100, 150, 250, 0.5)));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/* Apply thick border to column 1 in BOTH header and body tables,
|
|
817
|
+
BUT ONLY IF the scrolling table contains row headers in its tbody */
|
|
818
|
+
.scrolling-table:has(.scrolling-table-body :global(tbody > tr > th:first-child)) .fixed-table-head :global(tr > th:first-child),
|
|
819
|
+
.scrolling-table:has(.scrolling-table-body :global(tbody > tr > th:first-child)) .scrolling-table-body :global(tbody > tr > th:first-child) {
|
|
820
|
+
border-right: var(--table-first-row-bottom-border, var(--table-thick-border, 3px) solid var(--table-first-row-border-color, var(--secondary-bg))) !important;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/* 2. Standard non-scrolling single <table> setup */
|
|
824
|
+
table:has(:global(tbody > tr > th:first-child)) :global(thead > tr > th:first-child),
|
|
825
|
+
table:has(:global(tbody > tr > th:first-child)) :global(tbody > tr > th:first-child) {
|
|
826
|
+
border-right: var(--table-first-row-bottom-border, var(--table-thick-border, 3px) solid var(--table-first-row-border-color, var(--secondary-bg)));
|
|
811
827
|
}</style>
|
|
@@ -109,6 +109,33 @@
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Focus ring for controls living inside an overflow:hidden container
|
|
113
|
+
// (menus, tab bars). Drawn with a negative outline-offset so it sits inside
|
|
114
|
+
// the control and is not clipped by the container. Defined after `clickable`
|
|
115
|
+
// so `clickable-hover-affordance` is in scope.
|
|
116
|
+
@mixin focus-ring-inset {
|
|
117
|
+
outline: var(--focus-color, -webkit-focus-ring-color) auto 1px;
|
|
118
|
+
outline-offset: var(--focus-inset-outline-offset, -3px);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Clip-safe focus indicator: just the inset ring, no fill change. For
|
|
122
|
+
// controls that already carry their own resting/active styling (tabs).
|
|
123
|
+
@mixin focusable-inset($prefixes...) {
|
|
124
|
+
&:focus-visible {
|
|
125
|
+
@include focus-ring-inset();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// As `focusable-inset`, plus the hover affordance for the fill, so keyboard
|
|
130
|
+
// focus reads the same as pointer hover. For controls with no resting
|
|
131
|
+
// visual weight of their own (menu items).
|
|
132
|
+
@mixin focusable-as-hover($prefixes...) {
|
|
133
|
+
&:focus-visible {
|
|
134
|
+
@include clickable-hover-affordance($prefixes...);
|
|
135
|
+
@include focus-ring-inset();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
112
139
|
@mixin custom-scrollbar($prefixes...) {
|
|
113
140
|
overflow-y: auto;
|
|
114
141
|
|
|
@@ -20,8 +20,14 @@
|
|
|
20
20
|
--menu-item-hover-filter-custom: ;
|
|
21
21
|
--menu-item-hover-filter: var(--menu-item-hover-filter-brightness-hack,)
|
|
22
22
|
var(--menu-item-hover-filter-custom,);
|
|
23
|
-
/*
|
|
24
|
-
|
|
23
|
+
/* NB: do NOT default --menu-item-bg here. Declared at :root it would freeze
|
|
24
|
+
to var(--bg) (--menu-bg is not set yet at :root) and then win the
|
|
25
|
+
color-props(menu-item, menu, ...) chain ahead of a per-instance --menu-bg,
|
|
26
|
+
breaking overrides like <Menu --menu-bg="#111">. The chain already ends at
|
|
27
|
+
--bg, and color-props now computes an always-opaque --_background-color on
|
|
28
|
+
the element (evaluated where --menu-bg is live), which the hover/active
|
|
29
|
+
filter affordances derive from -- so the "give filters an opaque surface"
|
|
30
|
+
goal is met structurally without a frozen root default. */
|
|
25
31
|
--menu-item-active-filter-custom: ;
|
|
26
32
|
--menu-item-active-filter: var(--menu-item-active-filter-custom,);
|
|
27
33
|
/* --button-hover-transform: var(var(--button-hover-transform));
|
package/dist/vars/colors.css
CHANGED
|
@@ -36,7 +36,10 @@
|
|
|
36
36
|
--surface-fg: var(--fg);
|
|
37
37
|
--surface-link-fg: var(--link-fg, var(--primary-bg));
|
|
38
38
|
--container-link-fg: var(--surface-link-fg);
|
|
39
|
-
--menu-item-bg:
|
|
39
|
+
/* --menu-item-bg intentionally not defaulted at :root -- see the note in
|
|
40
|
+
affordances.css. The color-props(menu-item, menu, button, control) chain
|
|
41
|
+
resolves it to --menu-bg / --bg at the element, keeping per-instance
|
|
42
|
+
--menu-bg overrides working. */
|
|
40
43
|
--focus-color: var(--material-color-blue-a400);
|
|
41
44
|
--tooltip-border: none;
|
|
42
45
|
}
|
package/dist/vars/defaults.css
CHANGED
|
@@ -63,7 +63,10 @@
|
|
|
63
63
|
--surface-fg: var(--fg);
|
|
64
64
|
--surface-link-fg: var(--link-fg, var(--primary-bg));
|
|
65
65
|
--container-link-fg: var(--surface-link-fg);
|
|
66
|
-
--menu-item-bg:
|
|
66
|
+
/* --menu-item-bg intentionally not defaulted at :root -- see the note in
|
|
67
|
+
affordances.css. The color-props(menu-item, menu, button, control) chain
|
|
68
|
+
resolves it to --menu-bg / --bg at the element, keeping per-instance
|
|
69
|
+
--menu-bg overrides working. */
|
|
67
70
|
--focus-color: var(--material-color-blue-a400);
|
|
68
71
|
--tooltip-border: none;
|
|
69
72
|
}
|
|
@@ -424,8 +427,14 @@ a {
|
|
|
424
427
|
--menu-item-hover-filter-custom: ;
|
|
425
428
|
--menu-item-hover-filter: var(--menu-item-hover-filter-brightness-hack,)
|
|
426
429
|
var(--menu-item-hover-filter-custom,);
|
|
427
|
-
/*
|
|
428
|
-
|
|
430
|
+
/* NB: do NOT default --menu-item-bg here. Declared at :root it would freeze
|
|
431
|
+
to var(--bg) (--menu-bg is not set yet at :root) and then win the
|
|
432
|
+
color-props(menu-item, menu, ...) chain ahead of a per-instance --menu-bg,
|
|
433
|
+
breaking overrides like <Menu --menu-bg="#111">. The chain already ends at
|
|
434
|
+
--bg, and color-props now computes an always-opaque --_background-color on
|
|
435
|
+
the element (evaluated where --menu-bg is live), which the hover/active
|
|
436
|
+
filter affordances derive from -- so the "give filters an opaque surface"
|
|
437
|
+
goal is met structurally without a frozen root default. */
|
|
429
438
|
--menu-item-active-filter-custom: ;
|
|
430
439
|
--menu-item-active-filter: var(--menu-item-active-filter-custom,);
|
|
431
440
|
/* --button-hover-transform: var(var(--button-hover-transform));
|