@dumiflex/wp-kit 0.3.0 → 0.4.0

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