@dumiflex/wp-kit 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +5 -0
  3. package/package.json +4 -3
  4. package/src/Avatar.vue +1 -1
  5. package/src/Banner.vue +4 -4
  6. package/src/BundlePreview.vue +254 -0
  7. package/src/Button.vue +73 -44
  8. package/src/Checkbox.vue +201 -63
  9. package/src/Chip.vue +5 -2
  10. package/src/CodeBlock.vue +1 -1
  11. package/src/ColorField.vue +60 -6
  12. package/src/CombinePreview.vue +150 -0
  13. package/src/Combobox.vue +10 -14
  14. package/src/ConfirmModal.vue +12 -22
  15. package/src/ConstraintMatrixPreview.vue +6 -22
  16. package/src/ConstraintPreview.vue +259 -0
  17. package/src/ContextMenu.vue +11 -30
  18. package/src/CopyButton.vue +1 -1
  19. package/src/DataTable.vue +0 -2
  20. package/src/DatePicker.vue +1 -1
  21. package/src/DerivationCondition.vue +68 -0
  22. package/src/DerivationPreview.vue +345 -0
  23. package/src/Disclosure.vue +2 -1
  24. package/src/Drawer.vue +2 -2
  25. package/src/DropZone.vue +2 -2
  26. package/src/FixedValuesPreview.vue +184 -0
  27. package/src/HsvPicker.vue +2 -2
  28. package/src/IconPicker.vue +32 -103
  29. package/src/IconPickerPanel.vue +132 -0
  30. package/src/ImageGrid.vue +2 -2
  31. package/src/Lightbox.vue +2 -2
  32. package/src/LoadingMark.vue +6 -61
  33. package/src/Modal.vue +109 -44
  34. package/src/ModalShell.vue +26 -5
  35. package/src/Pagination.vue +4 -4
  36. package/src/PayloadPreview.vue +154 -0
  37. package/src/Popover.vue +68 -12
  38. package/src/PostDetailSkeleton.vue +65 -0
  39. package/src/PostGridSkeleton.vue +61 -0
  40. package/src/PreviewGroup.vue +75 -0
  41. package/src/ProfileSkeleton.vue +73 -0
  42. package/src/RadioGroup.vue +5 -4
  43. package/src/RichText.vue +61 -0
  44. package/src/SearchInput.vue +1 -1
  45. package/src/Select.vue +53 -158
  46. package/src/SelectionBar.vue +90 -0
  47. package/src/SettingGroup.vue +57 -0
  48. package/src/SettingRow.vue +127 -0
  49. package/src/Skeleton.vue +1 -1
  50. package/src/Slider.vue +166 -55
  51. package/src/SortableList.vue +1 -1
  52. package/src/TagInput.vue +1 -1
  53. package/src/Timeline.vue +2 -2
  54. package/src/WildcardPreview.vue +607 -0
  55. package/src/WorkingLine.vue +234 -0
  56. package/src/WpIconButton.vue +3 -2
  57. package/src/WpImageZoom.vue +7 -7
  58. package/src/WpSegmented.vue +3 -1
  59. package/src/WpSwitch.vue +1 -1
  60. package/src/WpTabs.vue +1 -1
  61. package/src/_modal-motion.css +1 -1
  62. package/src/button.css +8 -6
  63. package/src/canvas-motion.ts +251 -0
  64. package/src/floating.ts +5 -0
  65. package/src/hsv.ts +1 -1
  66. package/src/kind-colour.ts +24 -1
  67. package/src/linkify.ts +142 -0
  68. package/src/loading-mark-motion.ts +12 -155
  69. package/src/matrix-axis.ts +28 -0
  70. package/src/menu.css +51 -0
  71. package/src/motion.css +73 -0
  72. package/src/pi-icons.ts +51 -0
  73. package/src/preview-readers.ts +401 -0
  74. package/src/preview.css +194 -0
  75. package/src/slider-math.ts +45 -1
  76. package/src/theme/looks.css +6 -19
  77. package/src/theme/tokens.css +12 -1108
  78. package/src/tokens.ts +15 -4
  79. package/src/tooltip.css +8 -5
  80. package/src/useDialogFocus.ts +11 -2
  81. package/src/working-line-motion.ts +135 -0
  82. package/src/CloseButton.vue +0 -69
  83. package/src/ColorPicker.vue +0 -201
  84. package/src/ConfirmDialog.vue +0 -165
  85. package/src/RangeSlider.vue +0 -183
  86. package/src/Toggle.vue +0 -36
  87. package/src/WpButton.vue +0 -75
  88. package/src/WpCheck.vue +0 -104
  89. package/src/_skeleton.css +0 -50
