@dumiflex/wp-kit 0.3.0 → 0.5.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 (130) hide show
  1. package/CHANGELOG.md +92 -51
  2. package/LICENSE +674 -674
  3. package/README.md +73 -76
  4. package/package.json +78 -77
  5. package/src/Avatar.vue +83 -83
  6. package/src/Badge.vue +97 -97
  7. package/src/Banner.vue +118 -118
  8. package/src/BookmarkButton.vue +27 -27
  9. package/src/BundleMeta.vue +51 -51
  10. package/src/Button.vue +103 -103
  11. package/src/Card.vue +68 -68
  12. package/src/Checkbox.vue +109 -107
  13. package/src/Chip.vue +163 -97
  14. package/src/ChoiceCards.vue +133 -133
  15. package/src/CloseButton.vue +69 -69
  16. package/src/CodeBlock.vue +146 -119
  17. package/src/ColorField.vue +188 -206
  18. package/src/ColorPicker.vue +201 -201
  19. package/src/Combobox.vue +331 -334
  20. package/src/ConfirmDialog.vue +165 -163
  21. package/src/ConfirmModal.vue +147 -147
  22. package/src/ConstraintMatrixPreview.vue +708 -708
  23. package/src/ContextMenu.vue +386 -379
  24. package/src/CopyButton.vue +156 -156
  25. package/src/DataTable.vue +293 -292
  26. package/src/DatePicker.vue +234 -234
  27. package/src/DescriptionList.vue +73 -73
  28. package/src/DiffView.vue +68 -68
  29. package/src/Disclosure.vue +166 -166
  30. package/src/DragHandle.vue +49 -49
  31. package/src/Drawer.vue +131 -131
  32. package/src/DropZone.vue +196 -196
  33. package/src/DropdownMenu.vue +72 -72
  34. package/src/EmptyState.vue +111 -111
  35. package/src/Field.vue +28 -28
  36. package/src/HsvPicker.vue +208 -208
  37. package/src/Icon.vue +39 -39
  38. package/src/IconPicker.vue +209 -209
  39. package/src/ImageGrid.vue +127 -125
  40. package/src/InlineEdit.vue +183 -183
  41. package/src/Input.vue +99 -99
  42. package/src/Kbd.vue +56 -56
  43. package/src/Lightbox.vue +176 -173
  44. package/src/ListFilter.vue +85 -85
  45. package/src/ListRow.vue +204 -204
  46. package/src/LoadingMark.vue +217 -161
  47. package/src/Logo.vue +60 -60
  48. package/src/Modal.vue +78 -76
  49. package/src/ModalShell.vue +109 -107
  50. package/src/Pagination.vue +106 -106
  51. package/src/Popover.vue +180 -171
  52. package/src/PostCard.vue +210 -211
  53. package/src/ProgressBar.vue +90 -90
  54. package/src/RadioGroup.vue +177 -177
  55. package/src/RangeSlider.vue +183 -183
  56. package/src/RelativeDate.vue +35 -35
  57. package/src/ResizeHandle.vue +143 -143
  58. package/src/SearchInput.vue +192 -199
  59. package/src/Select.vue +644 -641
  60. package/src/Separator.vue +54 -54
  61. package/src/Skeleton.vue +98 -82
  62. package/src/Slider.vue +198 -198
  63. package/src/SortableList.vue +260 -260
  64. package/src/SplitPane.vue +68 -68
  65. package/src/StarButton.vue +40 -40
  66. package/src/Stepper.vue +141 -141
  67. package/src/SupportLinks.vue +93 -93
  68. package/src/TagInput.vue +297 -300
  69. package/src/Textarea.vue +57 -57
  70. package/src/Timeline.vue +92 -92
  71. package/src/ToastCard.vue +119 -119
  72. package/src/ToastHost.vue +40 -40
  73. package/src/Toggle.vue +36 -36
  74. package/src/TokenizedValue.vue +310 -310
  75. package/src/Toolbar.vue +110 -110
  76. package/src/VirtualList.vue +87 -87
  77. package/src/VisuallyHidden.vue +29 -29
  78. package/src/WpButton.vue +75 -75
  79. package/src/WpCheck.vue +104 -102
  80. package/src/WpIconButton.vue +107 -107
  81. package/src/WpImageZoom.vue +544 -544
  82. package/src/WpNumber.vue +178 -183
  83. package/src/WpSegmented.vue +175 -175
  84. package/src/WpSelect.vue +93 -89
  85. package/src/WpSwitch.vue +163 -163
  86. package/src/WpTabs.vue +188 -188
  87. package/src/_modal-motion.css +33 -33
  88. package/src/_skeleton.css +50 -50
  89. package/src/axis-color.ts +42 -42
  90. package/src/button.css +169 -169
  91. package/src/calendar.ts +59 -59
  92. package/src/clipboard.ts +50 -50
  93. package/src/colour-presets.ts +12 -12
  94. package/src/diff.ts +51 -51
  95. package/src/drop-zone.ts +41 -41
  96. package/src/field.css +50 -0
  97. package/src/flip.ts +237 -237
  98. package/src/floating-layer.ts +48 -0
  99. package/src/floating.ts +99 -99
  100. package/src/highlight.ts +199 -0
  101. package/src/hsv.ts +100 -100
  102. package/src/icon-catalog.ts +52 -52
  103. package/src/image-zoom.ts +93 -93
  104. package/src/kind-colour.ts +28 -0
  105. package/src/loading-mark-motion.ts +249 -0
  106. package/src/logo-paths.ts +21 -21
  107. package/src/matrix-axis.ts +91 -91
  108. package/src/pagination.ts +27 -27
  109. package/src/platform-keys.ts +29 -29
  110. package/src/rich-tokenize.ts +357 -357
  111. package/src/select-types.ts +32 -32
  112. package/src/slider-math.ts +45 -45
  113. package/src/sortable.ts +22 -22
  114. package/src/theme/fonts/OFL-Fraunces.txt +93 -93
  115. package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -93
  116. package/src/theme/fonts/OFL-Manrope.txt +93 -93
  117. package/src/theme/looks.css +428 -428
  118. package/src/theme/palettes.css +254 -254
  119. package/src/theme/tokens.css +2896 -2894
  120. package/src/tokens.ts +56 -56
  121. package/src/tooltip.css +43 -43
  122. package/src/tooltip.ts +146 -146
  123. package/src/useDialogFocus.ts +73 -73
  124. package/src/useGrowableField.ts +357 -357
  125. package/src/useModalKeyShield.ts +116 -116
  126. package/src/useToast.ts +48 -48
  127. package/src/wp-logo.svg +14 -14
  128. package/src/ChipToggle.vue +0 -92
  129. package/src/KindChip.vue +0 -53
  130. package/src/RatingBadge.vue +0 -42
