@dumiflex/wp-kit 0.5.0 → 0.7.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 (89) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +5 -0
  3. package/package.json +4 -3
  4. package/src/Avatar.vue +1 -1
  5. package/src/Banner.vue +4 -4
  6. package/src/BundlePreview.vue +254 -0
  7. package/src/Button.vue +73 -44
  8. package/src/Checkbox.vue +201 -63
  9. package/src/Chip.vue +5 -2
  10. package/src/CodeBlock.vue +1 -1
  11. package/src/ColorField.vue +60 -6
  12. package/src/CombinePreview.vue +150 -0
  13. package/src/Combobox.vue +10 -14
  14. package/src/ConfirmModal.vue +12 -22
  15. package/src/ConstraintMatrixPreview.vue +6 -22
  16. package/src/ConstraintPreview.vue +259 -0
  17. package/src/ContextMenu.vue +11 -30
  18. package/src/CopyButton.vue +1 -1
  19. package/src/DataTable.vue +0 -2
  20. package/src/DatePicker.vue +1 -1
  21. package/src/DerivationCondition.vue +68 -0
  22. package/src/DerivationPreview.vue +345 -0
  23. package/src/Disclosure.vue +2 -1
  24. package/src/Drawer.vue +2 -2
  25. package/src/DropZone.vue +2 -2
  26. package/src/FixedValuesPreview.vue +184 -0
  27. package/src/HsvPicker.vue +2 -2
  28. package/src/IconPicker.vue +32 -103
  29. package/src/IconPickerPanel.vue +132 -0
  30. package/src/ImageGrid.vue +2 -2
  31. package/src/Lightbox.vue +2 -2
  32. package/src/LoadingMark.vue +6 -61
  33. package/src/Modal.vue +109 -44
  34. package/src/ModalShell.vue +26 -5
  35. package/src/Pagination.vue +4 -4
  36. package/src/PayloadPreview.vue +154 -0
  37. package/src/Popover.vue +68 -12
  38. package/src/PostDetailSkeleton.vue +65 -0
  39. package/src/PostGridSkeleton.vue +61 -0
  40. package/src/PreviewGroup.vue +75 -0
  41. package/src/ProfileSkeleton.vue +73 -0
  42. package/src/RadioGroup.vue +5 -4
  43. package/src/RichText.vue +61 -0
  44. package/src/SearchInput.vue +1 -1
  45. package/src/Select.vue +53 -158
  46. package/src/SelectionBar.vue +90 -0
  47. package/src/SettingGroup.vue +57 -0
  48. package/src/SettingRow.vue +127 -0
  49. package/src/Skeleton.vue +1 -1
  50. package/src/Slider.vue +166 -55
  51. package/src/SortableList.vue +1 -1
  52. package/src/TagInput.vue +1 -1
  53. package/src/Timeline.vue +2 -2
  54. package/src/WildcardPreview.vue +607 -0
  55. package/src/WorkingLine.vue +234 -0
  56. package/src/WpIconButton.vue +3 -2
  57. package/src/WpImageZoom.vue +7 -7
  58. package/src/WpSegmented.vue +3 -1
  59. package/src/WpSwitch.vue +1 -1
  60. package/src/WpTabs.vue +1 -1
  61. package/src/_modal-motion.css +1 -1
  62. package/src/button.css +8 -6
  63. package/src/canvas-motion.ts +251 -0
  64. package/src/floating.ts +5 -0
  65. package/src/hsv.ts +1 -1
  66. package/src/kind-colour.ts +24 -1
  67. package/src/linkify.ts +142 -0
  68. package/src/loading-mark-motion.ts +12 -155
  69. package/src/matrix-axis.ts +28 -0
  70. package/src/menu.css +51 -0
  71. package/src/motion.css +73 -0
  72. package/src/pi-icons.ts +51 -0
  73. package/src/preview-readers.ts +401 -0
  74. package/src/preview.css +194 -0
  75. package/src/slider-math.ts +45 -1
  76. package/src/theme/looks.css +6 -19
  77. package/src/theme/tokens.css +12 -1108
  78. package/src/tokens.ts +15 -4
  79. package/src/tooltip.css +8 -5
  80. package/src/useDialogFocus.ts +11 -2
  81. package/src/working-line-motion.ts +135 -0
  82. package/src/CloseButton.vue +0 -69
  83. package/src/ColorPicker.vue +0 -201
  84. package/src/ConfirmDialog.vue +0 -165
  85. package/src/RangeSlider.vue +0 -183
  86. package/src/Toggle.vue +0 -36
  87. package/src/WpButton.vue +0 -75
  88. package/src/WpCheck.vue +0 -104
  89. package/src/_skeleton.css +0 -50
