@dumiflex/wp-kit 0.5.0 → 0.6.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 +32 -0
- package/README.md +5 -0
- package/package.json +4 -3
- package/src/Avatar.vue +1 -1
- package/src/Banner.vue +4 -4
- package/src/Button.vue +73 -44
- package/src/Checkbox.vue +201 -63
- package/src/Chip.vue +5 -2
- package/src/CodeBlock.vue +1 -1
- package/src/ColorField.vue +60 -6
- package/src/Combobox.vue +10 -14
- package/src/ConfirmModal.vue +12 -22
- package/src/ContextMenu.vue +11 -30
- package/src/CopyButton.vue +1 -1
- package/src/DataTable.vue +0 -2
- package/src/DatePicker.vue +1 -1
- package/src/Disclosure.vue +2 -1
- package/src/Drawer.vue +2 -2
- package/src/DropZone.vue +2 -2
- package/src/HsvPicker.vue +2 -2
- package/src/IconPicker.vue +1 -1
- package/src/ImageGrid.vue +2 -2
- package/src/Lightbox.vue +2 -2
- package/src/Modal.vue +109 -44
- package/src/ModalShell.vue +26 -5
- package/src/Pagination.vue +4 -4
- package/src/Popover.vue +68 -12
- package/src/RadioGroup.vue +5 -4
- package/src/SearchInput.vue +1 -1
- package/src/Select.vue +53 -158
- package/src/Skeleton.vue +1 -1
- package/src/Slider.vue +166 -55
- package/src/SortableList.vue +1 -1
- package/src/TagInput.vue +1 -1
- package/src/Timeline.vue +2 -2
- package/src/WorkingLine.vue +320 -0
- package/src/WpIconButton.vue +3 -2
- package/src/WpImageZoom.vue +7 -7
- package/src/WpSegmented.vue +3 -1
- package/src/WpSwitch.vue +1 -1
- package/src/WpTabs.vue +1 -1
- package/src/_modal-motion.css +1 -1
- package/src/button.css +8 -6
- package/src/floating.ts +5 -0
- package/src/hsv.ts +1 -1
- package/src/menu.css +51 -0
- package/src/motion.css +73 -0
- package/src/slider-math.ts +45 -1
- package/src/theme/looks.css +6 -19
- package/src/theme/tokens.css +12 -1108
- package/src/tokens.ts +8 -3
- package/src/tooltip.css +8 -5
- package/src/useDialogFocus.ts +11 -2
- package/src/CloseButton.vue +0 -69
- package/src/ColorPicker.vue +0 -201
- package/src/ConfirmDialog.vue +0 -165
- package/src/RangeSlider.vue +0 -183
- package/src/Toggle.vue +0 -36
- package/src/WpButton.vue +0 -75
- package/src/WpCheck.vue +0 -104
- package/src/_skeleton.css +0 -50
package/src/Modal.vue
CHANGED
|
@@ -1,36 +1,58 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Modal: the one dialog, in the manager, on the canvas and on the community
|
|
4
|
+
* site. A titled panel (header with ×, body, optional footer) on ModalShell,
|
|
5
|
+
* so it gets the shared overlay, open/close motion and the key shield that
|
|
6
|
+
* keeps typing away from ComfyUI's shortcuts; useDialogFocus adds the focus
|
|
7
|
+
* trap and focus return.
|
|
8
|
+
*
|
|
9
|
+
* <Modal v-model:open="open" title="Rename module">…<template #footer>…</template></Modal>
|
|
10
|
+
*
|
|
11
|
+
* Escape, the × and the backdrop close it (`close-on-backdrop="false"` keeps
|
|
12
|
+
* a backdrop click from closing a form that would lose work). A custom
|
|
13
|
+
* `#header` puts `:id="titleId"` on its own heading. `keydown` gives every key
|
|
14
|
+
* pressed inside (the shield stops them before `window`), so a dialog's own
|
|
15
|
+
* keys go there, not on a window listener. ConfirmModal is built on it.
|
|
16
|
+
*
|
|
17
|
+
* The panel styles are scoped and read only shared tokens, so it wears the
|
|
18
|
+
* colours of whichever side it is on.
|
|
19
|
+
*/
|
|
20
|
+
import { ref, useId, useSlots } from "vue";
|
|
21
|
+
import Button from "./Button.vue";
|
|
22
|
+
import ModalShell from "./ModalShell.vue";
|
|
4
23
|
import { useDialogFocus } from "./useDialogFocus";
|
|
5
24
|
|
|
6
|
-
|
|
25
|
+
const props = withDefaults(defineProps<{
|
|
7
26
|
open: boolean;
|
|
8
27
|
title?: string | undefined;
|
|
9
28
|
size?: "sm" | "md" | "lg" | undefined;
|
|
10
29
|
closeOnBackdrop?: boolean | undefined;
|
|
11
|
-
}
|
|
12
|
-
const props = withDefaults(defineProps<Props>(), { size: "md", closeOnBackdrop: true });
|
|
30
|
+
}>(), { size: "md", closeOnBackdrop: true, title: undefined });
|
|
13
31
|
|
|
14
32
|
const emit = defineEmits<{
|
|
15
33
|
(e: "update:open", v: boolean): void;
|
|
34
|
+
/** Every keydown inside the dialog. */
|
|
35
|
+
(e: "keydown", ev: KeyboardEvent): void;
|
|
16
36
|
}>();
|
|
17
37
|
|
|
18
38
|
const slots = useSlots();
|
|
19
|
-
|
|
20
|
-
const modalClasses = computed(() => ["wp-modal", `wp-modal--${props.size}`]);
|
|
21
39
|
const dialog = ref<HTMLElement | null>(null);
|
|
22
40
|
const titleId = `wp-modal-title-${useId()}`;
|
|
23
41
|
|
|
24
|
-
function close() {
|
|
42
|
+
function close(): void {
|
|
25
43
|
emit("update:open", false);
|
|
26
44
|
}
|
|
27
45
|
|
|
28
|
-
function
|
|
29
|
-
if (props.closeOnBackdrop)
|
|
46
|
+
function onShellClose(reason: "escape" | "backdrop"): void {
|
|
47
|
+
if (reason === "backdrop" && !props.closeOnBackdrop) return;
|
|
48
|
+
close();
|
|
30
49
|
}
|
|
31
50
|
|
|
32
|
-
useDialogFocus(dialog, () => props.open, {
|
|
33
|
-
|
|
51
|
+
const focus = useDialogFocus(dialog, () => props.open, {
|
|
52
|
+
// ModalShell closes on Escape; the shield stops keys before window, so the
|
|
53
|
+
// trap runs from the shell's keydown (below), not a window listener.
|
|
54
|
+
onEscape: () => undefined,
|
|
55
|
+
window: false,
|
|
34
56
|
// The first control in the body, else the dialog itself (the close button
|
|
35
57
|
// would make Enter close it).
|
|
36
58
|
initial: (root, els) => {
|
|
@@ -38,41 +60,84 @@ useDialogFocus(dialog, () => props.open, {
|
|
|
38
60
|
return els.find((el) => body?.contains(el)) ?? root;
|
|
39
61
|
},
|
|
40
62
|
});
|
|
63
|
+
|
|
64
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
65
|
+
focus.onKeydown(ev);
|
|
66
|
+
emit("keydown", ev);
|
|
67
|
+
}
|
|
41
68
|
</script>
|
|
42
69
|
|
|
43
70
|
<template>
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
tabindex="-1"
|
|
60
|
-
@mousedown.stop
|
|
61
|
-
>
|
|
62
|
-
<header v-if="title || slots.header" class="wp-modal__head">
|
|
63
|
-
<h3 v-if="title" :id="titleId" class="wp-modal__title">{{ title }}</h3>
|
|
64
|
-
<!-- A custom header puts :id="titleId" on its own heading. -->
|
|
65
|
-
<slot name="header" :title-id="titleId" />
|
|
66
|
-
<span class="wp-spacer" />
|
|
67
|
-
<CloseButton kbd="Esc" @click="close" />
|
|
68
|
-
</header>
|
|
69
|
-
<div class="wp-modal__body">
|
|
70
|
-
<slot />
|
|
71
|
-
</div>
|
|
72
|
-
<footer v-if="slots.footer" class="wp-modal__foot">
|
|
73
|
-
<slot name="footer" />
|
|
74
|
-
</footer>
|
|
71
|
+
<ModalShell
|
|
72
|
+
:visible="open"
|
|
73
|
+
:labelledby="title || slots.header ? titleId : undefined"
|
|
74
|
+
@close="onShellClose"
|
|
75
|
+
@keydown="onKeydown"
|
|
76
|
+
>
|
|
77
|
+
<div ref="dialog" class="wp-modal" :class="`wp-modal--${size}`" tabindex="-1" data-test="modal">
|
|
78
|
+
<header v-if="title || slots.header" class="wp-modal__head">
|
|
79
|
+
<h3 v-if="title" :id="titleId" class="wp-modal__title">{{ title }}</h3>
|
|
80
|
+
<slot name="header" :title-id="titleId" />
|
|
81
|
+
<span class="wp-modal__spacer" />
|
|
82
|
+
<Button variant="ghost" icon="times" aria-label="Close" kbd="Esc" data-test="modal-close" @click="close" />
|
|
83
|
+
</header>
|
|
84
|
+
<div class="wp-modal__body">
|
|
85
|
+
<slot />
|
|
75
86
|
</div>
|
|
87
|
+
<footer v-if="slots.footer" class="wp-modal__foot">
|
|
88
|
+
<slot name="footer" />
|
|
89
|
+
</footer>
|
|
76
90
|
</div>
|
|
77
|
-
</
|
|
91
|
+
</ModalShell>
|
|
78
92
|
</template>
|
|
93
|
+
|
|
94
|
+
<style scoped>
|
|
95
|
+
.wp-modal {
|
|
96
|
+
display: flex;
|
|
97
|
+
flex-direction: column;
|
|
98
|
+
width: 100%;
|
|
99
|
+
max-width: 560px;
|
|
100
|
+
max-height: calc(100vh - 48px);
|
|
101
|
+
overflow: hidden;
|
|
102
|
+
background: var(--wp-bg-2);
|
|
103
|
+
border: 1px solid var(--wp-border);
|
|
104
|
+
border-radius: var(--wp-radius-lg);
|
|
105
|
+
box-shadow: var(--wp-shadow-xl);
|
|
106
|
+
color: var(--wp-text);
|
|
107
|
+
font-family: var(--wp-font-sans);
|
|
108
|
+
font-size: var(--wp-text-base);
|
|
109
|
+
}
|
|
110
|
+
/* The dialog takes focus only when nothing in it can. */
|
|
111
|
+
.wp-modal:focus { outline: none; }
|
|
112
|
+
.wp-modal--sm { max-width: 380px; }
|
|
113
|
+
.wp-modal--md { max-width: 560px; }
|
|
114
|
+
.wp-modal--lg { max-width: 880px; }
|
|
115
|
+
.wp-modal__head {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: var(--wp-space-4);
|
|
119
|
+
padding: var(--wp-space-4) var(--wp-space-5);
|
|
120
|
+
border-bottom: 1px solid var(--wp-border);
|
|
121
|
+
background: var(--wp-bg);
|
|
122
|
+
}
|
|
123
|
+
.wp-modal__title { flex: 1; margin: 0; font-size: var(--wp-text-md); font-weight: 600; color: var(--wp-text); }
|
|
124
|
+
.wp-modal__spacer { flex: 1; }
|
|
125
|
+
.wp-modal__title + .wp-modal__spacer { display: none; }
|
|
126
|
+
.wp-modal__body {
|
|
127
|
+
flex: 1;
|
|
128
|
+
min-height: 0;
|
|
129
|
+
overflow: auto;
|
|
130
|
+
padding: var(--wp-space-5);
|
|
131
|
+
}
|
|
132
|
+
.wp-modal__foot {
|
|
133
|
+
display: flex;
|
|
134
|
+
justify-content: flex-end;
|
|
135
|
+
gap: var(--wp-space-3);
|
|
136
|
+
padding: var(--wp-space-4) var(--wp-space-5);
|
|
137
|
+
border-top: 1px solid var(--wp-border);
|
|
138
|
+
background: var(--wp-bg);
|
|
139
|
+
}
|
|
140
|
+
@media (max-width: 640px) {
|
|
141
|
+
.wp-modal__head, .wp-modal__body, .wp-modal__foot { padding-left: var(--wp-space-5); padding-right: var(--wp-space-5); }
|
|
142
|
+
}
|
|
143
|
+
</style>
|
package/src/ModalShell.vue
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
<Transition name="wp-modal" appear>
|
|
4
4
|
<!-- The overlay is a click target only; keyboard users close with Escape. -->
|
|
5
5
|
<!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -->
|
|
6
|
-
<div
|
|
6
|
+
<div
|
|
7
|
+
v-if="visible"
|
|
8
|
+
ref="overlayEl"
|
|
9
|
+
class="wp-modal-overlay"
|
|
10
|
+
data-test="modal-backdrop"
|
|
11
|
+
@mousedown="downOnBackdrop = isBackdrop($event)"
|
|
12
|
+
@click="onBackdropClick"
|
|
13
|
+
>
|
|
7
14
|
<div
|
|
15
|
+
ref="wrapperEl"
|
|
8
16
|
class="wp-modal-wrapper"
|
|
9
17
|
role="dialog"
|
|
10
18
|
aria-modal="true"
|
|
11
19
|
:aria-label="label"
|
|
12
20
|
:aria-labelledby="labelledby"
|
|
13
|
-
@click.stop
|
|
14
21
|
>
|
|
15
22
|
<slot></slot>
|
|
16
23
|
</div>
|
|
@@ -31,7 +38,8 @@ const props = defineProps<{
|
|
|
31
38
|
labelledby?: string | undefined;
|
|
32
39
|
}>();
|
|
33
40
|
const emit = defineEmits<{
|
|
34
|
-
|
|
41
|
+
/** `escape` or `backdrop`, so a dialog can ignore backdrop clicks. */
|
|
42
|
+
(e: "close", reason: "escape" | "backdrop"): void;
|
|
35
43
|
/** Every keydown inside the modal. Consumers MUST use this instead of a
|
|
36
44
|
* window listener: the shield stops inside-modal keys at the overlay, so a
|
|
37
45
|
* window listener would never see them. */
|
|
@@ -39,11 +47,24 @@ const emit = defineEmits<{
|
|
|
39
47
|
}>();
|
|
40
48
|
|
|
41
49
|
const overlayEl = ref<HTMLElement | null>(null);
|
|
50
|
+
const wrapperEl = ref<HTMLElement | null>(null);
|
|
51
|
+
|
|
52
|
+
/** The overlay or the wrapper around the panel, never the panel's own content. */
|
|
53
|
+
function isBackdrop(ev: MouseEvent): boolean {
|
|
54
|
+
return ev.target === overlayEl.value || ev.target === wrapperEl.value;
|
|
55
|
+
}
|
|
56
|
+
/** Only a press that also STARTED on the backdrop closes: a text selection
|
|
57
|
+
* dragged out of the dialog ends with a click on the overlay. */
|
|
58
|
+
const downOnBackdrop = ref(false);
|
|
59
|
+
function onBackdropClick(ev: MouseEvent): void {
|
|
60
|
+
if (downOnBackdrop.value && isBackdrop(ev)) emit("close", "backdrop");
|
|
61
|
+
downOnBackdrop.value = false;
|
|
62
|
+
}
|
|
42
63
|
|
|
43
64
|
function closeOnEscape(event: KeyboardEvent): void {
|
|
44
65
|
if (!props.visible || event.key !== "Escape") return;
|
|
45
66
|
event.preventDefault();
|
|
46
|
-
emit("close");
|
|
67
|
+
emit("close", "escape");
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
// Keys pressed INSIDE the modal are handled here and stop at the overlay, so
|
|
@@ -104,6 +125,6 @@ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
|
|
104
125
|
|
|
105
126
|
/* `wp-modal` open/close transition lives in
|
|
106
127
|
* src/components/shared/_modal-motion.css (imported globally via
|
|
107
|
-
* ContextWidget) so every modal — ModalShell consumers,
|
|
128
|
+
* ContextWidget) so every modal — ModalShell consumers (Modal, ConfirmModal),
|
|
108
129
|
* InjectorBindingModal — animates with the same rules. */
|
|
109
130
|
</style>
|
package/src/Pagination.vue
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { computed } from "vue";
|
|
15
15
|
import { pageItems } from "./pagination";
|
|
16
|
-
import
|
|
16
|
+
import Button from "./Button.vue";
|
|
17
17
|
|
|
18
18
|
const props = withDefaults(defineProps<{
|
|
19
19
|
/** pages: numbered pages. more: a Load more button. */
|
|
@@ -54,7 +54,7 @@ const btnSize = computed(() => (props.size === "sm" ? "sm" : "md"));
|
|
|
54
54
|
<span class="wp-paging__range" aria-live="polite">{{ from }}–{{ to }} of {{ total }}</span>
|
|
55
55
|
<ul class="wp-paging__list">
|
|
56
56
|
<li>
|
|
57
|
-
<
|
|
57
|
+
<Button variant="ghost" :size="btnSize" icon="pi-chevron-left" aria-label="Previous page" :disabled="disabled || props.page <= 1" @click="go(props.page - 1)" />
|
|
58
58
|
</li>
|
|
59
59
|
<li v-for="it in items" :key="it">
|
|
60
60
|
<span v-if="typeof it !== 'number'" class="wp-paging__gap" aria-hidden="true">…</span>
|
|
@@ -70,13 +70,13 @@ const btnSize = computed(() => (props.size === "sm" ? "sm" : "md"));
|
|
|
70
70
|
>{{ it }}</button>
|
|
71
71
|
</li>
|
|
72
72
|
<li>
|
|
73
|
-
<
|
|
73
|
+
<Button variant="ghost" :size="btnSize" icon="pi-chevron-right" aria-label="Next page" :disabled="disabled || props.page >= pages" @click="go(props.page + 1)" />
|
|
74
74
|
</li>
|
|
75
75
|
</ul>
|
|
76
76
|
</nav>
|
|
77
77
|
<div v-else-if="mode === 'more' && total > 0" class="wp-paging wp-paging--more" :data-size="size">
|
|
78
78
|
<span class="wp-paging__range" aria-live="polite">Showing {{ Math.min(props.shown, total) }} of {{ total }}{{ noun ? ` ${noun}` : "" }}</span>
|
|
79
|
-
<
|
|
79
|
+
<Button v-if="props.shown < total" variant="secondary" :size="btnSize" :loading="loading" :disabled="disabled" @click="emit('more')">Load more</Button>
|
|
80
80
|
</div>
|
|
81
81
|
</template>
|
|
82
82
|
|
package/src/Popover.vue
CHANGED
|
@@ -16,9 +16,15 @@
|
|
|
16
16
|
* element don't count as outside, so it can toggle the panel itself.
|
|
17
17
|
*
|
|
18
18
|
* <Popover :open="!!cell" :anchor="cell?.el" label="Cell rule" @update:open="cell = null">…</Popover>
|
|
19
|
+
*
|
|
20
|
+
* `variant="menu"` draws the panel as a menu (`menu.css`, the look Select's
|
|
21
|
+
* list, ContextMenu and Combobox share): tighter padding, 4px from the
|
|
22
|
+
* trigger, above modals, and it closes once its trigger scrolls out of view.
|
|
23
|
+
* Every panel is capped at `maxHeight` and at the room on its side, and
|
|
24
|
+
* scrolls past that instead of covering its trigger.
|
|
19
25
|
*/
|
|
20
26
|
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from "vue";
|
|
21
|
-
import { placeFloating, viewportSize, type Placement, type Side } from "./floating";
|
|
27
|
+
import { placeFloating, roomOn, viewportSize, type Placement, type Side } from "./floating";
|
|
22
28
|
import { provideFloatingLayer, useFloatingChild } from "./floating-layer";
|
|
23
29
|
|
|
24
30
|
const props = withDefaults(defineProps<{
|
|
@@ -34,14 +40,21 @@ const props = withDefaults(defineProps<{
|
|
|
34
40
|
role?: "dialog" | "menu" | "listbox" | "group" | undefined;
|
|
35
41
|
/** Place the panel against this element instead of the trigger slot. */
|
|
36
42
|
anchor?: HTMLElement | null | undefined;
|
|
37
|
-
|
|
43
|
+
/** "menu" for a list of choices (Select, Combobox): the shared menu look. */
|
|
44
|
+
variant?: "panel" | "menu" | undefined;
|
|
45
|
+
/** Tallest the panel grows, in px, before it scrolls. */
|
|
46
|
+
maxHeight?: number | undefined;
|
|
47
|
+
}>(), { placement: "bottom-start", autofocus: true, role: "dialog", anchor: null, variant: "panel" });
|
|
38
48
|
|
|
39
49
|
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
|
|
40
50
|
|
|
41
51
|
const id = `wp-popover-${useId()}`;
|
|
42
52
|
const anchorEl = ref<HTMLElement | null>(null);
|
|
43
53
|
const panelEl = ref<HTMLElement | null>(null);
|
|
44
|
-
const pos = ref<{ x: number; y: number; side: Side; minWidth?: number | undefined }>({ x: -9999, y: -9999, side: "bottom" });
|
|
54
|
+
const pos = ref<{ x: number; y: number; side: Side; minWidth?: number | undefined; maxHeight?: number | undefined }>({ x: -9999, y: -9999, side: "bottom" });
|
|
55
|
+
const isMenu = computed(() => props.variant === "menu");
|
|
56
|
+
/** A short panel still shows a few rows when the room on both sides is tight. */
|
|
57
|
+
const MIN_HEIGHT = 80;
|
|
45
58
|
|
|
46
59
|
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), '
|
|
47
60
|
+ 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
@@ -68,10 +81,39 @@ function place(): void {
|
|
|
68
81
|
const p = panelEl.value;
|
|
69
82
|
if (!t || !p) return;
|
|
70
83
|
const r = t.getBoundingClientRect();
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
const view = viewportSize();
|
|
85
|
+
const opts = { placement: props.placement, gap: isMenu.value ? 4 : 6 };
|
|
86
|
+
const width = Math.max(p.offsetWidth, props.matchWidth ? r.width : 0);
|
|
87
|
+
// The content's own height (scrollHeight ignores the cap set last time),
|
|
88
|
+
// so a panel that was squeezed can grow back when there is room again.
|
|
89
|
+
const borders = p.offsetHeight - p.clientHeight;
|
|
90
|
+
const natural = Math.min(p.scrollHeight + borders, props.maxHeight ?? Infinity);
|
|
91
|
+
const first = placeFloating(r, { width, height: natural }, view, opts);
|
|
92
|
+
const fits = Math.max(MIN_HEIGHT, roomOn(first.side, r, view, opts));
|
|
93
|
+
const height = Math.min(natural, fits);
|
|
94
|
+
const at = height < natural ? placeFloating(r, { width, height }, view, opts) : first;
|
|
95
|
+
pos.value = {
|
|
96
|
+
...at,
|
|
97
|
+
minWidth: props.matchWidth ? r.width : undefined,
|
|
98
|
+
maxHeight: Math.min(props.maxHeight ?? Infinity, fits),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True once no part of the trigger is on screen. */
|
|
103
|
+
function triggerGone(): boolean {
|
|
104
|
+
const t = trigger();
|
|
105
|
+
if (!t) return false;
|
|
106
|
+
const r = t.getBoundingClientRect();
|
|
107
|
+
const view = viewportSize();
|
|
108
|
+
return r.bottom < 0 || r.top > view.height || r.right < 0 || r.left > view.width;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onScroll(ev: Event): void {
|
|
112
|
+
// The panel's own scrolling changes nothing about where it sits.
|
|
113
|
+
if (ev.target instanceof Node && panelEl.value?.contains(ev.target)) return;
|
|
114
|
+
// A menu whose trigger has scrolled away has nothing left to follow.
|
|
115
|
+
if (isMenu.value && triggerGone()) { setOpen(false); return; }
|
|
116
|
+
place();
|
|
75
117
|
}
|
|
76
118
|
|
|
77
119
|
// A Select list, menu or Popover opened from inside this panel is part of it.
|
|
@@ -103,7 +145,7 @@ function listen(on: boolean): void {
|
|
|
103
145
|
window[m]("pointerdown", onPointerDown as EventListener, true);
|
|
104
146
|
window[m]("keydown", onKeydown as EventListener, true);
|
|
105
147
|
window[m]("resize", place);
|
|
106
|
-
window[m]("scroll",
|
|
148
|
+
window[m]("scroll", onScroll, true);
|
|
107
149
|
}
|
|
108
150
|
|
|
109
151
|
watch(() => props.open, async (v) => {
|
|
@@ -135,17 +177,26 @@ defineExpose({ place });
|
|
|
135
177
|
:id="id"
|
|
136
178
|
ref="panelEl"
|
|
137
179
|
class="wp-popover"
|
|
180
|
+
:class="{ 'wp-menu': isMenu }"
|
|
138
181
|
:data-side="pos.side"
|
|
182
|
+
:data-variant="variant"
|
|
139
183
|
:role="role"
|
|
140
184
|
:aria-label="label"
|
|
141
185
|
tabindex="-1"
|
|
142
|
-
:style="{
|
|
186
|
+
:style="{
|
|
187
|
+
left: `${pos.x}px`,
|
|
188
|
+
top: `${pos.y}px`,
|
|
189
|
+
minWidth: pos.minWidth ? `${pos.minWidth}px` : undefined,
|
|
190
|
+
maxHeight: pos.maxHeight !== undefined && Number.isFinite(pos.maxHeight) ? `${pos.maxHeight}px` : undefined,
|
|
191
|
+
}"
|
|
143
192
|
>
|
|
144
193
|
<slot :close="() => setOpen(false)" />
|
|
145
194
|
</div>
|
|
146
195
|
</Teleport>
|
|
147
196
|
</template>
|
|
148
197
|
|
|
198
|
+
<style scoped src="./menu.css"></style>
|
|
199
|
+
|
|
149
200
|
<style>
|
|
150
201
|
@layer wp-extension {
|
|
151
202
|
.wp-popover-anchor { display: inline-flex; }
|
|
@@ -155,12 +206,15 @@ defineExpose({ place });
|
|
|
155
206
|
max-width: min(420px, calc(100vw - 16px));
|
|
156
207
|
max-height: calc(100vh - 16px);
|
|
157
208
|
overflow: auto;
|
|
158
|
-
padding: var(--wp-space-3);
|
|
209
|
+
padding: calc(var(--wp-space-3) + 1px); /* plus the 1px the ring stands in for */
|
|
159
210
|
background: var(--wp-bg-2);
|
|
160
211
|
color: var(--wp-text);
|
|
161
|
-
border:
|
|
212
|
+
/* The outline is an inset ring, not a border: these rules are layered, and
|
|
213
|
+
a host's unlayered reset (Tailwind 3's preflight sets border-width: 0
|
|
214
|
+
on every element) would beat a border. Nothing resets box-shadow. */
|
|
215
|
+
border: 0;
|
|
162
216
|
border-radius: var(--wp-radius);
|
|
163
|
-
box-shadow: var(--wp-shadow-md);
|
|
217
|
+
box-shadow: inset 0 0 0 1px var(--wp-border-strong), var(--wp-shadow-md);
|
|
164
218
|
font-family: var(--wp-font-sans);
|
|
165
219
|
font-size: var(--wp-text-sm);
|
|
166
220
|
outline: none;
|
|
@@ -169,6 +223,8 @@ defineExpose({ place });
|
|
|
169
223
|
@keyframes wp-popover-in {
|
|
170
224
|
from { opacity: 0; transform: translateY(-2px); }
|
|
171
225
|
}
|
|
226
|
+
/* A menu is as wide as its longest row (up to 620px), not a 420px panel. */
|
|
227
|
+
.wp-popover[data-variant="menu"] { max-width: min(620px, calc(100vw - 16px)); }
|
|
172
228
|
.wp-popover[data-side="top"] { animation-name: wp-popover-in-up; }
|
|
173
229
|
@keyframes wp-popover-in-up {
|
|
174
230
|
from { opacity: 0; transform: translateY(2px); }
|
package/src/RadioGroup.vue
CHANGED
|
@@ -143,16 +143,17 @@ function onKeydown(ev: KeyboardEvent, at: T): void {
|
|
|
143
143
|
.wp-radio-group__opt[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
|
|
144
144
|
.wp-radio-group__dot {
|
|
145
145
|
position: relative; flex: none; box-sizing: border-box; width: 16px; height: 16px; margin-top: 1px;
|
|
146
|
-
|
|
146
|
+
/* Whole-pixel border and inset, so the dot sits dead centre (a square look shows any half pixel). */
|
|
147
|
+
border: 2px solid var(--wp-border-strong); border-radius: min(50%, var(--wp-radius-cap, 999px)); background: var(--wp-bg-1);
|
|
147
148
|
transition: border-color var(--wp-motion-hover);
|
|
148
149
|
}
|
|
149
|
-
.wp-radio-group[data-size="sm"] .wp-radio-group__dot { width:
|
|
150
|
+
.wp-radio-group[data-size="sm"] .wp-radio-group__dot { width: 12px; height: 12px; border-width: 1px; }
|
|
150
151
|
.wp-radio-group__opt:hover:not([aria-disabled="true"]) .wp-radio-group__dot { border-color: var(--wp-accent); }
|
|
151
152
|
.wp-radio-group__opt[data-checked] .wp-radio-group__dot { border-color: var(--wp-accent); }
|
|
152
153
|
.wp-radio-group__opt[data-checked] .wp-radio-group__dot::after {
|
|
153
|
-
content: ""; position: absolute; inset: 3px; border-radius: 50
|
|
154
|
+
content: ""; position: absolute; inset: 3px; border-radius: min(50%, var(--wp-radius-cap, 999px)); background: var(--wp-accent);
|
|
154
155
|
}
|
|
155
|
-
.wp-radio-group[data-size="sm"] .wp-radio-group__opt[data-checked] .wp-radio-group__dot::after { inset:
|
|
156
|
+
.wp-radio-group[data-size="sm"] .wp-radio-group__opt[data-checked] .wp-radio-group__dot::after { inset: 2px; }
|
|
156
157
|
/* A picked option that is disabled turns grey, so it can't pass for a live one. */
|
|
157
158
|
.wp-radio-group__opt[aria-disabled="true"][data-checked] .wp-radio-group__dot { border-color: var(--wp-text-dim); }
|
|
158
159
|
.wp-radio-group__opt[aria-disabled="true"][data-checked] .wp-radio-group__dot::after { background: var(--wp-text-dim); }
|
package/src/SearchInput.vue
CHANGED
|
@@ -186,7 +186,7 @@ defineExpose({
|
|
|
186
186
|
color: var(--wp-text-dim);
|
|
187
187
|
cursor: pointer;
|
|
188
188
|
}
|
|
189
|
-
.wp-search__clear .pi { font-size:
|
|
189
|
+
.wp-search__clear .pi { font-size: 10px; line-height: 1; }
|
|
190
190
|
.wp-search__clear:hover { color: var(--wp-text); background: var(--wp-bg-3); }
|
|
191
191
|
.wp-search__clear:focus-visible { outline: none; box-shadow: var(--wp-focus-ring); }
|
|
192
192
|
</style>
|