@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.
Files changed (128) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +674 -0
  3. package/README.md +72 -2
  4. package/package.json +76 -4
  5. package/src/Avatar.vue +83 -0
  6. package/src/Badge.vue +97 -0
  7. package/src/Banner.vue +118 -0
  8. package/src/BookmarkButton.vue +27 -0
  9. package/src/BundleMeta.vue +51 -0
  10. package/src/Button.vue +103 -0
  11. package/src/Card.vue +68 -0
  12. package/src/Checkbox.vue +109 -0
  13. package/src/Chip.vue +163 -0
  14. package/src/ChoiceCards.vue +133 -0
  15. package/src/CloseButton.vue +69 -0
  16. package/src/CodeBlock.vue +119 -0
  17. package/src/ColorField.vue +188 -0
  18. package/src/ColorPicker.vue +201 -0
  19. package/src/Combobox.vue +331 -0
  20. package/src/ConfirmDialog.vue +165 -0
  21. package/src/ConfirmModal.vue +147 -0
  22. package/src/ConstraintMatrixPreview.vue +708 -0
  23. package/src/ContextMenu.vue +386 -0
  24. package/src/CopyButton.vue +156 -0
  25. package/src/DataTable.vue +292 -0
  26. package/src/DatePicker.vue +234 -0
  27. package/src/DescriptionList.vue +73 -0
  28. package/src/DiffView.vue +68 -0
  29. package/src/Disclosure.vue +166 -0
  30. package/src/DragHandle.vue +49 -0
  31. package/src/Drawer.vue +131 -0
  32. package/src/DropZone.vue +196 -0
  33. package/src/DropdownMenu.vue +72 -0
  34. package/src/EmptyState.vue +111 -0
  35. package/src/Field.vue +28 -0
  36. package/src/HsvPicker.vue +208 -0
  37. package/src/Icon.vue +39 -0
  38. package/src/IconPicker.vue +209 -0
  39. package/src/ImageGrid.vue +125 -0
  40. package/src/InlineEdit.vue +183 -0
  41. package/src/Input.vue +99 -0
  42. package/src/Kbd.vue +56 -0
  43. package/src/Lightbox.vue +173 -0
  44. package/src/ListFilter.vue +85 -0
  45. package/src/ListRow.vue +204 -0
  46. package/src/LoadingMark.vue +175 -0
  47. package/src/Logo.vue +60 -0
  48. package/src/Modal.vue +78 -0
  49. package/src/ModalShell.vue +109 -0
  50. package/src/Pagination.vue +106 -0
  51. package/src/Popover.vue +180 -0
  52. package/src/PostCard.vue +210 -0
  53. package/src/ProgressBar.vue +90 -0
  54. package/src/RadioGroup.vue +177 -0
  55. package/src/RangeSlider.vue +183 -0
  56. package/src/RelativeDate.vue +35 -0
  57. package/src/ResizeHandle.vue +143 -0
  58. package/src/SearchInput.vue +192 -0
  59. package/src/Select.vue +644 -0
  60. package/src/Separator.vue +54 -0
  61. package/src/Skeleton.vue +82 -0
  62. package/src/Slider.vue +198 -0
  63. package/src/SortableList.vue +260 -0
  64. package/src/SplitPane.vue +68 -0
  65. package/src/StarButton.vue +40 -0
  66. package/src/Stepper.vue +141 -0
  67. package/src/SupportLinks.vue +93 -0
  68. package/src/TagInput.vue +297 -0
  69. package/src/Textarea.vue +57 -0
  70. package/src/Timeline.vue +92 -0
  71. package/src/ToastCard.vue +119 -0
  72. package/src/ToastHost.vue +40 -0
  73. package/src/Toggle.vue +36 -0
  74. package/src/TokenizedValue.vue +310 -0
  75. package/src/Toolbar.vue +110 -0
  76. package/src/VirtualList.vue +87 -0
  77. package/src/VisuallyHidden.vue +29 -0
  78. package/src/WpButton.vue +75 -0
  79. package/src/WpCheck.vue +104 -0
  80. package/src/WpIconButton.vue +107 -0
  81. package/src/WpImageZoom.vue +544 -0
  82. package/src/WpNumber.vue +178 -0
  83. package/src/WpSegmented.vue +175 -0
  84. package/src/WpSelect.vue +93 -0
  85. package/src/WpSwitch.vue +163 -0
  86. package/src/WpTabs.vue +188 -0
  87. package/src/_modal-motion.css +33 -0
  88. package/src/_skeleton.css +50 -0
  89. package/src/axis-color.ts +42 -0
  90. package/src/button.css +169 -0
  91. package/src/calendar.ts +59 -0
  92. package/src/clipboard.ts +50 -0
  93. package/src/colour-presets.ts +12 -0
  94. package/src/diff.ts +51 -0
  95. package/src/drop-zone.ts +41 -0
  96. package/src/field.css +50 -0
  97. package/src/flip.ts +237 -0
  98. package/src/floating-layer.ts +48 -0
  99. package/src/floating.ts +99 -0
  100. package/src/hsv.ts +100 -0
  101. package/src/icon-catalog.ts +52 -0
  102. package/src/image-zoom.ts +93 -0
  103. package/src/kind-colour.ts +28 -0
  104. package/src/logo-paths.ts +21 -0
  105. package/src/matrix-axis.ts +91 -0
  106. package/src/pagination.ts +27 -0
  107. package/src/platform-keys.ts +29 -0
  108. package/src/rich-tokenize.ts +357 -0
  109. package/src/select-types.ts +32 -0
  110. package/src/slider-math.ts +45 -0
  111. package/src/sortable.ts +22 -0
  112. package/src/theme/fonts/OFL-Fraunces.txt +93 -0
  113. package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
  114. package/src/theme/fonts/OFL-Manrope.txt +93 -0
  115. package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
  116. package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
  117. package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
  118. package/src/theme/looks.css +428 -0
  119. package/src/theme/palettes.css +254 -0
  120. package/src/theme/tokens.css +2896 -0
  121. package/src/tokens.ts +56 -0
  122. package/src/tooltip.css +43 -0
  123. package/src/tooltip.ts +146 -0
  124. package/src/useDialogFocus.ts +73 -0
  125. package/src/useGrowableField.ts +357 -0
  126. package/src/useModalKeyShield.ts +116 -0
  127. package/src/useToast.ts +48 -0
  128. package/src/wp-logo.svg +14 -0
