feat(media): align visual checks and variant UI with merged design

This commit is contained in:
Florian Sylvain committed 2026-10-13 02:35:20 +02:00
1 parent e6746f2be7
commit 160c0a49ce
23 files changed
+606 -99

No files matched your search

@@ -760,7 +760,7 @@ describe('Copy URL, Purge and Preview', () => {
const preview = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="dialog"]'),
).find((entry) => textOf(entry).includes('Preview: thumb'))!
expect(preview.className).toContain('max-sm:!bottom-4')
expect(preview.className).toContain('max-sm:!bottom-0')
expect(findButton('Close', preview)!.className).toContain('h-11')
})
})
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import {
CREATE,
alertDialog,
buttonByLabel,
buttonIn,
click,
clickBackdrop,
deferred,
field,
mountPage,
openAddForm,
openEditForm,
pressEscape,
rows,
slideover,
stubBrowser,
textOf,
typeInto,
unmountPage,
variant,
} from './variantsTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
function sheet() {
return slideover()!
}
function addButton() {
return document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
}
beforeEach(() => {
toasts.add.mockClear()
localStorage.clear()
initializeLocale()
stubBrowser()
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
vi.unstubAllGlobals()
await unmountPage()
localStorage.clear()
initializeLocale()
})
describe('Rule: The variant form follows the overlay pattern', () => {
it('Add variant is a right slide-over on desktop', async () => {
await mountPage()
await openAddForm()
const panel = sheet()
expect(panel.getAttribute('data-side')).toBe('right')
expect(panel.className).toContain('sm:max-w-240')
expect(panel.className).not.toContain('h-23/25')
expect(buttonByLabel('Close')).not.toBeNull()
expect(buttonByLabel('Back')).toBeNull()
expect(panel.querySelector('[data-slot="body"] form')!.className).toContain(
'sm:grid-cols-[440px_minmax(0,1fr)]',
)
expect(rows()).toHaveLength(6)
})
it('Add variant is a tall bottom sheet on mobile', async () => {
await mountPage({ desktop: false })
await openAddForm()
const panel = sheet()
expect(panel.getAttribute('data-side')).toBe('bottom')
expect(panel.className).toContain('h-23/25')
expect(panel.className).toContain('before:w-9')
expect(buttonByLabel('Close')!.className).toContain('size-11')
expect(buttonByLabel('Back')).toBeNull()
expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('h-18')
expect(rows()).toHaveLength(6)
})
it.each([
['desktop', true],
['mobile', false],
])('Edit uses the same container on %s', async (_viewport, desktop) => {
await mountPage({ desktop })
await openAddForm()
const addSide = sheet().getAttribute('data-side')
await click(buttonByLabel('Close'))
await openEditForm('hero')
expect(sheet().getAttribute('data-side')).toBe(addSide)
expect(sheet().querySelector('[data-slot="title"]')?.textContent?.trim()).toBe(
'Edit variant: hero',
)
})
it('Focus moves to the Name field in create mode', async () => {
await mountPage({ desktop: false })
await openAddForm()
expect(document.activeElement).toBe(field('name'))
})
})
describe('Rule: Dismissing the mobile sheet', () => {
const gestures: [string, () => Promise<void>][] = [
['the close button', () => click(buttonByLabel('Close'))],
['Cancel', () => click(buttonIn(sheet(), 'Cancel'))],
['Escape', () => pressEscape()],
['the scrim', () => clickBackdrop()],
]
it.each(gestures)(
'A pristine form closes with %s and returns focus',
async (_name, dismiss) => {
await mountPage({ desktop: false })
await openAddForm()
await dismiss()
expect(slideover()).toBeNull()
expect(alertDialog()).toBeNull()
expect(document.activeElement).toBe(addButton())
},
)
it.each(gestures)('A dirty form asks first with %s', async (_name, dismiss) => {
await mountPage({ desktop: false })
await openAddForm()
await typeInto(field('name'), 'banner')
await dismiss()
expect(textOf(alertDialog()!)).toContain('Discard changes?')
expect(slideover()).not.toBeNull()
})
it('Nothing closes while saving', async () => {
const pending = deferred<unknown>()
await mountPage({ desktop: false, routes: { [CREATE]: () => pending.promise } })
await openAddForm()
await typeInto(field('name'), 'banner')
await typeInto(field('width'), '400')
await click(buttonIn(sheet(), 'Add variant'))
await pressEscape()
await click(buttonByLabel('Close'))
await click(buttonIn(sheet(), 'Cancel'))
await clickBackdrop()
expect(slideover()).not.toBeNull()
expect(alertDialog()).toBeNull()
pending.resolve(variant({ name: 'banner' }))
await flushPromises()
})
})
+17 -7
View File
@@ -185,23 +185,33 @@ describe('Rule: Slide-over opening and closing', () => {
expect(rows()).toHaveLength(6)
})
it('Add variant opens a full page on mobile', async () => {
it('Add variant opens a bottom sheet on mobile', async () => {
await mountPage({ desktop: false })
await openAddForm()
const panel = form()
expect(buttonByLabel('Close')).toBeNull()
const back = buttonByLabel('Back')!
expect(iconNames(back)).toContain('i-lucide-arrow-left')
expect(panel.getAttribute('data-side')).toBe('bottom')
expect(panel.className).toContain('h-23/25')
expect(panel.className).toContain('before:w-9')
expect(panel.className).toContain('before:h-1')
expect(panel.className).toContain('before:bg-(--ui-border-accented)')
expect(buttonByLabel('Back')).toBeNull()
const close = buttonByLabel('Close')!
expect(iconNames(close)).toContain('i-lucide-x')
expect(close.className).toContain('size-11')
expect(panel.querySelector('[data-slot="title"]')?.textContent?.trim()).toBe('Add variant')
expect(panel.className).toContain('w-full')
expect(panel.className).toContain('inset-x-0')
expect(panel.querySelector('[data-slot="footer"]')!.className).toContain('h-18')
expect(buttonIn(panel, 'Cancel')!.className).toContain('h-11')
expect(buttonIn(panel, 'Cancel')!.className).toContain('flex-1')
expect(saveButton().className).toContain('h-11')
expect(saveButton().className).toContain('flex-1')
expect(panel.textContent).toContain('Preview')
const sections = panel.querySelectorAll('[data-slot="body"] form > div')
expect(sections[1]!.className).toContain('border-t')
expect(rows()).toHaveLength(6)
expect(document.body.querySelector('[data-slot="overlay"]')).not.toBeNull()
})
it('Edit opens with the stored values', async () => {
@@ -287,12 +297,12 @@ describe('Rule: Slide-over opening and closing', () => {
expect(dialog.querySelectorAll('button')[1]!.className).toContain('bg-error')
})
it('Dismissing a dirty form on mobile with the back arrow asks first', async () => {
it('Dismissing a dirty form on mobile with the close button asks first', async () => {
await mountPage({ desktop: false })
await openAddForm()
await typeInto(field('name'), 'banner')
await click(buttonByLabel('Back'))
await click(buttonByLabel('Close'))
expect(textOf(alertDialog()!)).toContain('Discard changes?')
})
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import { visualStates } from '../../../visual/states'
const GROUPS: Record<string, string[]> = {
'Variants list': [
'Loaded',
'Loading',
'Empty',
'Error loading',
'Forbidden',
'Row menu',
'Row action in progress',
'Purge confirm',
'Delete confirm',
'Delete in progress',
'Request reference',
'Entry: sidebar',
],
'Add edit variant': [
'Create: pristine',
'Create: dirty',
'Field errors',
'No dimension',
'Name already taken',
'Saving',
'Save error',
'Edit: pristine',
'Edit: dirty',
'Unsaved changes',
'PNG: no quality',
],
'Media library': [
'V3a · Grid with badges',
'V3b · Sort menu',
'V3c · Filter: With variants, sorted by total size',
'V3d · Filter: Without variants',
'V3e · Filter: nothing to show',
'V3f · Delete confirm',
],
'Image detail': [
'V4a · Partly generated',
'V4b · Nothing generated yet',
'V4c · Generating',
'V4d · Generation failed',
'V4e · No variants defined',
'V4f · Source is an SVG',
'V4h · Preview dialog',
],
'Cache panel': [
'V5a · Loaded',
'V5b · Loading',
'V5c · Empty cache',
'V5d · Purging',
'V5e · Purge done',
'V5f · Purge failed',
'V5g · Error loading',
'V5h · Purge all: confirm',
'V5i · Purge all: confirm, typed',
'V5j · Purge unused: confirm',
],
'Editor picker': [
'V6a · Variant select',
'V6b · Select open',
'V6c · No variants defined',
'V6d · Variant deleted',
'V6e · Variant disabled',
],
}
describe('Visual setups of the image variants screens', () => {
it.each(Object.entries(GROUPS))('Every state of %s has a setup', (_group, states) => {
const setups = visualStates['image-variants']!
expect(states.filter((state) => !(state in setups))).toEqual([])
})
it('No setup carries a clock-sensitive skip without a reason to', () => {
const setups = Object.values(visualStates['image-variants']!)
expect(setups.filter((setup) => setup.skip === 'pending')).toEqual([])
})
})