@dumiflex/wp-kit 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/LICENSE +674 -0
- package/README.md +72 -2
- package/package.json +76 -4
- package/src/Avatar.vue +83 -0
- package/src/Badge.vue +97 -0
- package/src/Banner.vue +118 -0
- package/src/BookmarkButton.vue +27 -0
- package/src/BundleMeta.vue +51 -0
- package/src/Button.vue +103 -0
- package/src/Card.vue +68 -0
- package/src/Checkbox.vue +109 -0
- package/src/Chip.vue +163 -0
- package/src/ChoiceCards.vue +133 -0
- package/src/CloseButton.vue +69 -0
- package/src/CodeBlock.vue +119 -0
- package/src/ColorField.vue +188 -0
- package/src/ColorPicker.vue +201 -0
- package/src/Combobox.vue +331 -0
- package/src/ConfirmDialog.vue +165 -0
- package/src/ConfirmModal.vue +147 -0
- package/src/ConstraintMatrixPreview.vue +708 -0
- package/src/ContextMenu.vue +386 -0
- package/src/CopyButton.vue +156 -0
- package/src/DataTable.vue +292 -0
- package/src/DatePicker.vue +234 -0
- package/src/DescriptionList.vue +73 -0
- package/src/DiffView.vue +68 -0
- package/src/Disclosure.vue +166 -0
- package/src/DragHandle.vue +49 -0
- package/src/Drawer.vue +131 -0
- package/src/DropZone.vue +196 -0
- package/src/DropdownMenu.vue +72 -0
- package/src/EmptyState.vue +111 -0
- package/src/Field.vue +28 -0
- package/src/HsvPicker.vue +208 -0
- package/src/Icon.vue +39 -0
- package/src/IconPicker.vue +209 -0
- package/src/ImageGrid.vue +125 -0
- package/src/InlineEdit.vue +183 -0
- package/src/Input.vue +99 -0
- package/src/Kbd.vue +56 -0
- package/src/Lightbox.vue +173 -0
- package/src/ListFilter.vue +85 -0
- package/src/ListRow.vue +204 -0
- package/src/LoadingMark.vue +175 -0
- package/src/Logo.vue +60 -0
- package/src/Modal.vue +78 -0
- package/src/ModalShell.vue +109 -0
- package/src/Pagination.vue +106 -0
- package/src/Popover.vue +180 -0
- package/src/PostCard.vue +210 -0
- package/src/ProgressBar.vue +90 -0
- package/src/RadioGroup.vue +177 -0
- package/src/RangeSlider.vue +183 -0
- package/src/RelativeDate.vue +35 -0
- package/src/ResizeHandle.vue +143 -0
- package/src/SearchInput.vue +192 -0
- package/src/Select.vue +644 -0
- package/src/Separator.vue +54 -0
- package/src/Skeleton.vue +82 -0
- package/src/Slider.vue +198 -0
- package/src/SortableList.vue +260 -0
- package/src/SplitPane.vue +68 -0
- package/src/StarButton.vue +40 -0
- package/src/Stepper.vue +141 -0
- package/src/SupportLinks.vue +93 -0
- package/src/TagInput.vue +297 -0
- package/src/Textarea.vue +57 -0
- package/src/Timeline.vue +92 -0
- package/src/ToastCard.vue +119 -0
- package/src/ToastHost.vue +40 -0
- package/src/Toggle.vue +36 -0
- package/src/TokenizedValue.vue +310 -0
- package/src/Toolbar.vue +110 -0
- package/src/VirtualList.vue +87 -0
- package/src/VisuallyHidden.vue +29 -0
- package/src/WpButton.vue +75 -0
- package/src/WpCheck.vue +104 -0
- package/src/WpIconButton.vue +107 -0
- package/src/WpImageZoom.vue +544 -0
- package/src/WpNumber.vue +178 -0
- package/src/WpSegmented.vue +175 -0
- package/src/WpSelect.vue +93 -0
- package/src/WpSwitch.vue +163 -0
- package/src/WpTabs.vue +188 -0
- package/src/_modal-motion.css +33 -0
- package/src/_skeleton.css +50 -0
- package/src/axis-color.ts +42 -0
- package/src/button.css +169 -0
- package/src/calendar.ts +59 -0
- package/src/clipboard.ts +50 -0
- package/src/colour-presets.ts +12 -0
- package/src/diff.ts +51 -0
- package/src/drop-zone.ts +41 -0
- package/src/field.css +50 -0
- package/src/flip.ts +237 -0
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -0
- package/src/hsv.ts +100 -0
- package/src/icon-catalog.ts +52 -0
- package/src/image-zoom.ts +93 -0
- package/src/kind-colour.ts +28 -0
- package/src/logo-paths.ts +21 -0
- package/src/matrix-axis.ts +91 -0
- package/src/pagination.ts +27 -0
- package/src/platform-keys.ts +29 -0
- package/src/rich-tokenize.ts +357 -0
- package/src/select-types.ts +32 -0
- package/src/slider-math.ts +45 -0
- package/src/sortable.ts +22 -0
- package/src/theme/fonts/OFL-Fraunces.txt +93 -0
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -0
- package/src/theme/fonts/OFL-Manrope.txt +93 -0
- package/src/theme/fonts/fraunces-latin-wght-normal.woff2 +0 -0
- package/src/theme/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/src/theme/fonts/manrope-latin-wght-normal.woff2 +0 -0
- package/src/theme/looks.css +428 -0
- package/src/theme/palettes.css +254 -0
- package/src/theme/tokens.css +2896 -0
- package/src/tokens.ts +56 -0
- package/src/tooltip.css +43 -0
- package/src/tooltip.ts +146 -0
- package/src/useDialogFocus.ts +73 -0
- package/src/useGrowableField.ts +357 -0
- package/src/useModalKeyShield.ts +116 -0
- package/src/useToast.ts +48 -0
- package/src/wp-logo.svg +14 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import Icon from "./Icon.vue";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
icon: string;
|
|
6
|
+
headline: string;
|
|
7
|
+
body?: string | undefined;
|
|
8
|
+
/** "library" = encouraging first-run; "no-results" = corrective for
|
|
9
|
+
* filter misses; "error" = network/permission failure with retry. */
|
|
10
|
+
variant?: "library" | "no-results" | "error" | undefined;
|
|
11
|
+
/** How much room it takes. `page` fills an empty list page, `panel` (the
|
|
12
|
+
* default) sits in a card or panel at its own height, `inline` is one
|
|
13
|
+
* compact row for a small box. */
|
|
14
|
+
size?: "page" | "panel" | "inline" | undefined;
|
|
15
|
+
}
|
|
16
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
17
|
+
variant: "library",
|
|
18
|
+
size: "panel",
|
|
19
|
+
});
|
|
20
|
+
const ICON_PX = { page: 40, panel: 26, inline: 16 } as const;
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="wp-empty" :class="[`wp-empty--${variant}`, `wp-empty--${props.size}`]" data-test="empty-state">
|
|
25
|
+
<div class="wp-empty__icon">
|
|
26
|
+
<Icon :name="icon" :size="ICON_PX[props.size]" />
|
|
27
|
+
</div>
|
|
28
|
+
<h3 class="wp-empty__headline">{{ headline }}</h3>
|
|
29
|
+
<p v-if="body" class="wp-empty__body">{{ body }}</p>
|
|
30
|
+
<div v-if="$slots.cta" class="wp-empty__cta">
|
|
31
|
+
<slot name="cta" />
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<style scoped>
|
|
37
|
+
.wp-empty {
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: column;
|
|
40
|
+
align-items: center;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
text-align: center;
|
|
43
|
+
padding: var(--wp-space-7) var(--wp-space-6);
|
|
44
|
+
gap: var(--wp-space-4);
|
|
45
|
+
box-sizing: border-box;
|
|
46
|
+
}
|
|
47
|
+
/* Page: an empty list page. The message reads as the page's content, not a
|
|
48
|
+
* line floating at the top of a tall area, but stops well short of the old
|
|
49
|
+
* 60vh that also hit cards and panels. `min-height` keeps the layout flat
|
|
50
|
+
* (no 100% chain through the table), so a full list is untouched. */
|
|
51
|
+
.wp-empty--page {
|
|
52
|
+
min-height: max(40vh, 320px);
|
|
53
|
+
padding: var(--wp-space-8) var(--wp-space-6);
|
|
54
|
+
gap: var(--wp-space-5);
|
|
55
|
+
}
|
|
56
|
+
/* Inline: one compact row for a small box or a list section. */
|
|
57
|
+
.wp-empty--inline {
|
|
58
|
+
flex-direction: row;
|
|
59
|
+
flex-wrap: wrap;
|
|
60
|
+
justify-content: flex-start;
|
|
61
|
+
text-align: left;
|
|
62
|
+
padding: var(--wp-space-4);
|
|
63
|
+
gap: var(--wp-space-3);
|
|
64
|
+
}
|
|
65
|
+
.wp-empty__icon {
|
|
66
|
+
display: inline-flex;
|
|
67
|
+
align-items: center;
|
|
68
|
+
justify-content: center;
|
|
69
|
+
flex-shrink: 0;
|
|
70
|
+
width: 52px;
|
|
71
|
+
height: 52px;
|
|
72
|
+
border-radius: var(--wp-radius-lg);
|
|
73
|
+
background: var(--wp-bg-2);
|
|
74
|
+
color: var(--wp-text-muted);
|
|
75
|
+
}
|
|
76
|
+
.wp-empty--page .wp-empty__icon { width: 80px; height: 80px; border-radius: var(--wp-radius-xl); }
|
|
77
|
+
.wp-empty--inline .wp-empty__icon { width: 28px; height: 28px; border-radius: var(--wp-radius-sm); }
|
|
78
|
+
.wp-empty--no-results .wp-empty__icon {
|
|
79
|
+
background: transparent;
|
|
80
|
+
border: 1px dashed var(--wp-border);
|
|
81
|
+
}
|
|
82
|
+
.wp-empty--error .wp-empty__icon {
|
|
83
|
+
background: color-mix(in oklab, var(--wp-danger) 12%, transparent);
|
|
84
|
+
color: var(--wp-danger);
|
|
85
|
+
border: 1px solid color-mix(in oklab, var(--wp-danger) 30%, transparent);
|
|
86
|
+
}
|
|
87
|
+
.wp-empty__headline {
|
|
88
|
+
margin: 0;
|
|
89
|
+
color: var(--wp-text);
|
|
90
|
+
font-size: var(--wp-text-md);
|
|
91
|
+
line-height: var(--wp-line-md, 1.4);
|
|
92
|
+
font-weight: var(--wp-weight-semibold);
|
|
93
|
+
}
|
|
94
|
+
.wp-empty--page .wp-empty__headline {
|
|
95
|
+
font-size: var(--wp-text-lg);
|
|
96
|
+
line-height: var(--wp-line-lg);
|
|
97
|
+
}
|
|
98
|
+
.wp-empty--inline .wp-empty__headline { font-size: var(--wp-text-base); }
|
|
99
|
+
.wp-empty__body {
|
|
100
|
+
margin: 0;
|
|
101
|
+
font-size: var(--wp-text-sm);
|
|
102
|
+
line-height: var(--wp-line-sm);
|
|
103
|
+
color: var(--wp-text-muted);
|
|
104
|
+
max-width: 360px;
|
|
105
|
+
}
|
|
106
|
+
.wp-empty--inline .wp-empty__body { flex-basis: 100%; max-width: none; padding-left: calc(28px + var(--wp-space-3)); margin-top: calc(var(--wp-space-3) * -1); }
|
|
107
|
+
.wp-empty__cta {
|
|
108
|
+
margin-top: var(--wp-space-2);
|
|
109
|
+
}
|
|
110
|
+
.wp-empty--inline .wp-empty__cta { margin: 0 0 0 auto; }
|
|
111
|
+
</style>
|
package/src/Field.vue
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
label?: string | undefined;
|
|
6
|
+
hint?: string | undefined;
|
|
7
|
+
error?: string | undefined;
|
|
8
|
+
for?: string | undefined;
|
|
9
|
+
required?: boolean | undefined;
|
|
10
|
+
}
|
|
11
|
+
const props = defineProps<Props>();
|
|
12
|
+
|
|
13
|
+
const classes = computed(() => [
|
|
14
|
+
"wp-field",
|
|
15
|
+
props.error && "wp-field--error",
|
|
16
|
+
]);
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<div :class="classes">
|
|
21
|
+
<label v-if="label" :for="props.for" class="wp-field__label">
|
|
22
|
+
{{ label }}<span v-if="required" aria-hidden="true"> *</span>
|
|
23
|
+
</label>
|
|
24
|
+
<slot :ariaInvalid="!!error" />
|
|
25
|
+
<div v-if="error" class="wp-field__error" role="alert">{{ error }}</div>
|
|
26
|
+
<div v-else-if="hint" class="wp-field__hint">{{ hint }}</div>
|
|
27
|
+
</div>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* HsvPicker — saturation/value square + hue slider.
|
|
4
|
+
*
|
|
5
|
+
* Shared between manager ColorPicker / TweaksPanel AND the canvas
|
|
6
|
+
* bundle modal. CSS uses `--wp-*` tokens with hardcoded fallbacks so
|
|
7
|
+
* the picker renders inside the extension where the manager spacing
|
|
8
|
+
* tokens (`--wp-space-*`) aren't loaded.
|
|
9
|
+
*
|
|
10
|
+
* Pure CSS gradient render — no canvas. The SV square stacks three
|
|
11
|
+
* gradients to produce the HSV picker visual; the hue strip is a
|
|
12
|
+
* single multi-stop linear gradient across the spectrum.
|
|
13
|
+
*
|
|
14
|
+
* Drag is driven by document-level `pointermove` + `pointerup`
|
|
15
|
+
* listeners attached on pointerdown and detached on release. The
|
|
16
|
+
* earlier `setPointerCapture`-based approach locked up the hue
|
|
17
|
+
* slider when the SV picker reached an edge: in certain hosts
|
|
18
|
+
* (notably the canvas Teleport popover), capture wasn't always
|
|
19
|
+
* released cleanly, so subsequent pointerdown events on the hue
|
|
20
|
+
* slider got rerouted back to the SV element and the slider read
|
|
21
|
+
* as "stuck". Document-level handlers don't depend on element
|
|
22
|
+
* capture and always cleanly tear down on pointerup, even when the
|
|
23
|
+
* release happens outside the picker.
|
|
24
|
+
*/
|
|
25
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
26
|
+
import { hexToHsv, hsvToHex, type Hsv } from "./hsv";
|
|
27
|
+
|
|
28
|
+
interface Props {
|
|
29
|
+
modelValue: string;
|
|
30
|
+
ariaLabel?: string | undefined;
|
|
31
|
+
}
|
|
32
|
+
const props = defineProps<Props>();
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
(e: "update:modelValue", value: string): void;
|
|
36
|
+
}>();
|
|
37
|
+
|
|
38
|
+
const hsv = ref<Hsv>({ h: 0, s: 1, v: 1 });
|
|
39
|
+
|
|
40
|
+
function syncFromProp(hex: string) {
|
|
41
|
+
const parsed = hexToHsv(hex);
|
|
42
|
+
if (!parsed) return;
|
|
43
|
+
// Preserve the local hue when incoming RGB is pure black/white —
|
|
44
|
+
// otherwise the user's hue selection snaps to 0 every time s or v
|
|
45
|
+
// hits 0, which is jarring during a vertical drag.
|
|
46
|
+
if (parsed.s === 0) parsed.h = hsv.value.h;
|
|
47
|
+
hsv.value = parsed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
watch(() => props.modelValue, syncFromProp, { immediate: true });
|
|
51
|
+
|
|
52
|
+
function emitHex() {
|
|
53
|
+
emit("update:modelValue", hsvToHex(hsv.value));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hueBg = computed(() => `hsl(${hsv.value.h}, 100%, 50%)`);
|
|
57
|
+
|
|
58
|
+
const svEl = ref<HTMLDivElement | null>(null);
|
|
59
|
+
const hueEl = ref<HTMLDivElement | null>(null);
|
|
60
|
+
|
|
61
|
+
/** Document-level drag state. Only one drag can be active at a time
|
|
62
|
+
* (one mouse / one finger), so a single `activeDrag` ref tracking
|
|
63
|
+
* which surface owns the current pointer is sufficient. */
|
|
64
|
+
let activeDrag: "sv" | "hue" | null = null;
|
|
65
|
+
|
|
66
|
+
function handleSvMove(evt: PointerEvent) {
|
|
67
|
+
const el = svEl.value;
|
|
68
|
+
if (!el) return;
|
|
69
|
+
const rect = el.getBoundingClientRect();
|
|
70
|
+
const x = Math.max(0, Math.min(rect.width, evt.clientX - rect.left));
|
|
71
|
+
const y = Math.max(0, Math.min(rect.height, evt.clientY - rect.top));
|
|
72
|
+
hsv.value = { h: hsv.value.h, s: x / rect.width, v: 1 - (y / rect.height) };
|
|
73
|
+
emitHex();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleHueMove(evt: PointerEvent) {
|
|
77
|
+
const el = hueEl.value;
|
|
78
|
+
if (!el) return;
|
|
79
|
+
const rect = el.getBoundingClientRect();
|
|
80
|
+
const x = Math.max(0, Math.min(rect.width, evt.clientX - rect.left));
|
|
81
|
+
hsv.value = { h: (x / rect.width) * 360, s: hsv.value.s, v: hsv.value.v };
|
|
82
|
+
emitHex();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function onDocPointerMove(evt: PointerEvent) {
|
|
86
|
+
if (activeDrag === "sv") handleSvMove(evt);
|
|
87
|
+
else if (activeDrag === "hue") handleHueMove(evt);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onDocPointerUp() {
|
|
91
|
+
activeDrag = null;
|
|
92
|
+
document.removeEventListener("pointermove", onDocPointerMove);
|
|
93
|
+
document.removeEventListener("pointerup", onDocPointerUp);
|
|
94
|
+
document.removeEventListener("pointercancel", onDocPointerUp);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function startDrag(kind: "sv" | "hue", evt: PointerEvent) {
|
|
98
|
+
activeDrag = kind;
|
|
99
|
+
if (kind === "sv") handleSvMove(evt);
|
|
100
|
+
else handleHueMove(evt);
|
|
101
|
+
document.addEventListener("pointermove", onDocPointerMove);
|
|
102
|
+
document.addEventListener("pointerup", onDocPointerUp);
|
|
103
|
+
document.addEventListener("pointercancel", onDocPointerUp);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function onSvPointerDown(evt: PointerEvent) {
|
|
107
|
+
evt.preventDefault();
|
|
108
|
+
startDrag("sv", evt);
|
|
109
|
+
}
|
|
110
|
+
function onHuePointerDown(evt: PointerEvent) {
|
|
111
|
+
evt.preventDefault();
|
|
112
|
+
startDrag("hue", evt);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
onBeforeUnmount(onDocPointerUp);
|
|
116
|
+
|
|
117
|
+
const svCursorStyle = computed(() => ({
|
|
118
|
+
left: `${hsv.value.s * 100}%`,
|
|
119
|
+
top: `${(1 - hsv.value.v) * 100}%`,
|
|
120
|
+
}));
|
|
121
|
+
const hueCursorStyle = computed(() => ({
|
|
122
|
+
left: `${(hsv.value.h / 360) * 100}%`,
|
|
123
|
+
}));
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<template>
|
|
127
|
+
<div class="wp-hsv-picker" :aria-label="ariaLabel">
|
|
128
|
+
<div
|
|
129
|
+
ref="svEl"
|
|
130
|
+
class="wp-hsv-picker__sv"
|
|
131
|
+
:style="{ '--wp-hsv-hue': hueBg }"
|
|
132
|
+
:aria-label="`Saturation and brightness, current ${Math.round(hsv.s * 100)}% / ${Math.round(hsv.v * 100)}%`"
|
|
133
|
+
data-test="hsv-sv"
|
|
134
|
+
@pointerdown="onSvPointerDown"
|
|
135
|
+
>
|
|
136
|
+
<div class="wp-hsv-picker__sv-cursor" :style="svCursorStyle" />
|
|
137
|
+
</div>
|
|
138
|
+
<div
|
|
139
|
+
ref="hueEl"
|
|
140
|
+
class="wp-hsv-picker__hue"
|
|
141
|
+
:aria-label="`Hue, current ${Math.round(hsv.h)}°`"
|
|
142
|
+
data-test="hsv-hue"
|
|
143
|
+
@pointerdown="onHuePointerDown"
|
|
144
|
+
>
|
|
145
|
+
<div class="wp-hsv-picker__hue-cursor" :style="hueCursorStyle" />
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<style scoped>
|
|
151
|
+
.wp-hsv-picker { display: flex; flex-direction: column; gap: var(--wp-space-4, 8px); width: 100%; }
|
|
152
|
+
.wp-hsv-picker__sv {
|
|
153
|
+
position: relative;
|
|
154
|
+
width: 100%;
|
|
155
|
+
height: 120px;
|
|
156
|
+
border-radius: var(--wp-radius-sm, 4px);
|
|
157
|
+
border: 1px solid var(--wp-border, #3a3a3a);
|
|
158
|
+
cursor: crosshair;
|
|
159
|
+
touch-action: none;
|
|
160
|
+
user-select: none;
|
|
161
|
+
background:
|
|
162
|
+
linear-gradient(to top, #000, transparent),
|
|
163
|
+
linear-gradient(to right, #fff, transparent),
|
|
164
|
+
var(--wp-hsv-hue, hsl(0, 100%, 50%));
|
|
165
|
+
}
|
|
166
|
+
.wp-hsv-picker__sv-cursor {
|
|
167
|
+
position: absolute;
|
|
168
|
+
width: 14px;
|
|
169
|
+
height: 14px;
|
|
170
|
+
border-radius: 50%; /* round: picker cursor on the colour field */
|
|
171
|
+
border: 2px solid #fff;
|
|
172
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 1px 2px rgba(0, 0, 0, 0.45);
|
|
173
|
+
transform: translate(-50%, -50%);
|
|
174
|
+
pointer-events: none;
|
|
175
|
+
}
|
|
176
|
+
.wp-hsv-picker__hue {
|
|
177
|
+
position: relative;
|
|
178
|
+
width: 100%;
|
|
179
|
+
height: 12px;
|
|
180
|
+
border-radius: var(--wp-radius-sm, 4px);
|
|
181
|
+
border: 1px solid var(--wp-border, #3a3a3a);
|
|
182
|
+
cursor: ew-resize;
|
|
183
|
+
touch-action: none;
|
|
184
|
+
user-select: none;
|
|
185
|
+
background: linear-gradient(
|
|
186
|
+
to right,
|
|
187
|
+
hsl(0, 100%, 50%),
|
|
188
|
+
hsl(60, 100%, 50%),
|
|
189
|
+
hsl(120, 100%, 50%),
|
|
190
|
+
hsl(180, 100%, 50%),
|
|
191
|
+
hsl(240, 100%, 50%),
|
|
192
|
+
hsl(300, 100%, 50%),
|
|
193
|
+
hsl(360, 100%, 50%)
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
.wp-hsv-picker__hue-cursor {
|
|
197
|
+
position: absolute;
|
|
198
|
+
top: 50%;
|
|
199
|
+
width: 12px;
|
|
200
|
+
height: 16px;
|
|
201
|
+
border-radius: var(--wp-radius-sm, 4px);
|
|
202
|
+
border: 2px solid #fff;
|
|
203
|
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 1px 2px rgba(0, 0, 0, 0.45);
|
|
204
|
+
transform: translate(-50%, -50%);
|
|
205
|
+
pointer-events: none;
|
|
206
|
+
background: transparent;
|
|
207
|
+
}
|
|
208
|
+
</style>
|
package/src/Icon.vue
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Canonical icon sizes. Use these instead of raw numbers on `:size`.
|
|
4
|
+
*
|
|
5
|
+
* ICON_SM (12) — micro indicators, badge glyphs, tight pill icons
|
|
6
|
+
* ICON_MD (14) — default toolbar / sidebar / inline icons
|
|
7
|
+
* ICON_LG (18) — emphasized icons (CTA buttons, empty-state subheadings,
|
|
8
|
+
* panel-header glyphs)
|
|
9
|
+
*
|
|
10
|
+
* Outliers (e.g. very large empty-state icons at 48-80px) stay as raw
|
|
11
|
+
* numbers — flag the locations in PR review so we can decide whether
|
|
12
|
+
* to grow the scale.
|
|
13
|
+
*/
|
|
14
|
+
export const ICON_SM = 12;
|
|
15
|
+
export const ICON_MD = 14;
|
|
16
|
+
export const ICON_LG = 18;
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
import { computed } from "vue";
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
name: string;
|
|
24
|
+
size?: number | undefined;
|
|
25
|
+
}
|
|
26
|
+
const props = defineProps<Props>();
|
|
27
|
+
|
|
28
|
+
const className = computed(() => {
|
|
29
|
+
// Accept either bare name ("plus", "check-circle") or already-prefixed ("pi-plus").
|
|
30
|
+
const n = props.name.startsWith("pi-") ? props.name : `pi-${props.name}`;
|
|
31
|
+
return `pi ${n}`;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const style = computed(() => (props.size ? { fontSize: `${props.size}px` } : undefined));
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<i :class="className" :style="style" aria-hidden="true" />
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* IconPicker — pick one PrimeIcons glyph, or none.
|
|
4
|
+
*
|
|
5
|
+
* The `icon` column has existed on categories end-to-end (engine column,
|
|
6
|
+
* `wp_api/categories.py` `_UPDATABLE_FIELDS`, `CategoryRow.icon`) with no UI
|
|
7
|
+
* to set it, so every category rendered with the same generic bookmark.
|
|
8
|
+
*
|
|
9
|
+
* The offered glyphs live in `./icon-catalog`, which is verified against the
|
|
10
|
+
* installed `primeicons.css` by a test — an unknown name renders as an empty
|
|
11
|
+
* cell rather than failing, so nothing else would catch a typo.
|
|
12
|
+
*
|
|
13
|
+
* The glyph name is stored WITHOUT the `pi pi-` prefix (e.g. `"user"`), which
|
|
14
|
+
* is what the engine column already holds and what `Icon.vue` expects.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, ref } from "vue";
|
|
17
|
+
import Popover from "./Popover.vue";
|
|
18
|
+
import { ICON_GROUPS } from "./icon-catalog";
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(
|
|
21
|
+
defineProps<{
|
|
22
|
+
/** Current glyph name, unprefixed (`"user"`). Empty / null = none. */
|
|
23
|
+
modelValue: string | null;
|
|
24
|
+
ariaLabel?: string | undefined;
|
|
25
|
+
dataTest?: string | undefined;
|
|
26
|
+
/** Icon only, no name beside it, for a tight table cell. */
|
|
27
|
+
compact?: boolean | undefined;
|
|
28
|
+
}>(),
|
|
29
|
+
{ ariaLabel: "Pick an icon", dataTest: undefined, compact: false },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{ "update:modelValue": [v: string | null] }>();
|
|
33
|
+
|
|
34
|
+
const open = ref(false);
|
|
35
|
+
const current = computed<string | null>(() => props.modelValue || null);
|
|
36
|
+
|
|
37
|
+
/** "arrow-right" → "Arrow right": the closed picker says which icon it holds. */
|
|
38
|
+
const currentName = computed(() => {
|
|
39
|
+
const n = current.value;
|
|
40
|
+
if (!n) return "Choose icon";
|
|
41
|
+
const words = n.replace(/-/g, " ");
|
|
42
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function pick(name: string | null): void {
|
|
46
|
+
emit("update:modelValue", name);
|
|
47
|
+
open.value = false;
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<!-- On the kit Popover: it follows the trigger on scroll, flips to stay on
|
|
53
|
+
screen, and closes on Escape or a click outside with focus back on
|
|
54
|
+
the trigger. -->
|
|
55
|
+
<Popover v-model:open="open" :label="ariaLabel" role="listbox">
|
|
56
|
+
<template #trigger="{ props: triggerProps }">
|
|
57
|
+
<button
|
|
58
|
+
v-bind="triggerProps"
|
|
59
|
+
type="button"
|
|
60
|
+
class="wp-iconpick__trigger"
|
|
61
|
+
:class="{ 'wp-iconpick__trigger--compact': compact }"
|
|
62
|
+
:aria-label="`${ariaLabel}: ${current ? currentName : 'none'}`"
|
|
63
|
+
:data-test="dataTest"
|
|
64
|
+
>
|
|
65
|
+
<!-- Both states share one fixed-size slot: an em-dash and a glyph have
|
|
66
|
+
different intrinsic widths and baselines, so without it the trigger
|
|
67
|
+
resized and the mark shifted the moment an icon was picked. -->
|
|
68
|
+
<span class="wp-iconpick__slot" aria-hidden="true">
|
|
69
|
+
<i v-if="current" :class="`pi pi-${current}`" />
|
|
70
|
+
<span v-else class="wp-iconpick__none">—</span>
|
|
71
|
+
</span>
|
|
72
|
+
<span
|
|
73
|
+
v-if="!compact"
|
|
74
|
+
class="wp-iconpick__name"
|
|
75
|
+
:class="{ 'wp-iconpick__name--empty': !current }"
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
>{{ currentName }}</span>
|
|
78
|
+
<i class="pi pi-chevron-down wp-iconpick__caret" aria-hidden="true" />
|
|
79
|
+
</button>
|
|
80
|
+
</template>
|
|
81
|
+
<div class="wp-iconpick__panel" :data-test="dataTest ? `${dataTest}-panel` : undefined">
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
class="wp-iconpick__clear"
|
|
85
|
+
data-test="icon-picker-none"
|
|
86
|
+
@click="pick(null)"
|
|
87
|
+
>No icon</button>
|
|
88
|
+
<div v-for="group in ICON_GROUPS" :key="group.label" class="wp-iconpick__group">
|
|
89
|
+
<div class="wp-iconpick__group-label">{{ group.label }}</div>
|
|
90
|
+
<div class="wp-iconpick__grid">
|
|
91
|
+
<button
|
|
92
|
+
v-for="name in group.icons"
|
|
93
|
+
:key="name"
|
|
94
|
+
type="button"
|
|
95
|
+
class="wp-iconpick__cell"
|
|
96
|
+
:class="{ 'wp-iconpick__cell--on': name === current }"
|
|
97
|
+
role="option"
|
|
98
|
+
:aria-selected="name === current"
|
|
99
|
+
:title="name"
|
|
100
|
+
:aria-label="name"
|
|
101
|
+
:data-test="`icon-picker-${name}`"
|
|
102
|
+
@click="pick(name)"
|
|
103
|
+
><i :class="`pi pi-${name}`" aria-hidden="true" /></button>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</Popover>
|
|
108
|
+
</template>
|
|
109
|
+
|
|
110
|
+
<style scoped>
|
|
111
|
+
.wp-iconpick__trigger {
|
|
112
|
+
display: inline-flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: var(--wp-space-2);
|
|
115
|
+
height: var(--wp-control-h-md, 32px);
|
|
116
|
+
min-width: 140px;
|
|
117
|
+
max-width: 220px;
|
|
118
|
+
padding: 0 var(--wp-space-4) 0 var(--wp-space-3);
|
|
119
|
+
background: var(--wp-bg-2);
|
|
120
|
+
border: 1px solid var(--wp-border);
|
|
121
|
+
border-radius: var(--wp-radius-sm);
|
|
122
|
+
font: inherit;
|
|
123
|
+
font-size: 12.5px;
|
|
124
|
+
color: var(--wp-text);
|
|
125
|
+
cursor: pointer;
|
|
126
|
+
}
|
|
127
|
+
.wp-iconpick__trigger:hover { border-color: var(--wp-border-strong); }
|
|
128
|
+
.wp-iconpick__trigger:focus-visible { outline: none; border-color: var(--wp-accent-500); box-shadow: var(--wp-focus-ring); }
|
|
129
|
+
.wp-iconpick__trigger--compact { min-width: 0; padding: 0 var(--wp-space-3); }
|
|
130
|
+
.wp-iconpick__name {
|
|
131
|
+
flex: 1; min-width: 0; text-align: left;
|
|
132
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
133
|
+
}
|
|
134
|
+
.wp-iconpick__name--empty { color: var(--wp-text-dim); }
|
|
135
|
+
/* Fixed square so the glyph is centred in it regardless of the glyph's own
|
|
136
|
+
* advance width, and so swapping "no icon" for an icon doesn't reflow. */
|
|
137
|
+
.wp-iconpick__slot {
|
|
138
|
+
display: inline-flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
justify-content: center;
|
|
141
|
+
width: 16px;
|
|
142
|
+
height: 16px;
|
|
143
|
+
font-size: 14px;
|
|
144
|
+
line-height: 1;
|
|
145
|
+
}
|
|
146
|
+
.wp-iconpick__none { color: var(--wp-text-dim); line-height: 1; }
|
|
147
|
+
.wp-iconpick__caret { font-size: 9px; color: var(--wp-text-dim); line-height: 1; }
|
|
148
|
+
</style>
|
|
149
|
+
|
|
150
|
+
<style>
|
|
151
|
+
/* Unscoped: the panel is teleported to <body> by Popover, so a scoped selector's
|
|
152
|
+
`[data-v-…]` attribute would never match it. Class names are
|
|
153
|
+
component-specific (`wp-iconpick*`), so global is safe. */
|
|
154
|
+
/* Lives inside the kit Popover's panel, which gives the frame and shadow. */
|
|
155
|
+
.wp-iconpick__panel {
|
|
156
|
+
width: 212px;
|
|
157
|
+
max-height: 276px;
|
|
158
|
+
overflow-y: auto;
|
|
159
|
+
}
|
|
160
|
+
.wp-iconpick__clear {
|
|
161
|
+
width: 100%;
|
|
162
|
+
margin-bottom: var(--wp-space-3);
|
|
163
|
+
padding: var(--wp-space-2);
|
|
164
|
+
background: transparent;
|
|
165
|
+
border: 1px dashed var(--wp-border);
|
|
166
|
+
border-radius: var(--wp-radius-sm);
|
|
167
|
+
color: var(--wp-text-muted);
|
|
168
|
+
font-size: var(--wp-text-xs);
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
}
|
|
171
|
+
.wp-iconpick__clear:hover { color: var(--wp-text); border-color: var(--wp-accent); }
|
|
172
|
+
.wp-iconpick__group + .wp-iconpick__group { margin-top: var(--wp-space-3); }
|
|
173
|
+
.wp-iconpick__group-label {
|
|
174
|
+
font-size: 9.5px;
|
|
175
|
+
font-weight: 700;
|
|
176
|
+
letter-spacing: 0.08em;
|
|
177
|
+
text-transform: uppercase;
|
|
178
|
+
color: var(--wp-text-dim);
|
|
179
|
+
margin-bottom: var(--wp-space-2);
|
|
180
|
+
}
|
|
181
|
+
.wp-iconpick__grid {
|
|
182
|
+
display: grid;
|
|
183
|
+
grid-template-columns: repeat(8, 1fr);
|
|
184
|
+
gap: 2px;
|
|
185
|
+
}
|
|
186
|
+
.wp-iconpick__cell {
|
|
187
|
+
display: flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
justify-content: center;
|
|
190
|
+
aspect-ratio: 1;
|
|
191
|
+
background: transparent;
|
|
192
|
+
border: 1px solid transparent;
|
|
193
|
+
border-radius: var(--wp-radius-sm);
|
|
194
|
+
color: var(--wp-text-muted);
|
|
195
|
+
cursor: pointer;
|
|
196
|
+
font-size: 13px;
|
|
197
|
+
}
|
|
198
|
+
/* Kill the icon font's own line box — it otherwise pushes the glyph off the
|
|
199
|
+
* cell's vertical centre even though the cell is a centred flex container. */
|
|
200
|
+
.wp-iconpick__cell i { line-height: 1; }
|
|
201
|
+
.wp-iconpick__cell:hover {
|
|
202
|
+
background: color-mix(in oklab, var(--wp-accent) 18%, transparent);
|
|
203
|
+
color: var(--wp-text);
|
|
204
|
+
}
|
|
205
|
+
.wp-iconpick__cell--on {
|
|
206
|
+
border-color: var(--wp-accent);
|
|
207
|
+
color: var(--wp-accent);
|
|
208
|
+
}
|
|
209
|
+
</style>
|