@@ -0,0 +1,178 @@
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 wp-field-box"
106
+ :class="[`wp-cnum--${size}`, `wp-field-box--${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 src="./field.css"></style>
143
+
144
+ <style scoped>
145
+ .wp-cnum {
146
+ --cnum-spin: 16px;
147
+ display: inline-flex; align-items: stretch; height: var(--wp-field-h); min-width: 0; overflow: hidden;
148
+ font-family: var(--wp-font-mono);
149
+ }
150
+ .wp-cnum--md { --cnum-spin: 20px; }
151
+ .wp-cnum--lg { --cnum-spin: 22px; }
152
+ .wp-cnum__field {
153
+ flex: 1; min-width: 0; padding: 0 6px 0 8px; background: transparent; border: 0;
154
+ color: var(--wp-text); font: inherit; text-align: right;
155
+ -moz-appearance: textfield; appearance: textfield;
156
+ }
157
+ .wp-cnum__field::-webkit-outer-spin-button, .wp-cnum__field::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
158
+ .wp-cnum__field::placeholder { color: var(--wp-text-dim); }
159
+ .wp-cnum__field:focus { outline: none; }
160
+ .wp-cnum__field:disabled { cursor: not-allowed; }
161
+ .wp-cnum__suffix {
162
+ display: inline-flex; align-items: center; flex-shrink: 0; padding-right: 6px;
163
+ color: var(--wp-text-dim); user-select: none;
164
+ }
165
+ .wp-cnum__spin {
166
+ display: flex; flex-direction: column; width: var(--cnum-spin); flex-shrink: 0;
167
+ border-left: 1px solid var(--wp-border); background: color-mix(in oklab, var(--wp-accent) 4%, transparent);
168
+ }
169
+ .wp-cnum__btn {
170
+ flex: 1; display: flex; align-items: center; justify-content: center; padding: 0; border: 0;
171
+ background: transparent; color: var(--wp-text-dim); cursor: pointer; line-height: 0;
172
+ }
173
+ .wp-cnum__btn .pi { font-size: 7px; }
174
+ .wp-cnum--md .wp-cnum__btn .pi, .wp-cnum--lg .wp-cnum__btn .pi { font-size: 8px; }
175
+ .wp-cnum__btn + .wp-cnum__btn { border-top: 1px solid var(--wp-border); }
176
+ .wp-cnum__btn:hover:not(:disabled) { color: var(--wp-accent-text); background: color-mix(in oklab, var(--wp-accent) 12%, transparent); }
177
+ .wp-cnum__btn:disabled { cursor: not-allowed; }
178
+ </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>
@@ -0,0 +1,93 @@
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` (sm 24px default, md 32px) sets its height
14
+ * and font on the control scale; the box is the kit's field box
15
+ * (`field.css`), the same as every other field. A parent can still size it
16
+ * with a class on the root (height, width, font-size).
17
+ * `dataTest` lands on the <select>.
18
+ */
19
+ import { nextTick } from "vue";
20
+
21
+ const props = defineProps<{
22
+ modelValue: T | null;
23
+ options: readonly { value: T; label: string; disabled?: boolean }[];
24
+ placeholder?: string | undefined;
25
+ ariaLabel?: string | undefined;
26
+ dataTest?: string | undefined;
27
+ disabled?: boolean | undefined;
28
+ /** Lands on the <select>, for a `<label for>`. */
29
+ id?: string | undefined;
30
+ size?: "sm" | "md" | undefined;
31
+ }>();
32
+
33
+ const emit = defineEmits<{ "update:modelValue": [value: T] }>();
34
+
35
+ function onChange(ev: Event): void {
36
+ const el = ev.target as HTMLSelectElement;
37
+ const i = el.selectedIndex - (props.placeholder === undefined ? 0 : 1);
38
+ const opt = props.options[i];
39
+ if (opt) emit("update:modelValue", opt.value);
40
+ // Stay controlled: when the parent keeps its value (a "pick to add" menu
41
+ // stays on its placeholder), put the shown entry back.
42
+ void nextTick(() => {
43
+ const at = props.options.findIndex((o) => o.value === props.modelValue);
44
+ el.selectedIndex = at < 0 ? 0 : at + (props.placeholder === undefined ? 0 : 1);
45
+ });
46
+ }
47
+
48
+ function selected(o: { value: T }): boolean {
49
+ return o.value === props.modelValue;
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <span class="wp-cselect" :class="[`wp-cselect--${size ?? 'sm'}`, { 'is-disabled': disabled }]">
55
+ <select
56
+ :id="id"
57
+ class="wp-cselect__field wp-field-box"
58
+ :class="`wp-field-box--${size ?? 'sm'}`"
59
+ :disabled="disabled"
60
+ :aria-label="ariaLabel"
61
+ :data-test="dataTest"
62
+ @change="onChange"
63
+ @keydown.stop
64
+ >
65
+ <option
66
+ v-if="placeholder !== undefined" value="" disabled
67
+ :selected="modelValue === null || modelValue === ''"
68
+ >{{ placeholder }}</option>
69
+ <option
70
+ v-for="o in options" :key="String(o.value)" :value="o.value"
71
+ :disabled="o.disabled" :selected="selected(o)"
72
+ >{{ o.label }}</option>
73
+ </select>
74
+ <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>
75
+ </span>
76
+ </template>
77
+
78
+ <style scoped src="./field.css"></style>
79
+
80
+ <style scoped>
81
+ .wp-cselect { position: relative; display: inline-flex; align-items: stretch; height: var(--wp-control-h-sm); min-width: 0; font-size: 12px; }
82
+ .wp-cselect--md { height: var(--wp-control-h-md); font-size: 12.5px; }
83
+ /* After field.css: the root's height and font (or a parent's class on the
84
+ root) size the box. */
85
+ .wp-cselect__field {
86
+ flex: 1; min-width: 0; height: 100%; min-height: 0; padding: 0 20px 0 8px; margin: 0;
87
+ font: inherit; cursor: pointer;
88
+ appearance: none; -webkit-appearance: none; text-overflow: ellipsis;
89
+ }
90
+ .wp-cselect__field option { background: var(--wp-bg2, var(--wp-bg)); color: var(--wp-text); }
91
+ .wp-cselect__chev { position: absolute; right: 7px; top: 50%; transform: translateY(-50%); color: var(--wp-text-dim, #7a7d88); pointer-events: none; }
92
+ .wp-cselect.is-disabled .wp-cselect__field { cursor: not-allowed; }
93
+ </style>
@@ -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>