@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/Skeleton.vue
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Skeleton: the grey shape of content that is still loading, so the layout
|
|
4
|
+
* doesn't jump when it lands. Use it where the shape is known (a list of
|
|
5
|
+
* rows, a card, a line of text); a whole page or panel that is loading uses
|
|
6
|
+
* `LoadingMark block` instead. Works on the canvas and in the manager.
|
|
7
|
+
*
|
|
8
|
+
* <Skeleton shape="row" :lines="4" label="Loading modules" />
|
|
9
|
+
* <Skeleton shape="line" :lines="3" />
|
|
10
|
+
* <Skeleton shape="block" height="120px" />
|
|
11
|
+
*
|
|
12
|
+
* The shapes are hidden from screen readers; `label` adds one polite
|
|
13
|
+
* "Loading…" line for them instead. The sweep stops under reduced motion.
|
|
14
|
+
*/
|
|
15
|
+
import { computed } from "vue";
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(defineProps<{
|
|
18
|
+
/** line: text lines (the last one shorter). block: a box. circle: an avatar or icon. row: an icon, a title and a hint line, like a list row. */
|
|
19
|
+
shape?: "line" | "block" | "circle" | "row" | undefined;
|
|
20
|
+
/** How many lines or rows. */
|
|
21
|
+
lines?: number | undefined;
|
|
22
|
+
/** CSS width of a line, block or circle. */
|
|
23
|
+
width?: string | undefined;
|
|
24
|
+
/** CSS height of a block or circle. */
|
|
25
|
+
height?: string | undefined;
|
|
26
|
+
/** Say "Loading…" to screen readers with this text. */
|
|
27
|
+
label?: string | undefined;
|
|
28
|
+
}>(), { shape: "line", lines: 1, width: undefined, height: undefined, label: undefined });
|
|
29
|
+
|
|
30
|
+
const count = computed(() => Math.max(1, Math.floor(props.lines)));
|
|
31
|
+
/** The last of several lines is shorter, like the end of a paragraph. */
|
|
32
|
+
const lineWidth = (i: number): string | undefined =>
|
|
33
|
+
props.width ?? (count.value > 1 && i === count.value - 1 ? "60%" : undefined);
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div class="wp-skeleton" :data-shape="shape" :role="label ? 'status' : undefined">
|
|
38
|
+
<span v-if="label" class="wp-skeleton__sr">{{ label }}…</span>
|
|
39
|
+
<template v-if="shape === 'line'">
|
|
40
|
+
<span v-for="i in count" :key="i" class="wp-skeleton__bone wp-skeleton__line" :style="{ width: lineWidth(i - 1) }" aria-hidden="true" />
|
|
41
|
+
</template>
|
|
42
|
+
<span v-else-if="shape === 'block'" class="wp-skeleton__bone wp-skeleton__block" :style="{ width, height }" aria-hidden="true" />
|
|
43
|
+
<span v-else-if="shape === 'circle'" class="wp-skeleton__bone wp-skeleton__circle" :style="{ width: width ?? height, height: height ?? width }" aria-hidden="true" />
|
|
44
|
+
<template v-else>
|
|
45
|
+
<span v-for="i in count" :key="i" class="wp-skeleton__row" aria-hidden="true">
|
|
46
|
+
<span class="wp-skeleton__bone wp-skeleton__circle" />
|
|
47
|
+
<span class="wp-skeleton__row-text">
|
|
48
|
+
<span class="wp-skeleton__bone wp-skeleton__line" :style="{ width: i % 2 ? '55%' : '42%' }" />
|
|
49
|
+
<span class="wp-skeleton__bone wp-skeleton__line wp-skeleton__line--hint" :style="{ width: i % 2 ? '80%' : '68%' }" />
|
|
50
|
+
</span>
|
|
51
|
+
</span>
|
|
52
|
+
</template>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
|
55
|
+
|
|
56
|
+
<style scoped>
|
|
57
|
+
.wp-skeleton { display: flex; flex-direction: column; gap: var(--wp-space-3); min-width: 0; width: 100%; }
|
|
58
|
+
.wp-skeleton[data-shape="circle"], .wp-skeleton[data-shape="block"] { width: auto; }
|
|
59
|
+
.wp-skeleton__bone {
|
|
60
|
+
position: relative; display: block; overflow: hidden;
|
|
61
|
+
border-radius: var(--wp-radius-sm); background: var(--wp-bg-3);
|
|
62
|
+
}
|
|
63
|
+
.wp-skeleton__bone::after {
|
|
64
|
+
content: ""; position: absolute; inset: 0; transform: translateX(-100%);
|
|
65
|
+
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--wp-text) 8%, transparent), transparent);
|
|
66
|
+
animation: wp-skeleton-sweep var(--wp-motion-shimmer) ease-in-out infinite;
|
|
67
|
+
}
|
|
68
|
+
.wp-skeleton__line { width: 100%; height: 0.75em; }
|
|
69
|
+
.wp-skeleton__line--hint { height: 0.6em; opacity: 0.7; }
|
|
70
|
+
.wp-skeleton__block { width: 100%; height: 64px; border-radius: var(--wp-radius); }
|
|
71
|
+
.wp-skeleton__circle { flex: none; width: 28px; height: 28px; border-radius: 50%; /* round: stands in for an avatar */ }
|
|
72
|
+
.wp-skeleton__row { display: flex; align-items: center; gap: var(--wp-space-4); padding: var(--wp-space-2) 0; }
|
|
73
|
+
.wp-skeleton__row-text { display: flex; flex-direction: column; gap: var(--wp-space-2); flex: 1; min-width: 0; }
|
|
74
|
+
.wp-skeleton__sr {
|
|
75
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
76
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
77
|
+
}
|
|
78
|
+
@keyframes wp-skeleton-sweep { to { transform: translateX(100%); } }
|
|
79
|
+
@media (prefers-reduced-motion: reduce) {
|
|
80
|
+
.wp-skeleton__bone::after { animation: none; display: none; }
|
|
81
|
+
}
|
|
82
|
+
</style>
|
package/src/Slider.vue
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Slider: a number picked by dragging along a range (a weight, a threshold,
|
|
4
|
+
* a size). Use it when the feel of "more or less" matters more than the
|
|
5
|
+
* exact number; add `with-input` to type the exact number too. Works on the
|
|
6
|
+
* canvas (`size="sm"`) and in the manager (`md`).
|
|
7
|
+
*
|
|
8
|
+
* <Slider v-model="weight" :min="0" :max="2" :step="0.05" aria-label="Weight" with-input />
|
|
9
|
+
* <Slider v-model="size" :min="4" :max="160" :marks="[{ value: 32, label: '32' }]" :format="(v) => `${v} px`" />
|
|
10
|
+
*
|
|
11
|
+
* APG slider: the thumb is the focus stop; arrows move one step, PageUp /
|
|
12
|
+
* PageDown ten, Home / End jump to the ends, and `format` gives screen
|
|
13
|
+
* readers the value as words ("32 px"). Clicking the track jumps there and
|
|
14
|
+
* the drag carries on from it.
|
|
15
|
+
*/
|
|
16
|
+
import { computed, ref } from "vue";
|
|
17
|
+
import { keyStep, percentOf, snap, valueAt } from "./slider-math";
|
|
18
|
+
import WpNumber from "./WpNumber.vue";
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<{
|
|
21
|
+
modelValue: number;
|
|
22
|
+
min?: number | undefined;
|
|
23
|
+
max?: number | undefined;
|
|
24
|
+
step?: number | undefined;
|
|
25
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
26
|
+
disabled?: boolean | undefined;
|
|
27
|
+
ariaLabel?: string | undefined;
|
|
28
|
+
/** Labelled ticks under the track. */
|
|
29
|
+
marks?: { value: number; label?: string }[] | undefined;
|
|
30
|
+
/** How the value reads (the tooltip-free label and screen readers). */
|
|
31
|
+
format?: ((v: number) => string) | undefined;
|
|
32
|
+
/** Show the value after the track. */
|
|
33
|
+
showValue?: boolean | undefined;
|
|
34
|
+
/** Put a WpNumber after the track to type the exact value. */
|
|
35
|
+
withInput?: boolean | undefined;
|
|
36
|
+
}>(), { min: 0, max: 100, step: 1, size: "md", ariaLabel: undefined, marks: undefined });
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{ (e: "update:modelValue", v: number): void; (e: "change", v: number): void }>();
|
|
39
|
+
|
|
40
|
+
const trackEl = ref<HTMLElement | null>(null);
|
|
41
|
+
const dragging = ref(false);
|
|
42
|
+
|
|
43
|
+
const value = computed(() => snap(props.modelValue, props.min, props.max, props.step));
|
|
44
|
+
const pct = computed(() => percentOf(value.value, props.min, props.max));
|
|
45
|
+
const text = computed(() => (props.format ? props.format(value.value) : String(value.value)));
|
|
46
|
+
|
|
47
|
+
function set(v: number): void {
|
|
48
|
+
if (props.disabled) return;
|
|
49
|
+
if (v !== props.modelValue) emit("update:modelValue", v);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fromPointer(ev: PointerEvent): number {
|
|
53
|
+
const r = trackEl.value?.getBoundingClientRect();
|
|
54
|
+
if (!r) return value.value;
|
|
55
|
+
return valueAt(ev.clientX, r.left, r.width, props.min, props.max, props.step);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onPointerDown(ev: PointerEvent): void {
|
|
59
|
+
if (props.disabled || ev.button !== 0) return;
|
|
60
|
+
ev.preventDefault();
|
|
61
|
+
dragging.value = true;
|
|
62
|
+
(ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
|
|
63
|
+
set(fromPointer(ev));
|
|
64
|
+
(trackEl.value?.querySelector("[role=slider]") as HTMLElement | null)?.focus();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onPointerMove(ev: PointerEvent): void {
|
|
68
|
+
if (dragging.value) set(fromPointer(ev));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function onPointerUp(ev: PointerEvent): void {
|
|
72
|
+
if (!dragging.value) return;
|
|
73
|
+
dragging.value = false;
|
|
74
|
+
(ev.currentTarget as HTMLElement).releasePointerCapture?.(ev.pointerId);
|
|
75
|
+
emit("change", value.value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
79
|
+
if (props.disabled) return;
|
|
80
|
+
const next = keyStep(ev.key, value.value, props.min, props.max, props.step);
|
|
81
|
+
if (next === null) return;
|
|
82
|
+
ev.preventDefault();
|
|
83
|
+
set(next);
|
|
84
|
+
emit("change", next);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function onTyped(v: number | null): void {
|
|
88
|
+
if (v === null) return;
|
|
89
|
+
const next = snap(v, props.min, props.max, props.step);
|
|
90
|
+
set(next);
|
|
91
|
+
emit("change", next);
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<div class="wp-slider" :data-size="size" :data-disabled="disabled || undefined" :data-dragging="dragging || undefined" :data-marked="marks?.length ? 'true' : undefined">
|
|
97
|
+
<div
|
|
98
|
+
ref="trackEl"
|
|
99
|
+
class="wp-slider__track"
|
|
100
|
+
@pointerdown="onPointerDown"
|
|
101
|
+
@pointermove="onPointerMove"
|
|
102
|
+
@pointerup="onPointerUp"
|
|
103
|
+
@pointercancel="onPointerUp"
|
|
104
|
+
>
|
|
105
|
+
<div class="wp-slider__rail" />
|
|
106
|
+
<div class="wp-slider__fill" :style="{ width: `calc(${pct}% + var(--_thumb) / 2)` }" />
|
|
107
|
+
<span
|
|
108
|
+
v-for="m in marks ?? []"
|
|
109
|
+
:key="m.value"
|
|
110
|
+
class="wp-slider__mark"
|
|
111
|
+
:data-passed="m.value <= value || undefined"
|
|
112
|
+
:style="{ left: `${percentOf(m.value, props.min, props.max)}%` }"
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
><span v-if="m.label" class="wp-slider__mark-label">{{ m.label }}</span></span>
|
|
115
|
+
<!-- Focusable unless disabled (tabindex is bound). -->
|
|
116
|
+
<!-- eslint-disable-next-line vuejs-accessibility/interactive-supports-focus -->
|
|
117
|
+
<div
|
|
118
|
+
class="wp-slider__thumb"
|
|
119
|
+
role="slider"
|
|
120
|
+
:tabindex="disabled ? -1 : 0"
|
|
121
|
+
:aria-label="ariaLabel"
|
|
122
|
+
:aria-valuemin="min"
|
|
123
|
+
:aria-valuemax="max"
|
|
124
|
+
:aria-valuenow="value"
|
|
125
|
+
:aria-valuetext="format ? text : undefined"
|
|
126
|
+
:aria-disabled="disabled || undefined"
|
|
127
|
+
:style="{ left: `${pct}%` }"
|
|
128
|
+
@keydown="onKeydown"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<span v-if="showValue && !withInput" class="wp-slider__value" aria-hidden="true">{{ text }}</span>
|
|
132
|
+
<WpNumber
|
|
133
|
+
v-if="withInput"
|
|
134
|
+
class="wp-slider__input"
|
|
135
|
+
:model-value="value"
|
|
136
|
+
:min="min"
|
|
137
|
+
:max="max"
|
|
138
|
+
:step="step"
|
|
139
|
+
:size="size"
|
|
140
|
+
:disabled="disabled"
|
|
141
|
+
:aria-label="ariaLabel ? `${ariaLabel}, exact value` : 'Exact value'"
|
|
142
|
+
@update:model-value="onTyped"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</template>
|
|
146
|
+
|
|
147
|
+
<style scoped>
|
|
148
|
+
.wp-slider {
|
|
149
|
+
--_h: var(--wp-control-h-md);
|
|
150
|
+
--_thumb: 16px;
|
|
151
|
+
display: flex; align-items: center; gap: var(--wp-space-4); min-width: 0; width: 100%;
|
|
152
|
+
min-height: var(--_h); font-family: var(--wp-font-sans);
|
|
153
|
+
}
|
|
154
|
+
.wp-slider[data-size="sm"] { --_h: var(--wp-control-h-sm); --_thumb: 12px; gap: var(--wp-space-3); }
|
|
155
|
+
.wp-slider[data-size="lg"] { --_h: var(--wp-control-h-lg); --_thumb: 18px; }
|
|
156
|
+
.wp-slider[data-marked] { padding-bottom: 14px; }
|
|
157
|
+
.wp-slider__track {
|
|
158
|
+
position: relative; flex: 1; min-width: 60px; height: var(--_h);
|
|
159
|
+
margin: 0 calc(var(--_thumb) / 2); cursor: pointer; touch-action: none;
|
|
160
|
+
}
|
|
161
|
+
.wp-slider__rail, .wp-slider__fill {
|
|
162
|
+
position: absolute; top: 50%; left: 0; height: 4px; margin-top: -2px; border-radius: var(--wp-radius-cap, 999px);
|
|
163
|
+
}
|
|
164
|
+
.wp-slider__rail { right: 0; left: calc(var(--_thumb) / -2); right: calc(var(--_thumb) / -2); background: var(--wp-bg-3); box-shadow: inset 0 0 0 1px var(--wp-border-soft); }
|
|
165
|
+
.wp-slider__fill { left: calc(var(--_thumb) / -2); background: var(--wp-accent); }
|
|
166
|
+
.wp-slider[data-size="sm"] .wp-slider__rail, .wp-slider[data-size="sm"] .wp-slider__fill { height: 3px; margin-top: -1.5px; }
|
|
167
|
+
.wp-slider__thumb {
|
|
168
|
+
position: absolute; top: 50%; box-sizing: border-box;
|
|
169
|
+
width: var(--_thumb); height: var(--_thumb); margin: calc(var(--_thumb) / -2) 0 0 calc(var(--_thumb) / -2);
|
|
170
|
+
border: 2px solid var(--wp-accent); border-radius: min(50%, var(--wp-radius-cap, 999px)); background: var(--wp-bg);
|
|
171
|
+
box-shadow: var(--wp-shadow-sm); outline: none;
|
|
172
|
+
transition: transform var(--wp-motion-quick), box-shadow var(--wp-motion-quick);
|
|
173
|
+
}
|
|
174
|
+
.wp-slider__track:hover .wp-slider__thumb, .wp-slider[data-dragging] .wp-slider__thumb { transform: scale(1.12); }
|
|
175
|
+
.wp-slider__thumb:focus-visible { box-shadow: 0 0 0 3px var(--wp-focus-ring); }
|
|
176
|
+
.wp-slider__mark {
|
|
177
|
+
position: absolute; top: 50%; width: 2px; height: 8px; margin: -4px 0 0 -1px; border-radius: min(1px, var(--wp-radius-cap, 999px));
|
|
178
|
+
background: var(--wp-border-strong); pointer-events: none;
|
|
179
|
+
}
|
|
180
|
+
.wp-slider__mark[data-passed] { background: color-mix(in srgb, var(--wp-accent) 60%, var(--wp-bg)); }
|
|
181
|
+
.wp-slider__mark-label {
|
|
182
|
+
position: absolute; top: calc(var(--_h) / 2 + 2px); left: 50%; transform: translateX(-50%);
|
|
183
|
+
color: var(--wp-text-dim); font-size: 10px; line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums;
|
|
184
|
+
}
|
|
185
|
+
.wp-slider__value {
|
|
186
|
+
flex: none; min-width: 3ch; text-align: right; color: var(--wp-text); font: 500 var(--wp-text-sm)/1 var(--wp-font-sans);
|
|
187
|
+
font-variant-numeric: tabular-nums;
|
|
188
|
+
}
|
|
189
|
+
.wp-slider[data-size="sm"] .wp-slider__value { font-size: var(--wp-text-xs); }
|
|
190
|
+
.wp-slider__input { flex: none; width: 84px; }
|
|
191
|
+
.wp-slider[data-size="sm"] .wp-slider__input { width: 64px; }
|
|
192
|
+
.wp-slider[data-disabled] { opacity: 0.45; }
|
|
193
|
+
.wp-slider[data-disabled] .wp-slider__track { cursor: not-allowed; }
|
|
194
|
+
.wp-slider[data-disabled] .wp-slider__thumb { transform: none; }
|
|
195
|
+
@media (prefers-reduced-motion: reduce) {
|
|
196
|
+
.wp-slider__thumb { transition: none; }
|
|
197
|
+
}
|
|
198
|
+
</style>
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
<script setup lang="ts" generic="T">
|
|
2
|
+
/**
|
|
3
|
+
* Sortable list: rows you put in order by dragging their grip, or from the
|
|
4
|
+
* keyboard. Works on the canvas (`size="sm"`) and in the manager (`md`).
|
|
5
|
+
*
|
|
6
|
+
* <SortableList v-model="rules" :item-key="(r) => r.id" :item-label="(r) => r.name" label="Rules">
|
|
7
|
+
* <template #item="{ item }">…the row…</template>
|
|
8
|
+
* </SortableList>
|
|
9
|
+
*
|
|
10
|
+
* Mouse, pen and touch: drag the grip; a line shows where the row lands and
|
|
11
|
+
* Escape cancels. Keyboard (on the grip): Space or Enter picks the row up,
|
|
12
|
+
* the arrows move it, Space or Enter drops it, Escape puts it back; Alt+↑ /
|
|
13
|
+
* Alt+↓ move it one step without picking it up. Each step is said in a
|
|
14
|
+
* polite live line ("Hair moved to 2 of 5"). Only the grip starts a drag,
|
|
15
|
+
* so text and controls in the row work as usual.
|
|
16
|
+
*/
|
|
17
|
+
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useId } from "vue";
|
|
18
|
+
import { dropIndex, moveItem } from "./sortable";
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<{
|
|
21
|
+
modelValue: T[];
|
|
22
|
+
/** A stable key per item. */
|
|
23
|
+
itemKey: (item: T) => string | number;
|
|
24
|
+
/** The item's name in the grip's label and the announcements. */
|
|
25
|
+
itemLabel?: ((item: T, index: number) => string) | undefined;
|
|
26
|
+
/** The list's name for screen readers. */
|
|
27
|
+
label: string;
|
|
28
|
+
disabled?: boolean | undefined;
|
|
29
|
+
size?: "sm" | "md" | undefined;
|
|
30
|
+
}>(), { size: "md" });
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{
|
|
33
|
+
(e: "update:modelValue", v: T[]): void;
|
|
34
|
+
(e: "move", m: { from: number; to: number }): void;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const uid = useId();
|
|
38
|
+
const helpId = `wp-sortable-help-${uid}`;
|
|
39
|
+
const listEl = ref<HTMLElement | null>(null);
|
|
40
|
+
const said = ref("");
|
|
41
|
+
|
|
42
|
+
/** Pointer drag: which row, where it started, where it would land. */
|
|
43
|
+
const drag = ref<{ from: number; to: number; startY: number; dy: number; line: number } | null>(null);
|
|
44
|
+
let mids: number[] = [];
|
|
45
|
+
let tops: number[] = [];
|
|
46
|
+
let bottoms: number[] = [];
|
|
47
|
+
let listTop = 0;
|
|
48
|
+
|
|
49
|
+
/** Keyboard pick-up: the row held and the order before it was picked up. */
|
|
50
|
+
const held = shallowRef<{ key: string | number; before: T[] } | null>(null);
|
|
51
|
+
|
|
52
|
+
const nameOf = (item: T, i: number): string => props.itemLabel?.(item, i) ?? `Item ${i + 1}`;
|
|
53
|
+
const total = computed(() => props.modelValue.length);
|
|
54
|
+
|
|
55
|
+
function announce(text: string): void {
|
|
56
|
+
said.value = "";
|
|
57
|
+
void nextTick(() => { said.value = text; });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rows(): HTMLElement[] {
|
|
61
|
+
return listEl.value ? [...listEl.value.querySelectorAll<HTMLElement>(":scope > .wp-sortable__row")] : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function focusGrip(key: string | number): void {
|
|
65
|
+
void nextTick(() => {
|
|
66
|
+
const i = props.modelValue.findIndex((it) => props.itemKey(it) === key);
|
|
67
|
+
rows()[i]?.querySelector<HTMLElement>(".wp-sortable__grip")?.focus();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function commit(from: number, to: number): void {
|
|
72
|
+
if (from === to) return;
|
|
73
|
+
emit("update:modelValue", moveItem(props.modelValue, from, to));
|
|
74
|
+
emit("move", { from, to });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Pointer ─────────────────────────────────────────────────────────────
|
|
78
|
+
function onPointerDown(ev: PointerEvent, i: number): void {
|
|
79
|
+
if (props.disabled || ev.button !== 0 || held.value) return;
|
|
80
|
+
ev.preventDefault();
|
|
81
|
+
const rs = rows().map((r) => r.getBoundingClientRect());
|
|
82
|
+
mids = rs.map((r) => r.top + r.height / 2);
|
|
83
|
+
tops = rs.map((r) => r.top);
|
|
84
|
+
bottoms = rs.map((r) => r.bottom);
|
|
85
|
+
listTop = listEl.value?.getBoundingClientRect().top ?? 0;
|
|
86
|
+
drag.value = { from: i, to: i, startY: ev.clientY, dy: 0, line: -1 };
|
|
87
|
+
(ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId);
|
|
88
|
+
window.addEventListener("keydown", onDragKey, true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function lineFor(from: number, to: number): number {
|
|
92
|
+
if (to === from) return -1;
|
|
93
|
+
// Insert before original row k (skipping the dragged one), or after the last.
|
|
94
|
+
const k = to < from ? to : to + 1;
|
|
95
|
+
return (k < tops.length ? tops[k] : bottoms[bottoms.length - 1]) - listTop;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function onPointerMove(ev: PointerEvent): void {
|
|
99
|
+
const d = drag.value;
|
|
100
|
+
if (!d) return;
|
|
101
|
+
const to = dropIndex(ev.clientY, mids, d.from);
|
|
102
|
+
drag.value = { ...d, dy: ev.clientY - d.startY, to, line: lineFor(d.from, to) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function endDrag(ev: PointerEvent | null, keep: boolean): void {
|
|
106
|
+
const d = drag.value;
|
|
107
|
+
if (!d) return;
|
|
108
|
+
drag.value = null;
|
|
109
|
+
window.removeEventListener("keydown", onDragKey, true);
|
|
110
|
+
if (ev) (ev.currentTarget as HTMLElement | null)?.releasePointerCapture?.(ev.pointerId);
|
|
111
|
+
if (keep && d.to !== d.from) {
|
|
112
|
+
const item = props.modelValue[d.from];
|
|
113
|
+
commit(d.from, d.to);
|
|
114
|
+
announce(`${nameOf(item, d.from)} moved to ${d.to + 1} of ${total.value}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A list removed mid-drag must not leave its Escape listener on the window.
|
|
119
|
+
onBeforeUnmount(() => window.removeEventListener("keydown", onDragKey, true));
|
|
120
|
+
|
|
121
|
+
function onDragKey(ev: KeyboardEvent): void {
|
|
122
|
+
if (ev.key !== "Escape") return;
|
|
123
|
+
ev.preventDefault();
|
|
124
|
+
ev.stopPropagation();
|
|
125
|
+
endDrag(null, false);
|
|
126
|
+
announce("Move cancelled");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Keyboard ────────────────────────────────────────────────────────────
|
|
130
|
+
function onGripKey(ev: KeyboardEvent, i: number): void {
|
|
131
|
+
if (props.disabled) return;
|
|
132
|
+
const item = props.modelValue[i];
|
|
133
|
+
const key = props.itemKey(item);
|
|
134
|
+
const up = ev.key === "ArrowUp" || ev.key === "ArrowLeft";
|
|
135
|
+
const down = ev.key === "ArrowDown" || ev.key === "ArrowRight";
|
|
136
|
+
|
|
137
|
+
if (!held.value) {
|
|
138
|
+
if (ev.key === " " || ev.key === "Enter") {
|
|
139
|
+
ev.preventDefault();
|
|
140
|
+
held.value = { key, before: [...props.modelValue] };
|
|
141
|
+
announce(`Picked up ${nameOf(item, i)}, ${i + 1} of ${total.value}. Arrows move it, Space drops it, Escape puts it back.`);
|
|
142
|
+
} else if (ev.altKey && (up || down)) {
|
|
143
|
+
ev.preventDefault();
|
|
144
|
+
const to = up ? i - 1 : i + 1;
|
|
145
|
+
if (to < 0 || to >= total.value) return;
|
|
146
|
+
commit(i, to);
|
|
147
|
+
announce(`${nameOf(item, i)} moved to ${to + 1} of ${total.value}`);
|
|
148
|
+
focusGrip(key);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (up || down || ev.key === "Home" || ev.key === "End") {
|
|
154
|
+
ev.preventDefault();
|
|
155
|
+
const to = ev.key === "Home" ? 0 : ev.key === "End" ? total.value - 1 : up ? i - 1 : i + 1;
|
|
156
|
+
if (to < 0 || to >= total.value || to === i) return;
|
|
157
|
+
commit(i, to);
|
|
158
|
+
announce(`${nameOf(item, i)} moved to ${to + 1} of ${total.value}`);
|
|
159
|
+
focusGrip(key);
|
|
160
|
+
} else if (ev.key === " " || ev.key === "Enter") {
|
|
161
|
+
ev.preventDefault();
|
|
162
|
+
held.value = null;
|
|
163
|
+
announce(`${nameOf(item, i)} dropped at ${i + 1} of ${total.value}`);
|
|
164
|
+
} else if (ev.key === "Escape") {
|
|
165
|
+
ev.preventDefault();
|
|
166
|
+
ev.stopPropagation();
|
|
167
|
+
const before = held.value.before;
|
|
168
|
+
held.value = null;
|
|
169
|
+
if (before.some((it, j) => it !== props.modelValue[j])) emit("update:modelValue", before);
|
|
170
|
+
const back = before.findIndex((it) => props.itemKey(it) === key);
|
|
171
|
+
announce(`Move cancelled. ${nameOf(item, i)} is back at ${back + 1} of ${total.value}`);
|
|
172
|
+
focusGrip(key);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function onGripBlur(i: number): void {
|
|
177
|
+
// Moving the row re-inserts its element, which can blur the grip for a
|
|
178
|
+
// moment; only a real focus loss drops it.
|
|
179
|
+
void nextTick(() => {
|
|
180
|
+
if (!held.value) return;
|
|
181
|
+
const grip = rows()[props.modelValue.findIndex((it) => props.itemKey(it) === held.value?.key)]?.querySelector(".wp-sortable__grip");
|
|
182
|
+
if (grip && document.activeElement !== grip) {
|
|
183
|
+
const item = props.modelValue[i];
|
|
184
|
+
held.value = null;
|
|
185
|
+
if (item) announce(`${nameOf(item, i)} dropped`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
</script>
|
|
190
|
+
|
|
191
|
+
<template>
|
|
192
|
+
<div class="wp-sortable" :data-size="size" :data-dragging="drag ? 'true' : undefined">
|
|
193
|
+
<ul ref="listEl" class="wp-sortable__list" :aria-label="label">
|
|
194
|
+
<li
|
|
195
|
+
v-for="(item, i) in modelValue"
|
|
196
|
+
:key="itemKey(item)"
|
|
197
|
+
class="wp-sortable__row"
|
|
198
|
+
:data-dragged="drag?.from === i || undefined"
|
|
199
|
+
:data-held="held?.key === itemKey(item) || undefined"
|
|
200
|
+
:style="drag?.from === i ? { transform: `translateY(${drag.dy}px)` } : undefined"
|
|
201
|
+
>
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
class="wp-sortable__grip"
|
|
205
|
+
:aria-label="`Move ${nameOf(item, i)}`"
|
|
206
|
+
:aria-describedby="helpId"
|
|
207
|
+
:aria-pressed="held?.key === itemKey(item)"
|
|
208
|
+
:disabled="disabled"
|
|
209
|
+
@pointerdown="onPointerDown($event, i)"
|
|
210
|
+
@pointermove="onPointerMove"
|
|
211
|
+
@pointerup="endDrag($event, true)"
|
|
212
|
+
@pointercancel="endDrag($event, false)"
|
|
213
|
+
@keydown="onGripKey($event, i)"
|
|
214
|
+
@blur="onGripBlur(i)"
|
|
215
|
+
><i class="pi pi-bars" aria-hidden="true" /></button>
|
|
216
|
+
<div class="wp-sortable__body"><slot name="item" :item="item" :index="i" /></div>
|
|
217
|
+
</li>
|
|
218
|
+
<li v-if="drag && drag.line >= 0" class="wp-sortable__line" :style="{ top: `${drag.line}px` }" aria-hidden="true" />
|
|
219
|
+
</ul>
|
|
220
|
+
<span :id="helpId" class="wp-sortable__sr">Space or Enter picks it up, the arrows move it, Space drops it, Escape puts it back. Alt and an arrow moves it one step.</span>
|
|
221
|
+
<span class="wp-sortable__sr" aria-live="polite">{{ said }}</span>
|
|
222
|
+
</div>
|
|
223
|
+
</template>
|
|
224
|
+
|
|
225
|
+
<style scoped>
|
|
226
|
+
.wp-sortable { position: relative; min-width: 0; }
|
|
227
|
+
.wp-sortable__list { position: relative; display: flex; flex-direction: column; gap: var(--wp-space-2); margin: 0; padding: 0; list-style: none; }
|
|
228
|
+
.wp-sortable[data-size="sm"] .wp-sortable__list { gap: 2px; }
|
|
229
|
+
.wp-sortable__row {
|
|
230
|
+
display: flex; align-items: center; gap: var(--wp-space-2); min-width: 0;
|
|
231
|
+
border-radius: var(--wp-radius); background: var(--wp-bg-1);
|
|
232
|
+
}
|
|
233
|
+
.wp-sortable__row[data-dragged] {
|
|
234
|
+
position: relative; z-index: 2; opacity: 0.92; box-shadow: var(--wp-shadow-md);
|
|
235
|
+
outline: 1px solid var(--wp-border-strong); pointer-events: none;
|
|
236
|
+
}
|
|
237
|
+
.wp-sortable__row[data-held] { outline: 1.5px dashed var(--wp-accent); outline-offset: 1px; }
|
|
238
|
+
.wp-sortable__grip {
|
|
239
|
+
display: inline-grid; place-items: center; flex: none; align-self: stretch;
|
|
240
|
+
width: 24px; min-height: 24px; margin: 0; padding: 0; border: 0; border-radius: var(--wp-radius-sm);
|
|
241
|
+
background: transparent; color: var(--wp-text-dim); cursor: grab; touch-action: none;
|
|
242
|
+
}
|
|
243
|
+
.wp-sortable[data-size="sm"] .wp-sortable__grip { width: 20px; min-height: 20px; }
|
|
244
|
+
.wp-sortable__grip .pi { font-size: 11px; }
|
|
245
|
+
.wp-sortable[data-size="sm"] .wp-sortable__grip .pi { font-size: 10px; }
|
|
246
|
+
.wp-sortable__grip:hover:not(:disabled) { color: var(--wp-text); background: var(--wp-row-hover); }
|
|
247
|
+
.wp-sortable__grip:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
248
|
+
.wp-sortable__grip[aria-pressed="true"] { color: var(--wp-accent-text); }
|
|
249
|
+
.wp-sortable__grip:disabled { cursor: not-allowed; opacity: 0.45; }
|
|
250
|
+
.wp-sortable[data-dragging] .wp-sortable__grip { cursor: grabbing; }
|
|
251
|
+
.wp-sortable__body { flex: 1; min-width: 0; }
|
|
252
|
+
.wp-sortable__line {
|
|
253
|
+
position: absolute; left: 0; right: 0; height: 2px; margin-top: -1px; border-radius: min(1px, var(--wp-radius-cap, 999px));
|
|
254
|
+
background: var(--wp-accent); box-shadow: 0 0 0 2px var(--wp-accent-glow); pointer-events: none;
|
|
255
|
+
}
|
|
256
|
+
.wp-sortable__sr {
|
|
257
|
+
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
258
|
+
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
|
259
|
+
}
|
|
260
|
+
</style>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Split pane: two panels side by side (or one above the other) with a
|
|
4
|
+
* handle between them that drags, takes focus and moves with the arrow
|
|
5
|
+
* keys. v-model is the first panel's size in px; the second panel takes the
|
|
6
|
+
* rest. Works on the canvas and in the manager.
|
|
7
|
+
*
|
|
8
|
+
* <SplitPane v-model="listWidth" :min="200" :max="520" :default-size="280" label="List width">
|
|
9
|
+
* <template #start>…list…</template>
|
|
10
|
+
* <template #end>…detail…</template>
|
|
11
|
+
* </SplitPane>
|
|
12
|
+
*
|
|
13
|
+
* The parent decides the pane's own height (or width, when stacked). Keep
|
|
14
|
+
* the size in a ref, or in the user's settings to remember it.
|
|
15
|
+
*/
|
|
16
|
+
import { useId } from "vue";
|
|
17
|
+
import ResizeHandle from "./ResizeHandle.vue";
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<{
|
|
20
|
+
/** The first panel's size in px. */
|
|
21
|
+
modelValue: number;
|
|
22
|
+
/** horizontal: side by side. vertical: one above the other. */
|
|
23
|
+
orientation?: "horizontal" | "vertical" | undefined;
|
|
24
|
+
min?: number | undefined;
|
|
25
|
+
max?: number | undefined;
|
|
26
|
+
defaultSize?: number | undefined;
|
|
27
|
+
/** What the handle resizes, for screen readers. */
|
|
28
|
+
label?: string | undefined;
|
|
29
|
+
disabled?: boolean | undefined;
|
|
30
|
+
}>(), { orientation: "horizontal", min: 120, max: Number.POSITIVE_INFINITY, defaultSize: undefined, label: "Resize panels" });
|
|
31
|
+
|
|
32
|
+
const emit = defineEmits<{ (e: "update:modelValue", v: number): void; (e: "end", v: number): void }>();
|
|
33
|
+
const startId = `wp-split-${useId()}`;
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<div class="wp-split" :data-orientation="orientation">
|
|
38
|
+
<div
|
|
39
|
+
:id="startId"
|
|
40
|
+
class="wp-split__start"
|
|
41
|
+
:style="orientation === 'horizontal' ? { width: `${modelValue}px` } : { height: `${modelValue}px` }"
|
|
42
|
+
>
|
|
43
|
+
<slot name="start" />
|
|
44
|
+
</div>
|
|
45
|
+
<ResizeHandle
|
|
46
|
+
:model-value="modelValue"
|
|
47
|
+
:axis="orientation === 'horizontal' ? 'x' : 'y'"
|
|
48
|
+
:min="min"
|
|
49
|
+
:max="max"
|
|
50
|
+
:default-size="defaultSize"
|
|
51
|
+
:label="props.label"
|
|
52
|
+
:controls="startId"
|
|
53
|
+
:disabled="disabled"
|
|
54
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
55
|
+
@end="emit('end', $event)"
|
|
56
|
+
/>
|
|
57
|
+
<div class="wp-split__end"><slot name="end" /></div>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<style scoped>
|
|
62
|
+
.wp-split { display: flex; width: 100%; height: 100%; min-width: 0; min-height: 0; }
|
|
63
|
+
.wp-split[data-orientation="vertical"] { flex-direction: column; }
|
|
64
|
+
.wp-split__start { flex: none; min-width: 0; min-height: 0; overflow: auto; }
|
|
65
|
+
.wp-split__end { flex: 1; min-width: 0; min-height: 0; overflow: auto; }
|
|
66
|
+
.wp-split[data-orientation="horizontal"] > .wp-split__start { border-right: 1px solid var(--wp-border); }
|
|
67
|
+
.wp-split[data-orientation="vertical"] > .wp-split__start { border-bottom: 1px solid var(--wp-border); }
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Star a post: a toggle Button with a filled star when on and the star count
|
|
4
|
+
* beside the label. The app owns the request; this only shows the state and
|
|
5
|
+
* emits `toggle`.
|
|
6
|
+
*
|
|
7
|
+
* <StarButton :starred="post.starred_by_me" :count="post.star_count" :disabled="pending" @toggle="star" />
|
|
8
|
+
*/
|
|
9
|
+
import Button from "./Button.vue";
|
|
10
|
+
|
|
11
|
+
withDefaults(defineProps<{
|
|
12
|
+
starred: boolean;
|
|
13
|
+
count?: number | undefined;
|
|
14
|
+
disabled?: boolean | undefined;
|
|
15
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
16
|
+
}>(), { count: undefined, disabled: false, size: "md" });
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits<{ (e: "toggle"): void }>();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<Button
|
|
23
|
+
:icon="starred ? 'star-fill' : 'star'"
|
|
24
|
+
:pressed="starred"
|
|
25
|
+
:disabled="disabled"
|
|
26
|
+
:size="size"
|
|
27
|
+
@click="emit('toggle')"
|
|
28
|
+
>
|
|
29
|
+
{{ starred ? "Starred" : "Star" }}
|
|
30
|
+
<span v-if="count !== undefined" class="wp-star-button__count">{{ count.toLocaleString() }}</span>
|
|
31
|
+
</Button>
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<style scoped>
|
|
35
|
+
.wp-star-button__count {
|
|
36
|
+
margin-left: 2px;
|
|
37
|
+
color: var(--wp-text-muted);
|
|
38
|
+
font-variant-numeric: tabular-nums;
|
|
39
|
+
}
|
|
40
|
+
</style>
|