package/src/Checkbox.vue CHANGED
@@ -1,109 +1,247 @@
1
1
  <script setup lang="ts">
2
- import { computed } from "vue";
2
+ /**
3
+ * The one checkbox, for the manager, the community site and canvas widgets.
4
+ *
5
+ * <Checkbox v-model="picked" label="Include in bundle" />
6
+ * <Checkbox v-model="on" size="sm" aria-label="Enabled" /> a row's box
7
+ * <Checkbox :model-value="false" indeterminate label="Some selected" />
8
+ * <Checkbox v-model="favorites" field label="Favorites only" />
9
+ *
10
+ * Box: a `<button role="checkbox">`, so a wrapping `<label>` ticks it like an
11
+ * input. Click, Space and Enter toggle. `size="sm"` is the 12px box of the
12
+ * canvas rows' on/off mark; md is 14px. A parent can tint the checked fill
13
+ * with `--check-on` (a bundle header uses its bundle colour).
14
+ *
15
+ * Without a label the box is the root: `class`, `title`, `data-test` and a
16
+ * parent's scoped styles land on it. With a label (prop or the default slot)
17
+ * or `field`, the root is a wrapper around the box and the words, and a click
18
+ * on the words toggles too.
19
+ *
20
+ * The styles are scoped and read only SHARED_TOKENS, so the box ships in the
21
+ * canvas chunk and wears each side's colours.
22
+ */
23
+ import { computed, useAttrs, useId, useSlots } from "vue";
24
+
25
+ defineOptions({ inheritAttrs: false });
3
26
 
4
27
  interface Props {
5
- modelValue: boolean;
28
+ /** Checked state: use with `v-model` or `:model-value` + `@update:model-value`. */
29
+ modelValue?: boolean | undefined;
6
30
  label?: string | undefined;
7
31
  disabled?: boolean | undefined;
8
32
  error?: boolean | undefined;
9
33
  id?: string | undefined;
10
34
  ariaLabel?: string | undefined;
35
+ size?: "sm" | "md" | undefined;
11
36
  /**
12
- * Tri-state indicator. When true AND `modelValue` is false, the
13
- * checkbox renders a horizontal dash instead of a checkmark and
14
- * announces `aria-checked="mixed"`. Used by group/select-all
15
- * controls where the underlying collection is partially selected.
16
- * `modelValue=true` always wins — a fully-checked box never
17
- * renders as indeterminate even if the prop is set.
37
+ * Tri-state indicator. When true AND `modelValue` is false, the box shows a
38
+ * dash and announces `aria-checked="mixed"` (a select-all over a partial
39
+ * selection). `modelValue=true` always wins.
18
40
  */
19
41
  indeterminate?: boolean | undefined;
20
42
  /**
21
43
  * Draw the whole row as an input-height field (border, fill, full width)
22
- * whose label fills it, for a yes/no filter that sits in a form grid
23
- * beside Selects (the list pages' "Favorites only").
44
+ * whose label fills it, for a yes/no filter in a form grid beside Selects
45
+ * (the list pages' "Favorites only").
24
46
  */
25
47
  field?: boolean | undefined;
26
48
  }
27
- const props = defineProps<Props>();
49
+ const props = withDefaults(defineProps<Props>(), {
50
+ modelValue: false,
51
+ label: undefined,
52
+ disabled: false,
53
+ error: false,
54
+ id: undefined,
55
+ ariaLabel: undefined,
56
+ size: "md",
57
+ indeterminate: false,
58
+ field: false,
59
+ });
28
60
 
29
- const emit = defineEmits<{
30
- (e: "update:modelValue", v: boolean): void;
31
- }>();
61
+ const emit = defineEmits<{ "update:modelValue": [next: boolean] }>();
32
62
 
33
- const showIndeterminate = computed<boolean>(
34
- () => !!props.indeterminate && !props.modelValue,
35
- );
63
+ const attrs = useAttrs();
64
+ const slots = useSlots();
65
+ const labelId = useId();
36
66
 
