@dumiflex/wp-kit 0.6.0 → 0.7.1
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 +22 -0
- package/package.json +1 -1
- package/src/BundlePreview.vue +255 -0
- package/src/CombinePreview.vue +151 -0
- package/src/ConstraintMatrixPreview.vue +6 -22
- package/src/ConstraintPreview.vue +260 -0
- package/src/DerivationCondition.vue +68 -0
- package/src/DerivationPreview.vue +346 -0
- package/src/FixedValuesPreview.vue +185 -0
- package/src/IconPicker.vue +31 -102
- package/src/IconPickerPanel.vue +132 -0
- package/src/LoadingMark.vue +6 -61
- package/src/PayloadPreview.vue +155 -0
- 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/RichText.vue +61 -0
- package/src/SelectionBar.vue +101 -0
- package/src/SettingGroup.vue +57 -0
- package/src/SettingRow.vue +127 -0
- package/src/WildcardPreview.vue +608 -0
- package/src/WorkingLine.vue +38 -124
- package/src/canvas-motion.ts +251 -0
- 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/pi-icons.ts +51 -0
- package/src/preview-readers.ts +401 -0
- package/src/preview.css +197 -0
- package/src/tokens.ts +8 -2
- package/src/working-line-motion.ts +135 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The shared preview sheet (wp-pv-* classes), loaded once for every preview.
|
|
3
|
+
import "./preview.css";
|
|
4
|
+
/**
|
|
5
|
+
* Rich, read-only preview of a published module or bundle payload: the
|
|
6
|
+
* community post page and its embed draw it, and the manager can too.
|
|
7
|
+
*
|
|
8
|
+
* <PayloadPreview :payload="row" :ref-names="names" :ref-groups="groups" />
|
|
9
|
+
*
|
|
10
|
+
* This file only picks the per-type preview (WildcardPreview,
|
|
11
|
+
* FixedValuesPreview, CombinePreview, DerivationPreview, ConstraintPreview,
|
|
12
|
+
* BundlePreview; each one
|
|
13
|
+
* has a stats strip, its own search / paging for big payloads, and routes
|
|
14
|
+
* every value through `TokenizedValue` so `$var` / `@{ref}` / picks render
|
|
15
|
+
* as the chips the editor shows) and owns the Preview / Raw JSON tabs.
|
|
16
|
+
*
|
|
17
|
+
* The community renders the PUBLISHED payload, which has NO `instance`
|
|
18
|
+
* layer — so only LIBRARY exceptions exist (`payload.exceptions`); the
|
|
19
|
+
* instance-only `extra_exceptions` is ignored even if one rides along.
|
|
20
|
+
*/
|
|
21
|
+
import { computed, ref } from "vue";
|
|
22
|
+
import WildcardPreview from "./WildcardPreview.vue";
|
|
23
|
+
import FixedValuesPreview from "./FixedValuesPreview.vue";
|
|
24
|
+
import CombinePreview from "./CombinePreview.vue";
|
|
25
|
+
import DerivationPreview from "./DerivationPreview.vue";
|
|
26
|
+
import ConstraintPreview from "./ConstraintPreview.vue";
|
|
27
|
+
import BundlePreview from "./BundlePreview.vue";
|
|
28
|
+
import { innerOf, payloadKind } from "./preview-readers";
|
|
29
|
+
import WpTabs, { type TabItem } from "./WpTabs.vue";
|
|
30
|
+
import CodeBlock from "./CodeBlock.vue";
|
|
31
|
+
|
|
32
|
+
const PREVIEW_TABS: TabItem<"preview" | "raw">[] = [
|
|
33
|
+
{ id: "preview", label: "Preview" },
|
|
34
|
+
{ id: "raw", label: "Raw JSON" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
defineOptions({ name: "PayloadPreview" });
|
|
38
|
+
|
|
39
|
+
const props = withDefaults(
|
|
40
|
+
defineProps<{
|
|
41
|
+
payload: Record<string, unknown> | null;
|
|
42
|
+
/**
|
|
43
|
+
* `module_id → display-name` map, built by the parent from the post's
|
|
44
|
+
* dependencies. Resolves constraint axis uuids AND any `@{uuid}` ref in
|
|
45
|
+
* a value string to readable names — retroactively, no re-publish.
|
|
46
|
+
*/
|
|
47
|
+
refNames?: Record<string, string>;
|
|
48
|
+
/**
|
|
49
|
+
* `module_id → tag_groups` map from the post's dependencies, so a
|
|
50
|
+
* constraint can group its matrix by the wildcards' axes.
|
|
51
|
+
*/
|
|
52
|
+
refGroups?: Record<string, Record<string, string[]>>;
|
|
53
|
+
/** Show the Preview / Raw JSON tabs. The post page draws its own. */
|
|
54
|
+
tabs?: boolean;
|
|
55
|
+
/** Rendered inside a bundle's child pane (no tabs). */
|
|
56
|
+
nested?: boolean;
|
|
57
|
+
}>(),
|
|
58
|
+
{ refNames: () => ({}), refGroups: () => ({}), tabs: true, nested: false },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const tab = ref<"preview" | "raw">("preview");
|
|
62
|
+
const showTabs = computed(() => props.tabs && !props.nested);
|
|
63
|
+
|
|
64
|
+
const kind = computed(() => payloadKind(props.payload));
|
|
65
|
+
const inner = computed(() => innerOf(props.payload));
|
|
66
|
+
// withDefaults fills these, but the optional prop type still reads
|
|
67
|
+
// `| undefined` under exactOptionalPropertyTypes; pin concrete values.
|
|
68
|
+
const names = computed<Record<string, string>>(() => props.refNames ?? {});
|
|
69
|
+
const groups = computed<Record<string, Record<string, string[]>>>(() => props.refGroups ?? {});
|
|
70
|
+
const refMap = computed<ReadonlyMap<string, string>>(
|
|
71
|
+
() => new Map(Object.entries(props.refNames)),
|
|
72
|
+
);
|
|
73
|
+
const rawJson = computed(() => (props.payload ? JSON.stringify(props.payload, null, 2) : ""));
|
|
74
|
+
const KNOWN = new Set(["wildcard", "fixed_values", "combine", "derivation", "constraint", "bundle"]);
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<template>
|
|
78
|
+
<div
|
|
79
|
+
v-if="payload"
|
|
80
|
+
class="wp-payload-preview"
|
|
81
|
+
>
|
|
82
|
+
<WpTabs
|
|
83
|
+
v-if="showTabs"
|
|
84
|
+
v-model="tab"
|
|
85
|
+
:tabs="PREVIEW_TABS"
|
|
86
|
+
aria-label="Preview or raw JSON"
|
|
87
|
+
/>
|
|
88
|
+
|
|
89
|
+
<div v-if="tab === 'preview' || !showTabs">
|
|
90
|
+
<WildcardPreview
|
|
91
|
+
v-if="kind === 'wildcard'"
|
|
92
|
+
:inner="inner"
|
|
93
|
+
:ref-map="refMap"
|
|
94
|
+
/>
|
|
95
|
+
<FixedValuesPreview
|
|
96
|
+
v-else-if="kind === 'fixed_values'"
|
|
97
|
+
:inner="inner"
|
|
98
|
+
:ref-map="refMap"
|
|
99
|
+
/>
|
|
100
|
+
<CombinePreview
|
|
101
|
+
v-else-if="kind === 'combine'"
|
|
102
|
+
:inner="inner"
|
|
103
|
+
:ref-map="refMap"
|
|
104
|
+
/>
|
|
105
|
+
<DerivationPreview
|
|
106
|
+
v-else-if="kind === 'derivation'"
|
|
107
|
+
:payload="payload"
|
|
108
|
+
:inner="inner"
|
|
109
|
+
:ref-map="refMap"
|
|
110
|
+
/>
|
|
111
|
+
<ConstraintPreview
|
|
112
|
+
v-else-if="kind === 'constraint'"
|
|
113
|
+
:inner="inner"
|
|
114
|
+
:ref-map="refMap"
|
|
115
|
+
:ref-names="names"
|
|
116
|
+
:ref-groups="groups"
|
|
117
|
+
/>
|
|
118
|
+
<BundlePreview
|
|
119
|
+
v-else-if="kind === 'bundle'"
|
|
120
|
+
:payload="payload"
|
|
121
|
+
:ref-names="names"
|
|
122
|
+
:ref-groups="groups"
|
|
123
|
+
/>
|
|
124
|
+
<p
|
|
125
|
+
v-if="!kind || !KNOWN.has(kind)"
|
|
126
|
+
class="wp-pv-empty"
|
|
127
|
+
>
|
|
128
|
+
Nothing to render in <code>{{ kind ?? "this payload" }}</code>. Switch to Raw JSON.
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
<div
|
|
132
|
+
v-else
|
|
133
|
+
class="wp-payload-raw"
|
|
134
|
+
>
|
|
135
|
+
<CodeBlock
|
|
136
|
+
:code="rawJson"
|
|
137
|
+
language="JSON"
|
|
138
|
+
default-wrap
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<p
|
|
143
|
+
v-else
|
|
144
|
+
class="wp-pv-empty"
|
|
145
|
+
style="padding: 24px"
|
|
146
|
+
>
|
|
147
|
+
No payload to preview.
|
|
148
|
+
</p>
|
|
149
|
+
</template>
|
|
150
|
+
|
|
151
|
+
<style scoped>
|
|
152
|
+
.wp-payload-raw {
|
|
153
|
+
padding: 18px 24px 22px;
|
|
154
|
+
}
|
|
155
|
+
</style>
|
|
@@ -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>
|
package/src/RichText.vue
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Inline rich-text renderer for plain-text prose (post descriptions, notes).
|
|
4
|
+
*
|
|
5
|
+
* <p class="my-description"><RichText :text="post.description" /></p>
|
|
6
|
+
*
|
|
7
|
+
* Turns URLs into links (new tab, `noopener noreferrer`) and `$var` /
|
|
8
|
+
* `@{ref}` mentions into `<code>` chips, WITHOUT `v-html`: every segment from
|
|
9
|
+
* `parseRichText` (linkify.ts) is rendered as a Vue node, so the output is
|
|
10
|
+
* XSS-safe by construction.
|
|
11
|
+
*
|
|
12
|
+
* Prose, not a grammar value: `{a|b}` stays text and a ref's body may be any
|
|
13
|
+
* words. A wildcard value (picks, refs resolved to names) is TokenizedValue.
|
|
14
|
+
*
|
|
15
|
+
* Renders INLINE (no wrapper element), so it drops into the host's own
|
|
16
|
+
* paragraph and inherits its typography.
|
|
17
|
+
*/
|
|
18
|
+
import { computed } from "vue";
|
|
19
|
+
import { parseRichText } from "./linkify";
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{ text: string }>();
|
|
22
|
+
|
|
23
|
+
const segments = computed(() => parseRichText(props.text));
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<template>
|
|
27
|
+
<template
|
|
28
|
+
v-for="(seg, idx) in segments"
|
|
29
|
+
:key="idx"
|
|
30
|
+
>
|
|
31
|
+
<a
|
|
32
|
+
v-if="seg.kind === 'link'"
|
|
33
|
+
class="wp-rich-text__link"
|
|
34
|
+
:href="seg.href"
|
|
35
|
+
target="_blank"
|
|
36
|
+
rel="noopener noreferrer"
|
|
37
|
+
>{{ seg.text }}</a>
|
|
38
|
+
<code
|
|
39
|
+
v-else-if="seg.kind === 'code'"
|
|
40
|
+
class="wp-rich-text__code"
|
|
41
|
+
>{{ seg.text }}</code>
|
|
42
|
+
<template v-else>{{ seg.text }}</template>
|
|
43
|
+
</template>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<style scoped>
|
|
47
|
+
.wp-rich-text__link {
|
|
48
|
+
color: var(--wp-accent-text);
|
|
49
|
+
text-decoration: underline;
|
|
50
|
+
text-underline-offset: 2px;
|
|
51
|
+
}
|
|
52
|
+
.wp-rich-text__code {
|
|
53
|
+
font-family: var(--wp-font-mono);
|
|
54
|
+
font-size: 0.9em;
|
|
55
|
+
background: var(--wp-bg-3);
|
|
56
|
+
color: var(--wp-accent-text);
|
|
57
|
+
padding: 1px 6px;
|
|
58
|
+
border-radius: min(5px, var(--wp-radius-cap, 999px));
|
|
59
|
+
border: 1px solid var(--wp-border);
|
|
60
|
+
}
|
|
61
|
+
</style>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SelectionBar: the bar over a list while rows are ticked. "3 selected", the
|
|
4
|
+
* bulk actions (the default slot) and Clear. One bar for every list: the
|
|
5
|
+
* editors' bulk mode, the moderation queue, a backups list.
|
|
6
|
+
*
|
|
7
|
+
* <SelectionBar v-if="picked.size" :count="picked.size" @clear="picked.clear()">
|
|
8
|
+
* <Button variant="danger" size="sm" icon="trash" @click="del">Delete</Button>
|
|
9
|
+
* </SelectionBar>
|
|
10
|
+
*
|
|
11
|
+
* `#start` sits right after the count (a filter, an option), the default slot
|
|
12
|
+
* on the right before Clear. `sticky` keeps it at the top of its scroll area
|
|
13
|
+
* while the list scrolls under it (`--wp-selection-bar-top` moves it below a
|
|
14
|
+
* fixed header). `noun` says what is selected ("3 options selected");
|
|
15
|
+
* `noun-one` is its singular for a count of 1 ("1 option selected"), by
|
|
16
|
+
* default the noun without its last "s". Escape
|
|
17
|
+
* inside the bar clears, like Clear; the count is read out as it changes.
|
|
18
|
+
*/
|
|
19
|
+
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
20
|
+
import Button from "./Button.vue";
|
|
21
|
+
|
|
22
|
+
const props = withDefaults(defineProps<{
|
|
23
|
+
count: number;
|
|
24
|
+
/** What a row is, plural ("options"); left out it says "3 selected". */
|
|
25
|
+
noun?: string | undefined;
|
|
26
|
+
/** The noun for one row ("option"); defaults to `noun` without its last "s". */
|
|
27
|
+
nounOne?: string | undefined;
|
|
28
|
+
/** Stick to the top of the scroll area. */
|
|
29
|
+
sticky?: boolean | undefined;
|
|
30
|
+
/** Hide the Clear button (the host clears another way). */
|
|
31
|
+
hideClear?: boolean | undefined;
|
|
32
|
+
clearLabel?: string | undefined;
|
|
33
|
+
/** The toolbar's accessible name. */
|
|
34
|
+
label?: string | undefined;
|
|
35
|
+
}>(), { noun: undefined, nounOne: undefined, clearLabel: "Clear", label: "Bulk actions" });
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{ clear: [] }>();
|
|
38
|
+
|
|
39
|
+
/** "3 options selected", "1 option selected", or "3 selected" with no noun. */
|
|
40
|
+
const countText = computed(() => {
|
|
41
|
+
if (!props.noun) return `${props.count} selected`;
|
|
42
|
+
const one = props.nounOne ?? props.noun.replace(/s$/, "");
|
|
43
|
+
return `${props.count} ${props.count === 1 ? one : props.noun} selected`;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function onKeydown(ev: KeyboardEvent): void {
|
|
47
|
+
if (ev.key !== "Escape" || props.hideClear) return;
|
|
48
|
+
ev.stopPropagation();
|
|
49
|
+
emit("clear");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Bound in code, not as @keydown: the bar is a toolbar of buttons, and the
|
|
53
|
+
// keys come from those buttons (a template handler on it reads to the a11y
|
|
54
|
+
// lint as a click target on a plain element).
|
|
55
|
+
const root = ref<HTMLElement | null>(null);
|
|
56
|
+
onMounted(() => root.value?.addEventListener("keydown", onKeydown));
|
|
57
|
+
onBeforeUnmount(() => root.value?.removeEventListener("keydown", onKeydown));
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<div
|
|
62
|
+
ref="root"
|
|
63
|
+
class="wp-selbar"
|
|
64
|
+
role="toolbar"
|
|
65
|
+
:aria-label="label"
|
|
66
|
+
:data-sticky="sticky || undefined"
|
|
67
|
+
data-test="selection-bar"
|
|
68
|
+
>
|
|
69
|
+
<span class="wp-selbar__count" aria-live="polite" data-test="selection-count">{{ countText }}</span>
|
|
70
|
+
<slot name="start" />
|
|
71
|
+
<span class="wp-selbar__spacer" />
|
|
72
|
+
<slot />
|
|
73
|
+
<Button v-if="!hideClear" variant="ghost" size="sm" data-test="selection-clear" @click="emit('clear')">{{ clearLabel }}</Button>
|
|
74
|
+
</div>
|
|
75
|
+
</template>
|
|
76
|
+
|
|
77
|
+
<style scoped>
|
|
78
|
+
.wp-selbar {
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-wrap: wrap;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: var(--wp-space-4);
|
|
83
|
+
padding: var(--wp-space-3) var(--wp-space-5);
|
|
84
|
+
border: 1px solid color-mix(in oklab, var(--wp-accent) 40%, transparent);
|
|
85
|
+
border-radius: min(8px, var(--wp-radius-cap, 999px));
|
|
86
|
+
background: color-mix(in oklab, var(--wp-accent) 12%, var(--wp-bg-2));
|
|
87
|
+
font-family: var(--wp-font-sans);
|
|
88
|
+
}
|
|
89
|
+
.wp-selbar[data-sticky] {
|
|
90
|
+
position: sticky;
|
|
91
|
+
top: var(--wp-selection-bar-top, 0px);
|
|
92
|
+
z-index: 20;
|
|
93
|
+
}
|
|
94
|
+
.wp-selbar__count {
|
|
95
|
+
font-size: var(--wp-text-base);
|
|
96
|
+
font-weight: 600;
|
|
97
|
+
color: var(--wp-text);
|
|
98
|
+
white-space: nowrap;
|
|
99
|
+
}
|
|
100
|
+
.wp-selbar__spacer { flex: 1; }
|
|
101
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SettingGroup: a titled block of SettingRows in one bordered panel, the
|
|
4
|
+
* rows split by hairlines, so a long settings page reads as a list rather
|
|
5
|
+
* than a stack of cards.
|
|
6
|
+
*
|
|
7
|
+
* <SettingGroup title="Content" note="Applies to this browser.">
|
|
8
|
+
* <SettingRow label="Show NSFW posts" hint="Off by default."><WpSwitch v-model="nsfw" size="md" /></SettingRow>
|
|
9
|
+
* </SettingGroup>
|
|
10
|
+
*
|
|
11
|
+
* `#title-extra` sits after the title (a Badge, a help link). The panel is a
|
|
12
|
+
* size container: its rows go to one column when it is narrower than 480px.
|
|
13
|
+
*/
|
|
14
|
+
defineProps<{
|
|
15
|
+
title?: string | undefined;
|
|
16
|
+
/** A line under the title. */
|
|
17
|
+
note?: string | undefined;
|
|
18
|
+
}>();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<template>
|
|
22
|
+
<section class="wp-set-group">
|
|
23
|
+
<h3 v-if="title" class="wp-set-group__title">
|
|
24
|
+
{{ title }}<slot name="title-extra" />
|
|
25
|
+
</h3>
|
|
26
|
+
<p v-if="note" class="wp-set-group__note">{{ note }}</p>
|
|
27
|
+
<div class="wp-set-group__panel">
|
|
28
|
+
<slot />
|
|
29
|
+
</div>
|
|
30
|
+
</section>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<style scoped>
|
|
34
|
+
.wp-set-group { margin-bottom: var(--wp-space-7); }
|
|
35
|
+
.wp-set-group__title {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: var(--wp-space-3);
|
|
39
|
+
margin: 0 0 var(--wp-space-3);
|
|
40
|
+
font-size: var(--wp-text-xs);
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
letter-spacing: 0.08em;
|
|
43
|
+
text-transform: uppercase;
|
|
44
|
+
color: var(--wp-text-dim);
|
|
45
|
+
}
|
|
46
|
+
.wp-set-group__note {
|
|
47
|
+
margin: 0 0 var(--wp-space-3);
|
|
48
|
+
font-size: var(--wp-text-sm);
|
|
49
|
+
color: var(--wp-text-dim);
|
|
50
|
+
}
|
|
51
|
+
.wp-set-group__panel {
|
|
52
|
+
container: wp-set-group / inline-size;
|
|
53
|
+
background: var(--wp-bg-2);
|
|
54
|
+
border: 1px solid var(--wp-border);
|
|
55
|
+
border-radius: var(--wp-radius-lg);
|
|
56
|
+
}
|
|
57
|
+
</style>
|