fix(accordion): overflow visible une fois ouvert pour ne pas rogner les popovers enfants [#MUI-37]

Le panneau garde overflow-hidden pendant l'animation puis passe en
overflow-visible à la fin de l'ouverture (et re-clippe à la fermeture),
afin qu'un datepicker/select ouvert dans une section ne soit pas coupé.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 09:10:42 +02:00
parent e28bbb4889
commit def0fc8805
2 changed files with 58 additions and 4 deletions
@@ -222,3 +222,35 @@ describe('MalioAccordion — defaultOpen, disabled & clavier', () => {
root.remove() root.remove()
}) })
}) })
describe('MalioAccordion — overflow du panneau (popovers enfants)', () => {
const ONE = `<MalioAccordionItem title="A" value="a"><p>contenu</p></MalioAccordionItem>`
const ONE_OPEN = `<MalioAccordionItem title="A" value="a" :default-open="true"><p>contenu</p></MalioAccordionItem>`
it('clips the panel (overflow-hidden) while collapsed', () => {
const wrapper = mountAccordion({}, ONE)
const inner = wrapper.find('[role="region"] > div')
expect(inner.classes()).toContain('overflow-hidden')
expect(inner.classes()).not.toContain('overflow-visible')
})
it('lets the panel overflow once open at mount (defaultOpen)', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('switches to overflow-visible after the open transition ends', async () => {
const wrapper = mountAccordion({}, ONE)
await wrapper.find('button[aria-expanded]').trigger('click')
await wrapper.find('[role="region"]').trigger('transitionend', {propertyName: 'grid-template-rows'})
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-visible')
})
it('re-clips (overflow-hidden) as soon as it closes', async () => {
const wrapper = mountAccordion({}, ONE_OPEN)
await nextTick()
await wrapper.find('button[aria-expanded]').trigger('click')
expect(wrapper.find('[role="region"] > div').classes()).toContain('overflow-hidden')
})
})
@@ -29,8 +29,12 @@
:aria-labelledby="headerId" :aria-labelledby="headerId"
class="grid transition-[grid-template-rows] duration-200 ease-out" class="grid transition-[grid-template-rows] duration-200 ease-out"
:class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'" :class="open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
@transitionend="onPanelTransitionEnd"
> >
<div class="overflow-hidden" :inert="!open || undefined"> <div
:class="overflowVisible ? 'overflow-visible' : 'overflow-hidden'"
:inert="!open || undefined"
>
<div :class="panelInnerClass"> <div :class="panelInnerClass">
<slot /> <slot />
</div> </div>
@@ -40,7 +44,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, inject, onBeforeUnmount, onMounted, ref, useId} from 'vue' import {computed, inject, onBeforeUnmount, onMounted, ref, useId, watch} from 'vue'
import {Icon as IconifyIcon} from '@iconify/vue' import {Icon as IconifyIcon} from '@iconify/vue'
import {twMerge} from 'tailwind-merge' import {twMerge} from 'tailwind-merge'
import {accordionContextKey} from './context' import {accordionContextKey} from './context'
@@ -74,6 +78,22 @@ const headerId = computed(() => `${ctx.baseId.value}-header-${value.value}`)
const panelId = computed(() => `${ctx.baseId.value}-panel-${value.value}`) const panelId = computed(() => `${ctx.baseId.value}-panel-${value.value}`)
const open = computed(() => ctx.isOpen(value.value)) const open = computed(() => ctx.isOpen(value.value))
// Le panneau garde `overflow-hidden` pendant l'animation (clipping requis par
// la transition grid-template-rows), puis passe en `overflow-visible` une fois
// complètement ouvert pour qu'un popover enfant (datepicker, select…) ne soit
// pas rogné. On re-clippe dès le début de la fermeture.
const overflowVisible = ref(false)
watch(open, (isOpen) => {
if (!isOpen) overflowVisible.value = false
})
function onPanelTransitionEnd(e: TransitionEvent) {
if (e.propertyName === 'grid-template-rows' && open.value) {
overflowVisible.value = true
}
}
function onToggle() { function onToggle() {
if (props.disabled) return if (props.disabled) return
ctx.toggle(value.value) ctx.toggle(value.value)
@@ -81,13 +101,13 @@ function onToggle() {
const headerClasses = computed(() => const headerClasses = computed(() =>
twMerge( twMerge(
'flex w-full items-center justify-between gap-4 px-7 pt-[28px] pb-[34px] text-left font-[600] text-[20px] transition-colors', 'flex w-full items-center justify-between gap-4 px-7 pt-[28px] pb-[20px] text-left font-[600] text-[20px] transition-colors',
props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface', props.disabled ? 'cursor-not-allowed text-m-muted' : 'cursor-pointer hover:bg-m-surface',
props.headerClass, props.headerClass,
), ),
) )
const panelInnerClass = computed(() => twMerge('px-7 pb-[10px]', props.panelClass)) const panelInnerClass = computed(() => twMerge('px-7 pt-[10px] pb-[20px]', props.panelClass))
onMounted(() => { onMounted(() => {
ctx.register( ctx.register(
@@ -98,6 +118,8 @@ onMounted(() => {
}, },
props.defaultOpen, props.defaultOpen,
) )
// Ouvert au montage (defaultOpen / contrôlé) : pas d'animation, overflow visible direct.
if (open.value) overflowVisible.value = true
}) })
onBeforeUnmount(() => ctx.unregister(value.value)) onBeforeUnmount(() => ctx.unregister(value.value))