Skip to content

Commit 7c351e7

Browse files
authored
feat: accordion bloc (#866)
1 parent 201c61e commit 7c351e7

16 files changed

+684
-264
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,5 @@ logs
2828
/playwright-report/
2929
/blob-report/
3030
/playwright/.cache/
31-
3231
playwright/.auth
32+
tests/**/screenshots/

components/Accordion/Accordion.global.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
:class="iconColor"
2323
size="24px"
2424
/>
25-
{{ title }}
25+
<slot name="title">
26+
{{ title }}
27+
</slot>
2628
</DisclosureButton>
2729
</component>
2830
<DisclosurePanel
@@ -56,7 +58,7 @@ const props = withDefaults(defineProps<{
5658
const { isOpen, open, toggle, unregister, withIcon } = inject(key) as AccordionRegister
5759
5860
const accordionId = props.id || useId()
59-
const titleAccordionId = props.title.toLocaleLowerCase().replace(/\s/g, '-')
61+
const titleAccordionId = props.id || props.title.toLocaleLowerCase().replace(/\s/g, '-')
6062
const route = useRoute()
6163
const icon = computed(() => {
6264
switch (props.state) {
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
<template>
2+
<BlocWithTitleAndSubtitle
3+
v-model="bloc"
4+
:edit
5+
>
6+
<!-- Accordion items -->
7+
<AccordionGroup>
8+
<div ref="sortableRef">
9+
<div
10+
v-for="(item, itemIndex) in bloc.items"
11+
:key="itemIndex"
12+
class="relative"
13+
:class="{ 'cursor-grab active:cursor-grabbing': edit }"
14+
>
15+
<!-- Delete button in edit mode -->
16+
<button
17+
v-if="edit"
18+
type="button"
19+
class="absolute -left-8 top-3 text-gray-500 hover:text-gray-700 z-10"
20+
:title="$t('Supprimer')"
21+
@click="bloc.items.splice(itemIndex, 1)"
22+
>
23+
<RiDeleteBinLine class="size-5" />
24+
</button>
25+
26+
<Accordion
27+
:id="`accordion-${bloc.id}-${itemIndex}`"
28+
:title="item.title || $t('Titre de l\'élément')"
29+
>
30+
<template
31+
v-if="edit"
32+
#title
33+
>
34+
<EditableText
35+
:model-value="item.title || $t('Titre de l\'élément')"
36+
tag="span"
37+
class="flex-1"
38+
@update:model-value="item.title = $event"
39+
/>
40+
</template>
41+
42+
<div class="space-y-8">
43+
<template
44+
v-for="(contentBloc, contentIndex) in item.content"
45+
:key="contentBloc.id"
46+
>
47+
<!-- Add button above each bloc -->
48+
<div
49+
v-if="edit"
50+
class="flex items-center justify-center"
51+
>
52+
<AddBlocDropdown
53+
content-only
54+
@new-bloc="item.content.splice(contentIndex, 0, $event as ContentBloc)"
55+
>
56+
<button
57+
type="button"
58+
class="text-gray-400 hover:text-gray-600 flex items-center gap-1 text-sm"
59+
>
60+
<RiAddLine class="size-4" />
61+
{{ $t('Ajouter un bloc') }}
62+
</button>
63+
</AddBlocDropdown>
64+
</div>
65+
66+
<div
67+
class="relative"
68+
data-testid="accordion-content-bloc"
69+
>
70+
<!-- Content bloc toolbar -->
71+
<div
72+
v-if="edit"
73+
class="absolute -left-14 top-1/2 -translate-y-1/2 flex flex-col gap-1"
74+
>
75+
<BrandedButton
76+
color="tertiary"
77+
size="xs"
78+
:icon="RiArrowUpLine"
79+
:disabled="contentIndex === 0"
80+
:title="$t('Monter')"
81+
@click="moveContent(item.content, contentIndex, -1)"
82+
/>
83+
<BrandedButton
84+
color="tertiary"
85+
size="xs"
86+
:icon="RiArrowDownLine"
87+
:disabled="contentIndex === item.content.length - 1"
88+
:title="$t('Descendre')"
89+
@click="moveContent(item.content, contentIndex, 1)"
90+
/>
91+
<BrandedButton
92+
color="tertiary"
93+
size="xs"
94+
:icon="RiDeleteBinLine"
95+
:title="$t('Supprimer')"
96+
@click="item.content.splice(contentIndex, 1)"
97+
/>
98+
</div>
99+
100+
<DatasetsListBloc
101+
v-if="contentBloc.class === 'DatasetsListBloc'"
102+
v-model="(item.content[contentIndex] as DatasetsListBlocType)"
103+
:edit
104+
/>
105+
<DataservicesListBloc
106+
v-if="contentBloc.class === 'DataservicesListBloc'"
107+
v-model="(item.content[contentIndex] as DataservicesListBlocType)"
108+
:edit
109+
/>
110+
<ReusesListBloc
111+
v-if="contentBloc.class === 'ReusesListBloc'"
112+
v-model="(item.content[contentIndex] as ReusesListBlocType)"
113+
:edit
114+
/>
115+
<LinksListBloc
116+
v-if="contentBloc.class === 'LinksListBloc'"
117+
v-model="(item.content[contentIndex] as LinksListBlocType)"
118+
:edit
119+
/>
120+
</div>
121+
</template>
122+
123+
<!-- Add button at the end (or when empty) -->
124+
<div
125+
v-if="edit"
126+
class="flex items-center justify-center"
127+
>
128+
<AddBlocDropdown
129+
content-only
130+
@new-bloc="item.content.push($event as ContentBloc)"
131+
>
132+
<button
133+
type="button"
134+
class="text-gray-400 hover:text-gray-600 flex items-center gap-1 text-sm"
135+
data-testid="accordion-add-content"
136+
>
137+
<RiAddLine class="size-4" />
138+
{{ $t('Ajouter un bloc') }}
139+
</button>
140+
</AddBlocDropdown>
141+
</div>
142+
</div>
143+
</Accordion>
144+
</div>
145+
</div>
146+
147+
<!-- New accordion placeholder in edit mode -->
148+
<div
149+
v-if="edit"
150+
data-testid="accordion-new-item"
151+
>
152+
<Accordion
153+
:id="`accordion-${bloc.id}-new-${newItemKey}`"
154+
:key="newItemKey"
155+
:title="$t('Titre de l\'élément')"
156+
>
157+
<template #title>
158+
<EditableText
159+
:model-value="newItemTitle"
160+
tag="span"
161+
class="flex-1"
162+
data-testid="accordion-new-item-title"
163+
:placeholder="$t('Titre de l\'élément')"
164+
@update:model-value="createNewItem($event)"
165+
/>
166+
</template>
167+
<div class="text-gray-400 text-sm">
168+
{{ $t('Renseignez un titre pour créer un nouvel élément') }}
169+
</div>
170+
</Accordion>
171+
</div>
172+
</AccordionGroup>
173+
</BlocWithTitleAndSubtitle>
174+
</template>
175+
176+
<script setup lang="ts">
177+
import { BrandedButton } from '@datagouv/components-next'
178+
import { RiAddLine, RiArrowDownLine, RiArrowUpLine, RiDeleteBinLine } from '@remixicon/vue'
179+
import BlocWithTitleAndSubtitle from './BlocWithTitleAndSubtitle.vue'
180+
import EditableText from './EditableText.vue'
181+
import AddBlocDropdown from './AddBlocDropdown.vue'
182+
import DatasetsListBloc from './DatasetsListBloc.vue'
183+
import DataservicesListBloc from './DataservicesListBloc.vue'
184+
import ReusesListBloc from './ReusesListBloc.vue'
185+
import LinksListBloc from './LinksListBloc.vue'
186+
import type {
187+
AccordionListBloc,
188+
ContentBloc,
189+
DatasetsListBloc as DatasetsListBlocType,
190+
DataservicesListBloc as DataservicesListBlocType,
191+
ReusesListBloc as ReusesListBlocType,
192+
LinksListBloc as LinksListBlocType,
193+
} from '~/types/pages'
194+
195+
const props = defineProps<{
196+
edit: boolean
197+
}>()
198+
199+
const bloc = defineModel<AccordionListBloc>({ required: true })
200+
201+
const { sortableRef } = useBlocSortable(
202+
computed(() => bloc.value.items),
203+
toRef(props, 'edit'),
204+
)
205+
206+
const newItemTitle = ref('')
207+
const newItemKey = ref(0)
208+
209+
function createNewItem(title: string) {
210+
if (title.trim()) {
211+
bloc.value.items.push({ title, content: [] })
212+
newItemTitle.value = ''
213+
newItemKey.value++
214+
}
215+
}
216+
217+
function moveContent(content: Array<ContentBloc>, index: number, direction: -1 | 1) {
218+
const newIndex = index + direction
219+
if (newIndex < 0 || newIndex >= content.length) return
220+
const temp = content[index]
221+
content[index] = content[newIndex]
222+
content[newIndex] = temp
223+
}
224+
</script>

components/Pages/AddBlocDropdown.vue

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,44 @@
1414
leave-from-class="transform opacity-100 scale-100"
1515
leave-to-class="transform opacity-0 scale-95"
1616
>
17-
<MenuItems class="overflow-hidden absolute right-0 z-10 mt-2 w-96 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-hidden">
17+
<!-- Content only: simple flat list -->
18+
<MenuItems
19+
v-if="contentOnly"
20+
class="absolute left-0 z-50 mt-2 w-80 origin-top-left divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-hidden"
21+
>
22+
<MenuItem
23+
v-for="key in contentBlocKeys"
24+
:key="contentBlocsTypes[key].name"
25+
v-slot="{ active }"
26+
>
27+
<button
28+
class="block px-4 py-2 text-sm w-full text-left space-y-1"
29+
:class="[active ? 'bg-gray-100 text-gray-900 outline-hidden' : 'text-gray-700']"
30+
type="button"
31+
:data-testid="`add-content-${key}`"
32+
@click="$emit('newBloc', { id: uuidv4(), ...contentBlocsTypes[key].default() } as PageBloc)"
33+
>
34+
<div class="flex space-x-1 items-center text-gray-title">
35+
<component
36+
:is="contentBlocsTypes[key].icon"
37+
class="size-4"
38+
/>
39+
<div class="text-sm">
40+
{{ contentBlocsTypes[key].name }}
41+
</div>
42+
</div>
43+
<div class="text-xs text-gray-plain">
44+
{{ contentBlocsTypes[key].description }}
45+
</div>
46+
</button>
47+
</MenuItem>
48+
</MenuItems>
49+
50+
<!-- All blocs: grouped list -->
51+
<MenuItems
52+
v-else
53+
class="overflow-hidden absolute right-0 z-10 mt-2 w-96 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black/5 focus:outline-hidden"
54+
>
1855
<div
1956
v-for="groupOfBloc in newBlocsTypes"
2057
:key="groupOfBloc.name"
@@ -58,14 +95,21 @@ import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue'
5895
import { v4 as uuidv4 } from 'uuid'
5996
import type { PageBloc } from '~/types/pages'
6097
98+
defineProps<{
99+
contentOnly?: boolean
100+
}>()
101+
61102
defineEmits<{
62103
newBloc: [PageBloc]
63104
}>()
105+
64106
const { t } = useTranslation()
65107
const blocsTypes = useBlocsTypes()
108+
const contentBlocsTypes = useContentBlocsTypes()
109+
const contentBlocKeys = Object.keys(contentBlocsTypes) as Array<keyof typeof contentBlocsTypes>
66110
67111
const newBlocsTypes: Array<{ name: string, blocsTypes: Array<keyof typeof blocsTypes> }> = [
68-
{ name: t('Mise en page'), blocsTypes: ['HeroBloc'] },
112+
{ name: t('Mise en page'), blocsTypes: ['HeroBloc', 'AccordionListBloc'] },
69113
{ name: t('Contenus à la une'), blocsTypes: ['DatasetsListBloc', 'ReusesListBloc', 'DataservicesListBloc', 'LinksListBloc'] },
70114
{ name: t('Texte'), blocsTypes: ['MarkdownBloc'] },
71115
]
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<template>
2+
<div class="space-y-6">
3+
<div class="space-y-2.5">
4+
<EditableText
5+
v-if="edit"
6+
:model-value="bloc.title ?? ''"
7+
class="text-gray-title text-3xl font-extrabold"
8+
:placeholder="$t('Titre du bloc')"
9+
@update:model-value="bloc.title = $event || null"
10+
/>
11+
<div
12+
v-else-if="bloc.title"
13+
class="text-gray-title text-3xl font-extrabold"
14+
>
15+
{{ bloc.title }}
16+
</div>
17+
18+
<EditableText
19+
v-if="edit && secondaryText"
20+
:model-value="secondaryText"
21+
class="text-gray-plain"
22+
@update:model-value="updateSecondaryText"
23+
/>
24+
<div
25+
v-else-if="secondaryText"
26+
class="text-gray-plain"
27+
>
28+
{{ secondaryText }}
29+
</div>
30+
<button
31+
v-else-if="edit"
32+
type="button"
33+
class="text-gray-400 hover:text-gray-600 text-sm"
34+
@click="updateSecondaryText(secondaryLabel)"
35+
>
36+
+ {{ hasSubtitle ? $t('Ajouter un sous-titre') : $t('Ajouter une description') }}
37+
</button>
38+
</div>
39+
40+
<slot />
41+
</div>
42+
</template>
43+
44+
<script setup lang="ts">
45+
import EditableText from './EditableText.vue'
46+
47+
type BlocWithSubtitle = { id: string, title: string | null, subtitle: string | null }
48+
type BlocWithDescription = { id: string, title: string | null, description: string | null }
49+
type BlocWithSecondaryText = BlocWithSubtitle | BlocWithDescription
50+
51+
defineProps<{
52+
edit: boolean
53+
}>()
54+
55+
const { t } = useTranslation()
56+
57+
const bloc = defineModel<BlocWithSecondaryText>({ required: true })
58+
59+
const hasSubtitle = computed(() => 'subtitle' in bloc.value)
60+
61+
const secondaryText = computed(() => {
62+
if (hasSubtitle.value) {
63+
return (bloc.value as { subtitle?: string | null }).subtitle ?? null
64+
}
65+
return (bloc.value as { description?: string | null }).description ?? null
66+
})
67+
68+
const secondaryLabel = computed(() => hasSubtitle.value ? t('Sous-titre') : t('Description'))
69+
70+
function updateSecondaryText(value: string | null) {
71+
if (hasSubtitle.value) {
72+
(bloc.value as { subtitle?: string | null }).subtitle = value || null
73+
}
74+
else {
75+
(bloc.value as { description?: string | null }).description = value || null
76+
}
77+
}
78+
</script>

0 commit comments

Comments
 (0)