@dumiflex/wp-kit 0.3.0 → 0.5.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 +92 -51
- package/LICENSE +674 -674
- package/README.md +73 -76
- package/package.json +78 -77
- package/src/Avatar.vue +83 -83
- package/src/Badge.vue +97 -97
- package/src/Banner.vue +118 -118
- package/src/BookmarkButton.vue +27 -27
- package/src/BundleMeta.vue +51 -51
- package/src/Button.vue +103 -103
- package/src/Card.vue +68 -68
- package/src/Checkbox.vue +109 -107
- package/src/Chip.vue +163 -97
- package/src/ChoiceCards.vue +133 -133
- package/src/CloseButton.vue +69 -69
- package/src/CodeBlock.vue +146 -119
- package/src/ColorField.vue +188 -206
- package/src/ColorPicker.vue +201 -201
- package/src/Combobox.vue +331 -334
- package/src/ConfirmDialog.vue +165 -163
- package/src/ConfirmModal.vue +147 -147
- package/src/ConstraintMatrixPreview.vue +708 -708
- package/src/ContextMenu.vue +386 -379
- package/src/CopyButton.vue +156 -156
- package/src/DataTable.vue +293 -292
- package/src/DatePicker.vue +234 -234
- package/src/DescriptionList.vue +73 -73
- package/src/DiffView.vue +68 -68
- package/src/Disclosure.vue +166 -166
- package/src/DragHandle.vue +49 -49
- package/src/Drawer.vue +131 -131
- package/src/DropZone.vue +196 -196
- package/src/DropdownMenu.vue +72 -72
- package/src/EmptyState.vue +111 -111
- package/src/Field.vue +28 -28
- package/src/HsvPicker.vue +208 -208
- package/src/Icon.vue +39 -39
- package/src/IconPicker.vue +209 -209
- package/src/ImageGrid.vue +127 -125
- package/src/InlineEdit.vue +183 -183
- package/src/Input.vue +99 -99
- package/src/Kbd.vue +56 -56
- package/src/Lightbox.vue +176 -173
- package/src/ListFilter.vue +85 -85
- package/src/ListRow.vue +204 -204
- package/src/LoadingMark.vue +217 -161
- package/src/Logo.vue +60 -60
- package/src/Modal.vue +78 -76
- package/src/ModalShell.vue +109 -107
- package/src/Pagination.vue +106 -106
- package/src/Popover.vue +180 -171
- package/src/PostCard.vue +210 -211
- package/src/ProgressBar.vue +90 -90
- package/src/RadioGroup.vue +177 -177
- package/src/RangeSlider.vue +183 -183
- package/src/RelativeDate.vue +35 -35
- package/src/ResizeHandle.vue +143 -143
- package/src/SearchInput.vue +192 -199
- package/src/Select.vue +644 -641
- package/src/Separator.vue +54 -54
- package/src/Skeleton.vue +98 -82
- package/src/Slider.vue +198 -198
- package/src/SortableList.vue +260 -260
- package/src/SplitPane.vue +68 -68
- package/src/StarButton.vue +40 -40
- package/src/Stepper.vue +141 -141
- package/src/SupportLinks.vue +93 -93
- package/src/TagInput.vue +297 -300
- package/src/Textarea.vue +57 -57
- package/src/Timeline.vue +92 -92
- package/src/ToastCard.vue +119 -119
- package/src/ToastHost.vue +40 -40
- package/src/Toggle.vue +36 -36
- package/src/TokenizedValue.vue +310 -310
- package/src/Toolbar.vue +110 -110
- package/src/VirtualList.vue +87 -87
- package/src/VisuallyHidden.vue +29 -29
- package/src/WpButton.vue +75 -75
- package/src/WpCheck.vue +104 -102
- package/src/WpIconButton.vue +107 -107
- package/src/WpImageZoom.vue +544 -544
- package/src/WpNumber.vue +178 -183
- package/src/WpSegmented.vue +175 -175
- package/src/WpSelect.vue +93 -89
- package/src/WpSwitch.vue +163 -163
- package/src/WpTabs.vue +188 -188
- package/src/_modal-motion.css +33 -33
- package/src/_skeleton.css +50 -50
- package/src/axis-color.ts +42 -42
- package/src/button.css +169 -169
- package/src/calendar.ts +59 -59
- package/src/clipboard.ts +50 -50
- package/src/colour-presets.ts +12 -12
- package/src/diff.ts +51 -51
- package/src/drop-zone.ts +41 -41
- package/src/field.css +50 -0
- package/src/flip.ts +237 -237
- package/src/floating-layer.ts +48 -0
- package/src/floating.ts +99 -99
- package/src/highlight.ts +199 -0
- package/src/hsv.ts +100 -100
- package/src/icon-catalog.ts +52 -52
- package/src/image-zoom.ts +93 -93
- package/src/kind-colour.ts +28 -0
- package/src/loading-mark-motion.ts +249 -0
- package/src/logo-paths.ts +21 -21
- package/src/matrix-axis.ts +91 -91
- package/src/pagination.ts +27 -27
- package/src/platform-keys.ts +29 -29
- package/src/rich-tokenize.ts +357 -357
- package/src/select-types.ts +32 -32
- package/src/slider-math.ts +45 -45
- package/src/sortable.ts +22 -22
- package/src/theme/fonts/OFL-Fraunces.txt +93 -93
- package/src/theme/fonts/OFL-JetBrainsMono.txt +93 -93
- package/src/theme/fonts/OFL-Manrope.txt +93 -93
- package/src/theme/looks.css +428 -428
- package/src/theme/palettes.css +254 -254
- package/src/theme/tokens.css +2896 -2894
- package/src/tokens.ts +56 -56
- package/src/tooltip.css +43 -43
- package/src/tooltip.ts +146 -146
- package/src/useDialogFocus.ts +73 -73
- package/src/useGrowableField.ts +357 -357
- package/src/useModalKeyShield.ts +116 -116
- package/src/useToast.ts +48 -48
- package/src/wp-logo.svg +14 -14
- package/src/ChipToggle.vue +0 -92
- package/src/KindChip.vue +0 -53
- package/src/RatingBadge.vue +0 -42
package/src/WpSegmented.vue
CHANGED
|
@@ -1,175 +1,175 @@
|
|
|
1
|
-
<script setup lang="ts" generic="T extends string">
|
|
2
|
-
/**
|
|
3
|
-
* Segmented control: two to five short choices shown as joined buttons, one
|
|
4
|
-
* of them on. For this few, showing them all is faster to read and change
|
|
5
|
-
* than a select. Works on the canvas (`size="sm"`) and in the manager (`md`).
|
|
6
|
-
*
|
|
7
|
-
* It is a radio group (APG): only the chosen option is in the Tab order, the
|
|
8
|
-
* arrow keys move to the next enabled option and choose it, Home/End jump to
|
|
9
|
-
* the ends. With `icon-only` the labels become the buttons' names and
|
|
10
|
-
* tooltips.
|
|
11
|
-
*
|
|
12
|
-
* <WpSegmented v-model="theme" :options="[{ value: 'dark', label: 'Dark', icon: 'pi-moon' }]" aria-label="Theme" />
|
|
13
|
-
*/
|
|
14
|
-
import { nextTick, ref } from "vue";
|
|
15
|
-
import { vTooltip } from "./tooltip";
|
|
16
|
-
|
|
17
|
-
export interface SegmentedOption<V extends string = string> {
|
|
18
|
-
value: V;
|
|
19
|
-
label: string;
|
|
20
|
-
/** A PrimeIcons class, e.g. `pi-moon`. */
|
|
21
|
-
icon?: string | undefined;
|
|
22
|
-
disabled?: boolean | undefined;
|
|
23
|
-
/** A tooltip saying what the option does. */
|
|
24
|
-
hint?: string | undefined;
|
|
25
|
-
/** A shortcut shown in the tooltip's key chip, e.g. "G". */
|
|
26
|
-
kbd?: string | undefined;
|
|
27
|
-
/** This option's `data-test`, when `testPrefix` doesn't fit. */
|
|
28
|
-
testId?: string | undefined;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const props = withDefaults(defineProps<{
|
|
32
|
-
modelValue: T;
|
|
33
|
-
options: SegmentedOption<T>[];
|
|
34
|
-
size?: "sm" | "md" | "lg" | undefined;
|
|
35
|
-
/** Show only the icons; each label names its button and is its tooltip. */
|
|
36
|
-
iconOnly?: boolean | undefined;
|
|
37
|
-
disabled?: boolean | undefined;
|
|
38
|
-
/** Fill the row: options share the width and long labels end in "…". */
|
|
39
|
-
block?: boolean | undefined;
|
|
40
|
-
ariaLabel?: string | undefined;
|
|
41
|
-
/** `data-test` of each option is `<testPrefix>-<value>`. */
|
|
42
|
-
testPrefix?: string | undefined;
|
|
43
|
-
}>(), { size: "md" });
|
|
44
|
-
|
|
45
|
-
const emit = defineEmits<{ (e: "update:modelValue", v: T): void }>();
|
|
46
|
-
const rootEl = ref<HTMLElement | null>(null);
|
|
47
|
-
|
|
48
|
-
function pick(v: T): void {
|
|
49
|
-
if (props.disabled) return;
|
|
50
|
-
if (v !== props.modelValue) emit("update:modelValue", v);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The option that takes Tab focus: the chosen one, else the first enabled. */
|
|
54
|
-
function tabStop(): T | undefined {
|
|
55
|
-
const live = props.options.filter((o) => !o.disabled);
|
|
56
|
-
return (live.find((o) => o.value === props.modelValue) ?? live[0])?.value;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function onKeydown(ev: KeyboardEvent): void {
|
|
60
|
-
if (props.disabled) return;
|
|
61
|
-
const live = props.options.filter((o) => !o.disabled);
|
|
62
|
-
if (!live.length) return;
|
|
63
|
-
const at = live.findIndex((o) => o.value === props.modelValue);
|
|
64
|
-
let next: number;
|
|
65
|
-
switch (ev.key) {
|
|
66
|
-
case "ArrowRight": case "ArrowDown": next = (at + 1) % live.length; break;
|
|
67
|
-
case "ArrowLeft": case "ArrowUp": next = at < 0 ? live.length - 1 : (at - 1 + live.length) % live.length; break;
|
|
68
|
-
case "Home": next = 0; break;
|
|
69
|
-
case "End": next = live.length - 1; break;
|
|
70
|
-
default: return;
|
|
71
|
-
}
|
|
72
|
-
ev.preventDefault();
|
|
73
|
-
const v = live[next].value;
|
|
74
|
-
pick(v);
|
|
75
|
-
void nextTick(() => {
|
|
76
|
-
const i = props.options.findIndex((o) => o.value === v);
|
|
77
|
-
rootEl.value?.querySelectorAll<HTMLElement>("[role=radio]")[i]?.focus();
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
</script>
|
|
81
|
-
|
|
82
|
-
<template>
|
|
83
|
-
<div
|
|
84
|
-
ref="rootEl"
|
|
85
|
-
class="wp-segmented"
|
|
86
|
-
role="radiogroup"
|
|
87
|
-
:aria-label="ariaLabel"
|
|
88
|
-
:aria-disabled="disabled || undefined"
|
|
89
|
-
:data-size="size"
|
|
90
|
-
:data-icon-only="iconOnly || undefined"
|
|
91
|
-
:data-block="block || undefined"
|
|
92
|
-
>
|
|
93
|
-
<button
|
|
94
|
-
v-for="o in options"
|
|
95
|
-
:key="o.value"
|
|
96
|
-
v-tooltip="{ text: o.hint ?? (iconOnly || o.kbd ? o.label : null), kbd: o.kbd }"
|
|
97
|
-
type="button"
|
|
98
|
-
role="radio"
|
|
99
|
-
class="wp-segmented__opt"
|
|
100
|
-
:aria-checked="modelValue === o.value"
|
|
101
|
-
:aria-label="iconOnly ? o.label : undefined"
|
|
102
|
-
:data-active="modelValue === o.value ? 'true' : 'false'"
|
|
103
|
-
:data-test="o.testId ?? (testPrefix ? `${testPrefix}-${o.value}` : undefined)"
|
|
104
|
-
:tabindex="o.value === tabStop() && !disabled ? 0 : -1"
|
|
105
|
-
:disabled="disabled || o.disabled"
|
|
106
|
-
@click="pick(o.value)"
|
|
107
|
-
@keydown="onKeydown"
|
|
108
|
-
>
|
|
109
|
-
<i v-if="o.icon" class="pi" :class="o.icon" aria-hidden="true" />
|
|
110
|
-
<span v-if="!iconOnly" class="wp-segmented__label">{{ o.label }}</span>
|
|
111
|
-
</button>
|
|
112
|
-
</div>
|
|
113
|
-
</template>
|
|
114
|
-
|
|
115
|
-
<style scoped>
|
|
116
|
-
.wp-segmented {
|
|
117
|
-
display: inline-flex;
|
|
118
|
-
flex: none;
|
|
119
|
-
/* Keep its own width inside a column flex parent; block fills the row. */
|
|
120
|
-
align-self: flex-start;
|
|
121
|
-
max-width: 100%;
|
|
122
|
-
padding: 2px;
|
|
123
|
-
gap: 2px;
|
|
124
|
-
border: 1px solid var(--wp-border);
|
|
125
|
-
border-radius: var(--wp-radius);
|
|
126
|
-
background: var(--wp-bg-2);
|
|
127
|
-
height: var(--wp-control-h-md);
|
|
128
|
-
box-sizing: border-box;
|
|
129
|
-
}
|
|
130
|
-
.wp-segmented[data-size="sm"] { height: var(--wp-control-h-sm); border-radius: var(--wp-radius-sm); }
|
|
131
|
-
.wp-segmented[data-size="lg"] { height: var(--wp-control-h-lg); }
|
|
132
|
-
.wp-segmented__opt {
|
|
133
|
-
display: inline-flex;
|
|
134
|
-
align-items: center;
|
|
135
|
-
justify-content: center;
|
|
136
|
-
gap: 6px;
|
|
137
|
-
min-width: 0;
|
|
138
|
-
height: 100%;
|
|
139
|
-
margin: 0;
|
|
140
|
-
padding: 0 var(--wp-space-5);
|
|
141
|
-
border: 0;
|
|
142
|
-
border-radius: calc(var(--wp-radius) - 2px);
|
|
143
|
-
background: transparent;
|
|
144
|
-
color: var(--wp-text-muted);
|
|
145
|
-
font: 500 var(--wp-text-sm)/1 var(--wp-font-sans);
|
|
146
|
-
white-space: nowrap;
|
|
147
|
-
cursor: pointer;
|
|
148
|
-
transition: background var(--wp-motion-hover), color var(--wp-motion-hover);
|
|
149
|
-
}
|
|
150
|
-
.wp-segmented[data-size="sm"] .wp-segmented__opt {
|
|
151
|
-
padding: 0 var(--wp-space-4);
|
|
152
|
-
border-radius: calc(var(--wp-radius-sm) - 1px);
|
|
153
|
-
font-size: var(--wp-text-xs);
|
|
154
|
-
gap: 4px;
|
|
155
|
-
}
|
|
156
|
-
.wp-segmented[data-size="lg"] .wp-segmented__opt { padding: 0 var(--wp-space-6); font-size: var(--wp-text-base); }
|
|
157
|
-
.wp-segmented[data-block] { display: flex; width: 100%; align-self: stretch; }
|
|
158
|
-
.wp-segmented[data-block] .wp-segmented__opt { flex: 1 1 0; }
|
|
159
|
-
.wp-segmented__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
160
|
-
.wp-segmented[data-icon-only] .wp-segmented__opt { aspect-ratio: 1; padding: 0; }
|
|
161
|
-
.wp-segmented__opt:hover:not(:disabled) { color: var(--wp-text); background: var(--wp-bg-3); }
|
|
162
|
-
.wp-segmented__opt[data-active="true"] {
|
|
163
|
-
background: var(--wp-bg-4);
|
|
164
|
-
color: var(--wp-text);
|
|
165
|
-
box-shadow: var(--wp-shadow-sm), inset 0 0 0 1px var(--wp-border-strong);
|
|
166
|
-
font-weight: 600;
|
|
167
|
-
}
|
|
168
|
-
.wp-segmented__opt[data-active="true"] .pi { color: var(--wp-accent-text); }
|
|
169
|
-
.wp-segmented__opt:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
170
|
-
.wp-segmented__opt:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
171
|
-
.wp-segmented__opt .pi { font-size: 0.95em; }
|
|
172
|
-
@media (prefers-reduced-motion: reduce) {
|
|
173
|
-
.wp-segmented__opt { transition: none; }
|
|
174
|
-
}
|
|
175
|
-
</style>
|
|
1
|
+
<script setup lang="ts" generic="T extends string">
|
|
2
|
+
/**
|
|
3
|
+
* Segmented control: two to five short choices shown as joined buttons, one
|
|
4
|
+
* of them on. For this few, showing them all is faster to read and change
|
|
5
|
+
* than a select. Works on the canvas (`size="sm"`) and in the manager (`md`).
|
|
6
|
+
*
|
|
7
|
+
* It is a radio group (APG): only the chosen option is in the Tab order, the
|
|
8
|
+
* arrow keys move to the next enabled option and choose it, Home/End jump to
|
|
9
|
+
* the ends. With `icon-only` the labels become the buttons' names and
|
|
10
|
+
* tooltips.
|
|
11
|
+
*
|
|
12
|
+
* <WpSegmented v-model="theme" :options="[{ value: 'dark', label: 'Dark', icon: 'pi-moon' }]" aria-label="Theme" />
|
|
13
|
+
*/
|
|
14
|
+
import { nextTick, ref } from "vue";
|
|
15
|
+
import { vTooltip } from "./tooltip";
|
|
16
|
+
|
|
17
|
+
export interface SegmentedOption<V extends string = string> {
|
|
18
|
+
value: V;
|
|
19
|
+
label: string;
|
|
20
|
+
/** A PrimeIcons class, e.g. `pi-moon`. */
|
|
21
|
+
icon?: string | undefined;
|
|
22
|
+
disabled?: boolean | undefined;
|
|
23
|
+
/** A tooltip saying what the option does. */
|
|
24
|
+
hint?: string | undefined;
|
|
25
|
+
/** A shortcut shown in the tooltip's key chip, e.g. "G". */
|
|
26
|
+
kbd?: string | undefined;
|
|
27
|
+
/** This option's `data-test`, when `testPrefix` doesn't fit. */
|
|
28
|
+
testId?: string | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const props = withDefaults(defineProps<{
|
|
32
|
+
modelValue: T;
|
|
33
|
+
options: SegmentedOption<T>[];
|
|
34
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
35
|
+
/** Show only the icons; each label names its button and is its tooltip. */
|
|
36
|
+
iconOnly?: boolean | undefined;
|
|
37
|
+
disabled?: boolean | undefined;
|
|
38
|
+
/** Fill the row: options share the width and long labels end in "…". */
|
|
39
|
+
block?: boolean | undefined;
|
|
40
|
+
ariaLabel?: string | undefined;
|
|
41
|
+
/** `data-test` of each option is `<testPrefix>-<value>`. */
|
|
42
|
+
testPrefix?: string | undefined;
|
|
43
|
+
}>(), { size: "md" });
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{ (e: "update:modelValue", v: T): void }>();
|
|
46
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
47
|
+
|
|
48
|
+
function pick(v: T): void {
|
|
49
|
+
if (props.disabled) return;
|
|
50
|
+
if (v !== props.modelValue) emit("update:modelValue", v);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The option that takes Tab focus: the chosen one, else the first enabled. */
|
|
54
|
+
function tabStop(): T | undefined {
|
|
55
|
+
const live = props.options.filter((o) => !o.disabled);
|
|
56
|
+
return (live.find((o) => o.value === props.modelValue) ?? live[0])?.value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
60
|
+
if (props.disabled) return;
|
|
61
|
+
const live = props.options.filter((o) => !o.disabled);
|
|
62
|
+
if (!live.length) return;
|
|
63
|
+
const at = live.findIndex((o) => o.value === props.modelValue);
|
|
64
|
+
let next: number;
|
|
65
|
+
switch (ev.key) {
|
|
66
|
+
case "ArrowRight": case "ArrowDown": next = (at + 1) % live.length; break;
|
|
67
|
+
case "ArrowLeft": case "ArrowUp": next = at < 0 ? live.length - 1 : (at - 1 + live.length) % live.length; break;
|
|
68
|
+
case "Home": next = 0; break;
|
|
69
|
+
case "End": next = live.length - 1; break;
|
|
70
|
+
default: return;
|
|
71
|
+
}
|
|
72
|
+
ev.preventDefault();
|
|
73
|
+
const v = live[next].value;
|
|
74
|
+
pick(v);
|
|
75
|
+
void nextTick(() => {
|
|
76
|
+
const i = props.options.findIndex((o) => o.value === v);
|
|
77
|
+
rootEl.value?.querySelectorAll<HTMLElement>("[role=radio]")[i]?.focus();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<div
|
|
84
|
+
ref="rootEl"
|
|
85
|
+
class="wp-segmented"
|
|
86
|
+
role="radiogroup"
|
|
87
|
+
:aria-label="ariaLabel"
|
|
88
|
+
:aria-disabled="disabled || undefined"
|
|
89
|
+
:data-size="size"
|
|
90
|
+
:data-icon-only="iconOnly || undefined"
|
|
91
|
+
:data-block="block || undefined"
|
|
92
|
+
>
|
|
93
|
+
<button
|
|
94
|
+
v-for="o in options"
|
|
95
|
+
:key="o.value"
|
|
96
|
+
v-tooltip="{ text: o.hint ?? (iconOnly || o.kbd ? o.label : null), kbd: o.kbd }"
|
|
97
|
+
type="button"
|
|
98
|
+
role="radio"
|
|
99
|
+
class="wp-segmented__opt"
|
|
100
|
+
:aria-checked="modelValue === o.value"
|
|
101
|
+
:aria-label="iconOnly ? o.label : undefined"
|
|
102
|
+
:data-active="modelValue === o.value ? 'true' : 'false'"
|
|
103
|
+
:data-test="o.testId ?? (testPrefix ? `${testPrefix}-${o.value}` : undefined)"
|
|
104
|
+
:tabindex="o.value === tabStop() && !disabled ? 0 : -1"
|
|
105
|
+
:disabled="disabled || o.disabled"
|
|
106
|
+
@click="pick(o.value)"
|
|
107
|
+
@keydown="onKeydown"
|
|
108
|
+
>
|
|
109
|
+
<i v-if="o.icon" class="pi" :class="o.icon" aria-hidden="true" />
|
|
110
|
+
<span v-if="!iconOnly" class="wp-segmented__label">{{ o.label }}</span>
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<style scoped>
|
|
116
|
+
.wp-segmented {
|
|
117
|
+
display: inline-flex;
|
|
118
|
+
flex: none;
|
|
119
|
+
/* Keep its own width inside a column flex parent; block fills the row. */
|
|
120
|
+
align-self: flex-start;
|
|
121
|
+
max-width: 100%;
|
|
122
|
+
padding: 2px;
|
|
123
|
+
gap: 2px;
|
|
124
|
+
border: 1px solid var(--wp-border);
|
|
125
|
+
border-radius: var(--wp-radius);
|
|
126
|
+
background: var(--wp-bg-2);
|
|
127
|
+
height: var(--wp-control-h-md);
|
|
128
|
+
box-sizing: border-box;
|
|
129
|
+
}
|
|
130
|
+
.wp-segmented[data-size="sm"] { height: var(--wp-control-h-sm); border-radius: var(--wp-radius-sm); }
|
|
131
|
+
.wp-segmented[data-size="lg"] { height: var(--wp-control-h-lg); }
|
|
132
|
+
.wp-segmented__opt {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
gap: 6px;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
height: 100%;
|
|
139
|
+
margin: 0;
|
|
140
|
+
padding: 0 var(--wp-space-5);
|
|
141
|
+
border: 0;
|
|
142
|
+
border-radius: calc(var(--wp-radius) - 2px);
|
|
143
|
+
background: transparent;
|
|
144
|
+
color: var(--wp-text-muted);
|
|
145
|
+
font: 500 var(--wp-text-sm)/1 var(--wp-font-sans);
|
|
146
|
+
white-space: nowrap;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
transition: background var(--wp-motion-hover), color var(--wp-motion-hover);
|
|
149
|
+
}
|
|
150
|
+
.wp-segmented[data-size="sm"] .wp-segmented__opt {
|
|
151
|
+
padding: 0 var(--wp-space-4);
|
|
152
|
+
border-radius: calc(var(--wp-radius-sm) - 1px);
|
|
153
|
+
font-size: var(--wp-text-xs);
|
|
154
|
+
gap: 4px;
|
|
155
|
+
}
|
|
156
|
+
.wp-segmented[data-size="lg"] .wp-segmented__opt { padding: 0 var(--wp-space-6); font-size: var(--wp-text-base); }
|
|
157
|
+
.wp-segmented[data-block] { display: flex; width: 100%; align-self: stretch; }
|
|
158
|
+
.wp-segmented[data-block] .wp-segmented__opt { flex: 1 1 0; }
|
|
159
|
+
.wp-segmented__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
160
|
+
.wp-segmented[data-icon-only] .wp-segmented__opt { aspect-ratio: 1; padding: 0; }
|
|
161
|
+
.wp-segmented__opt:hover:not(:disabled) { color: var(--wp-text); background: var(--wp-bg-3); }
|
|
162
|
+
.wp-segmented__opt[data-active="true"] {
|
|
163
|
+
background: var(--wp-bg-4);
|
|
164
|
+
color: var(--wp-text);
|
|
165
|
+
box-shadow: var(--wp-shadow-sm), inset 0 0 0 1px var(--wp-border-strong);
|
|
166
|
+
font-weight: 600;
|
|
167
|
+
}
|
|
168
|
+
.wp-segmented__opt[data-active="true"] .pi { color: var(--wp-accent-text); }
|
|
169
|
+
.wp-segmented__opt:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--wp-border-focus); }
|
|
170
|
+
.wp-segmented__opt:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
171
|
+
.wp-segmented__opt .pi { font-size: 0.95em; }
|
|
172
|
+
@media (prefers-reduced-motion: reduce) {
|
|
173
|
+
.wp-segmented__opt { transition: none; }
|
|
174
|
+
}
|
|
175
|
+
</style>
|
package/src/WpSelect.vue
CHANGED
|
@@ -1,89 +1,93 @@
|
|
|
1
|
-
<script setup lang="ts" generic="T extends string | number">
|
|
2
|
-
/**
|
|
3
|
-
* Shared dropdown for the canvas tree. A real <select> (keyboard, screen
|
|
4
|
-
* readers and the option list come from the browser) drawn with our field
|
|
5
|
-
* look and chevron instead of the browser's control, so every canvas
|
|
6
|
-
* dropdown matches. The SPA's `ui/Select.vue` is a full custom listbox;
|
|
7
|
-
* the canvas bundle has no room for one, and nodes sit on a zoomable
|
|
8
|
-
* canvas where a native list positions itself correctly.
|
|
9
|
-
*
|
|
10
|
-
* `options` values may be strings or numbers; the chosen option's own
|
|
11
|
-
* value is emitted (not the DOM's string). `placeholder` adds a disabled
|
|
12
|
-
* first entry shown while nothing is chosen (`modelValue` null or ""), for
|
|
13
|
-
* "pick to add" menus.
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
<option
|
|
66
|
-
v-
|
|
67
|
-
:
|
|
68
|
-
>{{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
</
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
.wp-cselect__field
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
1
|
+
<script setup lang="ts" generic="T extends string | number">
|
|
2
|
+
/**
|
|
3
|
+
* Shared dropdown for the canvas tree. A real <select> (keyboard, screen
|
|
4
|
+
* readers and the option list come from the browser) drawn with our field
|
|
5
|
+
* look and chevron instead of the browser's control, so every canvas
|
|
6
|
+
* dropdown matches. The SPA's `ui/Select.vue` is a full custom listbox;
|
|
7
|
+
* the canvas bundle has no room for one, and nodes sit on a zoomable
|
|
8
|
+
* canvas where a native list positions itself correctly.
|
|
9
|
+
*
|
|
10
|
+
* `options` values may be strings or numbers; the chosen option's own
|
|
11
|
+
* value is emitted (not the DOM's string). `placeholder` adds a disabled
|
|
12
|
+
* first entry shown while nothing is chosen (`modelValue` null or ""), for
|
|
13
|
+
* "pick to add" menus. `size` (sm 24px default, md 32px) sets its height
|
|
14
|
+
* and font on the control scale; the box is the kit's field box
|
|
15
|
+
* (`field.css`), the same as every other field. A parent can still size it
|
|
16
|
+
* with a class on the root (height, width, font-size).
|
|
17
|
+
* `dataTest` lands on the <select>.
|
|
18
|
+
*/
|
|
19
|
+
import { nextTick } from "vue";
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
modelValue: T | null;
|
|
23
|
+
options: readonly { value: T; label: string; disabled?: boolean }[];
|
|
24
|
+
placeholder?: string | undefined;
|
|
25
|
+
ariaLabel?: string | undefined;
|
|
26
|
+
dataTest?: string | undefined;
|
|
27
|
+
disabled?: boolean | undefined;
|
|
28
|
+
/** Lands on the <select>, for a `<label for>`. */
|
|
29
|
+
id?: string | undefined;
|
|
30
|
+
size?: "sm" | "md" | undefined;
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
const emit = defineEmits<{ "update:modelValue": [value: T] }>();
|
|
34
|
+
|
|
35
|
+
function onChange(ev: Event): void {
|
|
36
|
+
const el = ev.target as HTMLSelectElement;
|
|
37
|
+
const i = el.selectedIndex - (props.placeholder === undefined ? 0 : 1);
|
|
38
|
+
const opt = props.options[i];
|
|
39
|
+
if (opt) emit("update:modelValue", opt.value);
|
|
40
|
+
// Stay controlled: when the parent keeps its value (a "pick to add" menu
|
|
41
|
+
// stays on its placeholder), put the shown entry back.
|
|
42
|
+
void nextTick(() => {
|
|
43
|
+
const at = props.options.findIndex((o) => o.value === props.modelValue);
|
|
44
|
+
el.selectedIndex = at < 0 ? 0 : at + (props.placeholder === undefined ? 0 : 1);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function selected(o: { value: T }): boolean {
|
|
49
|
+
return o.value === props.modelValue;
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<template>
|
|
54
|
+
<span class="wp-cselect" :class="[`wp-cselect--${size ?? 'sm'}`, { 'is-disabled': disabled }]">
|
|
55
|
+
<select
|
|
56
|
+
:id="id"
|
|
57
|
+
class="wp-cselect__field wp-field-box"
|
|
58
|
+
:class="`wp-field-box--${size ?? 'sm'}`"
|
|
59
|
+
:disabled="disabled"
|
|
60
|
+
:aria-label="ariaLabel"
|
|
61
|
+
:data-test="dataTest"
|
|
62
|
+
@change="onChange"
|
|
63
|
+
@keydown.stop
|
|
64
|
+
>
|
|
65
|
+
<option
|
|
66
|
+
v-if="placeholder !== undefined" value="" disabled
|
|
67
|
+
:selected="modelValue === null || modelValue === ''"
|
|
68
|
+
>{{ placeholder }}</option>
|
|
69
|
+
<option
|
|
70
|
+
v-for="o in options" :key="String(o.value)" :value="o.value"
|
|
71
|
+
:disabled="o.disabled" :selected="selected(o)"
|
|
72
|
+
>{{ o.label }}</option>
|
|
73
|
+
</select>
|
|
74
|
+
<svg class="wp-cselect__chev" width="8" height="5" viewBox="0 0 8 5" aria-hidden="true"><path d="M0 0 L4 5 L8 0 Z" fill="currentColor" /></svg>
|
|
75
|
+
</span>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<style scoped src="./field.css"></style>
|
|
79
|
+
|
|
80
|
+
<style scoped>
|
|
81
|
+
.wp-cselect { position: relative; display: inline-flex; align-items: stretch; height: var(--wp-control-h-sm); min-width: 0; font-size: 12px; }
|
|
82
|
+
.wp-cselect--md { height: var(--wp-control-h-md); font-size: 12.5px; }
|
|
83
|
+
/* After field.css: the root's height and font (or a parent's class on the
|
|
84
|
+
root) size the box. */
|
|
85
|
+
.wp-cselect__field {
|
|
86
|
+
flex: 1; min-width: 0; height: 100%; min-height: 0; padding: 0 20px 0 8px; margin: 0;
|
|
87
|
+
font: inherit; cursor: pointer;
|
|
88
|
+
appearance: none; -webkit-appearance: none; text-overflow: ellipsis;
|
|
89
|
+
}
|
|
90
|
+
.wp-cselect__field option { background: var(--wp-bg2, var(--wp-bg)); color: var(--wp-text); }
|
|
91
|
+
.wp-cselect__chev { position: absolute; right: 7px; top: 50%; transform: translateY(-50%); color: var(--wp-text-dim, #7a7d88); pointer-events: none; }
|
|
92
|
+
.wp-cselect.is-disabled .wp-cselect__field { cursor: not-allowed; }
|
|
93
|
+
</style>
|