@dumiflex/wp-kit 0.0.0-stage → 0.4.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.
- package/CHANGELOG.md +77 -0
- package/LICENSE +674 -0
- package/README.md +72 -2
- package/package.json +76 -4
- package/src/Avatar.vue +83 -0
- package/src/Badge.vue +97 -0
- package/src/Banner.vue +118 -0
- package/src/BookmarkButton.vue +27 -0
- package/src/BundleMeta.vue +51 -0
- package/src/Button.vue +103 -0
- package/src/Card.vue +68 -0
- package/src/Checkbox.vue +109 -0
- package/src/Chip.vue +163 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +188 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +331 -0
- package/src/ConfirmDialog.vue +165 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +386 -0
- package/src/CopyButton.vue +156 -0
- package/src/DataTable.vue +292 -0
- package/src/DatePicker.vue +234 -0
- package/src/DescriptionList.vue +73 -0
- package/src/DiffView.vue +68 -0
- package/src/Disclosure.vue +166 -0
- package/src/DragHandle.vue +49 -0
- package/src/Drawer.vue +131 -0
- package/src/DropZone.vue +196 -0
- package/src/DropdownMenu.vue +72 -0
- package/src/EmptyState.vue +111 -0
- package/src/Field.vue +28 -0
- package/src/HsvPicker.vue +208 -0
- package/src/Icon.vue +39 -0
- package/src/IconPicker.vue +209 -0
- package/src/ImageGrid.vue +125 -0
- package/src/InlineEdit.vue +183 -0
- package/src/Input.vue +99 -0
- package/src/Kbd.vue +56 -0
- package/src/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +175 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +78 -0
- package/src/ModalShell.vue +109 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +180 -0
- package/src/PostCard.vue +210 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +192 -0
- package/src/Select.vue +644 -0
- package/src/Separator.vue +54 -0
- package/src/Skeleton.vue +82 -0
- package/src/Slider.vue +198 -0
- package/src/SortableList.vue +260 -0
- package/src/SplitPane.vue +68 -0
- package/src/StarButton.vue +40 -0
- package/src/Stepper.vue +141 -0
- package/src/SupportLinks.vue +93 -0
- package/src/TagInput.vue +297 -0
- package/src/Textarea.vue +57 -0
- package/src/Timeline.vue +92 -0
- package/src/ToastCard.vue +119 -0
- package/src/ToastHost.vue +40 -0
- package/src/Toggle.vue +36 -0
- package/src/TokenizedValue.vue +310 -0
- package/src/Toolbar.vue +110 -0
- package/src/VirtualList.vue +87 -0
- package/src/VisuallyHidden.vue +29 -0
- package/src/WpButton.vue +75 -0
- package/src/WpCheck.vue +104 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +178 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +93 -0
- package/src/WpSwitch.vue +163 -0
- package/src/WpTabs.vue +188 -0
- package/src/_modal-motion.css +33 -0
- package/src/_skeleton.css +50 -0
- package/src/axis-color.ts +42 -0
- package/src/button.css +169 -0
- package/src/calendar.ts +59 -0
- package/src/clipboard.ts +50 -0
- package/src/colour-presets.ts +12 -0
- package/src/diff.ts +51 -0
- package/src/drop-zone.ts +41 -0
- package/src/field.css +50 -0
- package/src/flip.ts +237 -0
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -0
- package/src/hsv.ts +100 -0
- package/src/icon-catalog.ts +52 -0
- package/src/image-zoom.ts +93 -0
- package/src/kind-colour.ts +28 -0
- package/src/logo-paths.ts +21 -0
- package/src/matrix-axis.ts +91 -0
- package/src/pagination.ts +27 -0
- package/src/platform-keys.ts +29 -0
- package/src/rich-tokenize.ts +357 -0
- package/src/select-types.ts +32 -0
- package/src/slider-math.ts +45 -0
- package/src/sortable.ts +22 -0
- package/src/theme/fonts/OFL-Fraunces.txt +93 -0
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/theme/fonts/OFL-Manrope.txt +93 -0
- package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
- package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
- package/src/theme/looks.css +428 -0
- package/src/theme/palettes.css +254 -0
- package/src/theme/tokens.css +2896 -0
- package/src/tokens.ts +56 -0
- package/src/tooltip.css +43 -0
- package/src/tooltip.ts +146 -0
- package/src/useDialogFocus.ts +73 -0
- package/src/useGrowableField.ts +357 -0
- package/src/useModalKeyShield.ts +116 -0
- package/src/useToast.ts +48 -0
- package/src/wp-logo.svg +14 -0
package/src/Timeline.vue
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Timeline: events in order, each a dot on one line with its time, title
|
|
4
|
+
* and an optional body (History, an AI trace, a pack build log). Works on
|
|
5
|
+
* the canvas and in the manager.
|
|
6
|
+
*
|
|
7
|
+
* <Timeline :items="[{ id: 1, title: 'Saved Hair colour', time: '5 min ago', tone: 'success', icon: 'pi-check' }]" />
|
|
8
|
+
* <Timeline :items="steps"><template #body="{ item }"><CodeBlock :code="item.args" bare /></template></Timeline>
|
|
9
|
+
*
|
|
10
|
+
* It is an ordered list. `#body` and `#time` slots let a screen draw its own
|
|
11
|
+
* body (a diff, a code block) or a RelativeDate. The last item can be
|
|
12
|
+
* `pending` (a pulsing dot) while a job is still running.
|
|
13
|
+
*/
|
|
14
|
+
export interface TimelineItem {
|
|
15
|
+
id: string | number;
|
|
16
|
+
title: string;
|
|
17
|
+
/** Shown after the title, e.g. "5 min ago". */
|
|
18
|
+
time?: string | undefined;
|
|
19
|
+
/** A line or two of text under the title. */
|
|
20
|
+
body?: string | undefined;
|
|
21
|
+
tone?: "neutral" | "info" | "success" | "warn" | "danger" | "accent" | undefined;
|
|
22
|
+
/** A PrimeIcons class inside the dot. */
|
|
23
|
+
icon?: string | undefined;
|
|
24
|
+
/** Still running: the dot pulses. */
|
|
25
|
+
pending?: boolean | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
withDefaults(defineProps<{
|
|
29
|
+
items: TimelineItem[];
|
|
30
|
+
size?: "sm" | "md" | undefined;
|
|
31
|
+
ariaLabel?: string | undefined;
|
|
32
|
+
}>(), { size: "md", ariaLabel: undefined });
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<ol class="wp-timeline" :data-size="size" :aria-label="ariaLabel">
|
|
37
|
+
<li
|
|
38
|
+
v-for="it in items"
|
|
39
|
+
:key="it.id"
|
|
40
|
+
class="wp-timeline__item"
|
|
41
|
+
:data-tone="it.tone ?? 'neutral'"
|
|
42
|
+
:data-pending="it.pending || undefined"
|
|
43
|
+
:data-icon="it.icon ? 'true' : undefined"
|
|
44
|
+
>
|
|
45
|
+
<span class="wp-timeline__dot" aria-hidden="true"><i v-if="it.icon" class="pi" :class="it.icon" /></span>
|
|
46
|
+
<div class="wp-timeline__main">
|
|
47
|
+
<div class="wp-timeline__head">
|
|
48
|
+
<span class="wp-timeline__title">{{ it.title }}</span>
|
|
49
|
+
<span v-if="it.time || $slots.time" class="wp-timeline__time"><slot name="time" :item="it">{{ it.time }}</slot></span>
|
|
50
|
+
</div>
|
|
51
|
+
<div v-if="it.body || $slots.body" class="wp-timeline__body"><slot name="body" :item="it">{{ it.body }}</slot></div>
|
|
52
|
+
</div>
|
|
53
|
+
</li>
|
|
54
|
+
</ol>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<style scoped>
|
|
58
|
+
.wp-timeline { --_dot: 20px; margin: 0; padding: 0; list-style: none; font: 400 var(--wp-text-sm)/1.45 var(--wp-font-sans); min-width: 0; }
|
|
59
|
+
.wp-timeline[data-size="sm"] { --_dot: 16px; font-size: var(--wp-text-xs); }
|
|
60
|
+
.wp-timeline__item { --_tone: var(--wp-text-dim); position: relative; display: flex; gap: var(--wp-space-4); padding-bottom: var(--wp-space-5); }
|
|
61
|
+
.wp-timeline[data-size="sm"] .wp-timeline__item { gap: var(--wp-space-3); padding-bottom: var(--wp-space-4); }
|
|
62
|
+
.wp-timeline__item:last-child { padding-bottom: 0; }
|
|
63
|
+
.wp-timeline__item[data-tone="info"] { --_tone: var(--wp-info); }
|
|
64
|
+
.wp-timeline__item[data-tone="success"] { --_tone: var(--wp-success); }
|
|
65
|
+
.wp-timeline__item[data-tone="warn"] { --_tone: var(--wp-warn); }
|
|
66
|
+
.wp-timeline__item[data-tone="danger"] { --_tone: var(--wp-danger); }
|
|
67
|
+
.wp-timeline__item[data-tone="accent"] { --_tone: var(--wp-accent); }
|
|
68
|
+
.wp-timeline__item:not(:last-child)::before {
|
|
69
|
+
content: ""; position: absolute; top: calc(var(--_dot) + 2px); bottom: 2px; left: calc(var(--_dot) / 2 - 1px);
|
|
70
|
+
width: 2px; border-radius: min(1px, var(--wp-radius-cap, 999px)); background: var(--wp-border);
|
|
71
|
+
}
|
|
72
|
+
.wp-timeline__dot {
|
|
73
|
+
position: relative; display: grid; place-items: center; flex: none; box-sizing: border-box;
|
|
74
|
+
width: var(--_dot); height: var(--_dot); margin-top: 1px; border-radius: 50%; /* round: timeline dot */
|
|
75
|
+
border: 2px solid var(--_tone); background: var(--wp-bg-1); color: var(--_tone);
|
|
76
|
+
}
|
|
77
|
+
.wp-timeline__item:not([data-icon]) .wp-timeline__dot { transform: scale(0.6); background: var(--_tone); }
|
|
78
|
+
.wp-timeline__dot .pi { font-size: calc(var(--_dot) * 0.45); }
|
|
79
|
+
.wp-timeline__item[data-pending] .wp-timeline__dot::after {
|
|
80
|
+
content: ""; position: absolute; inset: -4px; border-radius: 50%; /* round: timeline dot ring */ border: 2px solid var(--_tone);
|
|
81
|
+
animation: wp-timeline-pulse 1.6s ease-out infinite;
|
|
82
|
+
}
|
|
83
|
+
@keyframes wp-timeline-pulse { from { opacity: 0.7; transform: scale(0.8); } to { opacity: 0; transform: scale(1.4); } }
|
|
84
|
+
.wp-timeline__main { flex: 1; min-width: 0; }
|
|
85
|
+
.wp-timeline__head { display: flex; align-items: baseline; gap: var(--wp-space-3); flex-wrap: wrap; }
|
|
86
|
+
.wp-timeline__title { color: var(--wp-text); font-weight: 500; }
|
|
87
|
+
.wp-timeline__time { color: var(--wp-text-dim); font-size: var(--wp-text-xs); font-variant-numeric: tabular-nums; }
|
|
88
|
+
.wp-timeline__body { margin-top: 2px; color: var(--wp-text-muted); overflow-wrap: anywhere; }
|
|
89
|
+
@media (prefers-reduced-motion: reduce) {
|
|
90
|
+
.wp-timeline__item[data-pending] .wp-timeline__dot::after { animation: none; display: none; }
|
|
91
|
+
}
|
|
92
|
+
</style>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One toast: the look both toast stacks share.
|
|
4
|
+
*
|
|
5
|
+
* The manager (`ui/ToastHost.vue`, fed by `useToast()`) and the canvas
|
|
6
|
+
* (`shared/Toast.vue`, fed by `toast-store`) used to draw toasts two ways:
|
|
7
|
+
* different surfaces, borders, icons and action buttons. Now each host keeps
|
|
8
|
+
* only its own store and its own place on screen, and renders this card.
|
|
9
|
+
* It reads only SHARED_TOKENS, so it wears the colours of the side it is on.
|
|
10
|
+
*
|
|
11
|
+
* `tone` follows the kit's names; the canvas store's `warning` maps to
|
|
12
|
+
* `warn` in its host.
|
|
13
|
+
*/
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
tone: "info" | "success" | "warn" | "error";
|
|
16
|
+
summary: string;
|
|
17
|
+
detail?: string | undefined;
|
|
18
|
+
/** Label of the inline action (Undo, Open…); emits `action`. */
|
|
19
|
+
actionLabel?: string | undefined;
|
|
20
|
+
}>();
|
|
21
|
+
|
|
22
|
+
const emit = defineEmits<{ action: []; dismiss: [] }>();
|
|
23
|
+
|
|
24
|
+
const ICONS = {
|
|
25
|
+
info: "pi-info-circle",
|
|
26
|
+
success: "pi-check-circle",
|
|
27
|
+
warn: "pi-exclamation-triangle",
|
|
28
|
+
error: "pi-times-circle",
|
|
29
|
+
} as const;
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<div :class="['wp-toast', `wp-toast--${props.tone}`]" role="status" data-test="toast">
|
|
34
|
+
<i :class="['pi', ICONS[props.tone], 'wp-toast__icon']" aria-hidden="true" />
|
|
35
|
+
<div class="wp-toast__text">
|
|
36
|
+
<div class="wp-toast__summary">{{ summary }}</div>
|
|
37
|
+
<div v-if="detail" class="wp-toast__detail">{{ detail }}</div>
|
|
38
|
+
</div>
|
|
39
|
+
<button
|
|
40
|
+
v-if="actionLabel"
|
|
41
|
+
type="button"
|
|
42
|
+
class="wp-toast__action"
|
|
43
|
+
data-test="toast-action"
|
|
44
|
+
@click="emit('action')"
|
|
45
|
+
>{{ actionLabel }}</button>
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
class="wp-toast__close"
|
|
49
|
+
aria-label="Dismiss"
|
|
50
|
+
@click="emit('dismiss')"
|
|
51
|
+
><i class="pi pi-times" aria-hidden="true" /></button>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<style scoped>
|
|
56
|
+
.wp-toast {
|
|
57
|
+
--toast-tone: var(--wp-info);
|
|
58
|
+
pointer-events: auto;
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
gap: var(--wp-space-4);
|
|
62
|
+
min-width: 220px;
|
|
63
|
+
max-width: 420px;
|
|
64
|
+
padding: var(--wp-space-4) var(--wp-space-4) var(--wp-space-4) var(--wp-space-5);
|
|
65
|
+
/* Slightly see-through with a blur, so it reads as floating over
|
|
66
|
+
whatever is underneath without a heavy shadow. */
|
|
67
|
+
background: color-mix(in oklab, var(--wp-bg-3) 94%, transparent);
|
|
68
|
+
backdrop-filter: blur(10px);
|
|
69
|
+
-webkit-backdrop-filter: blur(10px);
|
|
70
|
+
border: 1px solid var(--wp-border-strong);
|
|
71
|
+
border-left: 3px solid var(--toast-tone);
|
|
72
|
+
border-radius: var(--wp-radius-md);
|
|
73
|
+
box-shadow: var(--wp-shadow-md);
|
|
74
|
+
color: var(--wp-text);
|
|
75
|
+
font-family: var(--wp-font-sans);
|
|
76
|
+
font-size: var(--wp-text-sm);
|
|
77
|
+
line-height: 1.4;
|
|
78
|
+
}
|
|
79
|
+
.wp-toast--success { --toast-tone: var(--wp-success); }
|
|
80
|
+
.wp-toast--warn { --toast-tone: var(--wp-warn); }
|
|
81
|
+
.wp-toast--error { --toast-tone: var(--wp-danger); }
|
|
82
|
+
|
|
83
|
+
.wp-toast__icon { flex-shrink: 0; font-size: 14px; color: var(--toast-tone); }
|
|
84
|
+
.wp-toast__text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
85
|
+
.wp-toast__summary { font-weight: 600; }
|
|
86
|
+
.wp-toast__detail { margin-top: 1px; color: var(--wp-text-muted); font-size: var(--wp-text-xs); }
|
|
87
|
+
|
|
88
|
+
.wp-toast__action {
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
height: var(--wp-control-h-sm);
|
|
91
|
+
padding: 0 var(--wp-space-4);
|
|
92
|
+
background: transparent;
|
|
93
|
+
border: 1px solid var(--wp-border-strong);
|
|
94
|
+
border-radius: var(--wp-radius-sm);
|
|
95
|
+
color: var(--wp-text);
|
|
96
|
+
font: 600 var(--wp-text-xs) var(--wp-font-sans);
|
|
97
|
+
cursor: pointer;
|
|
98
|
+
transition: background var(--wp-motion-quick), border-color var(--wp-motion-quick);
|
|
99
|
+
}
|
|
100
|
+
.wp-toast__action:hover { background: var(--wp-bg-4); border-color: var(--wp-accent-500); }
|
|
101
|
+
|
|
102
|
+
.wp-toast__close {
|
|
103
|
+
display: grid;
|
|
104
|
+
place-items: center;
|
|
105
|
+
flex-shrink: 0;
|
|
106
|
+
width: 24px;
|
|
107
|
+
height: 24px;
|
|
108
|
+
padding: 0;
|
|
109
|
+
background: none;
|
|
110
|
+
border: 0;
|
|
111
|
+
border-radius: var(--wp-radius-sm);
|
|
112
|
+
color: var(--wp-text-dim);
|
|
113
|
+
cursor: pointer;
|
|
114
|
+
}
|
|
115
|
+
.wp-toast__close .pi { font-size: 10px; }
|
|
116
|
+
.wp-toast__close:hover { color: var(--wp-text); background: var(--wp-bg-4); }
|
|
117
|
+
.wp-toast__action:focus-visible,
|
|
118
|
+
.wp-toast__close:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
|
|
119
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The manager's toast stack (bottom right). The toast itself is the shared
|
|
4
|
+
* ToastCard, the same one the canvas stack draws.
|
|
5
|
+
*/
|
|
6
|
+
import { useToast } from "./useToast";
|
|
7
|
+
import ToastCard from "./ToastCard.vue";
|
|
8
|
+
|
|
9
|
+
const { toasts, push, dismiss } = useToast();
|
|
10
|
+
|
|
11
|
+
async function runAction(id: string, handler: () => void | Promise<void>) {
|
|
12
|
+
try {
|
|
13
|
+
await handler();
|
|
14
|
+
} catch (e) {
|
|
15
|
+
push({
|
|
16
|
+
severity: "error",
|
|
17
|
+
summary: "Undo failed",
|
|
18
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
19
|
+
life: 4000,
|
|
20
|
+
});
|
|
21
|
+
} finally {
|
|
22
|
+
dismiss(id);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div class="wp-toast-stack" aria-live="polite" aria-atomic="true">
|
|
29
|
+
<ToastCard
|
|
30
|
+
v-for="t in toasts"
|
|
31
|
+
:key="t.id"
|
|
32
|
+
:tone="t.severity"
|
|
33
|
+
:summary="t.summary"
|
|
34
|
+
:detail="t.detail"
|
|
35
|
+
:action-label="t.action?.label"
|
|
36
|
+
@action="t.action && runAction(t.id, t.action.run)"
|
|
37
|
+
@dismiss="dismiss(t.id)"
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
package/src/Toggle.vue
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The manager's on/off switch: the shared WpSwitch at the
|
|
4
|
+
* manager size, so the canvas and the manager draw one switch. Use it for a
|
|
5
|
+
* setting that takes effect at once.
|
|
6
|
+
*/
|
|
7
|
+
import WpSwitch from "./WpSwitch.vue";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
modelValue: boolean;
|
|
11
|
+
label?: string | undefined;
|
|
12
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
13
|
+
disabled?: boolean | undefined;
|
|
14
|
+
invalid?: boolean | undefined;
|
|
15
|
+
/** @deprecated use `invalid`. */
|
|
16
|
+
error?: boolean | undefined;
|
|
17
|
+
ariaLabel?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
const props = withDefaults(defineProps<Props>(), { size: "md", label: undefined, ariaLabel: undefined });
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
(e: "update:modelValue", v: boolean): void;
|
|
23
|
+
}>();
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<WpSwitch
|
|
28
|
+
:model-value="props.modelValue"
|
|
29
|
+
:size="props.size"
|
|
30
|
+
:label="props.label"
|
|
31
|
+
:aria-label="props.ariaLabel ?? props.label"
|
|
32
|
+
:disabled="props.disabled"
|
|
33
|
+
:invalid="props.invalid || props.error"
|
|
34
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
35
|
+
/>
|
|
36
|
+
</template>
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Read-only renderer for a wildcard-grammar value string.
|
|
4
|
+
*
|
|
5
|
+
* Consumes the token stream from `tokenizeRich` (chunk A1) and paints each
|
|
6
|
+
* token as a coloured inline span or chip — vars (green), refs (magenta),
|
|
7
|
+
* brace alternations (amber scaffolding) and multi-picks (green scaffolding).
|
|
8
|
+
* The component is RECURSIVE: brace arms re-enter `TokenizedValue` so a
|
|
9
|
+
* `@{uuid}` or `$var` nested inside `{a|@{…}|$rank}` still renders as a chip.
|
|
10
|
+
*
|
|
11
|
+
* No `v-html` — every token becomes a `v-for`-rendered element (CLAUDE.md
|
|
12
|
+
* confines raw-HTML rendering to `utils/sanitize.ts`). This mirrors the
|
|
13
|
+
* sister project's RefChip + rich-text.css chrome (read, not imported — the
|
|
14
|
+
* two repos diverge) but emits a flat, framework-local span tree.
|
|
15
|
+
*
|
|
16
|
+
* The token stream is pre-flattened in `<script>` into a single list of
|
|
17
|
+
* `RenderItem`s (text / escape / var / ref / scaffolding-glyph / nested
|
|
18
|
+
* child). That keeps the template a flat `v-for` — no nested branch loops —
|
|
19
|
+
* which both reads cleanly and lets adjacent items abut with no stray
|
|
20
|
+
* whitespace (tokens are contiguous; an inter-element space would corrupt
|
|
21
|
+
* the rendered value).
|
|
22
|
+
*/
|
|
23
|
+
import { computed } from "vue";
|
|
24
|
+
import {
|
|
25
|
+
splitRefFilter,
|
|
26
|
+
tokenizeRich,
|
|
27
|
+
type RichToken,
|
|
28
|
+
} from "./rich-tokenize";
|
|
29
|
+
|
|
30
|
+
defineOptions({ name: "TokenizedValue" });
|
|
31
|
+
|
|
32
|
+
const props = withDefaults(
|
|
33
|
+
defineProps<{
|
|
34
|
+
/** The raw wildcard-grammar string to render. */
|
|
35
|
+
value: string;
|
|
36
|
+
/** uuid → display-name map for resolving `@{uuid}` refs. Empty by default
|
|
37
|
+
* (callers without a catalog still render; refs fall back to #name/uuid). */
|
|
38
|
+
uuidToName?: ReadonlyMap<string, string>;
|
|
39
|
+
}>(),
|
|
40
|
+
{ uuidToName: () => new Map<string, string>() },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Concrete (never-undefined) map for binding into nested instances —
|
|
44
|
+
* `withDefaults` guarantees a value, but the recursive self-reference's
|
|
45
|
+
* optional prop type needs an explicitly non-optional source under
|
|
46
|
+
* `exactOptionalPropertyTypes`. */
|
|
47
|
+
const refMap = computed<ReadonlyMap<string, string>>(() => props.uuidToName);
|
|
48
|
+
|
|
49
|
+
/** One drawable unit. A brace/multi token expands into several of these
|
|
50
|
+
* (scaffolding glyphs interleaved with `child` arms); every other token
|
|
51
|
+
* maps to exactly one. */
|
|
52
|
+
type RenderItem =
|
|
53
|
+
| { key: string; kind: "text" | "escape"; text: string }
|
|
54
|
+
| { key: string; kind: "var"; label: string; neg: boolean }
|
|
55
|
+
| { key: string; kind: "scaf"; text: string; flavor: "brace" | "multi" }
|
|
56
|
+
| { key: string; kind: "child"; value: string }
|
|
57
|
+
| {
|
|
58
|
+
key: string;
|
|
59
|
+
kind: "ref";
|
|
60
|
+
uuid: string;
|
|
61
|
+
label: string;
|
|
62
|
+
resolved: boolean;
|
|
63
|
+
expr: string;
|
|
64
|
+
excludeNull: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Var chip label: `$name` plus an optional `.K` list accessor (SP2a) and
|
|
68
|
+
* `.neg` negatives read (schema v8). */
|
|
69
|
+
function varLabel(meta: RichToken["meta"]): string {
|
|
70
|
+
const idx = meta?.index;
|
|
71
|
+
return (
|
|
72
|
+
"$" +
|
|
73
|
+
(meta?.name ?? "") +
|
|
74
|
+
(idx !== undefined ? "." + idx : "") +
|
|
75
|
+
(meta?.neg ? ".neg" : "")
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Resolve a ref token into its chip view. Resolution precedence: live map
|
|
80
|
+
* name → cached `#name` → bare uuid. The raw `:`-filter is peeled into an
|
|
81
|
+
* expression + exclude-null flag (the expression rides in the hover title,
|
|
82
|
+
* never inline, so long filters can't blow out the chip). */
|
|
83
|
+
function refItem(token: RichToken, key: string): Extract<RenderItem, { kind: "ref" }> {
|
|
84
|
+
const meta = token.meta ?? {};
|
|
85
|
+
const uuid = meta.uuid ?? "";
|
|
86
|
+
const mapped = props.uuidToName.get(uuid);
|
|
87
|
+
const fromMap = typeof mapped === "string" && mapped.length > 0 ? mapped : "";
|
|
88
|
+
const cached = typeof meta.name === "string" && meta.name.length > 0 ? meta.name : "";
|
|
89
|
+
const resolvedName = fromMap || cached;
|
|
90
|
+
const { expr, excludeNull } = splitRefFilter((meta.sub_categories ?? []).join(","));
|
|
91
|
+
return {
|
|
92
|
+
key,
|
|
93
|
+
kind: "ref",
|
|
94
|
+
uuid,
|
|
95
|
+
label: resolvedName || uuid,
|
|
96
|
+
resolved: resolvedName.length > 0,
|
|
97
|
+
expr,
|
|
98
|
+
excludeNull,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Reconstruct a multi-pick's opening scaffolding from its meta: `{N`, `-M`
|
|
103
|
+
* (range only), `~` (independent), then the `$$sep$$` delimiter. Inverse of
|
|
104
|
+
* the tokenizer so a decomposed block reads identically to its source. */
|
|
105
|
+
function multiPrefix(meta: RichToken["meta"]): string {
|
|
106
|
+
const min = meta?.min ?? 0;
|
|
107
|
+
const max = meta?.max ?? min;
|
|
108
|
+
const range = max !== min ? "-" + max : "";
|
|
109
|
+
const indep = meta?.independent ? "~" : "";
|
|
110
|
+
return "{" + min + range + indep + "$$" + (meta?.sep ?? "") + "$$";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Flatten a brace/multi token into scaffolding glyphs + child arms. */
|
|
114
|
+
function expandBlock(
|
|
115
|
+
token: RichToken,
|
|
116
|
+
flavor: "brace" | "multi",
|
|
117
|
+
open: string,
|
|
118
|
+
base: string,
|
|
119
|
+
): RenderItem[] {
|
|
120
|
+
const out: RenderItem[] = [];
|
|
121
|
+
const branches = token.meta?.branches ?? [];
|
|
122
|
+
out.push({ key: base + "-o", kind: "scaf", text: open, flavor });
|
|
123
|
+
branches.forEach((branch, bi) => {
|
|
124
|
+
if (bi > 0) out.push({ key: `${base}-p${bi}`, kind: "scaf", text: "|", flavor });
|
|
125
|
+
out.push({ key: `${base}-c${bi}`, kind: "child", value: branch });
|
|
126
|
+
});
|
|
127
|
+
out.push({ key: base + "-x", kind: "scaf", text: "}", flavor });
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const items = computed<RenderItem[]>(() => {
|
|
132
|
+
const out: RenderItem[] = [];
|
|
133
|
+
for (const token of tokenizeRich(props.value)) {
|
|
134
|
+
const key = String(token.start);
|
|
135
|
+
switch (token.kind) {
|
|
136
|
+
case "text":
|
|
137
|
+
out.push({ key, kind: "text", text: token.raw });
|
|
138
|
+
break;
|
|
139
|
+
case "escape":
|
|
140
|
+
out.push({ key, kind: "escape", text: token.raw });
|
|
141
|
+
break;
|
|
142
|
+
case "var":
|
|
143
|
+
out.push({ key, kind: "var", label: varLabel(token.meta), neg: token.meta?.neg === true });
|
|
144
|
+
break;
|
|
145
|
+
case "ref":
|
|
146
|
+
out.push(refItem(token, key));
|
|
147
|
+
break;
|
|
148
|
+
case "dp-brace":
|
|
149
|
+
out.push(...expandBlock(token, "brace", "{", key));
|
|
150
|
+
break;
|
|
151
|
+
case "dp-multi":
|
|
152
|
+
out.push(...expandBlock(token, "multi", multiPrefix(token.meta), key));
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
// Defensive: any future/unhandled kind still shows its raw text so
|
|
156
|
+
// the lossless invariant holds visually.
|
|
157
|
+
out.push({ key, kind: "text", text: token.raw });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
});
|
|
162
|
+
</script>
|
|
163
|
+
|
|
164
|
+
<template>
|
|
165
|
+
<span class="wp-tv">
|
|
166
|
+
<template
|
|
167
|
+
v-for="item in items"
|
|
168
|
+
:key="item.key"
|
|
169
|
+
>
|
|
170
|
+
<span
|
|
171
|
+
v-if="item.kind === 'text'"
|
|
172
|
+
class="wp-tv-text"
|
|
173
|
+
>{{ item.text }}</span>
|
|
174
|
+
<span
|
|
175
|
+
v-else-if="item.kind === 'escape'"
|
|
176
|
+
class="wp-tv-escape"
|
|
177
|
+
>{{ item.text }}</span>
|
|
178
|
+
<span
|
|
179
|
+
v-else-if="item.kind === 'var'"
|
|
180
|
+
class="wp-tv-var"
|
|
181
|
+
:data-neg="item.neg || null"
|
|
182
|
+
>{{ item.label }}</span>
|
|
183
|
+
<span
|
|
184
|
+
v-else-if="item.kind === 'scaf'"
|
|
185
|
+
class="wp-tv-scaf"
|
|
186
|
+
:class="item.flavor === 'multi' ? 'wp-tv-scaf--multi' : 'wp-tv-scaf--brace'"
|
|
187
|
+
>{{ item.text }}</span>
|
|
188
|
+
<TokenizedValue
|
|
189
|
+
v-else-if="item.kind === 'child'"
|
|
190
|
+
:value="item.value"
|
|
191
|
+
:uuid-to-name="refMap"
|
|
192
|
+
/>
|
|
193
|
+
<span
|
|
194
|
+
v-else-if="item.kind === 'ref'"
|
|
195
|
+
class="wp-tv-ref"
|
|
196
|
+
:class="{ 'wp-tv-ref--unresolved': !item.resolved }"
|
|
197
|
+
:title="item.expr || undefined"
|
|
198
|
+
>
|
|
199
|
+
<i
|
|
200
|
+
class="pi wp-tv-ref__icon"
|
|
201
|
+
:class="item.resolved ? 'pi-sparkles' : 'pi-question-circle'"
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
/>
|
|
204
|
+
<span class="wp-tv-ref__label">{{ item.label }}</span>
|
|
205
|
+
<i
|
|
206
|
+
v-if="item.expr"
|
|
207
|
+
class="pi pi-filter wp-tv-ref__funnel"
|
|
208
|
+
aria-hidden="true"
|
|
209
|
+
/>
|
|
210
|
+
<i
|
|
211
|
+
v-if="item.excludeNull"
|
|
212
|
+
class="pi pi-ban wp-tv-ref__nonull"
|
|
213
|
+
aria-hidden="true"
|
|
214
|
+
/>
|
|
215
|
+
</span>
|
|
216
|
+
</template>
|
|
217
|
+
</span>
|
|
218
|
+
</template>
|
|
219
|
+
|
|
220
|
+
<style scoped>
|
|
221
|
+
.wp-tv {
|
|
222
|
+
/* Host text size — chips deliberately read SMALLER than this line. */
|
|
223
|
+
font-size: 13px;
|
|
224
|
+
line-height: 1.6;
|
|
225
|
+
word-break: break-word;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.wp-tv-text {
|
|
229
|
+
color: var(--wp-text);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.wp-tv-escape {
|
|
233
|
+
color: var(--wp-text-dim);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ── Chips (var + ref) ───────────────────────────────────────────────
|
|
237
|
+
* Inline (not inline-flex) so they wrap with the surrounding prose and
|
|
238
|
+
* sit on the text baseline. 11px < the 13px host line keeps them from
|
|
239
|
+
* towering over the text. */
|
|
240
|
+
.wp-tv-var,
|
|
241
|
+
.wp-tv-ref {
|
|
242
|
+
display: inline;
|
|
243
|
+
font-size: 11px;
|
|
244
|
+
padding: 1px 4px;
|
|
245
|
+
border-radius: min(4px, var(--wp-radius-cap, 999px));
|
|
246
|
+
white-space: nowrap;
|
|
247
|
+
vertical-align: baseline;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* $var — green. */
|
|
251
|
+
.wp-tv-var {
|
|
252
|
+
background: color-mix(in oklab, var(--wp-success) 16%, transparent);
|
|
253
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-success) 50%, transparent);
|
|
254
|
+
color: var(--wp-success);
|
|
255
|
+
font-weight: 500;
|
|
256
|
+
}
|
|
257
|
+
/* $var.neg — reads the variable's negatives, so it wears the negative red. */
|
|
258
|
+
.wp-tv-var[data-neg] {
|
|
259
|
+
background: color-mix(in oklab, var(--wp-danger) 14%, transparent);
|
|
260
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-danger) 45%, transparent);
|
|
261
|
+
color: var(--wp-danger-text, var(--wp-danger));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* @{uuid} ref — magenta. Carries a hover title (the filter), so `help`. */
|
|
265
|
+
.wp-tv-ref {
|
|
266
|
+
background: color-mix(in oklab, var(--wp-kind-ref) 15%, transparent);
|
|
267
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-kind-ref) 50%, transparent);
|
|
268
|
+
color: var(--wp-kind-ref);
|
|
269
|
+
font-weight: 500;
|
|
270
|
+
cursor: help;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* Unresolved ref — red. Overrides the magenta tone above. */
|
|
274
|
+
.wp-tv-ref--unresolved {
|
|
275
|
+
background: color-mix(in oklab, var(--wp-danger) 15%, transparent);
|
|
276
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--wp-danger) 50%, transparent);
|
|
277
|
+
color: var(--wp-danger);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* In-chip PrimeIcons — small + baseline-nudged so they don't lift the line. */
|
|
281
|
+
.wp-tv-ref__icon,
|
|
282
|
+
.wp-tv-ref__funnel,
|
|
283
|
+
.wp-tv-ref__nonull {
|
|
284
|
+
font-size: 8px;
|
|
285
|
+
vertical-align: 0;
|
|
286
|
+
}
|
|
287
|
+
.wp-tv-ref__icon {
|
|
288
|
+
margin-right: 3px;
|
|
289
|
+
}
|
|
290
|
+
.wp-tv-ref__funnel {
|
|
291
|
+
margin-left: 3px;
|
|
292
|
+
color: var(--wp-warn);
|
|
293
|
+
}
|
|
294
|
+
.wp-tv-ref__nonull {
|
|
295
|
+
margin-left: 2px;
|
|
296
|
+
color: var(--wp-text-dim);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/* ── Brace / multi scaffolding ───────────────────────────────────────
|
|
300
|
+
* Colour-only (no background, no box) — the bordered chips are the nested
|
|
301
|
+
* arms. Brace = amber, multi = green. */
|
|
302
|
+
.wp-tv-scaf--brace {
|
|
303
|
+
color: var(--wp-warn);
|
|
304
|
+
font-weight: 600;
|
|
305
|
+
}
|
|
306
|
+
.wp-tv-scaf--multi {
|
|
307
|
+
color: var(--wp-kind-derivation);
|
|
308
|
+
font-weight: 600;
|
|
309
|
+
}
|
|
310
|
+
</style>
|