@dumiflex/wp-kit 0.5.0 → 0.7.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 +47 -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/BundlePreview.vue +254 -0
- 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/CombinePreview.vue +150 -0
- package/src/Combobox.vue +10 -14
- package/src/ConfirmModal.vue +12 -22
- package/src/ConstraintMatrixPreview.vue +6 -22
- package/src/ConstraintPreview.vue +259 -0
- 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/DerivationCondition.vue +68 -0
- package/src/DerivationPreview.vue +345 -0
- package/src/Disclosure.vue +2 -1
- package/src/Drawer.vue +2 -2
- package/src/DropZone.vue +2 -2
- package/src/FixedValuesPreview.vue +184 -0
- package/src/HsvPicker.vue +2 -2
- package/src/IconPicker.vue +32 -103
- package/src/IconPickerPanel.vue +132 -0
- package/src/ImageGrid.vue +2 -2
- package/src/Lightbox.vue +2 -2
- package/src/LoadingMark.vue +6 -61
- package/src/Modal.vue +109 -44
- package/src/ModalShell.vue +26 -5
- package/src/Pagination.vue +4 -4
- package/src/PayloadPreview.vue +154 -0
- package/src/Popover.vue +68 -12
- package/src/PostDetailSkeleton.vue +65 -0
- package/src/PostGridSkeleton.vue +61 -0
- package/src/PreviewGroup.vue +75 -0
- package/src/ProfileSkeleton.vue +73 -0
- package/src/RadioGroup.vue +5 -4
- package/src/RichText.vue +61 -0
- package/src/SearchInput.vue +1 -1
- package/src/Select.vue +53 -158
- package/src/SelectionBar.vue +90 -0
- package/src/SettingGroup.vue +57 -0
- package/src/SettingRow.vue +127 -0
- 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/WildcardPreview.vue +607 -0
- package/src/WorkingLine.vue +234 -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/canvas-motion.ts +251 -0
- package/src/floating.ts +5 -0
- package/src/hsv.ts +1 -1
- package/src/kind-colour.ts +24 -1
- package/src/linkify.ts +142 -0
- package/src/loading-mark-motion.ts +12 -155
- package/src/matrix-axis.ts +28 -0
- package/src/menu.css +51 -0
- package/src/motion.css +73 -0
- package/src/pi-icons.ts +51 -0
- package/src/preview-readers.ts +401 -0
- package/src/preview.css +194 -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 +15 -4
- package/src/tooltip.css +8 -5
- package/src/useDialogFocus.ts +11 -2
- package/src/working-line-motion.ts +135 -0
- 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/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
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Rich, read-only preview of a published module or bundle payload: the
|
|
4
|
+
* community post page and its embed draw it, and the manager can too.
|
|
5
|
+
*
|
|
6
|
+
* <PayloadPreview :payload="row" :ref-names="names" :ref-groups="groups" />
|
|
7
|
+
*
|
|
8
|
+
* This file only picks the per-type preview (WildcardPreview,
|
|
9
|
+
* FixedValuesPreview, CombinePreview, DerivationPreview, ConstraintPreview,
|
|
10
|
+
* BundlePreview; each one
|
|
11
|
+
* has a stats strip, its own search / paging for big payloads, and routes
|
|
12
|
+
* every value through `TokenizedValue` so `$var` / `@{ref}` / picks render
|
|
13
|
+
* as the chips the editor shows) and owns the Preview / Raw JSON tabs.
|
|
14
|
+
*
|
|
15
|
+
* The community renders the PUBLISHED payload, which has NO `instance`
|
|
16
|
+
* layer — so only LIBRARY exceptions exist (`payload.exceptions`); the
|
|
17
|
+
* instance-only `extra_exceptions` is ignored even if one rides along.
|
|
18
|
+
*/
|
|
19
|
+
import { computed, ref } from "vue";
|
|
20
|
+
import WildcardPreview from "./WildcardPreview.vue";
|
|
21
|
+
import FixedValuesPreview from "./FixedValuesPreview.vue";
|
|
22
|
+
import CombinePreview from "./CombinePreview.vue";
|
|
23
|
+
import DerivationPreview from "./DerivationPreview.vue";
|
|
24
|
+
import ConstraintPreview from "./ConstraintPreview.vue";
|
|
25
|
+
import BundlePreview from "./BundlePreview.vue";
|
|
26
|
+
import { innerOf, payloadKind } from "./preview-readers";
|
|
27
|
+
import WpTabs, { type TabItem } from "./WpTabs.vue";
|
|
28
|
+
import CodeBlock from "./CodeBlock.vue";
|
|
29
|
+
|
|
30
|
+
const PREVIEW_TABS: TabItem<"preview" | "raw">[] = [
|
|
31
|
+
{ id: "preview", label: "Preview" },
|
|
32
|
+
{ id: "raw", label: "Raw JSON" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
defineOptions({ name: "PayloadPreview" });
|
|
36
|
+
|
|
37
|
+
const props = withDefaults(
|
|
38
|
+
defineProps<{
|
|
39
|
+
payload: Record<string, unknown> | null;
|
|
40
|
+
/**
|
|
41
|
+
* `module_id → display-name` map, built by the parent from the post's
|
|
42
|
+
* dependencies. Resolves constraint axis uuids AND any `@{uuid}` ref in
|
|
43
|
+
* a value string to readable names — retroactively, no re-publish.
|
|
44
|
+
*/
|
|
45
|
+
refNames?: Record<string, string>;
|
|
46
|
+
/**
|
|
47
|
+
* `module_id → tag_groups` map from the post's dependencies, so a
|
|
48
|
+
* constraint can group its matrix by the wildcards' axes.
|
|
49
|
+
*/
|
|
50
|
+
refGroups?: Record<string, Record<string, string[]>>;
|
|
51
|
+
/** Show the Preview / Raw JSON tabs. The post page draws its own. */
|
|
52
|
+
tabs?: boolean;
|
|
53
|
+
/** Rendered inside a bundle's child pane (no tabs). */
|
|
54
|
+
nested?: boolean;
|
|
55
|
+
}>(),
|
|
56
|
+
{ refNames: () => ({}), refGroups: () => ({}), tabs: true, nested: false },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const tab = ref<"preview" | "raw">("preview");
|
|
60
|
+
const showTabs = computed(() => props.tabs && !props.nested);
|
|
61
|
+
|
|
62
|
+
const kind = computed(() => payloadKind(props.payload));
|
|
63
|
+
const inner = computed(() => innerOf(props.payload));
|
|
64
|
+
// withDefaults fills these, but the optional prop type still reads
|
|
65
|
+
// `| undefined` under exactOptionalPropertyTypes; pin concrete values.
|
|
66
|
+
const names = computed<Record<string, string>>(() => props.refNames ?? {});
|
|
67
|
+
const groups = computed<Record<string, Record<string, string[]>>>(() => props.refGroups ?? {});
|
|
68
|
+
const refMap = computed<ReadonlyMap<string, string>>(
|
|
69
|
+
() => new Map(Object.entries(props.refNames)),
|
|
70
|
+
);
|
|
71
|
+
const rawJson = computed(() => (props.payload ? JSON.stringify(props.payload, null, 2) : ""));
|
|
72
|
+
const KNOWN = new Set(["wildcard", "fixed_values", "combine", "derivation", "constraint", "bundle"]);
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<div
|
|
77
|
+
v-if="payload"
|
|
78
|
+
class="wp-payload-preview"
|
|
79
|
+
>
|
|
80
|
+
<WpTabs
|
|
81
|
+
v-if="showTabs"
|
|
82
|
+
v-model="tab"
|
|
83
|
+
:tabs="PREVIEW_TABS"
|
|
84
|
+
aria-label="Preview or raw JSON"
|
|
85
|
+
/>
|
|
86
|
+
|
|
87
|
+
<div v-if="tab === 'preview' || !showTabs">
|
|
88
|
+
<WildcardPreview
|
|
89
|
+
v-if="kind === 'wildcard'"
|
|
90
|
+
:inner="inner"
|
|
91
|
+
:ref-map="refMap"
|
|
92
|
+
/>
|
|
93
|
+
<FixedValuesPreview
|
|
94
|
+
v-else-if="kind === 'fixed_values'"
|
|
95
|
+
:inner="inner"
|
|
96
|
+
:ref-map="refMap"
|
|
97
|
+
/>
|
|
98
|
+
<CombinePreview
|
|
99
|
+
v-else-if="kind === 'combine'"
|
|
100
|
+
:inner="inner"
|
|
101
|
+
:ref-map="refMap"
|
|
102
|
+
/>
|
|
103
|
+
<DerivationPreview
|
|
104
|
+
v-else-if="kind === 'derivation'"
|
|
105
|
+
:payload="payload"
|
|
106
|
+
:inner="inner"
|
|
107
|
+
:ref-map="refMap"
|
|
108
|
+
/>
|
|
109
|
+
<ConstraintPreview
|
|
110
|
+
v-else-if="kind === 'constraint'"
|
|
111
|
+
:inner="inner"
|
|
112
|
+
:ref-map="refMap"
|
|
113
|
+
:ref-names="names"
|
|
114
|
+
:ref-groups="groups"
|
|
115
|
+
/>
|
|
116
|
+
<BundlePreview
|
|
117
|
+
v-else-if="kind === 'bundle'"
|
|
118
|
+
:payload="payload"
|
|
119
|
+
:ref-names="names"
|
|
120
|
+
:ref-groups="groups"
|
|
121
|
+
/>
|
|
122
|
+
<p
|
|
123
|
+
v-if="!kind || !KNOWN.has(kind)"
|
|
124
|
+
class="wp-pv-empty"
|
|
125
|
+
>
|
|
126
|
+
Nothing to render in <code>{{ kind ?? "this payload" }}</code>. Switch to Raw JSON.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
<div
|
|
130
|
+
v-else
|
|
131
|
+
class="wp-payload-raw"
|
|
132
|
+
>
|
|
133
|
+
<CodeBlock
|
|
134
|
+
:code="rawJson"
|
|
135
|
+
language="JSON"
|
|
136
|
+
default-wrap
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
<p
|
|
141
|
+
v-else
|
|
142
|
+
class="wp-pv-empty"
|
|
143
|
+
style="padding: 24px"
|
|
144
|
+
>
|
|
145
|
+
No payload to preview.
|
|
146
|
+
</p>
|
|
147
|
+
</template>
|
|
148
|
+
|
|
149
|
+
<style scoped src="./preview.css"></style>
|
|
150
|
+
<style scoped>
|
|
151
|
+
.wp-payload-raw {
|
|
152
|
+
padding: 18px 24px 22px;
|
|
153
|
+
}
|
|
154
|
+
</style>
|
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); }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The shape of a post page while it loads: meta line, title, description,
|
|
4
|
+
* tag chips and the preview card on the left, the download button and its
|
|
5
|
+
* details in the rail (the rail drops under the main column on narrow
|
|
6
|
+
* screens). Shared by the site's post page and the embed's.
|
|
7
|
+
*
|
|
8
|
+
* <PostDetailSkeleton v-if="loading" />
|
|
9
|
+
*/
|
|
10
|
+
import Skeleton from "./Skeleton.vue";
|
|
11
|
+
|
|
12
|
+
withDefaults(defineProps<{
|
|
13
|
+
/** What screen readers hear. */
|
|
14
|
+
label?: string | undefined;
|
|
15
|
+
}>(), { label: "Loading post" });
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div class="wp-post-detail-skeleton" data-testid="post-detail-skeleton">
|
|
20
|
+
<div class="wp-post-detail-skeleton__main">
|
|
21
|
+
<div class="wp-post-detail-skeleton__head">
|
|
22
|
+
<Skeleton width="30%" :label="label || undefined" />
|
|
23
|
+
<Skeleton shape="block" width="55%" height="32px" />
|
|
24
|
+
<Skeleton :lines="2" />
|
|
25
|
+
<Skeleton shape="chip" :lines="4" />
|
|
26
|
+
</div>
|
|
27
|
+
<Skeleton shape="block" height="320px" />
|
|
28
|
+
</div>
|
|
29
|
+
<aside class="wp-post-detail-skeleton__aside">
|
|
30
|
+
<Skeleton shape="block" height="40px" />
|
|
31
|
+
<Skeleton :lines="4" />
|
|
32
|
+
</aside>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
35
|
+
|
|
36
|
+
<style scoped>
|
|
37
|
+
.wp-post-detail-skeleton {
|
|
38
|
+
display: grid;
|
|
39
|
+
grid-template-columns: minmax(0, 1fr) 320px;
|
|
40
|
+
gap: 32px;
|
|
41
|
+
align-items: start;
|
|
42
|
+
container-type: inline-size;
|
|
43
|
+
}
|
|
44
|
+
.wp-post-detail-skeleton__main {
|
|
45
|
+
min-width: 0;
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
gap: 24px;
|
|
49
|
+
}
|
|
50
|
+
.wp-post-detail-skeleton__head {
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
gap: 18px;
|
|
54
|
+
}
|
|
55
|
+
.wp-post-detail-skeleton__aside {
|
|
56
|
+
display: flex;
|
|
57
|
+
flex-direction: column;
|
|
58
|
+
gap: 16px;
|
|
59
|
+
}
|
|
60
|
+
@media (max-width: 900px) {
|
|
61
|
+
.wp-post-detail-skeleton {
|
|
62
|
+
grid-template-columns: minmax(0, 1fr);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The shape of a post grid while its first page loads: card-sized blocks of
|
|
4
|
+
* mixed heights in the same masonry columns PostCard grids use (340px
|
|
5
|
+
* columns, one column on phones). Used by every post grid (browse, bundles,
|
|
6
|
+
* profiles, libraries, the embed).
|
|
7
|
+
*
|
|
8
|
+
* <PostGridSkeleton v-if="loading" :count="6" />
|
|
9
|
+
*
|
|
10
|
+
* `label` is what screen readers hear once; pass `""` when the grid sits
|
|
11
|
+
* inside another skeleton that already says it (ProfileSkeleton does).
|
|
12
|
+
*/
|
|
13
|
+
import Skeleton from "./Skeleton.vue";
|
|
14
|
+
|
|
15
|
+
withDefaults(defineProps<{
|
|
16
|
+
/** How many cards to draw. */
|
|
17
|
+
count?: number | undefined;
|
|
18
|
+
/** What screen readers hear. */
|
|
19
|
+
label?: string | undefined;
|
|
20
|
+
}>(), { count: 6, label: "Loading posts" });
|
|
21
|
+
|
|
22
|
+
const HEIGHTS = ["180px", "240px", "200px", "240px", "180px", "200px"];
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div class="wp-post-grid-skeleton" data-testid="post-grid-skeleton">
|
|
27
|
+
<Skeleton
|
|
28
|
+
v-for="i in count"
|
|
29
|
+
:key="i"
|
|
30
|
+
class="wp-post-grid-skeleton__card"
|
|
31
|
+
shape="block"
|
|
32
|
+
:height="HEIGHTS[(i - 1) % HEIGHTS.length]"
|
|
33
|
+
:label="i === 1 && label ? label : undefined"
|
|
34
|
+
/>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<style scoped>
|
|
39
|
+
.wp-post-grid-skeleton {
|
|
40
|
+
column-width: 340px;
|
|
41
|
+
column-gap: 20px;
|
|
42
|
+
}
|
|
43
|
+
.wp-post-grid-skeleton__card {
|
|
44
|
+
margin-bottom: 20px;
|
|
45
|
+
break-inside: avoid;
|
|
46
|
+
}
|
|
47
|
+
@media (max-width: 1200px) {
|
|
48
|
+
.wp-post-grid-skeleton {
|
|
49
|
+
column-width: 320px;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
@media (max-width: 560px) {
|
|
53
|
+
.wp-post-grid-skeleton {
|
|
54
|
+
column-width: auto;
|
|
55
|
+
column-count: 1;
|
|
56
|
+
}
|
|
57
|
+
.wp-post-grid-skeleton__card {
|
|
58
|
+
margin-bottom: 14px;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One group of a preview table: a flush kit Disclosure with the group's
|
|
4
|
+
* name, its count and a dim note at the right (a share, a summary). A group
|
|
5
|
+
* with no name (the options outside any group) has no heading and is always
|
|
6
|
+
* open, so the caller writes its body once for both.
|
|
7
|
+
*/
|
|
8
|
+
import Disclosure from "./Disclosure.vue";
|
|
9
|
+
|
|
10
|
+
withDefaults(defineProps<{
|
|
11
|
+
name?: string | undefined;
|
|
12
|
+
open: boolean;
|
|
13
|
+
count?: number | string | undefined;
|
|
14
|
+
aside?: string | undefined;
|
|
15
|
+
/** `data-test` on the heading button. */
|
|
16
|
+
testId?: string | undefined;
|
|
17
|
+
}>(), { name: "", count: undefined, aside: undefined, testId: undefined });
|
|
18
|
+
|
|
19
|
+
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<Disclosure
|
|
24
|
+
v-if="name"
|
|
25
|
+
flush
|
|
26
|
+
lazy
|
|
27
|
+
class="wp-pv-group"
|
|
28
|
+
:open="open"
|
|
29
|
+
:trigger-attrs="testId ? { 'data-test': testId } : undefined"
|
|
30
|
+
@update:open="(v: boolean) => emit('update:open', v)"
|
|
31
|
+
>
|
|
32
|
+
<template #summary>
|
|
33
|
+
<span class="wp-pv-group__name">{{ name }}</span>
|
|
34
|
+
<span
|
|
35
|
+
v-if="count !== undefined"
|
|
36
|
+
class="wp-pv-group__count"
|
|
37
|
+
>{{ count }}</span>
|
|
38
|
+
<span
|
|
39
|
+
v-if="aside"
|
|
40
|
+
class="wp-pv-group__aside"
|
|
41
|
+
>{{ aside }}</span>
|
|
42
|
+
</template>
|
|
43
|
+
<slot />
|
|
44
|
+
</Disclosure>
|
|
45
|
+
<div
|
|
46
|
+
v-else
|
|
47
|
+
class="wp-pv-group"
|
|
48
|
+
>
|
|
49
|
+
<slot />
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<style scoped>
|
|
54
|
+
.wp-pv-group__name {
|
|
55
|
+
min-width: 0;
|
|
56
|
+
overflow: hidden;
|
|
57
|
+
text-overflow: ellipsis;
|
|
58
|
+
white-space: nowrap;
|
|
59
|
+
}
|
|
60
|
+
.wp-pv-group__count {
|
|
61
|
+
padding: 0 7px;
|
|
62
|
+
border-radius: var(--wp-radius-cap, 999px);
|
|
63
|
+
background: var(--wp-bg-3);
|
|
64
|
+
color: var(--wp-text-dim);
|
|
65
|
+
font-family: var(--wp-font-mono);
|
|
66
|
+
font-size: 11px;
|
|
67
|
+
font-weight: 400;
|
|
68
|
+
}
|
|
69
|
+
.wp-pv-group__aside {
|
|
70
|
+
margin-left: auto;
|
|
71
|
+
color: var(--wp-text-dim);
|
|
72
|
+
font-size: 11.5px;
|
|
73
|
+
font-weight: 400;
|
|
74
|
+
}
|
|
75
|
+
</style>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The shape of a public profile while it loads: the head card (avatar,
|
|
4
|
+
* name, meta line), optionally the stat cards, then a post grid
|
|
5
|
+
* (PostGridSkeleton). Shared by the site's profile page and the embed's.
|
|
6
|
+
*
|
|
7
|
+
* <ProfileSkeleton v-if="loading" stats />
|
|
8
|
+
*
|
|
9
|
+
* The root is a column with the page's gaps, so the parts space out like
|
|
10
|
+
* the page they stand in for.
|
|
11
|
+
*/
|
|
12
|
+
import Card from "./Card.vue";
|
|
13
|
+
import Skeleton from "./Skeleton.vue";
|
|
14
|
+
import PostGridSkeleton from "./PostGridSkeleton.vue";
|
|
15
|
+
|
|
16
|
+
withDefaults(defineProps<{
|
|
17
|
+
/** Draw the three stat cards under the head (the site's layout). */
|
|
18
|
+
stats?: boolean | undefined;
|
|
19
|
+
/** Avatar size. */
|
|
20
|
+
avatar?: string | undefined;
|
|
21
|
+
/** What screen readers hear. */
|
|
22
|
+
label?: string | undefined;
|
|
23
|
+
}>(), { stats: false, avatar: "64px", label: "Loading profile" });
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<div class="wp-profile-skeleton" data-testid="profile-skeleton">
|
|
28
|
+
<Card :padding="false">
|
|
29
|
+
<div class="wp-profile-skeleton__head">
|
|
30
|
+
<Skeleton shape="circle" :width="avatar" :label="label || undefined" />
|
|
31
|
+
<div class="wp-profile-skeleton__name">
|
|
32
|
+
<Skeleton width="30%" />
|
|
33
|
+
<Skeleton width="20%" />
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</Card>
|
|
37
|
+
<div v-if="stats" class="wp-profile-skeleton__stats">
|
|
38
|
+
<Skeleton v-for="i in 3" :key="i" shape="block" height="72px" />
|
|
39
|
+
</div>
|
|
40
|
+
<PostGridSkeleton :count="3" label="" />
|
|
41
|
+
</div>
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<style scoped>
|
|
45
|
+
.wp-profile-skeleton {
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
gap: 24px;
|
|
49
|
+
}
|
|
50
|
+
.wp-profile-skeleton__head {
|
|
51
|
+
display: grid;
|
|
52
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
53
|
+
gap: 24px;
|
|
54
|
+
align-items: center;
|
|
55
|
+
padding: 32px;
|
|
56
|
+
}
|
|
57
|
+
.wp-profile-skeleton__name {
|
|
58
|
+
min-width: 0;
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
gap: 12px;
|
|
62
|
+
}
|
|
63
|
+
.wp-profile-skeleton__stats {
|
|
64
|
+
display: grid;
|
|
65
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
66
|
+
gap: 18px;
|
|
67
|
+
}
|
|
68
|
+
@media (max-width: 900px) {
|
|
69
|
+
.wp-profile-skeleton__stats {
|
|
70
|
+
grid-template-columns: minmax(0, 1fr);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</style>
|