@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
package/src/WpNumber.vue
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The one number field, on the canvas and in the manager: a number input
|
|
4
|
+
* with our stacked ▲/▼ arrows instead of the browser's spinner (hidden).
|
|
5
|
+
* It replaced the canvas steppers every widget used to hand-roll (seed rows,
|
|
6
|
+
* sweep limit, Image Filter timeout) and Input's `type="number"` mode.
|
|
7
|
+
*
|
|
8
|
+
* Emits a finite number only: typing commits on `change` (or every
|
|
9
|
+
* keystroke with `commit="input"`); the arrows, ArrowUp/ArrowDown and the
|
|
10
|
+
* wheel step by `step` and clamp to `min` / `max`. Typed values snap to 3
|
|
11
|
+
* decimals and stepped values to the step's own precision, so float fuzz
|
|
12
|
+
* (1.2999999999999998) never reaches a store. With `nullable`, clearing the
|
|
13
|
+
* box emits `null` (a "use the default" field with a placeholder).
|
|
14
|
+
*
|
|
15
|
+
* `size` follows the control scale (sm 24 / md 32 / lg 40); the canvas uses
|
|
16
|
+
* `sm`, manager forms `md`. `class` and `style` land on the box (set a width
|
|
17
|
+
* there); other attributes and listeners (`@blur`, `@keydown.enter`, `id`)
|
|
18
|
+
* land on the input. `dataTest` lands on the input, with `-up` / `-down` on
|
|
19
|
+
* the arrows. `suffix` shows a unit after the number ("px", "s"). Reads
|
|
20
|
+
* only SHARED_TOKENS, so it wears whichever side it is on.
|
|
21
|
+
*/
|
|
22
|
+
import { computed, ref, useAttrs } from "vue";
|
|
23
|
+
|
|
24
|
+
defineOptions({ inheritAttrs: false });
|
|
25
|
+
|
|
26
|
+
const props = withDefaults(
|
|
27
|
+
defineProps<{
|
|
28
|
+
modelValue: number | null;
|
|
29
|
+
min?: number | undefined;
|
|
30
|
+
max?: number | undefined;
|
|
31
|
+
step?: number | undefined;
|
|
32
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
33
|
+
placeholder?: string | undefined;
|
|
34
|
+
ariaLabel?: string | undefined;
|
|
35
|
+
upLabel?: string | undefined;
|
|
36
|
+
downLabel?: string | undefined;
|
|
37
|
+
dataTest?: string | undefined;
|
|
38
|
+
commit?: "change" | "input" | undefined;
|
|
39
|
+
/** Clearing the box emits null instead of being ignored. */
|
|
40
|
+
nullable?: boolean | undefined;
|
|
41
|
+
disabled?: boolean | undefined;
|
|
42
|
+
invalid?: boolean | undefined;
|
|
43
|
+
/** Unit shown after the number ("px", "s"). */
|
|
44
|
+
suffix?: string | undefined;
|
|
45
|
+
}>(),
|
|
46
|
+
{ step: 1, size: "sm", commit: "change", upLabel: "Increase", downLabel: "Decrease" },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const emit = defineEmits<{ "update:modelValue": [value: number | null] }>();
|
|
50
|
+
|
|
51
|
+
const attrs = useAttrs();
|
|
52
|
+
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }));
|
|
53
|
+
const inputAttrs = computed(() => {
|
|
54
|
+
const { class: _c, style: _s, ...rest } = attrs;
|
|
55
|
+
return rest;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function clamp(n: number): number {
|
|
59
|
+
let v = n;
|
|
60
|
+
if (props.min !== undefined) v = Math.max(props.min, v);
|
|
61
|
+
if (props.max !== undefined) v = Math.min(props.max, v);
|
|
62
|
+
return v;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function onType(ev: Event, kind: "change" | "input"): void {
|
|
66
|
+
if (kind !== props.commit) return;
|
|
67
|
+
const raw = (ev.target as HTMLInputElement).value;
|
|
68
|
+
if (raw === "") {
|
|
69
|
+
if (props.nullable) emit("update:modelValue", null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const n = Number(raw);
|
|
73
|
+
if (!Number.isFinite(n)) return;
|
|
74
|
+
emit("update:modelValue", Math.round(n * 1000) / 1000);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** ArrowUp / ArrowDown and the wheel (while focused) step like the arrows.
|
|
78
|
+
* Every key stops here so the canvas doesn't read it as a shortcut. */
|
|
79
|
+
function onKey(ev: KeyboardEvent): void {
|
|
80
|
+
ev.stopPropagation();
|
|
81
|
+
if (ev.key !== "ArrowUp" && ev.key !== "ArrowDown") return;
|
|
82
|
+
ev.preventDefault();
|
|
83
|
+
bump(ev.key === "ArrowUp" ? 1 : -1);
|
|
84
|
+
}
|
|
85
|
+
function onWheel(ev: WheelEvent): void {
|
|
86
|
+
if (document.activeElement !== ev.target) return;
|
|
87
|
+
ev.preventDefault();
|
|
88
|
+
bump(ev.deltaY < 0 ? 1 : -1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const field = ref<HTMLInputElement | null>(null);
|
|
92
|
+
defineExpose({ focus: () => field.value?.focus(), select: () => field.value?.select(), el: field });
|
|
93
|
+
|
|
94
|
+
/** Step with the step's decimal precision, so 1 + 0.1 shows 1.1. */
|
|
95
|
+
function bump(dir: 1 | -1): void {
|
|
96
|
+
if (props.disabled) return;
|
|
97
|
+
const dot = String(props.step).indexOf(".");
|
|
98
|
+
const f = dot < 0 ? 1 : 10 ** (String(props.step).length - dot - 1);
|
|
99
|
+
emit("update:modelValue", clamp(Math.round(((props.modelValue ?? 0) + dir * props.step) * f) / f));
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<template>
|
|
104
|
+
<span
|
|
105
|
+
class="wp-cnum"
|
|
106
|
+
:class="[`wp-cnum--${size}`, { 'is-disabled': disabled, 'is-invalid': invalid }]"
|
|
107
|
+
v-bind="rootAttrs"
|
|
108
|
+
>
|
|
109
|
+
<input
|
|
110
|
+
ref="field"
|
|
111
|
+
class="wp-cnum__field"
|
|
112
|
+
type="number"
|
|
113
|
+
:value="modelValue ?? ''"
|
|
114
|
+
:min="min"
|
|
115
|
+
:max="max"
|
|
116
|
+
step="any"
|
|
117
|
+
:placeholder="placeholder"
|
|
118
|
+
:disabled="disabled"
|
|
119
|
+
:aria-label="ariaLabel"
|
|
120
|
+
:aria-invalid="invalid || undefined"
|
|
121
|
+
:data-test="dataTest"
|
|
122
|
+
v-bind="inputAttrs"
|
|
123
|
+
@input="onType($event, 'input')"
|
|
124
|
+
@change="onType($event, 'change')"
|
|
125
|
+
@keydown="onKey"
|
|
126
|
+
@wheel="onWheel"
|
|
127
|
+
/>
|
|
128
|
+
<span v-if="suffix" class="wp-cnum__suffix" aria-hidden="true">{{ suffix }}</span>
|
|
129
|
+
<span class="wp-cnum__spin">
|
|
130
|
+
<button
|
|
131
|
+
type="button" class="wp-cnum__btn" tabindex="-1" :disabled="disabled"
|
|
132
|
+
:aria-label="upLabel" :data-test="dataTest && `${dataTest}-up`" @click="bump(1)"
|
|
133
|
+
><i class="pi pi-chevron-up" aria-hidden="true" /></button>
|
|
134
|
+
<button
|
|
135
|
+
type="button" class="wp-cnum__btn" tabindex="-1" :disabled="disabled"
|
|
136
|
+
:aria-label="downLabel" :data-test="dataTest && `${dataTest}-down`" @click="bump(-1)"
|
|
137
|
+
><i class="pi pi-chevron-down" aria-hidden="true" /></button>
|
|
138
|
+
</span>
|
|
139
|
+
</span>
|
|
140
|
+
</template>
|
|
141
|
+
|
|
142
|
+
<style scoped>
|
|
143
|
+
.wp-cnum {
|
|
144
|
+
--cnum-spin: 16px;
|
|
145
|
+
display: inline-flex; align-items: stretch; height: var(--wp-control-h-sm); min-width: 0; overflow: hidden;
|
|
146
|
+
box-sizing: border-box;
|
|
147
|
+
background: var(--wp-bg-2); border: 1px solid var(--wp-border); border-radius: var(--wp-radius-sm);
|
|
148
|
+
font: 11px var(--wp-font-mono);
|
|
149
|
+
transition: border-color var(--wp-motion-quick), box-shadow var(--wp-motion-quick);
|
|
150
|
+
}
|
|
151
|
+
.wp-cnum--md { --cnum-spin: 20px; height: var(--wp-control-h-md); font-size: 12.5px; }
|
|
152
|
+
.wp-cnum--lg { --cnum-spin: 22px; height: var(--wp-control-h-lg); font-size: 13.5px; }
|
|
153
|
+
.wp-cnum:hover { border-color: var(--wp-border-strong); }
|
|
154
|
+
.wp-cnum:focus-within { border-color: var(--wp-border-focus); box-shadow: var(--wp-focus-ring); }
|
|
155
|
+
.wp-cnum.is-invalid { border-color: var(--wp-error-border); }
|
|
156
|
+
.wp-cnum.is-disabled { opacity: .45; cursor: not-allowed; }
|
|
157
|
+
.wp-cnum__field {
|
|
158
|
+
flex: 1; min-width: 0; padding: 0 6px 0 8px; background: transparent; border: 0;
|
|
159
|
+
color: var(--wp-text); font: inherit; text-align: right;
|
|
160
|
+
-moz-appearance: textfield; appearance: textfield;
|
|
161
|
+
}
|
|
162
|
+
.wp-cnum__field::-webkit-outer-spin-button, .wp-cnum__field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
163
|
+
.wp-cnum__field::placeholder { color: var(--wp-text-dim); }
|
|
164
|
+
.wp-cnum__field:focus { outline: none; }
|
|
165
|
+
.wp-cnum__field:disabled { cursor: not-allowed; }
|
|
166
|
+
.wp-cnum__suffix {
|
|
167
|
+
display: inline-flex; align-items: center; flex-shrink: 0; padding-right: 6px;
|
|
168
|
+
color: var(--wp-text-dim); user-select: none;
|
|
169
|
+
}
|
|
170
|
+
.wp-cnum__spin {
|
|
171
|
+
display: flex; flex-direction: column; width: var(--cnum-spin); flex-shrink: 0;
|
|
172
|
+
border-left: 1px solid var(--wp-border); background: color-mix(in oklab, var(--wp-accent) 4%, transparent);
|
|
173
|
+
}
|
|
174
|
+
.wp-cnum__btn {
|
|
175
|
+
flex: 1; display: flex; align-items: center; justify-content: center; padding: 0; border: 0;
|
|
176
|
+
background: transparent; color: var(--wp-text-dim); cursor: pointer; line-height: 0;
|
|
177
|
+
}
|
|
178
|
+
.wp-cnum__btn .pi { font-size: 7px; }
|
|
179
|
+
.wp-cnum--md .wp-cnum__btn .pi, .wp-cnum--lg .wp-cnum__btn .pi { font-size: 8px; }
|
|
180
|
+
.wp-cnum__btn + .wp-cnum__btn { border-top: 1px solid var(--wp-border); }
|
|
181
|
+
.wp-cnum__btn:hover:not(:disabled) { color: var(--wp-accent-text); background: color-mix(in oklab, var(--wp-accent) 12%, transparent); }
|
|
182
|
+
.wp-cnum__btn:disabled { cursor: not-allowed; }
|
|
183
|
+
</style>
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends string">
|
|
2
|
+
/**
|
|
3
|
+
* Segmented control: two to five short choices shown as joined buttons, one
|
|
4
|
+
* of them on. For this few, showing them all is faster to read and change
|
|
5
|
+
* than a select. Works on the canvas (`size="sm"`) and in the manager (`md`).
|
|
6
|
+
*
|
|
7
|
+
* It is a radio group (APG): only the chosen option is in the Tab order, the
|
|
8
|
+
* arrow keys move to the next enabled option and choose it, Home/End jump to
|
|
9
|
+
* the ends. With `icon-only` the labels become the buttons' names and
|
|
10
|
+
* tooltips.
|
|
11
|
+
*
|
|
12
|
+
* <WpSegmented v-model="theme" :options="[{ value: 'dark', label: 'Dark', icon: 'pi-moon' }]" aria-label="Theme" />
|
|
13
|
+
*/
|
|
14
|
+
import { nextTick, ref } from "vue";
|
|
15
|
+
import { vTooltip } from "./tooltip";
|
|
16
|
+
|
|
17
|
+
export interface SegmentedOption<V extends string = string> {
|
|
18
|
+
value: V;
|
|
19
|
+
label: string;
|
|
20
|
+
/** A PrimeIcons class, e.g. `pi-moon`. */
|
|
21
|
+
icon?: string | undefined;
|
|
22
|
+
disabled?: boolean | undefined;
|
|
23
|
+
/** A tooltip saying what the option does. */
|
|
24
|
+
hint?: string | undefined;
|
|
25
|
+
/** A shortcut shown in the tooltip's key chip, e.g. "G". */
|
|
26
|
+
kbd?: string | undefined;
|
|
27
|
+
/** This option's `data-test`, when `testPrefix` doesn't fit. */
|
|
28
|
+
testId?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(defineProps<{
|
|
32
|
+
modelValue: T;
|
|
33
|
+
options: SegmentedOption<T>[];
|
|
34
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
35
|
+
/** Show only the icons; each label names its button and is its tooltip. */
|
|
36
|
+
iconOnly?: boolean | undefined;
|
|
37
|
+
disabled?: boolean | undefined;
|
|
38
|
+
/** Fill the row: options share the width and long labels end in "…". */
|
|
39
|
+
block?: boolean | undefined;
|
|
40
|
+
ariaLabel?: string | undefined;
|
|
41
|
+
/** `data-test` of each option is `<testPrefix>-<value>`. */
|
|
42
|
+
testPrefix?: string | undefined;
|
|
43
|
+
}>(), { size: "md" });
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{ (e: "update:modelValue", v: T): void }>();
|
|
46
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
47
|
+
|
|
48
|
+
function pick(v: T): void {
|
|
49
|
+
if (props.disabled) return;
|
|
50
|
+
if (v !== props.modelValue) emit("update:modelValue", v);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The option that takes Tab focus: the chosen one, else the first enabled. */
|
|
54
|
+
function tabStop(): T | undefined {
|
|
55
|
+
const live = props.options.filter((o) => !o.disabled);
|
|
56
|
+
return (live.find((o) => o.value === props.modelValue) ?? live[0])?.value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
60
|
+
if (props.disabled) return;
|
|
61
|
+
const live = props.options.filter((o) => !o.disabled);
|
|
62
|
+
if (!live.length) return;
|
|
63
|
+
const at = live.findIndex((o) => o.value === props.modelValue);
|
|
64
|
+
let next: number;
|
|
65
|
+
switch (ev.key) {
|
|
66
|
+
case "ArrowRight": case "ArrowDown": next = (at + 1) % live.length; break;
|
|
67
|
+
case "ArrowLeft": case "ArrowUp": next = at < 0 ? live.length - 1 : (at - 1 + live.length) % live.length; break;
|
|
68
|
+
case "Home": next = 0; break;
|
|
69
|
+
case "End": next = live.length - 1; break;
|
|
70
|
+
default: return;
|
|
71
|
+
}
|
|
72
|
+
ev.preventDefault();
|
|
73
|
+
const v = live[next].value;
|
|
74
|
+
pick(v);
|
|
75
|
+
void nextTick(() => {
|
|
76
|
+
const i = props.options.findIndex((o) => o.value === v);
|
|
77
|
+
rootEl.value?.querySelectorAll<HTMLElement>("[role=radio]")[i]?.focus();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<div
|
|
84
|
+
ref="rootEl"
|
|
85
|
+
class="wp-segmented"
|
|
86
|
+
role="radiogroup"
|
|
87
|
+
:aria-label="ariaLabel"
|
|
88
|
+
:aria-disabled="disabled || undefined"
|
|
89
|
+
:data-size="size"
|
|
90
|
+
:data-icon-only="iconOnly || undefined"
|
|
91
|
+
:data-block="block || undefined"
|
|
92
|
+
>
|
|
93
|
+
<button
|
|
94
|
+
v-for="o in options"
|
|
95
|
+
:key="o.value"
|
|
96
|
+
v-tooltip="{ text: o.hint ?? (iconOnly || o.kbd ? o.label : null), kbd: o.kbd }"
|
|
97
|
+
type="button"
|
|
98
|
+
role="radio"
|
|
99
|
+
class="wp-segmented__opt"
|
|
100
|
+
:aria-checked="modelValue === o.value"
|
|
101
|
+
:aria-label="iconOnly ? o.label : undefined"
|
|
102
|
+
:data-active="modelValue === o.value ? 'true' : 'false'"
|
|
103
|
+
:data-test="o.testId ?? (testPrefix ? `${testPrefix}-${o.value}` : undefined)"
|
|
104
|
+
:tabindex="o.value === tabStop() && !disabled ? 0 : -1"
|
|
105
|
+
:disabled="disabled || o.disabled"
|
|
106
|
+
@click="pick(o.value)"
|
|
107
|
+
@keydown="onKeydown"
|
|
108
|
+
>
|
|
109
|
+
<i v-if="o.icon" class="pi" :class="o.icon" aria-hidden="true" />
|
|
110
|
+
<span v-if="!iconOnly" class="wp-segmented__label">{{ o.label }}</span>
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<style scoped>
|
|
116
|
+
.wp-segmented {
|
|
117
|
+
display: inline-flex;
|
|
118
|
+
flex: none;
|
|
119
|
+
/* Keep its own width inside a column flex parent; block fills the row. */
|
|
120
|
+
align-self: flex-start;
|
|
121
|
+
max-width: 100%;
|
|
122
|
+
padding: 2px;
|
|
123
|
+
gap: 2px;
|
|
124
|
+
border: 1px solid var(--wp-border);
|
|
125
|
+
border-radius: var(--wp-radius);
|
|
126
|
+
background: var(--wp-bg-2);
|
|
127
|
+
height: var(--wp-control-h-md);
|
|
128
|
+
box-sizing: border-box;
|
|
129
|
+
}
|
|
130
|
+
.wp-segmented[data-size="sm"] { height: var(--wp-control-h-sm); border-radius: var(--wp-radius-sm); }
|
|
131
|
+
.wp-segmented[data-size="lg"] { height: var(--wp-control-h-lg); }
|
|
132
|
+
.wp-segmented__opt {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
gap: 6px;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
height: 100%;
|
|
139
|
+
margin: 0;
|
|
140
|
+
padding: 0 var(--wp-space-5);
|
|
141
|
+
border: 0;
|
|
142
|
+
border-radius: calc(var(--wp-radius) - 2px);
|
|
143
|
+
background: transparent;
|
|
144
|
+
color: var(--wp-text-muted);
|
|
145
|
+
font: 500 var(--wp-text-sm)/1 var(--wp-font-sans);
|
|
146
|
+
white-space: nowrap;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
transition: background var(--wp-motion-hover), color var(--wp-motion-hover);
|
|
149
|
+
}
|
|
150
|
+
.wp-segmented[data-size="sm"] .wp-segmented__opt {
|
|
151
|
+
padding: 0 var(--wp-space-4);
|
|
152
|
+
border-radius: calc(var(--wp-radius-sm) - 1px);
|
|
153
|
+
font-size: var(--wp-text-xs);
|
|
154
|
+
gap: 4px;
|
|
155
|
+
}
|
|
156
|
+
.wp-segmented[data-size="lg"] .wp-segmented__opt { padding: 0 var(--wp-space-6); font-size: var(--wp-text-base); }
|
|
157
|
+
.wp-segmented[data-block] { display: flex; width: 100%; align-self: stretch; }
|
|
158
|
+
.wp-segmented[data-block] .wp-segmented__opt { flex: 1 1 0; }
|
|
159
|
+
.wp-segmented__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
160
|
+
.wp-segmented[data-icon-only] .wp-segmented__opt { aspect-ratio: 1; padding: 0; }
|
|
161
|
+
.wp-segmented__opt:hover:not(:disabled) { color: var(--wp-text); background: var(--wp-bg-3); }
|
|
162
|
+
.wp-segmented__opt[data-active="true"] {
|
|
163
|
+
background: var(--wp-bg-4);
|
|
164
|
+
color: var(--wp-text);
|
|
165
|
+
box-shadow: var(--wp-shadow-sm), inset 0 0 0 1px var(--wp-border-strong);
|
|
166
|
+
font-weight: 600;
|
|
167
|
+
}
|
|
168
|
+
.wp-segmented__opt[data-active="true"] .pi { color: var(--wp-accent-text); }
|
|
169
|
+
.wp-segmented__opt:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
170
|
+
.wp-segmented__opt:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
171
|
+
.wp-segmented__opt .pi { font-size: 0.95em; }
|
|
172
|
+
@media (prefers-reduced-motion: reduce) {
|
|
173
|
+
.wp-segmented__opt { transition: none; }
|
|
174
|
+
}
|
|
175
|
+
</style>
|
package/src/WpSelect.vue
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T extends string | number">
|
|
2
|
+
/**
|
|
3
|
+
* Shared dropdown for the canvas tree. A real <select> (keyboard, screen
|
|
4
|
+
* readers and the option list come from the browser) drawn with our field
|
|
5
|
+
* look and chevron instead of the browser's control, so every canvas
|
|
6
|
+
* dropdown matches. The SPA's `ui/Select.vue` is a full custom listbox;
|
|
7
|
+
* the canvas bundle has no room for one, and nodes sit on a zoomable
|
|
8
|
+
* canvas where a native list positions itself correctly.
|
|
9
|
+
*
|
|
10
|
+
* `options` values may be strings or numbers; the chosen option's own
|
|
11
|
+
* value is emitted (not the DOM's string). `placeholder` adds a disabled
|
|
12
|
+
* first entry shown while nothing is chosen (`modelValue` null or ""), for
|
|
13
|
+
* "pick to add" menus. Size it from the parent with a class on the root
|
|
14
|
+
* (height, width, font-size).
|
|
15
|
+
* `dataTest` lands on the <select>.
|
|
16
|
+
*/
|
|
17
|
+
import { nextTick } from "vue";
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
modelValue: T | null;
|
|
21
|
+
options: readonly { value: T; label: string; disabled?: boolean }[];
|
|
22
|
+
placeholder?: string | undefined;
|
|
23
|
+
ariaLabel?: string | undefined;
|
|
24
|
+
dataTest?: string | undefined;
|
|
25
|
+
disabled?: boolean | undefined;
|
|
26
|
+
/** Lands on the <select>, for a `<label for>`. */
|
|
27
|
+
id?: string | undefined;
|
|
28
|
+
}>();
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{ "update:modelValue": [value: T] }>();
|
|
31
|
+
|
|
32
|
+
function onChange(ev: Event): void {
|
|
33
|
+
const el = ev.target as HTMLSelectElement;
|
|
34
|
+
const i = el.selectedIndex - (props.placeholder === undefined ? 0 : 1);
|
|
35
|
+
const opt = props.options[i];
|
|
36
|
+
if (opt) emit("update:modelValue", opt.value);
|
|
37
|
+
// Stay controlled: when the parent keeps its value (a "pick to add" menu
|
|
38
|
+
// stays on its placeholder), put the shown entry back.
|
|
39
|
+
void nextTick(() => {
|
|
40
|
+
const at = props.options.findIndex((o) => o.value === props.modelValue);
|
|
41
|
+
el.selectedIndex = at < 0 ? 0 : at + (props.placeholder === undefined ? 0 : 1);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function selected(o: { value: T }): boolean {
|
|
46
|
+
return o.value === props.modelValue;
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
<template>
|
|
51
|
+
<span class="wp-cselect" :class="{ 'is-disabled': disabled }">
|
|
52
|
+
<select
|
|
53
|
+
:id="id"
|
|
54
|
+
class="wp-cselect__field"
|
|
55
|
+
:disabled="disabled"
|
|
56
|
+
:aria-label="ariaLabel"
|
|
57
|
+
:data-test="dataTest"
|
|
58
|
+
@change="onChange"
|
|
59
|
+
@keydown.stop
|
|
60
|
+
>
|
|
61
|
+
<option
|
|
62
|
+
v-if="placeholder !== undefined" value="" disabled
|
|
63
|
+
:selected="modelValue === null || modelValue === ''"
|
|
64
|
+
>{{ placeholder }}</option>
|
|
65
|
+
<option
|
|
66
|
+
v-for="o in options" :key="String(o.value)" :value="o.value"
|
|
67
|
+
:disabled="o.disabled" :selected="selected(o)"
|
|
68
|
+
>{{ o.label }}</option>
|
|
69
|
+
</select>
|
|
70
|
+
<svg class="wp-cselect__chev" width="8" height="5" viewBox="0 0 8 5" aria-hidden="true"><path d="M0 0 L4 5 L8 0 Z" fill="currentColor" /></svg>
|
|
71
|
+
</span>
|
|
72
|
+
</template>
|
|
73
|
+
|
|
74
|
+
<style scoped>
|
|
75
|
+
.wp-cselect { position: relative; display: inline-flex; align-items: stretch; height: 24px; min-width: 0; font-size: 11px; }
|
|
76
|
+
.wp-cselect__field {
|
|
77
|
+
flex: 1; min-width: 0; height: 100%; padding: 0 20px 0 7px; margin: 0;
|
|
78
|
+
background: var(--wp-bg-deep, var(--wp-bg)); color: var(--wp-text);
|
|
79
|
+
border: 1px solid var(--wp-border); border-radius: min(3px, var(--wp-radius-cap, 999px));
|
|
80
|
+
font: inherit; cursor: pointer;
|
|
81
|
+
appearance: none; -webkit-appearance: none; text-overflow: ellipsis;
|
|
82
|
+
}
|
|
83
|
+
.wp-cselect__field:hover:not(:disabled) { border-color: var(--wp-border-strong, var(--wp-text-dim)); }
|
|
84
|
+
.wp-cselect__field:focus { outline: none; border-color: var(--wp-accent); }
|
|
85
|
+
.wp-cselect__field option { background: var(--wp-bg2, var(--wp-bg)); color: var(--wp-text); }
|
|
86
|
+
.wp-cselect__chev { position: absolute; right: 7px; top: 50%; transform: translateY(-50%); color: var(--wp-text-dim, #7a7d88); pointer-events: none; }
|
|
87
|
+
.wp-cselect.is-disabled { opacity: .5; }
|
|
88
|
+
.wp-cselect.is-disabled .wp-cselect__field { cursor: default; }
|
|
89
|
+
</style>
|
package/src/WpSwitch.vue
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The on/off switch for canvas widgets and the manager (Toggle.vue wraps it).
|
|
4
|
+
*
|
|
5
|
+
* <WpSwitch v-model="held" label="Hold across run" /> track
|
|
6
|
+
* <WpSwitch v-model="held" variant="chip" icon="pi-link" label="Hold across run" />
|
|
7
|
+
*
|
|
8
|
+
* `variant="track"` is a sliding knob; `variant="chip"` is a labelled chip
|
|
9
|
+
* that lights up when on (the editors' Runtime row). `mixed` draws the
|
|
10
|
+
* "some children on" state of a bulk switch; it still reads as off to a
|
|
11
|
+
* screen reader (a switch has no mixed state).
|
|
12
|
+
*
|
|
13
|
+
* The root is a focusable span, not a button, so the switch can sit inside a
|
|
14
|
+
* clickable header row (derivation rules) without nesting buttons. Space and
|
|
15
|
+
* Enter flip it. A disabled switch stays in the tab order (aria-disabled, as
|
|
16
|
+
* the ARIA guide allows) so its tooltip still says why it is off.
|
|
17
|
+
*
|
|
18
|
+
* One component on both sides means one stylesheet: the scoped block below
|
|
19
|
+
* reads only SHARED_TOKENS, so it wears each side's colours. (Scoped and unlayered on purpose: the manager's Tailwind preflight
|
|
20
|
+
* is unlayered and would beat a layered border.)
|
|
21
|
+
*/
|
|
22
|
+
const props = withDefaults(
|
|
23
|
+
defineProps<{
|
|
24
|
+
modelValue?: boolean | undefined;
|
|
25
|
+
variant?: "track" | "chip" | undefined;
|
|
26
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
27
|
+
label?: string | undefined;
|
|
28
|
+
/** PrimeIcons class (e.g. `pi-lock`), drawn before the label. */
|
|
29
|
+
icon?: string | undefined;
|
|
30
|
+
mixed?: boolean | undefined;
|
|
31
|
+
disabled?: boolean | undefined;
|
|
32
|
+
invalid?: boolean | undefined;
|
|
33
|
+
}>(),
|
|
34
|
+
{ modelValue: false, variant: "track", size: "sm", label: undefined, icon: undefined },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{ "update:modelValue": [next: boolean] }>();
|
|
38
|
+
|
|
39
|
+
function flip(): void {
|
|
40
|
+
if (props.disabled) return;
|
|
41
|
+
emit("update:modelValue", !props.modelValue);
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<template>
|
|
46
|
+
<span
|
|
47
|
+
class="wp-switch"
|
|
48
|
+
:class="[`wp-switch--${size}`, { 'wp-switch--chip': variant === 'chip', 'wp-switch--mixed': mixed && !modelValue }]"
|
|
49
|
+
role="switch"
|
|
50
|
+
:aria-checked="modelValue === true"
|
|
51
|
+
:aria-disabled="disabled || undefined"
|
|
52
|
+
:aria-invalid="invalid || undefined"
|
|
53
|
+
tabindex="0"
|
|
54
|
+
@click="flip"
|
|
55
|
+
@keydown.space.stop.prevent="flip"
|
|
56
|
+
@keydown.enter.prevent.stop="flip"
|
|
57
|
+
>
|
|
58
|
+
<span v-if="variant === 'track'" class="wp-switch__track" aria-hidden="true"><span class="wp-switch__knob" /></span>
|
|
59
|
+
<i v-else-if="icon" class="pi wp-switch__icon" :class="icon" aria-hidden="true" />
|
|
60
|
+
<slot><span v-if="label" class="wp-switch__label">{{ label }}</span></slot>
|
|
61
|
+
</span>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<style scoped>
|
|
65
|
+
/* Two looks. track: a sliding knob, for a setting that takes effect at once.
|
|
66
|
+
* chip: a labelled chip that lights up when on (Hide from prompt, Lock...).
|
|
67
|
+
* Heights follow the control scale (sm 24 / md 32 / lg 40). */
|
|
68
|
+
.wp-switch {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: var(--wp-space-3);
|
|
72
|
+
min-height: 20px;
|
|
73
|
+
flex-shrink: 0;
|
|
74
|
+
color: var(--wp-text-muted);
|
|
75
|
+
font: var(--wp-text-xs) / 1 var(--wp-font-sans);
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
user-select: none;
|
|
78
|
+
outline: none;
|
|
79
|
+
-webkit-tap-highlight-color: transparent;
|
|
80
|
+
}
|
|
81
|
+
.wp-switch[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
|
|
82
|
+
.wp-switch__label { color: var(--wp-text-muted); }
|
|
83
|
+
.wp-switch--md, .wp-switch--lg { min-height: 24px; font-size: var(--wp-text-sm); }
|
|
84
|
+
|
|
85
|
+
/* Track look */
|
|
86
|
+
.wp-switch__track {
|
|
87
|
+
--switch-w: 26px;
|
|
88
|
+
--switch-h: 14px;
|
|
89
|
+
position: relative;
|
|
90
|
+
flex-shrink: 0;
|
|
91
|
+
width: var(--switch-w);
|
|
92
|
+
height: var(--switch-h);
|
|
93
|
+
box-sizing: border-box;
|
|
94
|
+
border: 1px solid var(--wp-border-strong);
|
|
95
|
+
border-radius: var(--wp-radius-cap, 999px);
|
|
96
|
+
background: var(--wp-bg-3);
|
|
97
|
+
transition: background var(--wp-motion-quick), border-color var(--wp-motion-quick);
|
|
98
|
+
}
|
|
99
|
+
.wp-switch--md .wp-switch__track { --switch-w: 32px; --switch-h: 18px; }
|
|
100
|
+
.wp-switch--lg .wp-switch__track { --switch-w: 40px; --switch-h: 22px; }
|
|
101
|
+
.wp-switch__knob {
|
|
102
|
+
position: absolute;
|
|
103
|
+
top: 1px;
|
|
104
|
+
left: 1px;
|
|
105
|
+
width: calc(var(--switch-h) - 4px);
|
|
106
|
+
height: calc(var(--switch-h) - 4px);
|
|
107
|
+
border-radius: min(50%, var(--wp-radius-cap, 999px));
|
|
108
|
+
background: var(--wp-text-dim);
|
|
109
|
+
transition: transform var(--wp-motion-quick), background var(--wp-motion-quick);
|
|
110
|
+
}
|
|
111
|
+
.wp-switch:not([aria-disabled="true"]):hover .wp-switch__track { border-color: var(--wp-text-dim); }
|
|
112
|
+
.wp-switch[aria-checked="true"] .wp-switch__track { background: var(--wp-accent); border-color: var(--wp-accent); }
|
|
113
|
+
.wp-switch[aria-checked="true"] .wp-switch__knob {
|
|
114
|
+
background: white; /* the knob on the accent track, both themes */
|
|
115
|
+
transform: translateX(calc(var(--switch-w) - var(--switch-h)));
|
|
116
|
+
}
|
|
117
|
+
.wp-switch--mixed .wp-switch__track { border-style: dashed; border-color: var(--wp-accent); }
|
|
118
|
+
.wp-switch--mixed .wp-switch__knob {
|
|
119
|
+
background: var(--wp-accent);
|
|
120
|
+
transform: translateX(calc((var(--switch-w) - var(--switch-h)) / 2));
|
|
121
|
+
}
|
|
122
|
+
.wp-switch:focus-visible .wp-switch__track,
|
|
123
|
+
.wp-switch--chip:focus-visible { box-shadow: var(--wp-focus-ring); }
|
|
124
|
+
.wp-switch[aria-invalid="true"] .wp-switch__track,
|
|
125
|
+
.wp-switch--chip[aria-invalid="true"] { border-color: var(--wp-error-border); }
|
|
126
|
+
|
|
127
|
+
/* Chip look */
|
|
128
|
+
.wp-switch--chip {
|
|
129
|
+
height: var(--wp-control-h-sm);
|
|
130
|
+
min-height: 0;
|
|
131
|
+
gap: var(--wp-space-3);
|
|
132
|
+
padding: 0 var(--wp-space-4);
|
|
133
|
+
box-sizing: border-box;
|
|
134
|
+
border: 1px solid var(--wp-border);
|
|
135
|
+
border-radius: var(--wp-radius-sm);
|
|
136
|
+
background: var(--wp-bg);
|
|
137
|
+
transition: border-color var(--wp-motion-quick), background var(--wp-motion-quick);
|
|
138
|
+
}
|
|
139
|
+
.wp-switch--chip.wp-switch--md { height: var(--wp-control-h-md); padding: 0 var(--wp-space-5); }
|
|
140
|
+
.wp-switch--chip.wp-switch--lg { height: var(--wp-control-h-lg); padding: 0 var(--wp-space-6); }
|
|
141
|
+
.wp-switch--chip .wp-switch__icon { font-size: var(--wp-text-xs); color: var(--wp-text-dim); }
|
|
142
|
+
.wp-switch--chip:not([aria-disabled="true"]):hover { border-color: var(--wp-border-strong); }
|
|
143
|
+
.wp-switch--chip[aria-checked="true"] {
|
|
144
|
+
border-color: var(--wp-accent);
|
|
145
|
+
color: var(--wp-accent-text);
|
|
146
|
+
background: color-mix(in srgb, var(--wp-accent) 10%, transparent);
|
|
147
|
+
}
|
|
148
|
+
.wp-switch--chip[aria-checked="true"] .wp-switch__label,
|
|
149
|
+
.wp-switch--chip[aria-checked="true"] .wp-switch__icon { color: var(--wp-accent-text); }
|
|
150
|
+
.wp-switch--chip.wp-switch--mixed {
|
|
151
|
+
border-style: dashed;
|
|
152
|
+
border-color: var(--wp-accent);
|
|
153
|
+
background: color-mix(in srgb, var(--wp-accent) 6%, transparent);
|
|
154
|
+
}
|
|
155
|
+
/* accent-text, not the raw accent: the raw ramp sits near 3:1 on light
|
|
156
|
+
Tide / Ember pages. */
|
|
157
|
+
.wp-switch--chip.wp-switch--mixed .wp-switch__label,
|
|
158
|
+
.wp-switch--chip.wp-switch--mixed .wp-switch__icon { color: var(--wp-accent-text); }
|
|
159
|
+
|
|
160
|
+
@media (prefers-reduced-motion: reduce) {
|
|
161
|
+
.wp-switch__track, .wp-switch__knob, .wp-switch--chip { transition: none; }
|
|
162
|
+
}
|
|
163
|
+
</style>
|