@dumiflex/wp-kit 0.6.0 → 0.7.1

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.
@@ -0,0 +1,608 @@
1
+ <script setup lang="ts">
2
+ // The shared preview sheet (wp-pv-* classes), loaded once for every preview.
3
+ import "./preview.css";
4
+ /**
5
+ * Wildcard preview built for big pools: a stats strip, search + filters,
6
+ * tag facets with counts, and options as table rows (chance, tags,
7
+ * negative) grouped by one axis at a time. Groups past the first two start
8
+ * closed and each open group shows a page of rows behind "Show N more", so
9
+ * a 200-option wildcard stays a screen or two tall.
10
+ */
11
+ import { computed, ref } from "vue";
12
+ import TokenizedValue from "./TokenizedValue.vue";
13
+ import {
14
+ scanText,
15
+ wildcardAxes,
16
+ wildcardOptions,
17
+ type Obj,
18
+ type WildcardOption,
19
+ } from "./preview-readers";
20
+ import WpSegmented, { type SegmentedOption } from "./WpSegmented.vue";
21
+ import SearchInput from "./SearchInput.vue";
22
+ import Button from "./Button.vue";
23
+ import ListRow from "./ListRow.vue";
24
+ import Chip from "./Chip.vue";
25
+ import PreviewGroup from "./PreviewGroup.vue";
26
+
27
+ const props = defineProps<{
28
+ inner: Obj | null;
29
+ refMap: ReadonlyMap<string, string>;
30
+ }>();
31
+
32
+ const OPEN_GROUPS = 2;
33
+ const PAGE = 8;
34
+ const LONG_CHARS = 90;
35
+ const FACET_PAGE = 6;
36
+
37
+ const options = computed(() => wildcardOptions(props.inner));
38
+ const axes = computed(() => wildcardAxes(props.inner));
39
+ const totalWeight = computed(() =>
40
+ options.value.reduce((n, o) => n + Math.max(0, o.weight), 0),
41
+ );
42
+ const maxWeight = computed(() => Math.max(0, ...options.value.map((o) => o.weight)));
43
+ const refCount = computed(() => {
44
+ let n = 0;
45
+ for (const o of options.value) {
46
+ const s = scanText(o.value);
47
+ if (s.refs || s.picks) n++;
48
+ }
49
+ return n;
50
+ });
51
+ const negCount = computed(() => options.value.filter((o) => o.negative).length);
52
+ const weighted = computed(() => options.value.some((o) => o.weight !== 1));
53
+ const tagTotal = computed(() => new Set(axes.value.flatMap((a) => a.tags)).size);
54
+ const weightRange = computed(() => {
55
+ const ws = options.value.map((o) => o.weight);
56
+ const lo = Math.min(...ws);
57
+ const hi = Math.max(...ws);
58
+ return lo === hi ? null : `${fmt(lo)}–${fmt(hi)}×`;
59
+ });
60
+ function fmt(n: number): string {
61
+ return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, "");
62
+ }
63
+
64
+ // ─── filters ───
65
+ const search = ref("");
66
+ const onlyNeg = ref(false);
67
+ const onlyWeighted = ref(false);
68
+ const onlyRefs = ref(false);
69
+ /** Selected facet tags per axis. OR within an axis, AND across axes. */
70
+ const picked = ref<Record<string, string[]>>({});
71
+ function togglePick(axis: string, tag: string): void {
72
+ const cur = picked.value[axis] ?? [];
73
+ const next = cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag];
74
+ picked.value = { ...picked.value, [axis]: next };
75
+ }
76
+ function isPicked(axis: string, tag: string): boolean {
77
+ return (picked.value[axis] ?? []).includes(tag);
78
+ }
79
+ const pickCount = computed(() =>
80
+ Object.values(picked.value).reduce((n, t) => n + t.length, 0),
81
+ );
82
+ const filtering = computed(
83
+ () =>
84
+ search.value.trim() !== "" ||
85
+ onlyNeg.value ||
86
+ onlyWeighted.value ||
87
+ onlyRefs.value ||
88
+ pickCount.value > 0,
89
+ );
90
+ function clearFilters(): void {
91
+ search.value = "";
92
+ onlyNeg.value = false;
93
+ onlyWeighted.value = false;
94
+ onlyRefs.value = false;
95
+ picked.value = {};
96
+ }
97
+ function passes(o: WildcardOption): boolean {
98
+ const q = search.value.trim().toLowerCase();
99
+ if (
100
+ q &&
101
+ !o.value.toLowerCase().includes(q) &&
102
+ !o.negative.toLowerCase().includes(q) &&
103
+ !o.tags.some((t) => t.toLowerCase().includes(q))
104
+ )
105
+ return false;
106
+ if (onlyNeg.value && !o.negative) return false;
107
+ if (onlyWeighted.value && o.weight === 1) return false;
108
+ if (onlyRefs.value) {
109
+ const s = scanText(o.value);
110
+ if (!s.refs && !s.picks) return false;
111
+ }
112
+ for (const tags of Object.values(picked.value)) {
113
+ if (tags.length && !o.tags.some((t) => tags.includes(t))) return false;
114
+ }
115
+ return true;
116
+ }
117
+ const visible = computed(() => options.value.filter(passes));
118
+
119
+ // ─── facets ───
120
+ const facetOpen = ref(new Set<string>());
121
+ function facetTags(axis: { name: string; tags: string[] }): { tag: string; count: number }[] {
122
+ return axis.tags
123
+ .map((tag) => ({ tag, count: options.value.filter((o) => o.tags.includes(tag)).length }))
124
+ .filter((f) => f.count > 0);
125
+ }
126
+ function shownFacets(axis: { name: string; tags: string[] }): { tag: string; count: number }[] {
127
+ const all = facetTags(axis);
128
+ return facetOpen.value.has(axis.name) ? all : all.slice(0, FACET_PAGE);
129
+ }
130
+ function toggleFacetMore(name: string): void {
131
+ const next = new Set(facetOpen.value);
132
+ if (next.has(name)) next.delete(name);
133
+ else next.add(name);
134
+ facetOpen.value = next;
135
+ }
136
+ const showFacets = computed(() => axes.value.length > 0 && options.value.length >= 20);
137
+
138
+ // ─── grouping ───
139
+ const groupBy = ref<string>("");
140
+ const groupOptions = computed<SegmentedOption[]>(() => [
141
+ ...axes.value.map((a) => ({ value: a.name, label: a.name })),
142
+ { value: "none", label: "none" },
143
+ ]);
144
+ const effectiveAxis = computed(() =>
145
+ groupBy.value === "none" ? null : (axes.value.find((a) => a.name === groupBy.value) ?? axes.value[0] ?? null),
146
+ );
147
+ interface Group {
148
+ name: string;
149
+ options: WildcardOption[];
150
+ }
151
+ const groups = computed<Group[]>(() => {
152
+ const axis = effectiveAxis.value;
153
+ if (!axis) return visible.value.length ? [{ name: "", options: visible.value }] : [];
154
+ const buckets = new Map<string, WildcardOption[]>();
155
+ const untagged: WildcardOption[] = [];
156
+ for (const o of visible.value) {
157
+ // Each option lands ONCE: under the first of its tags this axis lists.
158
+ const tag = axis.tags.find((t) => o.tags.includes(t));
159
+ if (tag === undefined) untagged.push(o);
160
+ else buckets.set(tag, [...(buckets.get(tag) ?? []), o]);
161
+ }
162
+ const out: Group[] = [];
163
+ for (const tag of axis.tags) {
164
+ const opts = buckets.get(tag);
165
+ if (opts?.length) out.push({ name: tag, options: opts });
166
+ }
167
+ if (untagged.length) out.push({ name: "(untagged)", options: untagged });
168
+ return out;
169
+ });
170
+
171
+ /** Toggled groups flip their default (open for the first few; every group
172
+ * opens while a filter is on). */
173
+ const toggled = ref(new Set<string>());
174
+ function flip(set: Set<string>, key: string): Set<string> {
175
+ const next = new Set(set);
176
+ if (next.has(key)) next.delete(key);
177
+ else next.add(key);
178
+ return next;
179
+ }
180
+ function isOpen(name: string, index: number): boolean {
181
+ if (!name) return true;
182
+ const byDefault = filtering.value || index < OPEN_GROUPS;
183
+ return toggled.value.has(name) ? !byDefault : byDefault;
184
+ }
185
+ const fullGroups = ref(new Set<string>());
186
+ function rowsOf(g: Group): WildcardOption[] {
187
+ return fullGroups.value.has(g.name) ? g.options : g.options.slice(0, PAGE);
188
+ }
189
+ function share(g: Group): string {
190
+ if (!totalWeight.value) return "";
191
+ const w = g.options.reduce((n, o) => n + Math.max(0, o.weight), 0);
192
+ return `${pct(w)} of draws`;
193
+ }
194
+ function pct(w: number): string {
195
+ const p = (w / totalWeight.value) * 100;
196
+ if (p > 0 && p < 0.1) return "<0.1%";
197
+ return `${p < 10 ? p.toFixed(1) : p.toFixed(0)}%`;
198
+ }
199
+
200
+ // ─── rows ───
201
+ const view = ref<"list" | "chips">("list");
202
+ const VIEW_OPTIONS: SegmentedOption<"list" | "chips">[] = [
203
+ { value: "list", label: "List" },
204
+ { value: "chips", label: "Chips" },
205
+ ];
206
+ const expanded = ref(new Set<string>());
207
+ function isLong(o: WildcardOption): boolean {
208
+ return o.value.length > LONG_CHARS;
209
+ }
210
+ </script>
211
+
212
+ <template>
213
+ <div class="wp-pv wp-wc">
214
+ <div class="wp-pv-stats">
215
+ <div class="wp-pv-stat">
216
+ <b>{{ options.length }}</b><span>option{{ options.length === 1 ? "" : "s" }}</span>
217
+ </div>
218
+ <div
219
+ v-if="axes.length"
220
+ class="wp-pv-stat"
221
+ >
222
+ <b>{{ axes.length }}</b><span>tag ax{{ axes.length === 1 ? "is" : "es" }} · {{ tagTotal }} tags</span>
223
+ </div>
224
+ <div
225
+ v-if="negCount"
226
+ class="wp-pv-stat"
227
+ >
228
+ <b>{{ negCount }}</b><span>with a negative</span>
229
+ </div>
230
+ <div
231
+ v-if="weightRange"
232
+ class="wp-pv-stat"
233
+ >
234
+ <b>{{ weightRange }}</b><span>weight range</span>
235
+ </div>
236
+ <div
237
+ v-if="refCount"
238
+ class="wp-pv-stat"
239
+ >
240
+ <b>{{ refCount }}</b><span>use picks / @refs</span>
241
+ </div>
242
+ </div>
243
+
244
+ <div class="wp-pv-tool">
245
+ <SearchInput
246
+ v-if="options.length > 8"
247
+ v-model="search"
248
+ :placeholder="`Filter ${options.length} options…`"
249
+ data-test="wildcard-search"
250
+ class="wp-pv-search"
251
+ />
252
+ <template v-if="axes.length">
253
+ <span class="wp-pv-label">Group by</span>
254
+ <WpSegmented
255
+ :model-value="effectiveAxis?.name ?? 'none'"
256
+ :options="groupOptions"
257
+ size="sm"
258
+ aria-label="Group by"
259
+ test-prefix="groupby"
260
+ @update:model-value="(v) => (groupBy = v)"
261
+ />
262
+ </template>
263
+ <Chip
264
+ v-if="negCount"
265
+ size="sm"
266
+ color="var(--wp-danger)"
267
+ :pressed="onlyNeg"
268
+ data-test="filter-negative"
269
+ @click="onlyNeg = !onlyNeg"
270
+ >Has negative</Chip>
271
+ <Chip
272
+ v-if="weighted"
273
+ size="sm"
274
+ :pressed="onlyWeighted"
275
+ @click="onlyWeighted = !onlyWeighted"
276
+ >Weighted</Chip>
277
+ <Chip
278
+ v-if="refCount"
279
+ size="sm"
280
+ :pressed="onlyRefs"
281
+ @click="onlyRefs = !onlyRefs"
282
+ >Uses picks / refs</Chip>
283
+ <span
284
+ v-if="filtering"
285
+ class="wp-pv-count"
286
+ data-test="wildcard-matches"
287
+ >
288
+ {{ visible.length }} of {{ options.length }}
289
+ <Button
290
+ variant="link"
291
+ size="sm"
292
+ @click="clearFilters"
293
+ >clear</Button>
294
+ </span>
295
+ <WpSegmented
296
+ v-model="view"
297
+ class="wp-pv-tool__end"
298
+ :options="VIEW_OPTIONS"
299
+ size="sm"
300
+ aria-label="Show as"
301
+ test-prefix="view"
302
+ />
303
+ </div>
304
+
305
+ <div
306
+ class="wp-wc__split"
307
+ :data-facets="showFacets || null"
308
+ >
309
+ <aside
310
+ v-if="showFacets"
311
+ class="wp-wc__facets wp-pv-hide-narrow"
312
+ >
313
+ <div
314
+ v-for="a in axes"
315
+ :key="a.name"
316
+ class="wp-wc__facet"
317
+ >
318
+ <h5>{{ a.name }}</h5>
319
+ <ListRow
320
+ v-for="f in shownFacets(a)"
321
+ :key="f.tag"
322
+ size="sm"
323
+ toggle
324
+ class="wp-wc__facet-row"
325
+ :selected="isPicked(a.name, f.tag)"
326
+ :count="f.count"
327
+ :data-test="`facet-${f.tag}`"
328
+ @click="togglePick(a.name, f.tag)"
329
+ >
330
+ {{ f.tag }}
331
+ </ListRow>
332
+ <Button
333
+ v-if="facetTags(a).length > FACET_PAGE"
334
+ variant="link"
335
+ size="sm"
336
+ class="wp-wc__facet-more"
337
+ @click="toggleFacetMore(a.name)"
338
+ >
339
+ {{ facetOpen.has(a.name) ? "fewer" : `+ ${facetTags(a).length - FACET_PAGE} more` }}
340
+ </Button>
341
+ </div>
342
+ </aside>
343
+
344
+ <div class="wp-wildcard-groups">
345
+ <p
346
+ v-if="!groups.length"
347
+ class="wp-pv-empty"
348
+ >
349
+ No options match these filters.
350
+ </p>
351
+ <PreviewGroup
352
+ v-for="(g, gidx) in groups"
353
+ :key="g.name || '_'"
354
+ class="wp-wildcard-group"
355
+ :name="g.name"
356
+ :open="isOpen(g.name, gidx)"
357
+ :count="g.options.length"
358
+ :aside="share(g)"
359
+ :test-id="`group-${g.name}`"
360
+ @update:open="toggled = flip(toggled, g.name)"
361
+ >
362
+ <table
363
+ v-if="view === 'list'"
364
+ class="wp-pv-table"
365
+ >
366
+ <colgroup>
367
+ <col class="wp-pv-col-n">
368
+ <col>
369
+ <col class="wp-wc__col-chance">
370
+ <col
371
+ v-if="axes.length"
372
+ class="wp-pv-hide-narrow"
373
+ style="width: 22%"
374
+ >
375
+ <col
376
+ v-if="negCount"
377
+ style="width: 26%"
378
+ >
379
+ </colgroup>
380
+ <tbody>
381
+ <tr
382
+ v-for="(o, oidx) in rowsOf(g)"
383
+ :key="oidx"
384
+ class="wp-wildcard-option"
385
+ >
386
+ <td class="wp-pv-n">
387
+ {{ oidx + 1 }}
388
+ </td>
389
+ <td>
390
+ <div
391
+ :class="{ 'wp-pv-clamp': isLong(o) && !expanded.has(`${g.name}|${oidx}`) }"
392
+ class="wp-wildcard-option__value"
393
+ :data-long="isLong(o) || null"
394
+ >
395
+ <TokenizedValue
396
+ :value="o.value"
397
+ :uuid-to-name="refMap"
398
+ />
399
+ </div>
400
+ <Button
401
+ v-if="isLong(o)"
402
+ variant="link"
403
+ size="sm"
404
+ @click="expanded = flip(expanded, `${g.name}|${oidx}`)"
405
+ >{{ expanded.has(`${g.name}|${oidx}`) ? "Show less" : "Show all" }}</Button>
406
+ </td>
407
+ <td class="wp-wc__chance">
408
+ <span>{{ totalWeight ? pct(Math.max(0, o.weight)) : "—" }}<template v-if="o.weight !== 1"> · ×{{ fmt(o.weight) }}</template></span>
409
+ <span
410
+ v-if="o.fallback"
411
+ class="wp-pv-fbtag"
412
+ data-test="option-fallback"
413
+ title="Rolls normally, and is also used when nothing else is left to pick."
414
+ >fallback</span>
415
+ <div class="wp-wc__bar">
416
+ <b :style="{ width: `${maxWeight ? (Math.max(0, o.weight) / maxWeight) * 100 : 0}%` }" />
417
+ </div>
418
+ </td>
419
+ <td
420
+ v-if="axes.length"
421
+ class="wp-pv-hide-narrow"
422
+ >
423
+ <span
424
+ v-for="t in o.tags"
425
+ :key="t"
426
+ class="wp-wc__tag"
427
+ >{{ t }}</span>
428
+ </td>
429
+ <td
430
+ v-if="negCount"
431
+ class="wp-pv-neg-cell"
432
+ >
433
+ <span
434
+ v-if="o.negative"
435
+ data-test="option-negative"
436
+ >
437
+ <span class="wp-pv-negtag">NEG</span>
438
+ <TokenizedValue
439
+ :value="o.negative"
440
+ :uuid-to-name="refMap"
441
+ />
442
+ </span>
443
+ </td>
444
+ </tr>
445
+ </tbody>
446
+ </table>
447
+ <ul
448
+ v-else
449
+ class="wp-wc__chips"
450
+ >
451
+ <li
452
+ v-for="(o, oidx) in rowsOf(g)"
453
+ :key="oidx"
454
+ class="wp-wildcard-option"
455
+ :title="o.tags.join(' · ')"
456
+ >
457
+ <span :class="{ 'wp-pv-clamp': isLong(o) }">
458
+ <TokenizedValue
459
+ :value="o.value"
460
+ :uuid-to-name="refMap"
461
+ />
462
+ </span>
463
+ <span
464
+ v-if="o.negative"
465
+ class="wp-wc__chipneg"
466
+ data-test="option-negative"
467
+ >
468
+ <span class="wp-pv-negtag">NEG</span>
469
+ <TokenizedValue
470
+ :value="o.negative"
471
+ :uuid-to-name="refMap"
472
+ />
473
+ </span>
474
+ <span
475
+ v-if="o.fallback"
476
+ class="wp-pv-fbtag"
477
+ data-test="option-fallback"
478
+ title="Rolls normally, and is also used when nothing else is left to pick."
479
+ >fallback</span>
480
+ <span
481
+ v-if="o.weight !== 1"
482
+ class="wp-wc__w"
483
+ >×{{ fmt(o.weight) }}</span>
484
+ </li>
485
+ </ul>
486
+ <div
487
+ v-if="g.options.length > PAGE"
488
+ class="wp-pv-more"
489
+ >
490
+ <Button
491
+ variant="link"
492
+ size="sm"
493
+ :data-test="`more-${g.name || '_'}`"
494
+ @click="fullGroups = flip(fullGroups, g.name)"
495
+ >
496
+ {{
497
+ fullGroups.has(g.name)
498
+ ? "Show fewer"
499
+ : `Show ${g.options.length - PAGE} more${g.name ? ` in ${g.name}` : ""}`
500
+ }}
501
+ </Button>
502
+ </div>
503
+ </PreviewGroup>
504
+ </div>
505
+ </div>
506
+ </div>
507
+ </template>
508
+
509
+ <style scoped>
510
+ .wp-wc__split[data-facets] {
511
+ display: grid;
512
+ grid-template-columns: 190px minmax(0, 1fr);
513
+ }
514
+ @container (max-width: 640px) {
515
+ .wp-wc__split[data-facets] {
516
+ grid-template-columns: minmax(0, 1fr);
517
+ }
518
+ }
519
+ .wp-wc__facets {
520
+ border-right: 1px solid var(--wp-border);
521
+ padding: 6px 0 12px;
522
+ }
523
+ .wp-wc__facet h5 {
524
+ margin: 10px 16px 4px;
525
+ font-size: 10.5px;
526
+ font-weight: 600;
527
+ letter-spacing: 0.06em;
528
+ color: var(--wp-text-dim);
529
+ text-transform: uppercase;
530
+ }
531
+ .wp-wc__facet-more {
532
+ margin: 2px 12px 0;
533
+ }
534
+ .wp-wc__chance span {
535
+ font-family: var(--wp-font-mono);
536
+ font-size: 11px;
537
+ color: var(--wp-text-muted);
538
+ white-space: nowrap;
539
+ }
540
+ .wp-wc__chance .wp-pv-fbtag {
541
+ margin-left: 6px;
542
+ font-size: 10px;
543
+ color: var(--wp-accent-text);
544
+ }
545
+ .wp-wc__col-chance {
546
+ width: 112px;
547
+ }
548
+ @container (max-width: 640px) {
549
+ .wp-wc__col-chance {
550
+ width: 56px;
551
+ }
552
+ .wp-wc__bar {
553
+ display: none;
554
+ }
555
+ }
556
+ .wp-wc__bar {
557
+ height: 4px;
558
+ margin-top: 6px;
559
+ border-radius: min(2px, var(--wp-radius-cap, 999px));
560
+ background: var(--wp-bg-4);
561
+ }
562
+ .wp-wc__bar b {
563
+ display: block;
564
+ height: 4px;
565
+ border-radius: min(2px, var(--wp-radius-cap, 999px));
566
+ background: var(--wp-accent-500);
567
+ }
568
+ .wp-wc__tag {
569
+ display: inline-block;
570
+ margin: 0 3px 3px 0;
571
+ padding: 0 6px;
572
+ border-radius: min(4px, var(--wp-radius-cap, 999px));
573
+ background: var(--wp-bg-3);
574
+ color: var(--wp-text-muted);
575
+ font-size: 10.5px;
576
+ }
577
+ .wp-wc__chips {
578
+ list-style: none;
579
+ margin: 0;
580
+ padding: 10px 16px 12px;
581
+ display: flex;
582
+ flex-wrap: wrap;
583
+ gap: 6px;
584
+ border-bottom: 1px solid var(--wp-border);
585
+ }
586
+ .wp-wc__chips li {
587
+ display: inline-flex;
588
+ align-items: center;
589
+ gap: 6px;
590
+ max-width: 100%;
591
+ padding: 3px 10px;
592
+ border-radius: min(10px, var(--wp-radius-cap, 999px));
593
+ background: var(--wp-bg-3);
594
+ color: var(--wp-text);
595
+ font-size: 13px;
596
+ }
597
+ .wp-wc__chipneg {
598
+ padding-left: 6px;
599
+ border-left: 1px solid var(--wp-border);
600
+ color: var(--wp-danger-text, var(--wp-danger));
601
+ font-size: 12px;
602
+ }
603
+ .wp-wc__w {
604
+ font-family: var(--wp-font-mono);
605
+ font-size: 10.5px;
606
+ color: var(--wp-text-muted);
607
+ }
608
+ </style>