package/src/Lightbox.vue CHANGED
@@ -1,173 +1,176 @@
1
- <script setup lang="ts">
2
- /**
3
- * One image at a time, full screen, over a dimmed page: ← / → (or the side
4
- * buttons) step through the list, Escape or the ✕ closes, and focus goes
5
- * back to what opened it. Built on ModalShell, so keys pressed inside never
6
- * reach ComfyUI's shortcuts. ImageGrid opens it for you; use it alone for
7
- * a single image (a docs screenshot, a preview).
8
- *
9
- * <Lightbox v-model:open="open" v-model:index="at" :images="shots" />
10
- * <Lightbox v-model:open="open" :images="frames" zoom />
11
- *
12
- * `zoom` shows each picture in WpImageZoom (Fit / 100% / 200%, wheel, drag,
13
- * + − 0 1 2 keys); the level and the spot carry over as you step, so you
14
- * can compare the same part of every frame.
15
- */
16
- import { computed, nextTick, ref, watch } from "vue";
17
- import ModalShell from "./ModalShell.vue";
18
- import WpImageZoom from "./WpImageZoom.vue";
19
- import CloseButton from "./CloseButton.vue";
20
- import { focusablesIn } from "./useDialogFocus";
21
-
22
- export interface LightboxImage { src: string; alt: string; caption?: string | undefined }
23
-
24
- const props = withDefaults(defineProps<{
25
- open: boolean;
26
- images: LightboxImage[];
27
- index?: number | undefined;
28
- /** Go round from the last image to the first. */
29
- loop?: boolean | undefined;
30
- /** Real zoom levels (WpImageZoom) instead of a picture fitted to the screen. */
31
- zoom?: boolean | undefined;
32
- }>(), { index: 0, loop: false, zoom: false });
33
-
34
- /** The zoom level, kept across images while the viewer is open. */
35
- const level = ref<number | null>(null);
36
-
37
- const emit = defineEmits<{
38
- (e: "update:open", v: boolean): void;
39
- (e: "update:index", v: number): void;
40
- }>();
41
-
42
- const panelEl = ref<HTMLElement | null>(null);
43
- let returnTo: HTMLElement | null = null;
44
-
45
- const count = computed(() => props.images.length);
46
- const at = computed(() => Math.min(Math.max(props.index, 0), Math.max(count.value - 1, 0)));
47
- const image = computed(() => props.images[at.value]);
48
- const canPrev = computed(() => count.value > 1 && (props.loop || at.value > 0));
49
- const canNext = computed(() => count.value > 1 && (props.loop || at.value < count.value - 1));
50
-
51
- function go(dir: 1 | -1): void {
52
- if (dir < 0 ? !canPrev.value : !canNext.value) return;
53
- emit("update:index", (at.value + dir + count.value) % count.value);
54
- }
55
-
56
- function close(): void {
57
- emit("update:open", false);
58
- }
59
-
60
- function onKey(ev: KeyboardEvent): void {
61
- if (ev.key === "ArrowLeft") { ev.preventDefault(); go(-1); }
62
- else if (ev.key === "ArrowRight") { ev.preventDefault(); go(1); }
63
- else if (ev.key === "Home" && count.value) { ev.preventDefault(); emit("update:index", 0); }
64
- else if (ev.key === "End" && count.value) { ev.preventDefault(); emit("update:index", count.value - 1); }
65
- else if (ev.key === "Tab") {
66
- // Keep Tab inside: ModalShell stops the keys at its overlay, so this is the trap.
67
- const els = focusablesIn(panelEl.value);
68
- if (!els.length) return;
69
- const first = els[0];
70
- const last = els[els.length - 1];
71
- if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
72
- else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
73
- }
74
- }
75
-
76
- watch(() => props.open, async (v, was) => {
77
- if (typeof document === "undefined") return;
78
- if (v) {
79
- level.value = null;
80
- returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null;
81
- await nextTick();
82
- panelEl.value?.focus();
83
- } else if (was) {
84
- const el = returnTo;
85
- returnTo = null;
86
- if (el?.isConnected) el.focus();
87
- }
88
- }, { immediate: true });
89
- </script>
90
-
91
- <template>
92
- <ModalShell :visible="open" @close="close" @keydown="onKey">
93
- <!-- A click on the dark area closes, like the modal's own backdrop; Escape is the keyboard way. -->
94
- <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
95
- <div
96
- ref="panelEl"
97
- class="wp-lightbox"
98
- tabindex="-1"
99
- role="group"
100
- aria-roledescription="image viewer"
101
- :aria-label="image ? `${image.alt}, ${at + 1} of ${count}` : 'Image viewer'"
102
- @click.self="close"
103
- >
104
- <div class="wp-lightbox__bar">
105
- <span v-if="count > 1" class="wp-lightbox__count" aria-hidden="true">{{ at + 1 }} / {{ count }}</span>
106
- <CloseButton class="wp-lightbox__close" kbd="Esc" @click="close" />
107
- </div>
108
- <figure v-if="image" class="wp-lightbox__figure" :data-zoom="zoom || undefined">
109
- <WpImageZoom v-if="zoom" v-model:zoom="level" class="wp-lightbox__zoom" :src="image.src" :alt="image.alt" size="md" minimap />
110
- <img v-else class="wp-lightbox__img" :src="image.src" :alt="image.alt">
111
- <figcaption v-if="image.caption" class="wp-lightbox__caption">{{ image.caption }}</figcaption>
112
- </figure>
113
- <button
114
- v-if="count > 1"
115
- type="button"
116
- class="wp-lightbox__step"
117
- data-dir="prev"
118
- aria-label="Previous image"
119
- :disabled="!canPrev"
120
- @click="go(-1)"
121
- ><i class="pi pi-chevron-left" aria-hidden="true" /></button>
122
- <button
123
- v-if="count > 1"
124
- type="button"
125
- class="wp-lightbox__step"
126
- data-dir="next"
127
- aria-label="Next image"
128
- :disabled="!canNext"
129
- @click="go(1)"
130
- ><i class="pi pi-chevron-right" aria-hidden="true" /></button>
131
- </div>
132
- </ModalShell>
133
- </template>
134
-
135
- <style scoped>
136
- .wp-lightbox {
137
- position: relative; display: flex; flex-direction: column; align-items: center;
138
- width: min(1200px, 100%); max-height: calc(100vh - 40px); outline: none;
139
- color: var(--wp-text); font-family: var(--wp-font-sans);
140
- }
141
- /* Its own near-black scrim in both themes: the shared modal one is light enough to read a dialog
142
- over (and pale grey in light mode), too light to look at a picture on. A click on it closes. */
143
- .wp-lightbox::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 0.86); }
144
- .wp-lightbox__bar { display: flex; align-items: center; justify-content: flex-end; gap: var(--wp-space-3); align-self: stretch; padding-bottom: var(--wp-space-2); }
145
- .wp-lightbox__count { color: rgb(255 255 255 / 0.75); font-size: var(--wp-text-sm); font-variant-numeric: tabular-nums; }
146
- .wp-lightbox .wp-lightbox__close { color: rgb(255 255 255 / 0.85); }
147
- .wp-lightbox__figure { display: flex; flex-direction: column; align-items: center; gap: var(--wp-space-2); min-height: 0; margin: 0; }
148
- .wp-lightbox__figure[data-zoom] { align-self: stretch; }
149
- .wp-lightbox__zoom {
150
- width: 100%; height: calc(100vh - 140px);
151
- border-radius: var(--wp-radius); overflow: hidden; background: var(--wp-bg-1); box-shadow: var(--wp-shadow-lg);
152
- }
153
- .wp-lightbox__img {
154
- display: block; max-width: 100%; max-height: calc(100vh - 140px); object-fit: contain;
155
- border-radius: var(--wp-radius); box-shadow: var(--wp-shadow-lg); background: var(--wp-bg-1);
156
- }
157
- .wp-lightbox__caption { color: rgb(255 255 255 / 0.8); font-size: var(--wp-text-sm); text-align: center; }
158
- .wp-lightbox__step {
159
- position: absolute; top: 50%; transform: translateY(-50%);
160
- display: grid; place-items: center; width: var(--wp-control-h-lg); height: var(--wp-control-h-lg);
161
- border: 1px solid var(--wp-border-strong); border-radius: min(50%, var(--wp-radius-cap, 999px));
162
- background: var(--wp-bg-2); color: var(--wp-text); cursor: pointer; box-shadow: var(--wp-shadow-md);
163
- transition: background var(--wp-motion-hover);
164
- }
165
- .wp-lightbox__step[data-dir="prev"] { left: var(--wp-space-2); }
166
- .wp-lightbox__step[data-dir="next"] { right: var(--wp-space-2); }
167
- .wp-lightbox__step:hover:not(:disabled) { background: var(--wp-bg-3); }
168
- .wp-lightbox__step:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--wp-focus-ring); border-color: var(--wp-border-focus); }
169
- .wp-lightbox__step:disabled { opacity: 0.35; cursor: not-allowed; }
170
- @media (prefers-reduced-motion: reduce) {
171
- .wp-lightbox__step { transition: none; }
172
- }
173
- </style>
1
+ <script setup lang="ts">
2
+ /**
3
+ * One image at a time, full screen, over a dimmed page: ← / → (or the side
4
+ * buttons) step through the list, Escape or the ✕ closes, and focus goes
5
+ * back to what opened it. Built on ModalShell, so keys pressed inside never
6
+ * reach ComfyUI's shortcuts. ImageGrid opens it for you; use it alone for
7
+ * a single image (a docs screenshot, a preview).
8
+ *
9
+ * <Lightbox v-model:open="open" v-model:index="at" :images="shots" />
10
+ * <Lightbox v-model:open="open" :images="frames" zoom />
11
+ *
12
+ * `zoom` shows each picture in WpImageZoom (Fit / 100% / 200%, wheel, drag,
13
+ * + − 0 1 2 keys); the level and the spot carry over as you step, so you
14
+ * can compare the same part of every frame.
15
+ */
16
+ import { computed, nextTick, ref, watch } from "vue";
17
+ import ModalShell from "./ModalShell.vue";
18
+ import WpImageZoom from "./WpImageZoom.vue";
19
+ import CloseButton from "./CloseButton.vue";
20
+ import Skeleton from "./Skeleton.vue";
21
+ import { focusablesIn } from "./useDialogFocus";
22
+
23
+ export interface LightboxImage { src: string; alt: string; caption?: string | undefined }
24
+
25
+ const props = withDefaults(defineProps<{
26
+ open: boolean;
27
+ images: LightboxImage[];
28
+ index?: number | undefined;
29
+ /** Go round from the last image to the first. */
30
+ loop?: boolean | undefined;
31
+ /** Real zoom levels (WpImageZoom) instead of a picture fitted to the screen. */
32
+ zoom?: boolean | undefined;
33
+ }>(), { index: 0, loop: false, zoom: false });
34
+
35
+ /** The zoom level, kept across images while the viewer is open. */
36
+ const level = ref<number | null>(null);
37
+
38
+ const emit = defineEmits<{
39
+ (e: "update:open", v: boolean): void;
40
+ (e: "update:index", v: number): void;
41
+ }>();
42
+
43
+ const panelEl = ref<HTMLElement | null>(null);
44
+ let returnTo: HTMLElement | null = null;
45
+
46
+ const count = computed(() => props.images.length);
47
+ const at = computed(() => Math.min(Math.max(props.index, 0), Math.max(count.value - 1, 0)));
48
+ const image = computed(() => props.images[at.value]);
49
+ const canPrev = computed(() => count.value > 1 && (props.loop || at.value > 0));
50
+ const canNext = computed(() => count.value > 1 && (props.loop || at.value < count.value - 1));
51
+
52
+ function go(dir: 1 | -1): void {
53
+ if (dir < 0 ? !canPrev.value : !canNext.value) return;
54
+ emit("update:index", (at.value + dir + count.value) % count.value);
55
+ }
56
+
57
+ function close(): void {
58
+ emit("update:open", false);
59
+ }
60
+
61
+ function onKey(ev: KeyboardEvent): void {
62
+ if (ev.key === "ArrowLeft") { ev.preventDefault(); go(-1); }
63
+ else if (ev.key === "ArrowRight") { ev.preventDefault(); go(1); }
64
+ else if (ev.key === "Home" && count.value) { ev.preventDefault(); emit("update:index", 0); }
65
+ else if (ev.key === "End" && count.value) { ev.preventDefault(); emit("update:index", count.value - 1); }
66
+ else if (ev.key === "Tab") {
67
+ // Keep Tab inside: ModalShell stops the keys at its overlay, so this is the trap.
68
+ const els = focusablesIn(panelEl.value);
69
+ if (!els.length) return;
70
+ const first = els[0];
71
+ const last = els[els.length - 1];
72
+ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
73
+ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
74
+ }
75
+ }
76
+
77
+ watch(() => props.open, async (v, was) => {
78
+ if (typeof document === "undefined") return;
79
+ if (v) {
80
+ level.value = null;
81
+ returnTo = document.activeElement instanceof HTMLElement ? document.activeElement : null;
82
+ await nextTick();
83
+ panelEl.value?.focus();
84
+ } else if (was) {
85
+ const el = returnTo;
86
+ returnTo = null;
87
+ if (el?.isConnected) el.focus();
88
+ }
89
+ }, { immediate: true });
90
+ </script>
91
+
92
+ <template>
93
+ <ModalShell :visible="open" @close="close" @keydown="onKey">
94
+ <!-- A click on the dark area closes, like the modal's own backdrop; Escape is the keyboard way. -->
95
+ <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
96
+ <div
97
+ ref="panelEl"
98
+ class="wp-lightbox"
99
+ tabindex="-1"
100
+ role="group"
101
+ aria-roledescription="image viewer"
102
+ :aria-label="image ? `${image.alt}, ${at + 1} of ${count}` : 'Image viewer'"
103
+ @click.self="close"
104
+ >
105
+ <div class="wp-lightbox__bar">
106
+ <span v-if="count > 1" class="wp-lightbox__count" aria-hidden="true">{{ at + 1 }} / {{ count }}</span>
107
+ <CloseButton class="wp-lightbox__close" kbd="Esc" @click="close" />
108
+ </div>
109
+ <figure v-if="image" class="wp-lightbox__figure" :data-zoom="zoom || undefined">
110
+ <!-- An image with no src yet (still rendering): its shape, not an empty frame. -->
111
+ <Skeleton v-if="!image.src" class="wp-lightbox__loading" shape="block" width="min(70vw, 640px)" height="min(60vh, 480px)" label="Still loading" />
112
+ <WpImageZoom v-else-if="zoom" v-model:zoom="level" class="wp-lightbox__zoom" :src="image.src" :alt="image.alt" size="md" minimap />
113
+ <img v-else class="wp-lightbox__img" :src="image.src" :alt="image.alt">
114
+ <figcaption v-if="image.caption" class="wp-lightbox__caption">{{ image.caption }}</figcaption>
115
+ </figure>
116
+ <button
117
+ v-if="count > 1"
118
+ type="button"
119
+ class="wp-lightbox__step"
120
+ data-dir="prev"
121
+ aria-label="Previous image"
122
+ :disabled="!canPrev"
123
+ @click="go(-1)"
124
+ ><i class="pi pi-chevron-left" aria-hidden="true" /></button>
125
+ <button
126
+ v-if="count > 1"
127
+ type="button"
128
+ class="wp-lightbox__step"
129
+ data-dir="next"
130
+ aria-label="Next image"
131
+ :disabled="!canNext"
132
+ @click="go(1)"
133
+ ><i class="pi pi-chevron-right" aria-hidden="true" /></button>
134
+ </div>
135
+ </ModalShell>
136
+ </template>
137
+
138
+ <style scoped>
139
+ .wp-lightbox {
140
+ position: relative; display: flex; flex-direction: column; align-items: center;
141
+ width: min(1200px, 100%); max-height: calc(100vh - 40px); outline: none;
142
+ color: var(--wp-text); font-family: var(--wp-font-sans);
143
+ }
144
+ /* Its own near-black scrim in both themes: the shared modal one is light enough to read a dialog
145
+ over (and pale grey in light mode), too light to look at a picture on. A click on it closes. */
146
+ .wp-lightbox::before { content: ""; position: fixed; inset: 0; z-index: -1; background: rgb(0 0 0 / 0.86); }
147
+ .wp-lightbox__bar { display: flex; align-items: center; justify-content: flex-end; gap: var(--wp-space-3); align-self: stretch; padding-bottom: var(--wp-space-2); }
148
+ .wp-lightbox__count { color: rgb(255 255 255 / 0.75); font-size: var(--wp-text-sm); font-variant-numeric: tabular-nums; }
149
+ .wp-lightbox .wp-lightbox__close { color: rgb(255 255 255 / 0.85); }
150
+ .wp-lightbox__figure { display: flex; flex-direction: column; align-items: center; gap: var(--wp-space-2); min-height: 0; margin: 0; }
151
+ .wp-lightbox__figure[data-zoom] { align-self: stretch; }
152
+ .wp-lightbox__zoom {
153
+ width: 100%; height: calc(100vh - 140px);
154
+ border-radius: var(--wp-radius); overflow: hidden; background: var(--wp-bg-1); box-shadow: var(--wp-shadow-lg);
155
+ }
156
+ .wp-lightbox__img {
157
+ display: block; max-width: 100%; max-height: calc(100vh - 140px); object-fit: contain;
158
+ border-radius: var(--wp-radius); box-shadow: var(--wp-shadow-lg); background: var(--wp-bg-1);
159
+ }
160
+ .wp-lightbox__caption { color: rgb(255 255 255 / 0.8); font-size: var(--wp-text-sm); text-align: center; }
161
+ .wp-lightbox__step {
162
+ position: absolute; top: 50%; transform: translateY(-50%);
163
+ display: grid; place-items: center; width: var(--wp-control-h-lg); height: var(--wp-control-h-lg);
164
+ border: 1px solid var(--wp-border-strong); border-radius: min(50%, var(--wp-radius-cap, 999px));
165
+ background: var(--wp-bg-2); color: var(--wp-text); cursor: pointer; box-shadow: var(--wp-shadow-md);
166
+ transition: background var(--wp-motion-hover);
167
+ }
168
+ .wp-lightbox__step[data-dir="prev"] { left: var(--wp-space-2); }
169
+ .wp-lightbox__step[data-dir="next"] { right: var(--wp-space-2); }
170
+ .wp-lightbox__step:hover:not(:disabled) { background: var(--wp-bg-3); }
171
+ .wp-lightbox__step:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); border-color: var(--wp-border-focus); }
172
+ .wp-lightbox__step:disabled { opacity: 0.35; cursor: not-allowed; }
173
+ @media (prefers-reduced-motion: reduce) {
174
+ .wp-lightbox__step { transition: none; }
175
+ }
176
+ </style>
@@ -1,85 +1,85 @@
1
- <script setup lang="ts">
2
- import SearchInput from "./SearchInput.vue";
3
-
4
- /**
5
- * Text filter for a long list inside an editor card.
6
- *
7
- * Extracted from the fixed-values editor, which grew this pattern first
8
- * (`fv-filter` in FixedEditor.vue). The wildcard options list has its own,
9
- * richer variant with a tag menu attached; this is the plain-text case, which
10
- * is what the constraint exceptions and derivation rules need.
11
- *
12
- * Since the component merge it is SearchInput with a list's rules on top:
13
- * the "3 of 12" count inside the box, and the hide-until-long gate below.
14
- *
15
- * Deliberately dumb: it owns the query string and the count readout, and
16
- * nothing else. Each caller keeps its own filtering, because what counts as a
17
- * match differs per surface — an exception matches on resolved source/target
18
- * labels, a rule matches on every variable name and value across its branches.
19
- */
20
- const props = withDefaults(
21
- defineProps<{
22
- /** Unfiltered row count. Drives the "n of N" readout and the show/hide gate. */
23
- total: number;
24
- /** Row count after filtering. */
25
- visible: number;
26
- /** Plural noun for the placeholder and idle readout, e.g. "exceptions". */
27
- noun: string;
28
- /** `data-test` prefix; yields `<prefix>-search`, `-count`, `-clear` (the ✕). */
29
- testPrefix: string;
30
- /**
31
- * Hide the control until the list is longer than this.
32
- *
33
- * Callers set it from how tall their rows are, not from a house style: a
34
- * derivation rule is a whole card and three of them already scroll, while
35
- * a fixed-value is one table row and eight fit comfortably.
36
- *
37
- * The default of 8 came from the fixed-values filter and was applied
38
- * unchanged to the constraint and derivation editors, where it hid the
39
- * feature outright — a real library measured 2026-08-06 had at most 3
40
- * rules and 2 exceptions per module, so nobody ever saw it.
41
- *
42
- * Gated on `total`, never on `visible`, so the box cannot disappear
43
- * underneath a query that filtered the list down past the threshold.
44
- */
45
- minRows?: number | undefined;
46
- }>(),
47
- { minRows: 8 },
48
- );
49
-
50
- const query = defineModel<string>({ required: true });
51
- </script>
52
-
53
- <template>
54
- <SearchInput
55
- v-if="total > props.minRows"
56
- v-model="query"
57
- class="wp-lfilter"
58
- size="sm"
59
- :placeholder="`Filter ${total} ${noun}…`"
60
- :aria-label="`Filter ${noun}`"
61
- :total="total"
62
- :visible="visible"
63
- :noun="noun"
64
- clear-label="Clear filter"
65
- :test-prefix="testPrefix"
66
- :data-test="`${testPrefix}-search`"
67
- />
68
- </template>
69
-
70
- <style scoped>
71
- .wp-lfilter {
72
- /* Card's header puts a `.wp-spacer` (flex: 1) between the title and this
73
- slot. With a grow factor of 1 the two split the free space evenly and the
74
- search box ends up half the width it should be. A far larger factor takes
75
- effectively all of the slack while leaving the spacer in place, which is
76
- what still separates the title from the controls. */
77
- flex: 1000 1 auto;
78
- /* A floor, not a width. Below this the input is too small to read what you
79
- typed, so the card header (which wraps) drops the filter onto its own row
80
- instead of shaving it to nothing. The derivation card, with four buttons
81
- beside it, is the case that forces this. */
82
- min-width: 220px;
83
- margin-right: var(--wp-space-4);
84
- }
85
- </style>
1
+ <script setup lang="ts">
2
+ import SearchInput from "./SearchInput.vue";
3
+
4
+ /**
5
+ * Text filter for a long list inside an editor card.
6
+ *
7
+ * Extracted from the fixed-values editor, which grew this pattern first
8
+ * (`fv-filter` in FixedEditor.vue). The wildcard options list has its own,
9
+ * richer variant with a tag menu attached; this is the plain-text case, which
10
+ * is what the constraint exceptions and derivation rules need.
11
+ *
12
+ * Since the component merge it is SearchInput with a list's rules on top:
13
+ * the "3 of 12" count inside the box, and the hide-until-long gate below.
14
+ *
15
+ * Deliberately dumb: it owns the query string and the count readout, and
16
+ * nothing else. Each caller keeps its own filtering, because what counts as a
17
+ * match differs per surface — an exception matches on resolved source/target
18
+ * labels, a rule matches on every variable name and value across its branches.
19
+ */
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ /** Unfiltered row count. Drives the "n of N" readout and the show/hide gate. */
23
+ total: number;
24
+ /** Row count after filtering. */
25
+ visible: number;
26
+ /** Plural noun for the placeholder and idle readout, e.g. "exceptions". */
27
+ noun: string;
28
+ /** `data-test` prefix; yields `<prefix>-search`, `-count`, `-clear` (the ✕). */
29
+ testPrefix: string;
30
+ /**
31
+ * Hide the control until the list is longer than this.
32
+ *
33
+ * Callers set it from how tall their rows are, not from a house style: a
34
+ * derivation rule is a whole card and three of them already scroll, while
35
+ * a fixed-value is one table row and eight fit comfortably.
36
+ *
37
+ * The default of 8 came from the fixed-values filter and was applied
38
+ * unchanged to the constraint and derivation editors, where it hid the
39
+ * feature outright — a real library measured 2026-08-06 had at most 3
40
+ * rules and 2 exceptions per module, so nobody ever saw it.
41
+ *
42
+ * Gated on `total`, never on `visible`, so the box cannot disappear
43
+ * underneath a query that filtered the list down past the threshold.
44
+ */
45
+ minRows?: number | undefined;
46
+ }>(),
47
+ { minRows: 8 },
48
+ );
49
+
50
+ const query = defineModel<string>({ required: true });
51
+ </script>
52
+
53
+ <template>
54
+ <SearchInput
55
+ v-if="total > props.minRows"
56
+ v-model="query"
57
+ class="wp-lfilter"
58
+ size="sm"
59
+ :placeholder="`Filter ${total} ${noun}…`"
60
+ :aria-label="`Filter ${noun}`"
61
+ :total="total"
62
+ :visible="visible"
63
+ :noun="noun"
64
+ clear-label="Clear filter"
65
+ :test-prefix="testPrefix"
66
+ :data-test="`${testPrefix}-search`"
67
+ />
68
+ </template>
69
+
70
+ <style scoped>
71
+ .wp-lfilter {
72
+ /* Card's header puts a `.wp-spacer` (flex: 1) between the title and this
73
+ slot. With a grow factor of 1 the two split the free space evenly and the
74
+ search box ends up half the width it should be. A far larger factor takes
75
+ effectively all of the slack while leaving the spacer in place, which is
76
+ what still separates the title from the controls. */
77
+ flex: 1000 1 auto;
78
+ /* A floor, not a width. Below this the input is too small to read what you
79
+ typed, so the card header (which wraps) drops the filter onto its own row
80
+ instead of shaving it to nothing. The derivation card, with four buttons
81
+ beside it, is the case that forces this. */
82
+ min-width: 220px;
83
+ margin-right: var(--wp-space-4);
84
+ }
85
+ </style>