@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/Toolbar.vue
CHANGED
|
@@ -1,110 +1,110 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Toolbar: a row of controls that is one Tab stop. Inside it the arrow keys
|
|
4
|
-
* move between the controls, Home/End jump to the ends, and the toolbar
|
|
5
|
-
* remembers where you were (APG toolbar). `attached` joins its buttons into
|
|
6
|
-
* one button group. Works on the canvas and in the manager.
|
|
7
|
-
*
|
|
8
|
-
* <Toolbar label="Text">
|
|
9
|
-
* <Button variant="ghost" icon="pi-bold" aria-label="Bold" />
|
|
10
|
-
* <Separator orientation="vertical" />
|
|
11
|
-
* <Button variant="ghost" icon="pi-copy" aria-label="Copy" />
|
|
12
|
-
* </Toolbar>
|
|
13
|
-
*
|
|
14
|
-
* Put buttons, toggles and selects in it. A text box inside a toolbar keeps
|
|
15
|
-
* its own left/right keys.
|
|
16
|
-
*/
|
|
17
|
-
import { onMounted, onUpdated, ref } from "vue";
|
|
18
|
-
|
|
19
|
-
const props = withDefaults(defineProps<{
|
|
20
|
-
/** The toolbar's name for screen readers. */
|
|
21
|
-
label: string;
|
|
22
|
-
orientation?: "horizontal" | "vertical" | undefined;
|
|
23
|
-
/** Join the buttons into one group with shared borders. */
|
|
24
|
-
attached?: boolean | undefined;
|
|
25
|
-
/** Gap between controls. */
|
|
26
|
-
size?: "sm" | "md" | undefined;
|
|
27
|
-
}>(), { orientation: "horizontal", size: "md" });
|
|
28
|
-
|
|
29
|
-
const rootEl = ref<HTMLElement | null>(null);
|
|
30
|
-
const last = ref(0);
|
|
31
|
-
|
|
32
|
-
const ITEMS = "button:not([disabled]), [role=button]:not([aria-disabled=true]), a[href], input:not([disabled]), select:not([disabled]), [role=switch], [role=checkbox], [role=radio]";
|
|
33
|
-
|
|
34
|
-
function items(): HTMLElement[] {
|
|
35
|
-
return rootEl.value ? [...rootEl.value.querySelectorAll<HTMLElement>(ITEMS)] : [];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Roving tabindex: only the remembered control is in the Tab order. */
|
|
39
|
-
function sync(): void {
|
|
40
|
-
const list = items();
|
|
41
|
-
if (!list.length) return;
|
|
42
|
-
const at = Math.min(last.value, list.length - 1);
|
|
43
|
-
list.forEach((el, i) => el.setAttribute("tabindex", i === at ? "0" : "-1"));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function isTextField(el: Element | null): boolean {
|
|
47
|
-
return !!el && el.tagName === "INPUT" && !["checkbox", "radio", "button"].includes((el as HTMLInputElement).type);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function onKeydown(ev: KeyboardEvent): void {
|
|
51
|
-
const list = items();
|
|
52
|
-
const at = list.indexOf(document.activeElement as HTMLElement);
|
|
53
|
-
if (at < 0) return;
|
|
54
|
-
const fwd = props.orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
55
|
-
const back = props.orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
|
|
56
|
-
if (isTextField(document.activeElement) && (ev.key === "ArrowLeft" || ev.key === "ArrowRight" || ev.key === "Home" || ev.key === "End")) return;
|
|
57
|
-
let next: number;
|
|
58
|
-
if (ev.key === fwd) next = (at + 1) % list.length;
|
|
59
|
-
else if (ev.key === back) next = (at - 1 + list.length) % list.length;
|
|
60
|
-
else if (ev.key === "Home") next = 0;
|
|
61
|
-
else if (ev.key === "End") next = list.length - 1;
|
|
62
|
-
else return;
|
|
63
|
-
ev.preventDefault();
|
|
64
|
-
last.value = next;
|
|
65
|
-
sync();
|
|
66
|
-
list[next].focus();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function onFocusin(ev: FocusEvent): void {
|
|
70
|
-
const i = items().indexOf(ev.target as HTMLElement);
|
|
71
|
-
if (i >= 0 && i !== last.value) { last.value = i; sync(); }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
onMounted(sync);
|
|
75
|
-
onUpdated(sync);
|
|
76
|
-
</script>
|
|
77
|
-
|
|
78
|
-
<!-- role="toolbar" moves focus between its own controls. -->
|
|
79
|
-
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
|
80
|
-
<template>
|
|
81
|
-
<div
|
|
82
|
-
ref="rootEl"
|
|
83
|
-
class="wp-tools"
|
|
84
|
-
role="toolbar"
|
|
85
|
-
:aria-label="label"
|
|
86
|
-
:aria-orientation="orientation"
|
|
87
|
-
:data-orientation="orientation"
|
|
88
|
-
:data-attached="attached || undefined"
|
|
89
|
-
:data-size="size"
|
|
90
|
-
@keydown="onKeydown"
|
|
91
|
-
@focusin="onFocusin"
|
|
92
|
-
>
|
|
93
|
-
<slot />
|
|
94
|
-
</div>
|
|
95
|
-
</template>
|
|
96
|
-
|
|
97
|
-
<style scoped>
|
|
98
|
-
.wp-tools { display: inline-flex; align-items: center; gap: var(--wp-space-2); flex-wrap: wrap; min-width: 0; }
|
|
99
|
-
.wp-tools[data-size="md"] { gap: var(--wp-space-3); }
|
|
100
|
-
.wp-tools[data-orientation="vertical"] { flex-direction: column; align-items: stretch; flex-wrap: nowrap; }
|
|
101
|
-
.wp-tools[data-attached] { gap: 0; flex-wrap: nowrap; }
|
|
102
|
-
.wp-tools[data-attached] > :deep(*) { border-radius: 0; }
|
|
103
|
-
.wp-tools[data-attached] > :deep(*:not(:first-child)) { margin-left: -1px; }
|
|
104
|
-
.wp-tools[data-attached] > :deep(*:first-child) { border-top-left-radius: var(--wp-radius); border-bottom-left-radius: var(--wp-radius); }
|
|
105
|
-
.wp-tools[data-attached] > :deep(*:last-child) { border-top-right-radius: var(--wp-radius); border-bottom-right-radius: var(--wp-radius); }
|
|
106
|
-
.wp-tools[data-attached] > :deep(*:focus-visible) { position: relative; z-index: 1; }
|
|
107
|
-
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:not(:first-child)) { margin-left: 0; margin-top: -1px; }
|
|
108
|
-
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:first-child) { border-radius: var(--wp-radius) var(--wp-radius) 0 0; }
|
|
109
|
-
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:last-child) { border-radius: 0 0 var(--wp-radius) var(--wp-radius); }
|
|
110
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Toolbar: a row of controls that is one Tab stop. Inside it the arrow keys
|
|
4
|
+
* move between the controls, Home/End jump to the ends, and the toolbar
|
|
5
|
+
* remembers where you were (APG toolbar). `attached` joins its buttons into
|
|
6
|
+
* one button group. Works on the canvas and in the manager.
|
|
7
|
+
*
|
|
8
|
+
* <Toolbar label="Text">
|
|
9
|
+
* <Button variant="ghost" icon="pi-bold" aria-label="Bold" />
|
|
10
|
+
* <Separator orientation="vertical" />
|
|
11
|
+
* <Button variant="ghost" icon="pi-copy" aria-label="Copy" />
|
|
12
|
+
* </Toolbar>
|
|
13
|
+
*
|
|
14
|
+
* Put buttons, toggles and selects in it. A text box inside a toolbar keeps
|
|
15
|
+
* its own left/right keys.
|
|
16
|
+
*/
|
|
17
|
+
import { onMounted, onUpdated, ref } from "vue";
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<{
|
|
20
|
+
/** The toolbar's name for screen readers. */
|
|
21
|
+
label: string;
|
|
22
|
+
orientation?: "horizontal" | "vertical" | undefined;
|
|
23
|
+
/** Join the buttons into one group with shared borders. */
|
|
24
|
+
attached?: boolean | undefined;
|
|
25
|
+
/** Gap between controls. */
|
|
26
|
+
size?: "sm" | "md" | undefined;
|
|
27
|
+
}>(), { orientation: "horizontal", size: "md" });
|
|
28
|
+
|
|
29
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
30
|
+
const last = ref(0);
|
|
31
|
+
|
|
32
|
+
const ITEMS = "button:not([disabled]), [role=button]:not([aria-disabled=true]), a[href], input:not([disabled]), select:not([disabled]), [role=switch], [role=checkbox], [role=radio]";
|
|
33
|
+
|
|
34
|
+
function items(): HTMLElement[] {
|
|
35
|
+
return rootEl.value ? [...rootEl.value.querySelectorAll<HTMLElement>(ITEMS)] : [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Roving tabindex: only the remembered control is in the Tab order. */
|
|
39
|
+
function sync(): void {
|
|
40
|
+
const list = items();
|
|
41
|
+
if (!list.length) return;
|
|
42
|
+
const at = Math.min(last.value, list.length - 1);
|
|
43
|
+
list.forEach((el, i) => el.setAttribute("tabindex", i === at ? "0" : "-1"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isTextField(el: Element | null): boolean {
|
|
47
|
+
return !!el && el.tagName === "INPUT" && !["checkbox", "radio", "button"].includes((el as HTMLInputElement).type);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
51
|
+
const list = items();
|
|
52
|
+
const at = list.indexOf(document.activeElement as HTMLElement);
|
|
53
|
+
if (at < 0) return;
|
|
54
|
+
const fwd = props.orientation === "horizontal" ? "ArrowRight" : "ArrowDown";
|
|
55
|
+
const back = props.orientation === "horizontal" ? "ArrowLeft" : "ArrowUp";
|
|
56
|
+
if (isTextField(document.activeElement) && (ev.key === "ArrowLeft" || ev.key === "ArrowRight" || ev.key === "Home" || ev.key === "End")) return;
|
|
57
|
+
let next: number;
|
|
58
|
+
if (ev.key === fwd) next = (at + 1) % list.length;
|
|
59
|
+
else if (ev.key === back) next = (at - 1 + list.length) % list.length;
|
|
60
|
+
else if (ev.key === "Home") next = 0;
|
|
61
|
+
else if (ev.key === "End") next = list.length - 1;
|
|
62
|
+
else return;
|
|
63
|
+
ev.preventDefault();
|
|
64
|
+
last.value = next;
|
|
65
|
+
sync();
|
|
66
|
+
list[next].focus();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function onFocusin(ev: FocusEvent): void {
|
|
70
|
+
const i = items().indexOf(ev.target as HTMLElement);
|
|
71
|
+
if (i >= 0 && i !== last.value) { last.value = i; sync(); }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onMounted(sync);
|
|
75
|
+
onUpdated(sync);
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<!-- role="toolbar" moves focus between its own controls. -->
|
|
79
|
+
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
|
|
80
|
+
<template>
|
|
81
|
+
<div
|
|
82
|
+
ref="rootEl"
|
|
83
|
+
class="wp-tools"
|
|
84
|
+
role="toolbar"
|
|
85
|
+
:aria-label="label"
|
|
86
|
+
:aria-orientation="orientation"
|
|
87
|
+
:data-orientation="orientation"
|
|
88
|
+
:data-attached="attached || undefined"
|
|
89
|
+
:data-size="size"
|
|
90
|
+
@keydown="onKeydown"
|
|
91
|
+
@focusin="onFocusin"
|
|
92
|
+
>
|
|
93
|
+
<slot />
|
|
94
|
+
</div>
|
|
95
|
+
</template>
|
|
96
|
+
|
|
97
|
+
<style scoped>
|
|
98
|
+
.wp-tools { display: inline-flex; align-items: center; gap: var(--wp-space-2); flex-wrap: wrap; min-width: 0; }
|
|
99
|
+
.wp-tools[data-size="md"] { gap: var(--wp-space-3); }
|
|
100
|
+
.wp-tools[data-orientation="vertical"] { flex-direction: column; align-items: stretch; flex-wrap: nowrap; }
|
|
101
|
+
.wp-tools[data-attached] { gap: 0; flex-wrap: nowrap; }
|
|
102
|
+
.wp-tools[data-attached] > :deep(*) { border-radius: 0; }
|
|
103
|
+
.wp-tools[data-attached] > :deep(*:not(:first-child)) { margin-left: -1px; }
|
|
104
|
+
.wp-tools[data-attached] > :deep(*:first-child) { border-top-left-radius: var(--wp-radius); border-bottom-left-radius: var(--wp-radius); }
|
|
105
|
+
.wp-tools[data-attached] > :deep(*:last-child) { border-top-right-radius: var(--wp-radius); border-bottom-right-radius: var(--wp-radius); }
|
|
106
|
+
.wp-tools[data-attached] > :deep(*:focus-visible) { position: relative; z-index: 1; }
|
|
107
|
+
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:not(:first-child)) { margin-left: 0; margin-top: -1px; }
|
|
108
|
+
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:first-child) { border-radius: var(--wp-radius) var(--wp-radius) 0 0; }
|
|
109
|
+
.wp-tools[data-attached][data-orientation="vertical"] > :deep(*:last-child) { border-radius: 0 0 var(--wp-radius) var(--wp-radius); }
|
|
110
|
+
</style>
|
package/src/VirtualList.vue
CHANGED
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
<script setup lang="ts" generic="T">
|
|
2
|
-
/**
|
|
3
|
-
* Virtual list: a long list (thousands of options, every library item)
|
|
4
|
-
* where only the rows in view are built, so it scrolls smoothly. Rows have
|
|
5
|
-
* one fixed height. Works on the canvas and in the manager.
|
|
6
|
-
*
|
|
7
|
-
* <VirtualList :items="options" :item-height="32" height="320px" :item-key="(o) => o.id" label="Options">
|
|
8
|
-
* <template #item="{ item, index }">…</template>
|
|
9
|
-
* </VirtualList>
|
|
10
|
-
*
|
|
11
|
-
* The rows keep their place in the list for screen readers
|
|
12
|
-
* (`aria-setsize` / `aria-posinset`). `scrollToIndex(i)` brings a row into
|
|
13
|
-
* view, e.g. the one a keyboard user moved to.
|
|
14
|
-
*/
|
|
15
|
-
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
16
|
-
|
|
17
|
-
const props = withDefaults(defineProps<{
|
|
18
|
-
items: T[];
|
|
19
|
-
itemKey: (item: T, index: number) => string | number;
|
|
20
|
-
/** Every row's height in px. */
|
|
21
|
-
itemHeight: number;
|
|
22
|
-
/** CSS height of the scrolling box. */
|
|
23
|
-
height?: string | undefined;
|
|
24
|
-
/** Rows built above and below the view. */
|
|
25
|
-
overscan?: number | undefined;
|
|
26
|
-
label?: string | undefined;
|
|
27
|
-
}>(), { height: "320px", overscan: 6, label: undefined });
|
|
28
|
-
|
|
29
|
-
const boxEl = ref<HTMLElement | null>(null);
|
|
30
|
-
const top = ref(0);
|
|
31
|
-
const viewH = ref(320);
|
|
32
|
-
|
|
33
|
-
const first = computed(() => Math.max(0, Math.floor(top.value / props.itemHeight) - props.overscan));
|
|
34
|
-
const last = computed(() => Math.min(props.items.length, Math.ceil((top.value + viewH.value) / props.itemHeight) + props.overscan));
|
|
35
|
-
const shown = computed(() => props.items.slice(first.value, last.value).map((item, k) => ({ item, index: first.value + k })));
|
|
36
|
-
|
|
37
|
-
function onScroll(): void {
|
|
38
|
-
top.value = boxEl.value?.scrollTop ?? 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
let ro: ResizeObserver | null = null;
|
|
42
|
-
onMounted(() => {
|
|
43
|
-
viewH.value = boxEl.value?.clientHeight || viewH.value;
|
|
44
|
-
if (typeof ResizeObserver !== "undefined" && boxEl.value) {
|
|
45
|
-
ro = new ResizeObserver(() => { viewH.value = boxEl.value?.clientHeight || viewH.value; });
|
|
46
|
-
ro.observe(boxEl.value);
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
onBeforeUnmount(() => ro?.disconnect());
|
|
50
|
-
|
|
51
|
-
/** Scroll so row `i` is in view (nearest edge). */
|
|
52
|
-
function scrollToIndex(i: number): void {
|
|
53
|
-
const el = boxEl.value;
|
|
54
|
-
if (!el) return;
|
|
55
|
-
const y = i * props.itemHeight;
|
|
56
|
-
if (y < el.scrollTop) el.scrollTop = y;
|
|
57
|
-
else if (y + props.itemHeight > el.scrollTop + el.clientHeight) el.scrollTop = y + props.itemHeight - el.clientHeight;
|
|
58
|
-
onScroll();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
defineExpose({ scrollToIndex });
|
|
62
|
-
</script>
|
|
63
|
-
|
|
64
|
-
<template>
|
|
65
|
-
<div ref="boxEl" class="wp-vlist" :style="{ height }" tabindex="0" role="list" :aria-label="label" @scroll.passive="onScroll">
|
|
66
|
-
<div class="wp-vlist__space" :style="{ height: `${items.length * itemHeight}px` }">
|
|
67
|
-
<div
|
|
68
|
-
v-for="row in shown"
|
|
69
|
-
:key="itemKey(row.item, row.index)"
|
|
70
|
-
class="wp-vlist__row"
|
|
71
|
-
role="listitem"
|
|
72
|
-
:aria-setsize="items.length"
|
|
73
|
-
:aria-posinset="row.index + 1"
|
|
74
|
-
:style="{ top: `${row.index * itemHeight}px`, height: `${itemHeight}px` }"
|
|
75
|
-
>
|
|
76
|
-
<slot name="item" :item="row.item" :index="row.index" />
|
|
77
|
-
</div>
|
|
78
|
-
</div>
|
|
79
|
-
</div>
|
|
80
|
-
</template>
|
|
81
|
-
|
|
82
|
-
<style scoped>
|
|
83
|
-
.wp-vlist { position: relative; overflow-y: auto; overscroll-behavior: contain; min-width: 0; outline: none; border-radius: var(--wp-radius); }
|
|
84
|
-
.wp-vlist:focus-visible { box-shadow: inset 0 0 0 2px var(--wp-border-focus); }
|
|
85
|
-
.wp-vlist__space { position: relative; width: 100%; }
|
|
86
|
-
.wp-vlist__row { position: absolute; left: 0; right: 0; display: flex; align-items: center; min-width: 0; box-sizing: border-box; }
|
|
87
|
-
</style>
|
|
1
|
+
<script setup lang="ts" generic="T">
|
|
2
|
+
/**
|
|
3
|
+
* Virtual list: a long list (thousands of options, every library item)
|
|
4
|
+
* where only the rows in view are built, so it scrolls smoothly. Rows have
|
|
5
|
+
* one fixed height. Works on the canvas and in the manager.
|
|
6
|
+
*
|
|
7
|
+
* <VirtualList :items="options" :item-height="32" height="320px" :item-key="(o) => o.id" label="Options">
|
|
8
|
+
* <template #item="{ item, index }">…</template>
|
|
9
|
+
* </VirtualList>
|
|
10
|
+
*
|
|
11
|
+
* The rows keep their place in the list for screen readers
|
|
12
|
+
* (`aria-setsize` / `aria-posinset`). `scrollToIndex(i)` brings a row into
|
|
13
|
+
* view, e.g. the one a keyboard user moved to.
|
|
14
|
+
*/
|
|
15
|
+
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
16
|
+
|
|
17
|
+
const props = withDefaults(defineProps<{
|
|
18
|
+
items: T[];
|
|
19
|
+
itemKey: (item: T, index: number) => string | number;
|
|
20
|
+
/** Every row's height in px. */
|
|
21
|
+
itemHeight: number;
|
|
22
|
+
/** CSS height of the scrolling box. */
|
|
23
|
+
height?: string | undefined;
|
|
24
|
+
/** Rows built above and below the view. */
|
|
25
|
+
overscan?: number | undefined;
|
|
26
|
+
label?: string | undefined;
|
|
27
|
+
}>(), { height: "320px", overscan: 6, label: undefined });
|
|
28
|
+
|
|
29
|
+
const boxEl = ref<HTMLElement | null>(null);
|
|
30
|
+
const top = ref(0);
|
|
31
|
+
const viewH = ref(320);
|
|
32
|
+
|
|
33
|
+
const first = computed(() => Math.max(0, Math.floor(top.value / props.itemHeight) - props.overscan));
|
|
34
|
+
const last = computed(() => Math.min(props.items.length, Math.ceil((top.value + viewH.value) / props.itemHeight) + props.overscan));
|
|
35
|
+
const shown = computed(() => props.items.slice(first.value, last.value).map((item, k) => ({ item, index: first.value + k })));
|
|
36
|
+
|
|
37
|
+
function onScroll(): void {
|
|
38
|
+
top.value = boxEl.value?.scrollTop ?? 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let ro: ResizeObserver | null = null;
|
|
42
|
+
onMounted(() => {
|
|
43
|
+
viewH.value = boxEl.value?.clientHeight || viewH.value;
|
|
44
|
+
if (typeof ResizeObserver !== "undefined" && boxEl.value) {
|
|
45
|
+
ro = new ResizeObserver(() => { viewH.value = boxEl.value?.clientHeight || viewH.value; });
|
|
46
|
+
ro.observe(boxEl.value);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
onBeforeUnmount(() => ro?.disconnect());
|
|
50
|
+
|
|
51
|
+
/** Scroll so row `i` is in view (nearest edge). */
|
|
52
|
+
function scrollToIndex(i: number): void {
|
|
53
|
+
const el = boxEl.value;
|
|
54
|
+
if (!el) return;
|
|
55
|
+
const y = i * props.itemHeight;
|
|
56
|
+
if (y < el.scrollTop) el.scrollTop = y;
|
|
57
|
+
else if (y + props.itemHeight > el.scrollTop + el.clientHeight) el.scrollTop = y + props.itemHeight - el.clientHeight;
|
|
58
|
+
onScroll();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
defineExpose({ scrollToIndex });
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div ref="boxEl" class="wp-vlist" :style="{ height }" tabindex="0" role="list" :aria-label="label" @scroll.passive="onScroll">
|
|
66
|
+
<div class="wp-vlist__space" :style="{ height: `${items.length * itemHeight}px` }">
|
|
67
|
+
<div
|
|
68
|
+
v-for="row in shown"
|
|
69
|
+
:key="itemKey(row.item, row.index)"
|
|
70
|
+
class="wp-vlist__row"
|
|
71
|
+
role="listitem"
|
|
72
|
+
:aria-setsize="items.length"
|
|
73
|
+
:aria-posinset="row.index + 1"
|
|
74
|
+
:style="{ top: `${row.index * itemHeight}px`, height: `${itemHeight}px` }"
|
|
75
|
+
>
|
|
76
|
+
<slot name="item" :item="row.item" :index="row.index" />
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
</template>
|
|
81
|
+
|
|
82
|
+
<style scoped>
|
|
83
|
+
.wp-vlist { position: relative; overflow-y: auto; overscroll-behavior: contain; min-width: 0; outline: none; border-radius: var(--wp-radius); }
|
|
84
|
+
.wp-vlist:focus-visible { box-shadow: inset 0 0 0 2px var(--wp-border-focus); }
|
|
85
|
+
.wp-vlist__space { position: relative; width: 100%; }
|
|
86
|
+
.wp-vlist__row { position: absolute; left: 0; right: 0; display: flex; align-items: center; min-width: 0; box-sizing: border-box; }
|
|
87
|
+
</style>
|
package/src/VisuallyHidden.vue
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* Visually hidden: text for screen readers only (a table header with no
|
|
4
|
-
* visible name, "(opens in a new tab)", a live status line). With
|
|
5
|
-
* `focusable` it shows itself while it has focus, for a "Skip to the list"
|
|
6
|
-
* link. Works on the canvas and in the manager.
|
|
7
|
-
*
|
|
8
|
-
* <VisuallyHidden>Select</VisuallyHidden>
|
|
9
|
-
* <VisuallyHidden as="div" aria-live="polite">{{ status }}</VisuallyHidden>
|
|
10
|
-
*/
|
|
11
|
-
withDefaults(defineProps<{
|
|
12
|
-
/** The element to draw. */
|
|
13
|
-
as?: string | undefined;
|
|
14
|
-
/** Show while focused (skip links). */
|
|
15
|
-
focusable?: boolean | undefined;
|
|
16
|
-
}>(), { as: "span" });
|
|
17
|
-
</script>
|
|
18
|
-
|
|
19
|
-
<template>
|
|
20
|
-
<component :is="as" class="wp-visually-hidden" :data-focusable="focusable || undefined"><slot /></component>
|
|
21
|
-
</template>
|
|
22
|
-
|
|
23
|
-
<style scoped>
|
|
24
|
-
.wp-visually-hidden:not([data-focusable]),
|
|
25
|
-
.wp-visually-hidden[data-focusable]:not(:focus):not(:focus-within) {
|
|
26
|
-
position: absolute !important; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
27
|
-
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
28
|
-
}
|
|
29
|
-
</style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Visually hidden: text for screen readers only (a table header with no
|
|
4
|
+
* visible name, "(opens in a new tab)", a live status line). With
|
|
5
|
+
* `focusable` it shows itself while it has focus, for a "Skip to the list"
|
|
6
|
+
* link. Works on the canvas and in the manager.
|
|
7
|
+
*
|
|
8
|
+
* <VisuallyHidden>Select</VisuallyHidden>
|
|
9
|
+
* <VisuallyHidden as="div" aria-live="polite">{{ status }}</VisuallyHidden>
|
|
10
|
+
*/
|
|
11
|
+
withDefaults(defineProps<{
|
|
12
|
+
/** The element to draw. */
|
|
13
|
+
as?: string | undefined;
|
|
14
|
+
/** Show while focused (skip links). */
|
|
15
|
+
focusable?: boolean | undefined;
|
|
16
|
+
}>(), { as: "span" });
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<template>
|
|
20
|
+
<component :is="as" class="wp-visually-hidden" :data-focusable="focusable || undefined"><slot /></component>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<style scoped>
|
|
24
|
+
.wp-visually-hidden:not([data-focusable]),
|
|
25
|
+
.wp-visually-hidden[data-focusable]:not(:focus):not(:focus-within) {
|
|
26
|
+
position: absolute !important; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden;
|
|
27
|
+
clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0;
|
|
28
|
+
}
|
|
29
|
+
</style>
|
package/src/WpButton.vue
CHANGED
|
@@ -1,75 +1,75 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* The canvas twin of the manager Button (manager/components/ui/Button.vue).
|
|
4
|
-
* Same variants, sizes and states, same stylesheet (`button.css`), but the
|
|
5
|
-
* icon is a PrimeIcons class, as everywhere on the canvas, and the default
|
|
6
|
-
* size is the canvas `sm`.
|
|
7
|
-
*
|
|
8
|
-
* <WpButton variant="primary" icon="pi-plus">Add module</WpButton>
|
|
9
|
-
* <WpButton variant="ghost" icon="pi-download" aria-label="Download snapshot" />
|
|
10
|
-
* <WpButton variant="add" icon="pi-plus" block>Add rule</WpButton>
|
|
11
|
-
*
|
|
12
|
-
* With no slot content it is an icon button (`.wp-btn--icon`); its
|
|
13
|
-
* `aria-label` is also its tooltip (`hint` sets another, `kbd` adds a key
|
|
14
|
-
* chip). `pressed` turns it into a toggle (a "favourites only" star, a
|
|
15
|
-
* filter panel trigger). Loading swaps the icon for the sparkle, sets
|
|
16
|
-
* aria-busy and ignores clicks.
|
|
17
|
-
*
|
|
18
|
-
* The stylesheet is pulled in scoped, so the canvas never carries the
|
|
19
|
-
* `.wp-btn` classes globally. Row icon buttons are WpIconButton.
|
|
20
|
-
*/
|
|
21
|
-
import { computed, useSlots } from "vue";
|
|
22
|
-
import LoadingMark from "./LoadingMark.vue";
|
|
23
|
-
import { vTooltip } from "./tooltip";
|
|
24
|
-
|
|
25
|
-
const props = withDefaults(defineProps<{
|
|
26
|
-
variant?: "primary" | "secondary" | "ghost" | "danger" | "link" | "outline" | "add" | undefined;
|
|
27
|
-
size?: "sm" | "md" | "lg" | undefined;
|
|
28
|
-
/** PrimeIcons class (e.g. `pi-plus`), drawn before the label. */
|
|
29
|
-
icon?: string | undefined;
|
|
30
|
-
loading?: boolean | undefined;
|
|
31
|
-
disabled?: boolean | undefined;
|
|
32
|
-
/** Stretch to the container's full width. */
|
|
33
|
-
block?: boolean | undefined;
|
|
34
|
-
/** Makes it a toggle (`aria-pressed`): on, it wears the accent tint. */
|
|
35
|
-
pressed?: boolean | undefined;
|
|
36
|
-
type?: "button" | "submit" | "reset" | undefined;
|
|
37
|
-
ariaLabel?: string | undefined;
|
|
38
|
-
/** Tooltip text; an icon button shows its aria-label when this is unset.
|
|
39
|
-
* Use this, not `v-tooltip` on the component, which would fight the
|
|
40
|
-
* button's own. */
|
|
41
|
-
hint?: string | undefined;
|
|
42
|
-
/** A shortcut shown in the tooltip's key chip, e.g. "P". */
|
|
43
|
-
kbd?: string | undefined;
|
|
44
|
-
}>(), { variant: "secondary", size: "sm", icon: undefined, type: "button", ariaLabel: undefined, hint: undefined, kbd: undefined, pressed: undefined });
|
|
45
|
-
|
|
46
|
-
const emit = defineEmits<{ click: [evt: MouseEvent] }>();
|
|
47
|
-
|
|
48
|
-
const slots = useSlots();
|
|
49
|
-
const iconOnly = computed(() => !slots.default);
|
|
50
|
-
|
|
51
|
-
function onClick(e: MouseEvent): void {
|
|
52
|
-
if (props.disabled || props.loading) return;
|
|
53
|
-
emit("click", e);
|
|
54
|
-
}
|
|
55
|
-
</script>
|
|
56
|
-
|
|
57
|
-
<template>
|
|
58
|
-
<button
|
|
59
|
-
v-tooltip="{ text: hint ?? (iconOnly ? ariaLabel : undefined), kbd }"
|
|
60
|
-
:type="type"
|
|
61
|
-
class="wp-btn"
|
|
62
|
-
:class="[`wp-btn--${variant}`, `wp-btn--${size}`, { 'wp-btn--icon': iconOnly, 'wp-btn--block': block }]"
|
|
63
|
-
:disabled="disabled || loading"
|
|
64
|
-
:aria-label="ariaLabel"
|
|
65
|
-
:aria-busy="loading || undefined"
|
|
66
|
-
:aria-pressed="pressed === undefined ? undefined : pressed"
|
|
67
|
-
@click="onClick"
|
|
68
|
-
>
|
|
69
|
-
<LoadingMark v-if="loading" variant="sparkle" mono :size="size === 'lg' ? 16 : size === 'md' ? 14 : 12" decorative />
|
|
70
|
-
<i v-else-if="icon" class="pi" :class="icon" aria-hidden="true" />
|
|
71
|
-
<slot />
|
|
72
|
-
</button>
|
|
73
|
-
</template>
|
|
74
|
-
|
|
75
|
-
<style scoped src="./button.css"></style>
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The canvas twin of the manager Button (manager/components/ui/Button.vue).
|
|
4
|
+
* Same variants, sizes and states, same stylesheet (`button.css`), but the
|
|
5
|
+
* icon is a PrimeIcons class, as everywhere on the canvas, and the default
|
|
6
|
+
* size is the canvas `sm`.
|
|
7
|
+
*
|
|
8
|
+
* <WpButton variant="primary" icon="pi-plus">Add module</WpButton>
|
|
9
|
+
* <WpButton variant="ghost" icon="pi-download" aria-label="Download snapshot" />
|
|
10
|
+
* <WpButton variant="add" icon="pi-plus" block>Add rule</WpButton>
|
|
11
|
+
*
|
|
12
|
+
* With no slot content it is an icon button (`.wp-btn--icon`); its
|
|
13
|
+
* `aria-label` is also its tooltip (`hint` sets another, `kbd` adds a key
|
|
14
|
+
* chip). `pressed` turns it into a toggle (a "favourites only" star, a
|
|
15
|
+
* filter panel trigger). Loading swaps the icon for the sparkle, sets
|
|
16
|
+
* aria-busy and ignores clicks.
|
|
17
|
+
*
|
|
18
|
+
* The stylesheet is pulled in scoped, so the canvas never carries the
|
|
19
|
+
* `.wp-btn` classes globally. Row icon buttons are WpIconButton.
|
|
20
|
+
*/
|
|
21
|
+
import { computed, useSlots } from "vue";
|
|
22
|
+
import LoadingMark from "./LoadingMark.vue";
|
|
23
|
+
import { vTooltip } from "./tooltip";
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
26
|
+
variant?: "primary" | "secondary" | "ghost" | "danger" | "link" | "outline" | "add" | undefined;
|
|
27
|
+
size?: "sm" | "md" | "lg" | undefined;
|
|
28
|
+
/** PrimeIcons class (e.g. `pi-plus`), drawn before the label. */
|
|
29
|
+
icon?: string | undefined;
|
|
30
|
+
loading?: boolean | undefined;
|
|
31
|
+
disabled?: boolean | undefined;
|
|
32
|
+
/** Stretch to the container's full width. */
|
|
33
|
+
block?: boolean | undefined;
|
|
34
|
+
/** Makes it a toggle (`aria-pressed`): on, it wears the accent tint. */
|
|
35
|
+
pressed?: boolean | undefined;
|
|
36
|
+
type?: "button" | "submit" | "reset" | undefined;
|
|
37
|
+
ariaLabel?: string | undefined;
|
|
38
|
+
/** Tooltip text; an icon button shows its aria-label when this is unset.
|
|
39
|
+
* Use this, not `v-tooltip` on the component, which would fight the
|
|
40
|
+
* button's own. */
|
|
41
|
+
hint?: string | undefined;
|
|
42
|
+
/** A shortcut shown in the tooltip's key chip, e.g. "P". */
|
|
43
|
+
kbd?: string | undefined;
|
|
44
|
+
}>(), { variant: "secondary", size: "sm", icon: undefined, type: "button", ariaLabel: undefined, hint: undefined, kbd: undefined, pressed: undefined });
|
|
45
|
+
|
|
46
|
+
const emit = defineEmits<{ click: [evt: MouseEvent] }>();
|
|
47
|
+
|
|
48
|
+
const slots = useSlots();
|
|
49
|
+
const iconOnly = computed(() => !slots.default);
|
|
50
|
+
|
|
51
|
+
function onClick(e: MouseEvent): void {
|
|
52
|
+
if (props.disabled || props.loading) return;
|
|
53
|
+
emit("click", e);
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<button
|
|
59
|
+
v-tooltip="{ text: hint ?? (iconOnly ? ariaLabel : undefined), kbd }"
|
|
60
|
+
:type="type"
|
|
61
|
+
class="wp-btn"
|
|
62
|
+
:class="[`wp-btn--${variant}`, `wp-btn--${size}`, { 'wp-btn--icon': iconOnly, 'wp-btn--block': block }]"
|
|
63
|
+
:disabled="disabled || loading"
|
|
64
|
+
:aria-label="ariaLabel"
|
|
65
|
+
:aria-busy="loading || undefined"
|
|
66
|
+
:aria-pressed="pressed === undefined ? undefined : pressed"
|
|
67
|
+
@click="onClick"
|
|
68
|
+
>
|
|
69
|
+
<LoadingMark v-if="loading" variant="sparkle" mono :size="size === 'lg' ? 16 : size === 'md' ? 14 : 12" decorative />
|
|
70
|
+
<i v-else-if="icon" class="pi" :class="icon" aria-hidden="true" />
|
|
71
|
+
<slot />
|
|
72
|
+
</button>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<style scoped src="./button.css"></style>
|