Skip to content

Commit e9480fd

Browse files
[6.x] Replicator and Bard Set Preview Images (#12532)
* wip * switch from a modal to a drawer-esque stack ... modals currently sit on top of everything. if you open the asset browser, you cant use it. the drawer component doesn't stack correctly when used within stacks. * tests * Style the set thumbnail feature. Adds new HoverCard component. * move the v-if up further * increase contrast as per Figma mockup * Return null if invalid path * Replace static method call with config with a default * Only show field if container is configured * refactor method to return an array or null * Avoid rendering the hover card contents when there's no thumbnail * Allow hover card to be opened by a prop, and open it when using keyboard in the set picker * rename thumbnails to preview images * allow setting the config as the container handle string if you dont care about a folder * Show / command shortcut * wip Grid Mode * Switch to a modal for grid * Fix Grid Mode search * small screens * Use icons if no image, increase max-width * Add HoverCard to ui package export test * Actually export HoverCard * export HoverCard here too --------- Co-authored-by: Jack McDade <jack@jackmcdade.com>
1 parent c5673d5 commit e9480fd

15 files changed

Lines changed: 519 additions & 112 deletions

File tree

config/assets.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,4 +235,20 @@
235235
'cache_path' => storage_path('statamic/glide/ffmpeg'),
236236
],
237237

238+
/*
239+
|--------------------------------------------------------------------------
240+
| Replicator and Bard Set Preview Images
241+
|--------------------------------------------------------------------------
242+
|
243+
| Replicator and Bard sets may have preview images to give users a visual
244+
| representation of the content within. Here you may specify the asset
245+
| container and folder where these preview images are to be stored.
246+
|
247+
*/
248+
249+
'set_preview_images' => [
250+
'container' => 'assets',
251+
'folder' => 'set-previews',
252+
],
253+
238254
];