67
+ const hasLabel = computed(() => !!props.label || !!slots.default);
68
+ /** The wrapper is a real box (label beside the box, or the field look). */
69
+ const wrapped = computed(() => hasLabel.value || props.field);
70
+ const showIndeterminate = computed(() => props.indeterminate && !props.modelValue);
37
71
  const ariaChecked = computed<"true" | "false" | "mixed">(() => {
38
72
  if (props.modelValue) return "true";
39
- if (showIndeterminate.value) return "mixed";
40
- return "false";
73
+ return showIndeterminate.value ? "mixed" : "false";
41
74
  });
42
75
 
43
- function toggle() {
76
+ function toggle(): void {
44
77
  if (props.disabled) return;
45
78
  emit("update:modelValue", !props.modelValue);
46
79
  }
47
80
  </script>
48
81
 
49
82
  <template>
50
- <span class="wp-checkbox" :class="{ 'is-disabled': disabled, 'wp-checkbox--field': field }">
51
- <!-- Wrapper is a <span> rather than a <label> on purpose. Native <label>
52
- re-dispatches click on its associated form control in some pointer
53
- paths (notably when the click target is the SVG inside the button),
54
- firing the button's @click twice — which double-toggled and left
55
- the underlying model unchanged when a parent also wired a click
56
- handler. The span wrapper has no synthetic-dispatch behavior. -->
83
+ <span
84
+ v-if="wrapped"
85
+ v-bind="attrs"
86
+ class="wp-checkbox"
87
+ :class="[`wp-checkbox--${size}`, { 'wp-checkbox--field': field, 'is-disabled': disabled }]"
88
+ >
89
+ <!-- With a label the root is a <span>, not a <label>: a native label
90
+ re-dispatches the click on its control in some pointer paths (a click
91
+ on the tick SVG), which toggled twice and left the model unchanged.
92
+ No comment sits beside the roots: it would make the root a fragment. -->
57
93
  <button
94
+ :id="id"
95
+ :aria-label="ariaLabel ?? label"
96
+ :aria-labelledby="!ariaLabel && !label ? labelId : undefined"
58
97
  type="button"
98
+ role="checkbox"
59
99
  class="wp-check"
100
+ :class="{ 'wp-check--sm': size === 'sm' }"
60
101
  :data-checked="modelValue ? 'true' : 'false'"
61
102
  :data-indeterminate="showIndeterminate ? 'true' : 'false'"
62
103
  :aria-checked="ariaChecked"
63
- :aria-label="ariaLabel ?? label"
64
104
  :aria-invalid="error || undefined"
105
+ :aria-disabled="disabled || undefined"
65
106
  :disabled="disabled"
66
- role="checkbox"
67
107
  @click="toggle"
108
+ @keydown.enter.prevent="toggle"
68
109
  >
69
- <!-- tokens.css gives `.wp-check svg` pointer-events: none so the entire
70
- button area (including the check mark) is a single click target —
71
- clicks land on the button itself, not the SVG child. Without this,
72
- a click on the SVG centerpoint could route through wrapper-bubble
73
- paths in some browsers/automation drivers and double-fire toggle.
74
- No inline style here: the TUI look hides the drawn mark (it reads
75
- [x] instead), and an inline display would beat it. -->
76
- <svg
77
- v-if="modelValue"
78
- viewBox="0 0 12 12"
79
- fill="none"
80
- aria-hidden="true"
81
- >
82
- <path
83
- d="M3 6.2l2.2 2.2L9 4.4"
84
- stroke="currentColor"
85
- stroke-width="1.8"
86
- stroke-linecap="round"
87
- stroke-linejoin="round"
88
- />
110
+ <!-- No inline style on the marks: the TUI look hides them (it writes
111
+ [x] / [-]) and an inline display would beat that rule. -->
112
+ <svg v-if="modelValue" viewBox="0 0 12 12" fill="none" aria-hidden="true">
113
+ <path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
89
114
  </svg>
90
- <!-- Indeterminate dash. Same pointer-events rule as the checkmark. -->
91
- <svg
92
- v-else-if="showIndeterminate"
93
- viewBox="0 0 12 12"
94
- fill="none"
95
- aria-hidden="true"
96
- >
97
- <path
98
- d="M3 6h6"
99
- stroke="currentColor"
100
- stroke-width="1.8"
101
- stroke-linecap="round"
102
- />
115
+ <svg v-else-if="showIndeterminate" viewBox="0 0 12 12" fill="none" aria-hidden="true">
116
+ <path d="M3 6h6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
103
117
  </svg>
104
118
  </button>
105
119
  <!-- A click on the words toggles too, like a <label>; keyboard users use the box itself. -->
106
120
  <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
107
- <span v-if="label" class="wp-checkbox__label" @click="toggle">{{ label }}</span>
121
+ <span :id="labelId" class="wp-checkbox__label" @click="toggle"><slot>{{ label }}</slot></span>
108
122
  </span>
123
+ <button
124
+ v-else
125
+ v-bind="attrs"
126
+ :id="id"
127
+ :aria-label="ariaLabel"
128
+ type="button"
129
+ role="checkbox"
130
+ class="wp-check"
131
+ :class="{ 'wp-check--sm': size === 'sm' }"
132
+ :data-checked="modelValue ? 'true' : 'false'"
133
+ :data-indeterminate="showIndeterminate ? 'true' : 'false'"
134
+ :aria-checked="ariaChecked"
135
+ :aria-invalid="error || undefined"
136
+ :aria-disabled="disabled || undefined"
137
+ :disabled="disabled"
138
+ @click="toggle"
139
+ @keydown.enter.prevent="toggle"
140
+ >
141
+ <!-- Box only: the box is the root, so the caller's class, scoped styles,
142
+ title and data-test land on it. -->
143
+ <svg v-if="modelValue" viewBox="0 0 12 12" fill="none" aria-hidden="true">
144
+ <path d="M3 6.2l2.2 2.2L9 4.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
145
+ </svg>
146
+ <svg v-else-if="showIndeterminate" viewBox="0 0 12 12" fill="none" aria-hidden="true">
147
+ <path d="M3 6h6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
148
+ </svg>
149
+ </button>
109
150
  </template>
151
+
152
+ <style scoped>
153
+ /* State selectors sit in :where() so every rule stays at the base box's
154
+ * weight: the looks (theme/looks.css: TUI's [x], Soft's round corners) are
155
+ * one notch heavier and win, as they did over the old global sheet. */
156
+ .wp-checkbox {
157
+ display: inline-flex;
158
+ align-items: center;
159
+ gap: 8px;
160
+ min-height: var(--wp-control-h-md);
161
+ color: var(--wp-text);
162
+ font: var(--wp-text-sm) / 1.3 var(--wp-font-sans);
163
+ cursor: pointer;
164
+ user-select: none;
165
+ }
166
+ .wp-checkbox--sm { gap: 6px; min-height: var(--wp-control-h-sm); font-size: var(--wp-text-xs); }
167
+ .wp-checkbox:where(.is-disabled) { cursor: not-allowed; }
168
+ .wp-checkbox:where(.is-disabled) .wp-checkbox__label { opacity: 0.5; }
169
+
170
+ .wp-check {
171
+ appearance: none;
172
+ box-sizing: border-box;
173
+ width: 14px;
174
+ height: 14px;
175
+ flex-shrink: 0;
176
+ display: inline-flex;
177
+ align-items: center;
178
+ justify-content: center;
179
+ padding: 0;
180
+ margin: 0;
181
+ /* A visible edge on the empty box: the hairline border tokens vanish on a dark row. */
182
+ border: 1.5px solid color-mix(in srgb, var(--wp-text-dim) 70%, transparent);
183
+ border-radius: min(3px, var(--wp-radius-cap, 999px));
184
+ background: var(--wp-bg);
185
+ color: transparent;
186
+ font: inherit;
187
+ line-height: 0;
188
+ cursor: pointer;
189
+ transition: background var(--wp-motion-quick), border-color var(--wp-motion-quick), color var(--wp-motion-quick);
190
+ }
191
+ .wp-check svg { width: 9px; height: 9px; display: block; pointer-events: none; }
192
+ .wp-check--sm { width: 12px; height: 12px; border-width: 1px; border-radius: min(2px, var(--wp-radius-cap, 999px)); }
193
+ .wp-check--sm svg { width: 7px; height: 7px; }
194
+ .wp-check:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
195
+ .wp-check:where(:not(:disabled, [aria-checked="true"], [aria-checked="mixed"]):hover) { border-color: var(--wp-accent-500); }
196
+ .wp-check:where([aria-invalid="true"]) { border-color: var(--wp-error-border); }
197
+ /* accent-600 under a white mark: ≥ 4.5:1 in both themes. */
198
+ .wp-check:where([aria-checked="true"], [aria-checked="mixed"]) {
199
+ background: var(--check-on, var(--wp-accent-600));
200
+ border-color: var(--check-on, var(--wp-accent-500));
201
+ color: var(--wp-on-accent);
202
+ }
203
+ /* Disabled: grey, so a ticked disabled box doesn't read as a live one. */
204
+ .wp-check:where(:disabled) {
205
+ cursor: not-allowed;
206
+ background: var(--wp-bg-3);
207
+ border-color: var(--wp-border);
208
+ opacity: 0.6;
209
+ }
210
+ .wp-check:where(:disabled[aria-checked="true"], :disabled[aria-checked="mixed"]) {
211
+ background: var(--wp-text-dim);
212
+ border-color: var(--wp-text-dim);
213
+ color: var(--wp-bg-1);
214
+ }
215
+
216
+ /* Field look: an input-height box in a form grid. The label fills it, so a
217
+ * click anywhere toggles once (the label toggles, the box toggles). */
218
+ .wp-checkbox--field {
219
+ display: flex;
220
+ width: 100%;
221
+ height: var(--wp-control-h-md);
222
+ box-sizing: border-box;
223
+ gap: 0;
224
+ background: var(--wp-bg-2);
225
+ border: 1px solid var(--wp-border);
226
+ border-radius: var(--wp-radius-sm);
227
+ transition: border-color var(--wp-motion-quick);
228
+ }
229
+ .wp-checkbox--field.wp-checkbox--sm { height: var(--wp-control-h-sm); }
230
+ .wp-checkbox--field:hover { border-color: var(--wp-border-strong); }
231
+ .wp-checkbox--field:focus-within { border-color: var(--wp-accent-500); }
232
+ .wp-checkbox--field .wp-check { margin-left: 10px; }
233
+ .wp-checkbox--field .wp-checkbox__label {
234
+ flex: 1;
235
+ align-self: stretch;
236
+ display: flex;
237
+ align-items: center;
238
+ padding: 0 10px 0 8px;
239
+ white-space: nowrap;
240
+ overflow: hidden;
241
+ text-overflow: ellipsis;
242
+ }
243
+
244
+ @media (prefers-reduced-motion: reduce) {
245
+ .wp-check, .wp-checkbox--field { transition: none; }
246
+ }
247
+ </style>
package/src/Chip.vue CHANGED
@@ -94,7 +94,7 @@ function onRemove(e: MouseEvent) {
94
94
  <span v-if="dot" class="wp-chip__dot" aria-hidden="true" />
95
95
  <!-- 10px sub-ICON_SM: the chip font is 10.5px and a 12px icon read
96
96
  oversized next to it. Larger chips get a larger icon. -->
97
- <Icon v-if="icon" class="wp-chip__icon" :name="icon" :size="size === 'xs' ? 10 : 11" />
97
+ <Icon v-if="icon" class="wp-chip__icon" :name="icon" :size="size === 'xs' ? 10 : 12" />
98
98
  <slot />
99
99
  <span v-if="count !== undefined" class="wp-chip__count">{{ count }}</span>
100
100
  <button
@@ -113,7 +113,10 @@ function onRemove(e: MouseEvent) {
113
113
  </template>
114
114
 
115
115
  <style scoped>
116
- /* xs is the base `.wp-chip` in theme/tokens.css (19px). sm and md sit on the
116
+ /* xs is the base `.wp-chip` in theme/tokens.css, held at 20px here: its
117
+ content height was 19.2px, which put every icon half a pixel off. */
118
+ .wp-chip--xs { box-sizing: border-box; height: 20px; }
119
+ /* xs is the base `.wp-chip` in theme/tokens.css. sm and md sit on the
117
120
  control height scale so a filter row lines up with the fields beside it. */
118
121
  .wp-chip--sm, .wp-chip--md {
119
122
  height: var(--wp-control-h-sm);
package/src/CodeBlock.vue CHANGED
@@ -107,7 +107,7 @@ const name = computed(() => props.title ?? props.language ?? "Code");
107
107
  margin: 0; padding: 0; border: 0; border-radius: var(--wp-radius-sm);
108
108
  background: transparent; color: var(--wp-text-muted); cursor: pointer;
109
109
  }
110
- .wp-code__tool .pi { font-size: 11px; }
110
+ .wp-code__tool .pi { font-size: 12px; }
111
111
  .wp-code__tool:hover { background: var(--wp-row-hover); color: var(--wp-text); }
112
112
  .wp-code__tool[aria-pressed="true"] { color: var(--wp-accent-text); background: var(--wp-bg-3); }
113
113
  .wp-code__tool:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
@@ -8,14 +8,20 @@
8
8
  * so it follows the swatch on scroll and closes on Escape or a click
9
9
  * outside. Works on the canvas (`size="sm"`) and in the manager.
10
10
  *
11
+ * `presets` adds a row of preset swatches under the square (`true` for the
12
+ * kit's COLOUR_PRESETS, or a list of hex colours); a click takes one and
13
+ * closes. `compact` shows the swatch alone, for a tight spot (a name row).
14
+ *
11
15
  * <ColorField v-model="labelColor" alpha allow-none size="sm" aria-label="Label band" />
16
+ * <ColorField v-model="color" presets size="md" aria-label="Category colour" />
12
17
  */
13
18
  import { computed, ref, watch } from "vue";
14
19
  import HsvPicker from "./HsvPicker.vue";
15
20
  import Popover from "./Popover.vue";
16
21
  import WpNumber from "./WpNumber.vue";
17
- import WpButton from "./WpButton.vue";
22
+ import Button from "./Button.vue";
18
23
  import { rgbToHex } from "./hsv";
24
+ import { COLOUR_PRESETS } from "./colour-presets";
19
25
 
20
26
  const props = withDefaults(defineProps<{
21
27
  modelValue: string;
@@ -23,11 +29,15 @@ const props = withDefaults(defineProps<{
23
29
  alpha?: boolean | undefined;
24
30
  /** Offer "None" (writes `none`). */
25
31
  allowNone?: boolean | undefined;
32
+ /** Preset swatches: `true` for COLOUR_PRESETS, or your own hex list. */
33
+ presets?: readonly string[] | boolean | undefined;
34
+ /** The swatch alone, without the value beside it. */
35
+ compact?: boolean | undefined;
26
36
  size?: "sm" | "md" | undefined;
27
37
  ariaLabel?: string | undefined;
28
38
  disabled?: boolean | undefined;
29
39
  dataTest?: string | undefined;
30
- }>(), { alpha: false, allowNone: false, size: "sm", ariaLabel: "Colour", disabled: false, dataTest: undefined });
40
+ }>(), { alpha: false, allowNone: false, presets: false, compact: false, size: "sm", ariaLabel: "Colour", disabled: false, dataTest: undefined });
31
41
 
32
42
  const emit = defineEmits<{ "update:modelValue": [value: string] }>();
33
43
 
@@ -79,6 +89,17 @@ function onDraft(ev: Event): void {
79
89
  const p = parse(v);
80
90
  if (p) emit("update:modelValue", compose(p.rgb, props.alpha ? p.alpha : 255));
81
91
  }
92
+ const presetList = computed<readonly string[]>(() => {
93
+ if (props.presets === true) return COLOUR_PRESETS;
94
+ return Array.isArray(props.presets) ? props.presets : [];
95
+ });
96
+ function isActivePreset(preset: string): boolean {
97
+ return !isNone.value && preset.trim().toLowerCase() === rgb.value && alphaValue.value === 255;
98
+ }
99
+ function pickPreset(preset: string): void {
100
+ emit("update:modelValue", preset);
101
+ open.value = false;
102
+ }
82
103
  function pickNone(): void {
83
104
  emit("update:modelValue", "none");
84
105
  open.value = false;
@@ -92,7 +113,7 @@ function pickNone(): void {
92
113
  v-bind="triggerProps"
93
114
  type="button"
94
115
  class="wp-color-field wp-field-box"
95
- :class="[`wp-color-field--${size}`, `wp-field-box--${size}`, { 'is-none': isNone }]"
116
+ :class="[`wp-color-field--${size}`, `wp-field-box--${size}`, { 'is-none': isNone, 'wp-color-field--compact': compact }]"
96
117
  :disabled="disabled"
97
118
  :aria-label="`${ariaLabel}: ${shown}`"
98
119
  :data-test="dataTest"
@@ -100,7 +121,7 @@ function pickNone(): void {
100
121
  <span class="wp-color-field__chip" aria-hidden="true">
101
122
  <span class="wp-color-field__fill" :style="{ background: swatch }" />
102
123
  </span>
103
- <span class="wp-color-field__value" aria-hidden="true">{{ shown }}</span>
124
+ <span v-if="!compact" class="wp-color-field__value" aria-hidden="true">{{ shown }}</span>
104
125
  </button>
105
126
  </template>
106
127
  <div class="wp-color-field__panel" @keydown.stop>
@@ -128,7 +149,21 @@ function pickNone(): void {
128
149
  @update:model-value="onOpacity"
129
150
  />
130
151
  </div>
131
- <WpButton
152
+ <div v-if="presetList.length" class="wp-color-field__presets" role="group" :aria-label="`${ariaLabel} presets`">
153
+ <button
154
+ v-for="preset in presetList"
155
+ :key="preset"
156
+ type="button"
157
+ class="wp-color-field__preset"
158
+ :style="{ background: preset }"
159
+ :title="preset"
160
+ :aria-pressed="isActivePreset(preset)"
161
+ :aria-label="`Use ${preset}`"
162
+ :data-test="dataTest ? `${dataTest}-preset` : undefined"
163
+ @click="pickPreset(preset)"
164
+ />
165
+ </div>
166
+ <Button
132
167
  v-if="allowNone"
133
168
  size="sm"
134
169
  variant="ghost"
@@ -136,7 +171,7 @@ function pickNone(): void {
136
171
  :pressed="isNone"
137
172
  :data-test="dataTest ? `${dataTest}-none` : undefined"
138
173
  @click="pickNone"
139
- >None</WpButton>
174
+ >None</Button>
140
175
  </div>
141
176
  </Popover>
142
177
  </template>
@@ -165,6 +200,8 @@ function pickNone(): void {
165
200
  /* Checks show through a see-through colour. */
166
201
  background: repeating-conic-gradient(var(--wp-bg-4) 0 25%, var(--wp-bg-1) 0 50%) 0 0 / 8px 8px;
167
202
  }
203
+ .wp-color-field--md .wp-color-field__chip { width: 20px; height: 20px; border-radius: min(4px, var(--wp-radius-cap, 999px)); }
204
+ .wp-color-field--compact { padding: 0 3px; }
168
205
  .wp-color-field__fill { position: absolute; inset: 0; }
169
206
  .wp-color-field.is-none .wp-color-field__chip::after {
170
207
  content: "";
@@ -185,4 +222,21 @@ function pickNone(): void {
185
222
  .wp-color-field__row { display: flex; gap: 6px; align-items: center; }
186
223
  .wp-color-field__hex { flex: 1; min-width: 0; height: var(--wp-field-h); padding: 0 6px; font-family: var(--wp-font-mono, monospace); }
187
224
  .wp-color-field__alpha { width: 72px; flex: none; }
225
+ .wp-color-field__presets { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
226
+ .wp-color-field__preset {
227
+ width: 100%;
228
+ aspect-ratio: 1 / 1;
229
+ min-width: 0;
230
+ padding: 0;
231
+ border: 1px solid var(--wp-border);
232
+ border-radius: min(4px, var(--wp-radius-cap, 999px));
233
+ cursor: pointer;
234
+ transition: transform var(--wp-motion-quick), box-shadow var(--wp-motion-quick);
235
+ }
236
+ .wp-color-field__preset:hover { transform: scale(1.08); }
237
+ .wp-color-field__preset:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
238
+ .wp-color-field__preset[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--wp-bg-1), 0 0 0 4px var(--wp-accent-500); }
239
+ @media (prefers-reduced-motion: reduce) {
240
+ .wp-color-field__preset { transition: none; }
241
+ }
188
242
  </style>
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ /** Combine: the template in its own block, its negative under it, and a
3
+ * list of the variables it reads with where each one is used. */
4
+ import { computed, ref } from "vue";
5
+ import TokenizedValue from "./TokenizedValue.vue";
6
+ import Button from "./Button.vue";
7
+ import { readString, readStrings, scanText, type Obj } from "./preview-readers";
8
+
9
+ const props = defineProps<{
10
+ inner: Obj | null;
11
+ refMap: ReadonlyMap<string, string>;
12
+ }>();
13
+
14
+ const PAGE = 6;
15
+
16
+ const template = computed(() => readString(props.inner, "template") ?? "");
17
+ const negative = computed(() => readString(props.inner, "negative") ?? "");
18
+ const output = computed(() => readString(props.inner, "output_var"));
19
+ const tplScan = computed(() => scanText(template.value));
20
+ const negScan = computed(() => scanText(negative.value));
21
+
22
+ interface InputRow {
23
+ name: string;
24
+ where: string;
25
+ }
26
+ const inputs = computed<InputRow[]>(() => {
27
+ const names = [
28
+ ...new Set([
29
+ ...readStrings(props.inner, "input_vars"),
30
+ ...tplScan.value.vars,
31
+ ...negScan.value.vars,
32
+ ...negScan.value.negVars,
33
+ ]),
34
+ ];
35
+ return names.map((name) => {
36
+ const where: string[] = [];
37
+ if (tplScan.value.vars.includes(name)) where.push("template");
38
+ if (negScan.value.vars.includes(name)) where.push("negative");
39
+ if (negScan.value.negVars.includes(name) || tplScan.value.negVars.includes(name))
40
+ where.push("negative (.neg)");
41
+ return { name, where: where.join(", ") || "declared, not used" };
42
+ });
43
+ });
44
+ const showAll = ref(false);
45
+ const shown = computed(() => (showAll.value ? inputs.value : inputs.value.slice(0, PAGE)));
46
+ </script>
47
+
48
+ <template>
49
+ <div class="wp-pv">
50
+ <div class="wp-pv-stats">
51
+ <div class="wp-pv-stat">
52
+ <b>{{ inputs.length }}</b><span>input{{ inputs.length === 1 ? "" : "s" }}</span>
53
+ </div>
54
+ <div
55
+ v-if="tplScan.picks"
56
+ class="wp-pv-stat"
57
+ >
58
+ <b>{{ tplScan.picks }}</b><span>inline pick{{ tplScan.picks === 1 ? "" : "s" }}</span>
59
+ </div>
60
+ <div class="wp-pv-stat">
61
+ <b>{{ negative ? "yes" : "no" }}</b><span>negative</span>
62
+ </div>
63
+ </div>
64
+ <div class="wp-pv-sec">
65
+ Template<template v-if="output">
66
+ → <span class="wp-cb__out">${{ output }}</span>
67
+ </template>
68
+ </div>
69
+ <div
70
+ class="wp-pv-block wp-combine__template"
71
+ data-test="combine-template"
72
+ >
73
+ <TokenizedValue
74
+ :value="template"
75
+ :uuid-to-name="refMap"
76
+ />
77
+ </div>
78
+ <template v-if="negative">
79
+ <div class="wp-pv-sec wp-cb__neglabel">
80
+ Negative
81
+ </div>
82
+ <div
83
+ class="wp-pv-block wp-pv-block--neg"
84
+ data-test="combine-negative"
85
+ >
86
+ <TokenizedValue
87
+ :value="negative"
88
+ :uuid-to-name="refMap"
89
+ />
90
+ </div>
91
+ </template>
92
+ <template v-if="inputs.length">
93
+ <div class="wp-pv-sec">
94
+ Inputs
95
+ </div>
96
+ <table class="wp-pv-table">
97
+ <colgroup>
98
+ <col style="width: 180px">
99
+ <col>
100
+ </colgroup>
101
+ <tbody>
102
+ <tr
103
+ v-for="i in shown"
104
+ :key="i.name"
105
+ data-test="combine-input"
106
+ >
107
+ <td class="wp-pv-var">
108
+ ${{ i.name }}
109
+ </td>
110
+ <td :class="{ 'wp-cb__unused': i.where === 'declared, not used' }">
111
+ {{ i.where }}
112
+ </td>
113
+ </tr>
114
+ </tbody>
115
+ </table>
116
+ <div
117
+ v-if="inputs.length > PAGE"
118
+ class="wp-pv-more"
119
+ >
120
+ <Button
121
+ variant="link"
122
+ size="sm"
123
+ @click="showAll = !showAll"
124
+ >
125
+ {{ showAll ? "Show fewer" : `Show ${inputs.length - PAGE} more` }}
126
+ </Button>
127
+ </div>
128
+ </template>
129
+ <div class="wp-cb__pad" />
130
+ </div>
131
+ </template>
132
+
133
+ <style scoped src="./preview.css"></style>
134
+ <style scoped>
135
+ .wp-cb__out {
136
+ font-family: var(--wp-font-mono);
137
+ color: var(--wp-success);
138
+ text-transform: none;
139
+ letter-spacing: 0;
140
+ }
141
+ .wp-cb__neglabel {
142
+ color: var(--wp-danger-text, var(--wp-danger));
143
+ }
144
+ .wp-cb__unused {
145
+ color: var(--wp-text-dim);
146
+ }
147
+ .wp-cb__pad {
148
+ height: 6px;
149
+ }
150
+ </style>