@dumiflex/wp-kit 0.0.0-stage → 0.3.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 +51 -0
- package/LICENSE +674 -0
- package/README.md +76 -3
- package/package.json +77 -6
- 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 +107 -0
- package/src/Chip.vue +97 -0
- package/src/ChipToggle.vue +92 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +206 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +334 -0
- package/src/ConfirmDialog.vue +163 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +379 -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/KindChip.vue +53 -0
- package/src/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +161 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +76 -0
- package/src/ModalShell.vue +107 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +171 -0
- package/src/PostCard.vue +211 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RatingBadge.vue +42 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +199 -0
- package/src/Select.vue +641 -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 +300 -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 +102 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +183 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +89 -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/flip.ts +237 -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/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 +2894 -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
|
@@ -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>
|
package/src/Toolbar.vue
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Toolbar: a row of controls that is one Tab stop. Inside it the arrow keys
|
|
4
|
+
* move between the controls, Home/End jump to the ends, and the toolbar
|
|
5
|
+
* remembers where you were (APG toolbar). `attached` joins its buttons into
|
|
6
|
+
* one button group. Works on the canvas and in the manager.
|
|
7
|
+
*
|
|
8
|
+
* <Toolbar label="Text">
|
|
9
|
+
* <Button variant="ghost" icon="pi-bold" aria-label="Bold" />
|
|
10
|
+
* <Separator orientation="vertical" />
|
|
11
|
+
* <Button variant="ghost" icon="pi-copy" aria-label="Copy" />
|
|
12
|
+
* </Toolbar>
|
|
13
|
+
*
|
|
14
|
+
* Put buttons, toggles and selects in it. A text box inside a toolbar keeps
|
|
15
|
+
* its own left/right keys.
|
|
16
|
+
*/
|
|
17
|
+
import { onMounted, onUpdated, ref } from "vue";
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<{
|
|
20
|
+
/** The toolbar's name for screen readers. */
|
|
21
|
+
label: string;
|
|
22
|
+
orientation?: "horizontal" | "vertical" | undefined;
|
|
23
|
+
/** Join the buttons into one group with shared borders. */
|
|
24
|
+
attached?: boolean | undefined;
|
|
25
|
+
/** Gap between controls. */
|
|
26
|
+
size?: "sm" | "md" | undefined;
|
|
27
|
+
}>(), { orientation: "horizontal", size: "md" });
|
|
28
|
+
|
|
29
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
30
|
+
const last = ref(0);
|
|
31
|
+
|
|
32
|
+
const ITEMS = "button:not([disabled]), [role=button]:not([aria-disabled=true]), a[href], input:not([disabled]), select:not([disabled]), [role=switch], [role=checkbox], [role=radio]";
|
|
33
|
+
|
|
34
|
+
function items(): HTMLElement[] {
|
|
35
|
+
return rootEl.value ? [...rootEl.value.querySelectorAll<HTMLElement>(ITEMS)] : [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Roving tabindex: only the remembered control is in the Tab order. */
|
|
39
|
+
function sync(): void {
|
|
40
|
+
const list = items();
|
|
41
|
+
if (!list.length) return;
|
|
42
|
+
const at = Math.min(last.value, list.length - 1);
|
|
43
|
+
list.forEach((el, i) => el.setAttribute("tabindex", i === at ? "0" : "-1"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isTextField(el: Element | null): boolean {
|
|
47
|
+
return !!el && el.tagName === "INPUT" && !["checkbox", "radio", "button"].includes((el as HTMLInputElement).type);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
51
|
+
const list = items();
|
|
52
|
+
const at = list.indexOf(document.activeElement as HTMLElement);
|
|
53
|
+
if (at < 0) return;
|
|
54
|
+
const fwd = props.orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
55
|
+
const back = props.orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
|
|
56
|
+
if (isTextField(document.activeElement) && (ev.key === "ArrowLeft" || ev.key === "ArrowRight" || ev.key === "Home" || ev.key === "End")) return;
|
|
57
|
+
let next: number;
|
|
58
|
+
if (ev.key === fwd) next = (at + 1) % list.length;
|
|
59
|
+
else if (ev.key === back) next = (at - 1 + list.length) % list.length;
|
|
60
|
+
else if (ev.key === "Home") next = 0;
|
|
61
|
+
else if (ev.key === "End") next = list.length - 1;
|
|
62
|
+
else return;
|
|
63
|
+
ev.preventDefault();
|
|
64
|
+
last.value = next;
|
|
65
|
+
sync();
|
|
66
|
+
list[next].focus();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function onFocusin(ev: FocusEvent): void {
|
|
70
|
+
const i = items().indexOf(ev.target as HTMLElement);
|
|
71
|
+
if (i >= 0 && i !== last.value) { last.value = i; sync(); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onMounted(sync);
|
|
75
|
+
onUpdated(sync);
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<!-- role="toolbar" moves focus between its own controls. -->
|
|
79
|
+
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
|
80
|
+
<template>
|
|
81
|
+
<div
|
|
82
|
+
ref="rootEl"
|
|
83
|
+
class="wp-tools"
|
|
84
|
+
role="toolbar"
|
|
85
|
+
:aria-label="label"
|
|
86
|
+
:aria-orientation="orientation"
|
|
87
|
+
:data-orientation="orientation"
|
|
88
|
+
:data-attached="attached || undefined"
|
|
89
|
+
:data-size="size"
|
|
90
|
+
@keydown="onKeydown"
|
|
91
|
+
@focusin="onFocusin"
|
|
92
|
+
>
|
|
93
|
+
<slot />
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<style scoped>
|
|
98
|
+
.wp-tools { display: inline-flex; align-items: center; gap: var(--wp-space-2); flex-wrap: wrap; min-width: 0; }
|
|
99
|
+
.wp-tools[data-size="md"] { gap: var(--wp-space-3); }
|
|
100
|
+
.wp-tools[data-orientation="vertical"] { flex-direction: column; align-items: stretch; flex-wrap: nowrap; }
|
|
101
|
+
.wp-tools[data-attached] { gap: 0; flex-wrap: nowrap; }
|
|
102
|
+
.wp-tools[data-attached] > :deep(*) { border-radius: 0; }
|
|
103
|
+
.wp-tools[data-attached] > :deep(*:not(:first-child)) { margin-left: -1px; }
|
|
104
|
+
.wp-tools[data-attached] > :deep(*:first-child) { border-top-left-radius: var(--wp-radius); border-bottom-left-radius: var(--wp-radius); }
|
|
105
|
+
.wp-tools[data-attached] > :deep(*:last-child) { border-top-right-radius: var(--wp-radius); border-bottom-right-radius: var(--wp-radius); }
|
|
106
|
+
.wp-tools[data-attached] > :deep(*:focus-visible) { position: relative; z-index: 1; }
|
|
107
|
+
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:not(:first-child)) { margin-left: 0; margin-top: -1px; }
|
|
108
|
+
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:first-child) { border-radius: var(--wp-radius) var(--wp-radius) 0 0; }
|
|
109
|
+
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:last-child) { border-radius: 0 0 var(--wp-radius) var(--wp-radius); }
|
|
110
|
+
</style>
|