packages/cms/src/ui.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export const {
4141
Field,
4242
Header,
4343
Heading,
44+
HoverCard,
4445
Icon,
4546
Input,
4647
InputGroup,

packages/ui/src/HoverCard.vue

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<script setup>
2+
import { cva } from 'cva';
3+
import { HoverCardArrow, HoverCardContent, HoverCardPortal, HoverCardRoot, HoverCardTrigger } from 'reka-ui';
4+
import { computed, getCurrentInstance, ref, watch } from 'vue';
5+
6+
defineOptions({
7+
inheritAttrs: false,
8+
});
9+
10+
const emit = defineEmits(['update:open']);
11+
12+
const props = defineProps({
13+
align: { type: String, default: 'center' },
14+
arrow: { type: Boolean, default: true },
15+
delay: { type: Number, default: 200 },
16+
inset: { type: Boolean, default: false },
17+
offset: { type: Number, default: 25 },
18+
side: { type: String, default: 'left' },
19+
open: { type: Boolean, default: false },
20+
});
21+
22+
const HoverCardContentClasses = cva({
23+
base: [
24+
'rounded-xl bg-white dark:bg-gray-800 outline-hidden overflow-hidden',
25+
'border border-gray-200 dark:border-white/10 dark:border-b-0 shadow-lg',
26+
'duration-100 will-change-[transform,opacity]',
27+
'data-[state=open]:animate-in data-[state=closed]:animate-out',
28+
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
29+
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
30+
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
31+
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
32+
],
33+
variants: {
34+
inset: { true: 'inset-0', false: 'p-4' },
35+
},
36+
})({
37+
...props,
38+
});
39+
40+
41+
const instance = getCurrentInstance();
42+
const isUsingOpenProp = computed(() => instance?.vnode.props?.hasOwnProperty('open'));
43+
const open = ref(props.open);
44+
watch(
45+
() => props.open,
46+
(value) => open.value = value,
47+
);
48+
// When the parent component controls the open state, emit an update event
49+
// so it can update its state, which eventually gets passed down as a prop.
50+
// Otherwise, update the local state.
51+
function updateOpen(value) {
52+
if (isUsingOpenProp.value) {
53+
emit('update:open', value);
54+
return;
55+
}
56+
57+
open.value = value;
58+
}
59+
</script>
60+
61+
<template>
62+
<HoverCardRoot
63+
v-slot="slotProps"
64+
:open-delay="delay"
65+
:open="open"
66+
@update:open="updateOpen"
67+
>
68+
<HoverCardTrigger data-ui-hover-card-trigger as-child>
69+
<slot name="trigger" />
70+
</HoverCardTrigger>
71+
<HoverCardPortal v-if="$slots.default">
72+
<HoverCardContent
73+
data-ui-hover-card-content
74+
:class="[HoverCardContentClasses, $attrs.class]"
75+
:align
76+
:sideOffset="offset"
77+
:side
78+
>
79+
<slot v-bind="slotProps" />
80+
<HoverCardArrow v-if="arrow" class="fill-white stroke-gray-300" />
81+
</HoverCardContent>
82+
</HoverCardPortal>
83+
</HoverCardRoot>
84+
</template>
85+
86+
<style>
87+
[data-ui-hover-card-content] {
88+
max-height: var(--reka-hover-card-content-available-height);
89+
transform-origin: var(--reka-hover-card-content-transform-origin);
90+
}
91+
</style>

packages/ui/src/Input/Input.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const inputClasses = computed(() => {
5353
variant: {
5454
default: '',
5555
light: 'dark:bg-gray-800/20',
56+
ghost: 'bg-transparent border-none shadow-none! inset-shadow-none!',
5657
},
5758
hasLimit: {
5859
true: 'pe-9',

packages/ui/src/Modal/Modal.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Icon from '../Icon/Icon.vue';
88
const emit = defineEmits(['update:open']);
99
1010
const props = defineProps({
11+
blur: { type: Boolean, default: true },
1112
title: { type: String, default: '' },
1213
icon: { type: [String, null], default: null },
1314
open: { type: Boolean, default: false },
@@ -16,6 +17,15 @@ const props = defineProps({
1617
1718
const hasModalTitleComponent = hasComponent('ModalTitle');
1819
20+
const overlayClasses = cva({
21+
base: 'data-[state=open]:show fixed inset-0 z-30 bg-gray-800/20 dark:bg-gray-800/50',
22+
variants: {
23+
blur: {
24+
true: 'backdrop-blur-[2px]',
25+
},
26+
},
27+
})({ ...props });
28+
1929
const modalClasses = cva({
2030
base: [
2131
'fixed outline-hidden left-1/2 top-1/6 z-50 w-full max-w-2xl -translate-x-1/2',
@@ -63,7 +73,7 @@ function preventIfNotDismissible(event) {
6373
<slot name="trigger" />
6474
</DialogTrigger>
6575
<DialogPortal>
66-
<DialogOverlay class="data-[state=open]:show fixed inset-0 z-30 bg-gray-800/20 backdrop-blur-[2px] dark:bg-gray-800/50" />
76+
<DialogOverlay :class="overlayClasses" />
6777
<DialogContent
6878
:class="[modalClasses, $attrs.class]"
6979
data-ui-modal-content

packages/ui/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export { default as EmptyStateMenu } from './EmptyState/Menu.vue';
3737
export { default as Field } from './Field.vue';
3838
export { default as Header } from './Header.vue';
3939
export { default as Heading } from './Heading.vue';
40+
export { default as HoverCard } from './HoverCard.vue';
4041
export { default as Icon } from './Icon/Icon.vue';
4142
export { registerIconSet, registerIconSetFromStrings } from './Icon/registry.js';
4243
export { default as Input } from './Input/Input.vue';

resources/css/components/fieldtypes/bard.css

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,6 @@
176176
}
177177
}
178178

179-
.bard-add-set-button {
180-
@apply absolute top-[-6px] z-1 flex items-center justify-center -start-4;
181-
}
182-
183179
.bard-footer-toolbar {
184180
@apply flex items-center justify-between rounded-b border-t bg-white p-2 text-xs text-gray-700 shadow-none dark:border-dark-300 dark:bg-dark-550 dark:text-dark-175;
185181
padding: 8px 12px;
@@ -217,10 +213,6 @@
217213
}
218214
}
219215
}
220-
221-
.set-picker .popover .popover-content {
222-
min-width: 320px !important;
223-
}
224216
}
225217
/* Fullscreen mode
226218
========================================================================== */

resources/js/bootstrap/cms/ui.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export {
4141
Field,
4242
Header,
4343
Heading,
44+
HoverCard,
4445
Icon,
4546
Input,
4647
InputGroup,

resources/js/components/blueprints/Section.vue

Lines changed: 93 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -34,59 +34,95 @@
3434
</Fields>
3535
</ui-panel>
3636

37-
<confirmation-modal
37+
<stack
38+
narrow
3839
v-if="editingSection"
39-
:title="editText"
4040
@opened="$refs.displayInput?.select()"
41-
@confirm="editConfirmed"
42-
@cancel="editCancelled"
41+
@closed="editCancelled"
4342
>
44-
<div class="space-y-6">
45-
<ui-field :label="__('Display')">
46-
<ui-input ref="displayInput" type="text" v-model="editingSection.display" />
47-
</ui-field>
48-
<ui-field :label="__('Handle')" v-if="showHandleField">
49-
<ui-input
50-
type="text"
51-
class="font-mono text-sm"
52-
v-model="editingSection.handle"
53-
@input="handleSyncedWithDisplay = false"
54-
/>
55-
</ui-field>
56-
<ui-field :label="__('Instructions')">
57-
<ui-input type="text" v-model="editingSection.instructions" />
58-
</ui-field>
59-
<ui-field :label="__('Collapsible')">
60-
<ui-switch v-model="editingSection.collapsible" />
61-
</ui-field>
62-
<ui-field :label="__('Collapsed by default')" v-if="editingSection.collapsible">
63-
<ui-switch v-model="editingSection.collapsed" />
64-
</ui-field>
65-
<ui-field :label="__('Icon')" v-if="showHandleField">
66-
<publish-field-meta
67-
:config="{
68-
handle: 'icon',
69-
type: 'icon',
70-
set: iconSet,
71-
}"
72-
:initial-value="editingSection.icon"
73-
v-slot="{ meta, value, loading, config }"
74-
>
75-
<icon-fieldtype
76-
v-if="!loading"
77-
handle="icon"
78-
:config="config"
79-
:meta="meta"
80-
:value="value"
81-
@update:value="editingSection.icon = $event"
43+
<div class="h-full overflow-scroll overflow-x-auto bg-white px-6 rounded-l-xl dark:bg-dark-800">
44+
<header class="py-2">
45+
<div class="flex items-center justify-between">
46+
<ui-heading size="lg">
47+
{{ editText }}
48+
</ui-heading>
49+
<ui-button icon="x" variant="ghost" class="-me-2" @click="editCancelled" />
50+
</div>
51+
</header>
52+
<div class="space-y-6">
53+
<ui-field :label="__('Display')">
54+
<ui-input ref="displayInput" type="text" v-model="editingSection.display" />
55+
</ui-field>
56+
<ui-field :label="__('Handle')" v-if="showHandleField">
57+
<ui-input
58+
type="text"
59+
class="font-mono text-sm"
60+
v-model="editingSection.handle"
61+
@input="handleSyncedWithDisplay = false"
8262
/>
83-
</publish-field-meta>
84-
</ui-field>
85-
<ui-field :label="__('Hidden')" v-if="showHideField">
86-
<ui-switch v-model="editingSection.hide" />
87-
</ui-field>
63+
</ui-field>
64+
<ui-field :label="__('Instructions')">
65+
<ui-input type="text" v-model="editingSection.instructions" />
66+
</ui-field>
67+
<ui-field :label="__('Collapsible')">
68+
<ui-switch v-model="editingSection.collapsible" />
69+
</ui-field>
70+
<ui-field :label="__('Collapsed by default')" v-if="editingSection.collapsible">
71+
<ui-switch v-model="editingSection.collapsed" />
72+
</ui-field>
73+
<ui-field :label="__('Icon')" v-if="showHandleField">
74+
<publish-field-meta
75+
:config="{
76+
handle: 'icon',
77+
type: 'icon',
78+
set: iconSet,
79+
}"
80+
:initial-value="editingSection.icon"
81+
v-slot="{ meta, value, loading, config }"
82+
>
83+
<icon-fieldtype
84+
v-if="!loading"
85+
handle="icon"
86+
:config="config"
87+
:meta="meta"
88+
:value="value"
89+
@update:value="editingSection.icon = $event"
90+
/>
91+
</publish-field-meta>
92+
</ui-field>
93+
<ui-field :label="__('Preview Image')" v-if="showHandleField && previewImageContainer">
94+
<publish-field-meta
95+
:config="{
96+
handle: 'image',
97+
type: 'assets',
98+
container: previewImageContainer,
99+
folder: previewImageFolder,
100+
restrict: !! previewImageFolder,
101+
allow_uploads: true,
102+
}"
103+
:initial-value="editingSection.image"
104+
v-slot="{ meta, value, loading, config }"
105+
>
106+
<assets-fieldtype
107+
v-if="!loading"
108+
handle="image"
109+
:config="config"
110+
:meta="meta"
111+
:value="value"
112+
@update:value="editingSection.image = $event?.[0] || null"
113+
/>
114+
</publish-field-meta>
115+
</ui-field>
116+
<ui-field :label="__('Hidden')" v-if="showHideField">
117+
<ui-switch v-model="editingSection.hide" />
118+
</ui-field>
119+
<div class="py-4 space-x-2">
120+
<ui-button :text="__('Confirm')" @click="editConfirmed" variant="primary" />
121+
<ui-button :text="__('Cancel')" @click="editCancelled" variant="ghost" />
122+
</div>
123+
</div>
88124
</div>
89-
</confirmation-modal>
125+
</stack>
90126
</div>
91127
</template>
92128

@@ -132,6 +168,14 @@ export default {
132168
iconSet() {
133169
return this.$config.get('replicatorSetIcons') || undefined;
134170
},
171+
172+
previewImageContainer() {
173+
return this.$config.get('setPreviewImages.container') || null;
174+
},
175+
176+
previewImageFolder() {
177+
return this.$config.get('setPreviewImages.folder') || null;
178+
},
135179
},
136180
137181
watch: {
@@ -185,6 +229,7 @@ export default {
185229
handle: this.section.handle,
186230
instructions: this.section.instructions,
187231
icon: this.section.icon,
232+
image: this.section.image,
188233
hide: this.section.hide,
189234
collapsible: this.section.collapsible,
190235
collapsed: this.section.collapsed,

resources/js/components/fieldtypes/bard/BardFieldtype.vue

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -95,15 +95,15 @@
9595
@added="addSet"
9696
>
9797
<template #trigger>
98-
<button
99-
type="button"
100-
class="btn-round bard-add-set-button group size-7!"
101-
:style="{ transform: `translateY(${y+2}px)` }"
102-
:aria-label="__('Add Set')"
103-
v-tooltip="__('Add Set')"
104-
>
105-
<ui-icon name="plus" class="size-4" />
106-
</button>
98+
<div class="absolute flex items-center gap-2 top-[-6px] z-1 -start-4.5 group" :style="{ transform: `translateY(${y}px)` }">
99+
<ui-button
100+
icon="plus"
101+
size="sm"
102+
:aria-label="__('Add Set')"
103+
v-tooltip="__('Add Set')"
104+
/>
105+
<ui-description v-if="!$refs.setPicker?.isOpen" :text="__('Type \'/\' to insert a set')" />
106+
</div>
107107
</template>
108108
</set-picker>
109109
</floating-menu>

0 commit comments

Comments
 (0)