@@ -143,16 +143,17 @@ function onKeydown(ev: KeyboardEvent, at: T): void {
143
143
  .wp-radio-group__opt[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
144
144
  .wp-radio-group__dot {
145
145
  position: relative; flex: none; box-sizing: border-box; width: 16px; height: 16px; margin-top: 1px;
146
- border: 1.5px solid var(--wp-border-strong); border-radius: 50%; /* round: a radio is round in every look */ background: var(--wp-bg-1);
146
+ /* Whole-pixel border and inset, so the dot sits dead centre (a square look shows any half pixel). */
147
+ border: 2px solid var(--wp-border-strong); border-radius: min(50%, var(--wp-radius-cap, 999px)); background: var(--wp-bg-1);
147
148
  transition: border-color var(--wp-motion-hover);
148
149
  }
149
- .wp-radio-group[data-size="sm"] .wp-radio-group__dot { width: 13px; height: 13px; }
150
+ .wp-radio-group[data-size="sm"] .wp-radio-group__dot { width: 12px; height: 12px; border-width: 1px; }
150
151
  .wp-radio-group__opt:hover:not([aria-disabled="true"]) .wp-radio-group__dot { border-color: var(--wp-accent); }
151
152
  .wp-radio-group__opt[data-checked] .wp-radio-group__dot { border-color: var(--wp-accent); }
152
153
  .wp-radio-group__opt[data-checked] .wp-radio-group__dot::after {
153
- content: ""; position: absolute; inset: 3px; border-radius: 50%; /* round: radio dot */ background: var(--wp-accent);
154
+ content: ""; position: absolute; inset: 3px; border-radius: min(50%, var(--wp-radius-cap, 999px)); background: var(--wp-accent);
154
155
  }
155
- .wp-radio-group[data-size="sm"] .wp-radio-group__opt[data-checked] .wp-radio-group__dot::after { inset: 2.5px; }
156
+ .wp-radio-group[data-size="sm"] .wp-radio-group__opt[data-checked] .wp-radio-group__dot::after { inset: 2px; }
156
157
  /* A picked option that is disabled turns grey, so it can't pass for a live one. */
157
158
  .wp-radio-group__opt[aria-disabled="true"][data-checked] .wp-radio-group__dot { border-color: var(--wp-text-dim); }
158
159
  .wp-radio-group__opt[aria-disabled="true"][data-checked] .wp-radio-group__dot::after { background: var(--wp-text-dim); }
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Inline rich-text renderer for plain-text prose (post descriptions, notes).
4
+ *
5
+ * <p class="my-description"><RichText :text="post.description" /></p>
6
+ *
7
+ * Turns URLs into links (new tab, `noopener noreferrer`) and `$var` /
8
+ * `@{ref}` mentions into `<code>` chips, WITHOUT `v-html`: every segment from
9
+ * `parseRichText` (linkify.ts) is rendered as a Vue node, so the output is
10
+ * XSS-safe by construction.
11
+ *
12
+ * Prose, not a grammar value: `{a|b}` stays text and a ref's body may be any
13
+ * words. A wildcard value (picks, refs resolved to names) is TokenizedValue.
14
+ *
15
+ * Renders INLINE (no wrapper element), so it drops into the host's own
16
+ * paragraph and inherits its typography.
17
+ */
18
+ import { computed } from "vue";
19
+ import { parseRichText } from "./linkify";
20
+
21
+ const props = defineProps<{ text: string }>();
22
+
23
+ const segments = computed(() => parseRichText(props.text));
24
+ </script>
25
+
26
+ <template>
27
+ <template
28
+ v-for="(seg, idx) in segments"
29
+ :key="idx"
30
+ >
31
+ <a
32
+ v-if="seg.kind === 'link'"
33
+ class="wp-rich-text__link"
34
+ :href="seg.href"
35
+ target="_blank"
36
+ rel="noopener noreferrer"
37
+ >{{ seg.text }}</a>
38
+ <code
39
+ v-else-if="seg.kind === 'code'"
40
+ class="wp-rich-text__code"
41
+ >{{ seg.text }}</code>
42
+ <template v-else>{{ seg.text }}</template>
43
+ </template>
44
+ </template>
45
+
46
+ <style scoped>
47
+ .wp-rich-text__link {
48
+ color: var(--wp-accent-text);
49
+ text-decoration: underline;
50
+ text-underline-offset: 2px;
51
+ }
52
+ .wp-rich-text__code {
53
+ font-family: var(--wp-font-mono);
54
+ font-size: 0.9em;
55
+ background: var(--wp-bg-3);
56
+ color: var(--wp-accent-text);
57
+ padding: 1px 6px;
58
+ border-radius: min(5px, var(--wp-radius-cap, 999px));
59
+ border: 1px solid var(--wp-border);
60
+ }
61
+ </style>
@@ -186,7 +186,7 @@ defineExpose({
186
186
  color: var(--wp-text-dim);
187
187
  cursor: pointer;
188
188
  }
189
- .wp-search__clear .pi { font-size: 9px; line-height: 1; }
189
+ .wp-search__clear .pi { font-size: 10px; line-height: 1; }
190
190
  .wp-search__clear:hover { color: var(--wp-text); background: var(--wp-bg-3); }
191
191
  .wp-search__clear:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
192
192
  </style>
package/src/Select.vue CHANGED
@@ -1,12 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
+ import { computed, nextTick, ref, useId, watch } from "vue";
3
3
  import Icon, { ICON_SM } from "./Icon.vue";
4
+ import Popover from "./Popover.vue";
4
5
 
5
6
  // Declared in `./select-types` so plain `.ts` helpers can build option lists
6
7
  // (a `.ts` importer only sees the `*.vue` ambient shim). Re-exported here so
7
8
  // existing `from "./Select.vue"` imports keep resolving.
8
9
  import type { SelectOption } from "./select-types";
9
- import { useFloatingChild } from "./floating-layer";
10
10
  export type { SelectOption };
11
11
 
12
12
  interface Props {
@@ -31,21 +31,16 @@ const emit = defineEmits<{
31
31
  (e: "change", v: string | number | null): void;
32
32
  }>();
33
33
 
34
- const wrapRef = ref<HTMLDivElement | null>(null);
34
+ const listId = `wp-select-list-${useId()}`;
35
35
  const btnRef = ref<HTMLButtonElement | null>(null);
36
36
  const open = ref(false);
37
- const flip = ref(false);
38
37
  const active = ref(0);
39
38
  /** Type-to-filter query, built from printable keystrokes while the menu
40
39
  * is open. Reset every time the menu opens or closes. */
41
40
  const query = ref("");
42
- /** The teleported menu, so the outside-click guard can exclude it. */
43
- const menuRef = ref<HTMLElement | null>(null);
44
- // Inside a Popover, clicks and Escape on the list are the list's own.
45
- useFloatingChild(() => menuRef.value);
46
-
47
- /** Fixed-position coordinates for the teleported menu. */
48
- const menuStyle = ref<Record<string, string>>({});
41
+ // The list floats in a kit Popover (variant "menu"): it places, flips, fits
42
+ // and follows the trigger, closes on a press outside or Escape, and counts as
43
+ // part of a Popover the Select sits in. The keys stay on the trigger.
49
44
 
50
45
  const selected = computed(() => props.options.find((o) => o.value === props.modelValue) ?? null);
51
46
 
@@ -107,108 +102,18 @@ const composedAriaLabel = computed<string | undefined>(() => {
107
102
  return props.ariaLabel;
108
103
  });
109
104
 
110
- function onDocClick(e: MouseEvent) {
111
- const t = e.target as Node | null;
112
- if (!t) return;
113
- // Close when the press is outside the trigger AND outside the menu. The menu
114
- // is teleported to <body>, so it is NOT inside `wrapRef` — checking only the
115
- // trigger meant a mousedown on the menu's own scrollbar counted as "outside"
116
- // and dismissed it. The wheel worked, the scrollbar did not.
117
- if (wrapRef.value?.contains(t)) return;
118
- if (menuRef.value?.contains(t)) return;
119
- open.value = false;
120
- }
121
-
122
- onMounted(() => {
123
- document.addEventListener("mousedown", onDocClick, true);
124
- });
125
- onBeforeUnmount(() => {
126
- document.removeEventListener("mousedown", onDocClick, true);
127
- window.removeEventListener("scroll", followOnScroll, true);
128
- window.removeEventListener("resize", reposition);
129
- });
130
-
131
- /** Any scroll in the page moves the menu with its trigger (like every kit
132
- * overlay), instead of closing it or leaving it where the trigger used to
133
- * be. Only once the trigger has scrolled right out of view does it close. */
134
- function followOnScroll(e: Event) {
135
- // The menu's own list scroll changes nothing about where it sits.
136
- const t = e.target as Element | null;
137
- if (t && typeof t.closest === "function" && t.closest(".wp-select__menu")) return;
138
- if (!open.value || !btnRef.value) return;
139
- const r = btnRef.value.getBoundingClientRect();
140
- if (r.bottom < 0 || r.top > window.innerHeight || r.right < 0 || r.left > window.innerWidth) {
141
- open.value = false;
142
- return;
143
- }
144
- computeMenuStyle();
145
- }
146
- function reposition() {
147
- if (!open.value || !btnRef.value) return;
148
- computeMenuStyle();
149
- }
150
-
151
- function computeMenuStyle() {
152
- if (!btnRef.value) return;
153
- const rect = btnRef.value.getBoundingClientRect();
154
- // Ideal menu height = enough room for every option (~36px each + 12px
155
- // padding), capped at 240px so a huge library doesn't blanket the
156
- // viewport. The actual rendered height also gets capped by available
157
- // screen space below — see `effectiveMaxHeight` — so the menu never
158
- // bleeds past the viewport edge.
159
- const idealHeight = Math.min(240, props.options.length * 36 + 12);
160
- const gap = 4; // spacing between trigger and menu
161
- const margin = 8; // breathing room from the viewport edge
162
- const spaceBelow = window.innerHeight - rect.bottom - gap - margin;
163
- const spaceAbove = rect.top - gap - margin;
164
-
165
- // Flip above only when there isn't enough room below AND there's
166
- // more room above than below — otherwise the menu still grows under
167
- // the trigger and `effectiveMaxHeight` clamps it to whatever fits.
168
- flip.value = spaceBelow < idealHeight && spaceAbove > spaceBelow;
169
-
170
- const available = flip.value ? spaceAbove : spaceBelow;
171
- // Floor at 80px so a tightly-cramped row (e.g. inside a modal near the
172
- // viewport edge) still surfaces a usable scrolling list rather than
173
- // collapsing to a 1-row sliver.
174
- const effectiveMaxHeight = Math.max(80, Math.min(idealHeight, available));
175
-
176
- menuStyle.value = flip.value
177
- ? {
178
- position: "fixed",
179
- bottom: (window.innerHeight - rect.top + gap) + "px",
180
- top: "auto",
181
- left: rect.left + "px",
182
- minWidth: rect.width + "px",
183
- maxHeight: effectiveMaxHeight + "px",
184
- }
185
- : {
186
- position: "fixed",
187
- top: (rect.bottom + gap) + "px",
188
- bottom: "auto",
189
- left: rect.left + "px",
190
- minWidth: rect.width + "px",
191
- maxHeight: effectiveMaxHeight + "px",
192
- };
193
- }
194
-
195
105
  watch(open, (v) => {
196
- if (!v) {
197
- query.value = "";
198
- window.removeEventListener("scroll", followOnScroll, true);
199
- window.removeEventListener("resize", reposition);
200
- return;
201
- }
202
- if (!btnRef.value) return;
203
106
  query.value = "";
204
- computeMenuStyle();
107
+ if (!v) return;
205
108
  // Highlight currently-selected option (or first).
206
109
  const idx = props.options.findIndex((o) => o.value === props.modelValue);
207
110
  active.value = idx >= 0 ? idx : 0;
208
- window.addEventListener("scroll", followOnScroll, true);
209
- window.addEventListener("resize", reposition);
210
111
  });
211
112
 
113
+ function onOpen(v: boolean) {
114
+ open.value = v;
115
+ }
116
+
212
117
  function toggle() {
213
118
  if (props.disabled) return;
214
119
  open.value = !open.value;
@@ -246,6 +151,9 @@ function onKeydown(e: KeyboardEvent) {
246
151
  } else if (e.key === "ArrowUp") {
247
152
  e.preventDefault();
248
153
  if (list.length) active.value = (active.value - 1 + list.length) % list.length;
154
+ } else if (e.key === "Home" || e.key === "End") {
155
+ e.preventDefault();
156
+ if (list.length) active.value = e.key === "Home" ? 0 : list.length - 1;
249
157
  } else if (e.key === "Enter") {
250
158
  e.preventDefault();
251
159
  const opt = list[active.value];
@@ -264,7 +172,7 @@ function onKeydown(e: KeyboardEvent) {
264
172
  </script>
265
173
 
266
174
  <template>
267
- <div ref="wrapRef" class="wp-select-wrap">
175
+ <div class="wp-select-wrap">
268
176
  <button
269
177
  ref="btnRef"
270
178
  type="button"
@@ -273,6 +181,7 @@ function onKeydown(e: KeyboardEvent) {
273
181
  :aria-label="composedAriaLabel"
274
182
  :aria-expanded="open"
275
183
  :aria-haspopup="'listbox'"
184
+ :aria-controls="open ? listId : undefined"
276
185
  :aria-invalid="error || undefined"
277
186
  data-test="select-trigger"
278
187
  @click="toggle"
@@ -332,15 +241,27 @@ function onKeydown(e: KeyboardEvent) {
332
241
  <Icon class="wp-select__chevron" name="chevron-down" />
333
242
  </button>
334
243
 
335
- <!-- Menu teleported to <body> so it escapes any overflow:hidden ancestor
336
- and renders above the sticky footer regardless of z-index stacking contexts. -->
337
- <Teleport to="body">
244
+ <!-- The list floats on <body> in a menu Popover, so it escapes any
245
+ overflow:hidden ancestor and clears every modal. -->
246
+ <!-- Popover's autofocus is turned OFF: focus stays on the trigger, which has the keys. -->
247
+ <!-- eslint-disable vuejs-accessibility/no-autofocus -->
248
+ <Popover
249
+ :open="open && !disabled"
250
+ :anchor="btnRef"
251
+ variant="menu"
252
+ role="group"
253
+ match-width
254
+ :max-height="240"
255
+ :autofocus="false"
256
+ :label="ariaLabel ?? placeholder"
257
+ @update:open="onOpen"
258
+ >
259
+ <!-- eslint-enable vuejs-accessibility/no-autofocus -->
338
260
  <ul
339
- v-if="open"
340
- ref="menuRef"
261
+ :id="listId"
341
262
  class="wp-select__menu"
342
263
  role="listbox"
343
- :style="menuStyle"
264
+ :aria-label="ariaLabel ?? placeholder"
344
265
  data-test="select-menu"
345
266
  >
346
267
  <li
@@ -364,7 +285,7 @@ function onKeydown(e: KeyboardEvent) {
364
285
  <li
365
286
  v-for="(opt, i) in filtered"
366
287
  :key="String(opt.value)"
367
- class="wp-select__option"
288
+ class="wp-select__option wp-menu__item"
368
289
  role="option"
369
290
  tabindex="-1"
370
291
  :aria-selected="opt.value === modelValue"
@@ -407,7 +328,7 @@ function onKeydown(e: KeyboardEvent) {
407
328
  style="padding:6px 12px;opacity:0.55;"
408
329
  >No matches</li>
409
330
  </ul>
410
- </Teleport>
331
+ </Popover>
411
332
  </div>
412
333
  </template>
413
334
 
@@ -415,14 +336,11 @@ function onKeydown(e: KeyboardEvent) {
415
336
  Self-contained styles so <Select> works everywhere it's used — including the
416
337
  ComfyUI CANVAS widget bundle, which does NOT load the manager-global
417
338
  tokens.css (that's why an unstyled Select rendered behind the modal there).
418
- Class names stay wp-select* (global-namespaced, no collision); the menu is
419
- teleported to <body>, so these are UNSCOPED — same pattern as the
420
- RichTextInput autocomplete (wp-rt-suggestions), sharing its POPOVER tier
421
- (10020) so the menu clears every modal overlay — those run up to 10010, so
422
- the previous 9999 rendered the menu behind any modal above that. Vars fall
423
- back to literals for the few the
424
- canvas theme doesn't define (--wp-input-h[-sm], --wp-focus-ring). In the SPA
425
- these duplicate tokens.css with identical rules — harmless.
339
+ Class names stay wp-select* (global-namespaced, no collision). The list's
340
+ look is the shared menu sheet (menu.css, scoped, at the end), whose layer
341
+ (10020) clears every modal overlay (those run up to 10010). Vars fall back
342
+ to literals for the few the canvas theme doesn't define (--wp-input-h[-sm],
343
+ --wp-focus-ring).
426
344
  -->
427
345
  <style>
428
346
  .wp-select-wrap { position: relative; display: inline-block; width: 100%; }
@@ -465,7 +383,7 @@ function onKeydown(e: KeyboardEvent) {
465
383
  display: flex; align-items: center; gap: 6px;
466
384
  flex: 1; min-width: 0; overflow: hidden;
467
385
  }
468
- .wp-select__dot { width: 8px; height: 8px; border-radius: 50%; /* round: colour dot */ flex-shrink: 0; display: inline-block; }
386
+ .wp-select__dot { width: 8px; height: 8px; border-radius: min(50%, var(--wp-radius-cap, 999px)); flex-shrink: 0; display: inline-block; }
469
387
  /* Empty stand-in that holds the icon/dot column open for options without one.
470
388
  * Width is set inline from `markerWidth`, since it depends on what the list
471
389
  * actually contains. */
@@ -510,7 +428,7 @@ function onKeydown(e: KeyboardEvent) {
510
428
  font-size: 10px;
511
429
  color: var(--wp-text-dim);
512
430
  }
513
- .wp-select__chevron { color: var(--wp-text-dim); font-size: 11px; margin-left: 6px; flex-shrink: 0; }
431
+ .wp-select__chevron { color: var(--wp-text-dim); font-size: 12px; margin-left: 6px; flex-shrink: 0; }
514
432
  /* Clear ✕: a plain glyph kept well clear of the chevron (a 1px rule and
515
433
  * 8px either side) so it is not mis-clicked, and shown only while the
516
434
  * field is hovered or focused. The ::after gives it a 20px click target. */
@@ -535,44 +453,20 @@ function onKeydown(e: KeyboardEvent) {
535
453
  @media (hover: none) { .wp-select__clear { opacity: 1; } }
536
454
  .wp-spacer { flex: 1 1 auto; }
537
455
 
538
- /* Menu — teleported to <body>. z-index 9999 matches wp-rt-suggestions, which
539
- * already renders above the canvas modals. */
456
+ /* The list. Its panel (background, border, corners, padding, shadow, layer)
457
+ * and its rows' padding, corners, text and states are the shared menu look:
458
+ * Popover's `variant="menu"` and `menu.css` below. */
540
459
  .wp-select__menu {
541
460
  /* One line box for an option label, in px so a leading dot/icon can be
542
461
  * centred against it without the em-resolves-against-its-own-font-size trap
543
- * (the icon runs at 12px, the label at 12.5px, so `1.5em` means two
544
- * different heights depending on which element it is declared on).
545
- * The menu teleports to <body>, so it carries its own copy. */
546
- --wp-select-line-h: 18.75px;
547
- background: var(--wp-bg-3);
548
- border: 1px solid var(--wp-border-strong);
549
- border-radius: min(7px, var(--wp-radius-cap, 999px));
550
- box-shadow: var(--wp-shadow-md);
551
- z-index: 10020;
552
- padding: 4px;
553
- max-height: 240px;
554
- overflow: auto;
462
+ * (the icon runs at 12px and so does the label; `1.5em` on the icon would
463
+ * still measure against a different box). */
464
+ --wp-select-line-h: 18px;
555
465
  list-style: none;
556
466
  margin: 0;
467
+ padding: 0;
557
468
  }
558
- .wp-select__option {
559
- display: flex; align-items: center; gap: 6px;
560
- padding: 6px 8px;
561
- border-radius: min(6px, var(--wp-radius-cap, 999px));
562
- color: var(--wp-text-muted);
563
- cursor: pointer;
564
- font-size: 12.5px;
565
- user-select: none;
566
- }
567
- .wp-select__option:hover { background: var(--wp-bg-4); color: var(--wp-text); }
568
- .wp-select__option[data-active="true"] {
569
- background: color-mix(in oklab, var(--wp-accent-500) 20%, transparent);
570
- color: var(--wp-text);
571
- }
572
- .wp-select__option[data-selected="true"] {
573
- background: color-mix(in oklab, var(--wp-accent-500) 18%, transparent);
574
- color: var(--wp-text);
575
- }
469
+ .wp-select__option { display: flex; gap: 6px; }
576
470
  /* Option rows WRAP (to two lines) rather than ellipsis-clipping at the first.
577
471
  * The menu is up to 620px wide and these values run long — a constraint
578
472
  * exception source is a whole phrase plus a nested `@ref` — so clipping them
@@ -640,5 +534,6 @@ function onKeydown(e: KeyboardEvent) {
640
534
  white-space: normal !important;
641
535
  overflow-wrap: anywhere;
642
536
  }
643
- .wp-select__menu { max-width: min(620px, 92vw); }
644
537
  </style>
538
+
539
+ <style scoped src="./menu.css"></style>
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * SelectionBar: the bar over a list while rows are ticked. "3 selected", the
4
+ * bulk actions (the default slot) and Clear. One bar for every list: the
5
+ * editors' bulk mode, the moderation queue, a backups list.
6
+ *
7
+ * <SelectionBar v-if="picked.size" :count="picked.size" @clear="picked.clear()">
8
+ * <Button variant="danger" size="sm" icon="trash" @click="del">Delete</Button>
9
+ * </SelectionBar>
10
+ *
11
+ * `#start` sits right after the count (a filter, an option), the default slot
12
+ * on the right before Clear. `sticky` keeps it at the top of its scroll area
13
+ * while the list scrolls under it (`--wp-selection-bar-top` moves it below a
14
+ * fixed header). `noun` says what is selected ("3 options selected"). Escape
15
+ * inside the bar clears, like Clear; the count is read out as it changes.
16
+ */
17
+ import { onBeforeUnmount, onMounted, ref } from "vue";
18
+ import Button from "./Button.vue";
19
+
20
+ const props = withDefaults(defineProps<{
21
+ count: number;
22
+ /** What a row is, plural ("options"); left out it says "3 selected". */
23
+ noun?: string | undefined;
24
+ /** Stick to the top of the scroll area. */
25
+ sticky?: boolean | undefined;
26
+ /** Hide the Clear button (the host clears another way). */
27
+ hideClear?: boolean | undefined;
28
+ clearLabel?: string | undefined;
29
+ /** The toolbar's accessible name. */
30
+ label?: string | undefined;
31
+ }>(), { noun: undefined, clearLabel: "Clear", label: "Bulk actions" });
32
+
33
+ const emit = defineEmits<{ clear: [] }>();
34
+
35
+ function onKeydown(ev: KeyboardEvent): void {
36
+ if (ev.key !== "Escape" || props.hideClear) return;
37
+ ev.stopPropagation();
38
+ emit("clear");
39
+ }
40
+
41
+ // Bound in code, not as @keydown: the bar is a toolbar of buttons, and the
42
+ // keys come from those buttons (a template handler on it reads to the a11y
43
+ // lint as a click target on a plain element).
44
+ const root = ref<HTMLElement | null>(null);
45
+ onMounted(() => root.value?.addEventListener("keydown", onKeydown));
46
+ onBeforeUnmount(() => root.value?.removeEventListener("keydown", onKeydown));
47
+ </script>
48
+
49
+ <template>
50
+ <div
51
+ ref="root"
52
+ class="wp-selbar"
53
+ role="toolbar"
54
+ :aria-label="label"
55
+ :data-sticky="sticky || undefined"
56
+ data-test="selection-bar"
57
+ >
58
+ <span class="wp-selbar__count" aria-live="polite" data-test="selection-count">{{ count }}{{ noun ? ` ${noun}` : "" }} selected</span>
59
+ <slot name="start" />
60
+ <span class="wp-selbar__spacer" />
61
+ <slot />
62
+ <Button v-if="!hideClear" variant="ghost" size="sm" data-test="selection-clear" @click="emit('clear')">{{ clearLabel }}</Button>
63
+ </div>
64
+ </template>
65
+
66
+ <style scoped>
67
+ .wp-selbar {
68
+ display: flex;
69
+ flex-wrap: wrap;
70
+ align-items: center;
71
+ gap: var(--wp-space-4);
72
+ padding: var(--wp-space-3) var(--wp-space-5);
73
+ border: 1px solid color-mix(in oklab, var(--wp-accent) 40%, transparent);
74
+ border-radius: min(8px, var(--wp-radius-cap, 999px));
75
+ background: color-mix(in oklab, var(--wp-accent) 12%, var(--wp-bg-2));
76
+ font-family: var(--wp-font-sans);
77
+ }
78
+ .wp-selbar[data-sticky] {
79
+ position: sticky;
80
+ top: var(--wp-selection-bar-top, 0px);
81
+ z-index: 20;
82
+ }
83
+ .wp-selbar__count {
84
+ font-size: var(--wp-text-base);
85
+ font-weight: 600;
86
+ color: var(--wp-text);
87
+ white-space: nowrap;
88
+ }
89
+ .wp-selbar__spacer { flex: 1; }
90
+ </style>
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * SettingGroup: a titled block of SettingRows in one bordered panel, the
4
+ * rows split by hairlines, so a long settings page reads as a list rather
5
+ * than a stack of cards.
6
+ *
7
+ * <SettingGroup title="Content" note="Applies to this browser.">
8
+ * <SettingRow label="Show NSFW posts" hint="Off by default."><WpSwitch v-model="nsfw" size="md" /></SettingRow>
9
+ * </SettingGroup>
10
+ *
11
+ * `#title-extra` sits after the title (a Badge, a help link). The panel is a
12
+ * size container: its rows go to one column when it is narrower than 480px.
13
+ */
14
+ defineProps<{
15
+ title?: string | undefined;
16
+ /** A line under the title. */
17
+ note?: string | undefined;
18
+ }>();
19
+ </script>
20
+
21
+ <template>
22
+ <section class="wp-set-group">
23
+ <h3 v-if="title" class="wp-set-group__title">
24
+ {{ title }}<slot name="title-extra" />
25
+ </h3>
26
+ <p v-if="note" class="wp-set-group__note">{{ note }}</p>
27
+ <div class="wp-set-group__panel">
28
+ <slot />
29
+ </div>
30
+ </section>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .wp-set-group { margin-bottom: var(--wp-space-7); }
35
+ .wp-set-group__title {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: var(--wp-space-3);
39
+ margin: 0 0 var(--wp-space-3);
40
+ font-size: var(--wp-text-xs);
41
+ font-weight: 600;
42
+ letter-spacing: 0.08em;
43
+ text-transform: uppercase;
44
+ color: var(--wp-text-dim);
45
+ }
46
+ .wp-set-group__note {
47
+ margin: 0 0 var(--wp-space-3);
48
+ font-size: var(--wp-text-sm);
49
+ color: var(--wp-text-dim);
50
+ }
51
+ .wp-set-group__panel {
52
+ container: wp-set-group / inline-size;
53
+ background: var(--wp-bg-2);
54
+ border: 1px solid var(--wp-border);
55
+ border-radius: var(--wp-radius-lg);
56
+ }
57
+ </style>