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.
@@ -1,8 +1,36 @@
1
1
  <script lang="ts">let { children, value, ...restProps } = $props();
2
2
  let template = $state();
3
- // Extract text content from rendered children
4
- let textContent = $derived(template ? template.textContent ?? "" : "");
5
- let htmlContent = $derived(template ? template.innerHTML : "");
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
- // Observe changes in the select element
12
- observer = new MutationObserver((mutations) => {
13
- mutations.forEach((mutation) => {
14
- if (mutation.type === "childList") {
15
- updateOptions();
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, { childList: true });
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 triggerAuditAction={dropdownAuditAction}>
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, 2px);
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
@@ -126,6 +126,7 @@ const components = [
126
126
  ...[
127
127
  "button",
128
128
  "mini-button",
129
+ "menu",
129
130
  "menu-trigger",
130
131
  "menu-item",
131
132
  "dropdown-menu",
@@ -1,12 +1,12 @@
1
- <script module lang="ts">"use strict";
2
- var idPostfix = 1;
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
- maybeFocusMatch(searchString);
89
+ applySearch();
90
+ scheduleSearchClear();
70
91
  }
71
92
  }
72
- else {
73
- searchString = "";
74
- if (event.key === "Escape") {
75
- popoverDiv?.hidePopover();
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 (event.key === "ArrowDown" || event.key === "ArrowUp") {
78
- event.preventDefault(); // Prevent default to stop scrolling the page
79
- navigateMenu(event.key);
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
- function maybeFocusMatch(searchString) {
173
+ /** Undo {@link applyFilter}, leaving any consumer-set `hidden` rows alone. */
174
+ function clearFilter() {
85
175
  if (!dropdownContentElement)
86
176
  return;
87
- let focusableItems = dropdownContentElement.querySelectorAll("a,button,[tabindex]");
88
- for (let element of focusableItems) {
89
- if (element.textContent &&
90
- element.textContent.toLowerCase().startsWith(searchString.toLowerCase())) {
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 = Array.from(dropdownContentElement.querySelectorAll("button, a, [tabindex]:not([tabindex='-1'])"));
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, 2px);
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, center);
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, 2px);
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) {
@@ -1,5 +1,4 @@
1
- <script lang="ts">import { run } from "svelte/legacy";
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
- /* Default menu items to the menu surface so row-wide filters read clearly. */
24
- --menu-item-bg: var(--menu-bg, var(--bg));
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));
@@ -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: var(--menu-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
  }
@@ -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: var(--menu-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
- /* Default menu items to the menu surface so row-wide filters read clearly. */
428
- --menu-item-bg: var(--menu-bg, var(--bg));
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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contain-css-svelte",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "license": "MIT",
5
5
  "scripts": {
6
6
  "capture:review": "node scripts/capture-review.mjs",