@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +5 -0
  3. package/package.json +4 -3
  4. package/src/Avatar.vue +1 -1
  5. package/src/Banner.vue +4 -4
  6. package/src/BundlePreview.vue +254 -0
  7. package/src/Button.vue +73 -44
  8. package/src/Checkbox.vue +201 -63
  9. package/src/Chip.vue +5 -2
  10. package/src/CodeBlock.vue +1 -1
  11. package/src/ColorField.vue +60 -6
  12. package/src/CombinePreview.vue +150 -0
  13. package/src/Combobox.vue +10 -14
  14. package/src/ConfirmModal.vue +12 -22
  15. package/src/ConstraintMatrixPreview.vue +6 -22
  16. package/src/ConstraintPreview.vue +259 -0
  17. package/src/ContextMenu.vue +11 -30
  18. package/src/CopyButton.vue +1 -1
  19. package/src/DataTable.vue +0 -2
  20. package/src/DatePicker.vue +1 -1
  21. package/src/DerivationCondition.vue +68 -0
  22. package/src/DerivationPreview.vue +345 -0
  23. package/src/Disclosure.vue +2 -1
  24. package/src/Drawer.vue +2 -2
  25. package/src/DropZone.vue +2 -2
  26. package/src/FixedValuesPreview.vue +184 -0
  27. package/src/HsvPicker.vue +2 -2
  28. package/src/IconPicker.vue +32 -103
  29. package/src/IconPickerPanel.vue +132 -0
  30. package/src/ImageGrid.vue +2 -2
  31. package/src/Lightbox.vue +2 -2
  32. package/src/LoadingMark.vue +6 -61
  33. package/src/Modal.vue +109 -44
  34. package/src/ModalShell.vue +26 -5
  35. package/src/Pagination.vue +4 -4
  36. package/src/PayloadPreview.vue +154 -0
  37. package/src/Popover.vue +68 -12
  38. package/src/PostDetailSkeleton.vue +65 -0
  39. package/src/PostGridSkeleton.vue +61 -0
  40. package/src/PreviewGroup.vue +75 -0
  41. package/src/ProfileSkeleton.vue +73 -0
  42. package/src/RadioGroup.vue +5 -4
  43. package/src/RichText.vue +61 -0
  44. package/src/SearchInput.vue +1 -1
  45. package/src/Select.vue +53 -158
  46. package/src/SelectionBar.vue +90 -0
  47. package/src/SettingGroup.vue +57 -0
  48. package/src/SettingRow.vue +127 -0
  49. package/src/Skeleton.vue +1 -1
  50. package/src/Slider.vue +166 -55
  51. package/src/SortableList.vue +1 -1
  52. package/src/TagInput.vue +1 -1
  53. package/src/Timeline.vue +2 -2
  54. package/src/WildcardPreview.vue +607 -0
  55. package/src/WorkingLine.vue +234 -0
  56. package/src/WpIconButton.vue +3 -2
  57. package/src/WpImageZoom.vue +7 -7
  58. package/src/WpSegmented.vue +3 -1
  59. package/src/WpSwitch.vue +1 -1
  60. package/src/WpTabs.vue +1 -1
  61. package/src/_modal-motion.css +1 -1
  62. package/src/button.css +8 -6
  63. package/src/canvas-motion.ts +251 -0
  64. package/src/floating.ts +5 -0
  65. package/src/hsv.ts +1 -1
  66. package/src/kind-colour.ts +24 -1
  67. package/src/linkify.ts +142 -0
  68. package/src/loading-mark-motion.ts +12 -155
  69. package/src/matrix-axis.ts +28 -0
  70. package/src/menu.css +51 -0
  71. package/src/motion.css +73 -0
  72. package/src/pi-icons.ts +51 -0
  73. package/src/preview-readers.ts +401 -0
  74. package/src/preview.css +194 -0
  75. package/src/slider-math.ts +45 -1
  76. package/src/theme/looks.css +6 -19
  77. package/src/theme/tokens.css +12 -1108
  78. package/src/tokens.ts +15 -4
  79. package/src/tooltip.css +8 -5
  80. package/src/useDialogFocus.ts +11 -2
  81. package/src/working-line-motion.ts +135 -0
  82. package/src/CloseButton.vue +0 -69
  83. package/src/ColorPicker.vue +0 -201
  84. package/src/ConfirmDialog.vue +0 -165
  85. package/src/RangeSlider.vue +0 -183
  86. package/src/Toggle.vue +0 -36
  87. package/src/WpButton.vue +0 -75
  88. package/src/WpCheck.vue +0 -104
  89. package/src/_skeleton.css +0 -50
@@ -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 v-if="visible" ref="overlayEl" class="wp-modal-overlay" @click="$emit('close')">
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
- (e: "close"): void;
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, ConfirmDialog,
128
+ * ContextWidget) so every modal — ModalShell consumers (Modal, ConfirmModal),
108
129
  * InjectorBindingModal — animates with the same rules. */
109
130
  </style>
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { computed } from "vue";
15
15
  import { pageItems } from "./pagination";
16
- import WpButton from "./WpButton.vue";
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
- <WpButton variant="ghost" :size="btnSize" icon="pi-chevron-left" aria-label="Previous page" :disabled="disabled || props.page <= 1" @click="go(props.page - 1)" />
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
- <WpButton variant="ghost" :size="btnSize" icon="pi-chevron-right" aria-label="Next page" :disabled="disabled || props.page >= pages" @click="go(props.page + 1)" />
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
- <WpButton v-if="props.shown < total" variant="secondary" :size="btnSize" :loading="loading" :disabled="disabled" @click="emit('more')">Load more</WpButton>
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
- }>(), { placement: "bottom-start", autofocus: true, role: "dialog", anchor: null });
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 at = placeFloating(r, { width: Math.max(p.offsetWidth, props.matchWidth ? r.width : 0), height: p.offsetHeight }, viewportSize(), {
72
- placement: props.placement,
73
- });
74
- pos.value = { ...at, minWidth: props.matchWidth ? r.width : undefined };
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", place, true);
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="{ left: `${pos.x}px`, top: `${pos.y}px`, minWidth: pos.minWidth ? `${pos.minWidth}px` : undefined }"
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: 1px solid var(--wp-border-strong);
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>