diff --git a/apps/frontend/src/shared/ui/Alert.vue b/apps/frontend/src/shared/ui/Alert.vue new file mode 100644 index 0000000..fa2aa7d --- /dev/null +++ b/apps/frontend/src/shared/ui/Alert.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/apps/frontend/src/shared/ui/Pill.spec.ts b/apps/frontend/src/shared/ui/Pill.spec.ts new file mode 100644 index 0000000..dd37093 --- /dev/null +++ b/apps/frontend/src/shared/ui/Pill.spec.ts @@ -0,0 +1,83 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent, h } from 'vue' + +import Alert from '@/shared/ui/Alert.vue' +import Pill from '@/shared/ui/Pill.vue' + +const Glyph = defineComponent({ name: 'Glyph', render: () => h('svg') }) + +describe('Pill', () => { + it('says what it is asked to say', () => { + const pill = mount(Pill, { slots: { default: 'Publié' } }) + + expect(pill.text()).toBe('Publié') + }) + + it('is neutral unless told otherwise', () => { + // The safe default: a tone claims something about a state, and a component + // that claims by default claims wrongly on the screen that forgot to say. + expect(mount(Pill).classes()).toContain('neutral') + }) + + it('takes the icon it is given rather than deriving one', () => { + // Draft and retired share the neutral tone and mean opposite things. If the + // icon came from the tone they would be indistinguishable — which is the + // fault an icon is here to prevent. + const pill = mount(Pill, { props: { icon: Glyph }, slots: { default: 'Brouillon' } }) + + expect(pill.findComponent(Glyph).exists()).toBe(true) + }) + + it('renders without an icon when none is given', () => { + expect(mount(Pill, { slots: { default: 'Sans' } }).find('svg').exists()).toBe(false) + }) + + it('carries each tone it declares', () => { + for (const tone of ['neutral', 'accent', 'warning', 'danger'] as const) { + expect(mount(Pill, { props: { tone } }).classes()).toContain(tone) + } + }) + + it('never animates', () => { + // A state is read, not watched. Asserted on the rendered markup so a + // spinner cannot come back through a class somebody adds later. + const pill = mount(Pill, { props: { icon: Glyph }, slots: { default: 'En préparation' } }) + + expect(pill.html()).not.toMatch(/animation|animate|spin/i) + }) +}) + +describe('Alert', () => { + it('announces itself, so it reaches someone not looking at it', () => { + const alert = mount(Alert, { slots: { default: 'La boutique n’a pas répondu.' } }) + + // The role is the component's, never the caller's to remember — which is + // what the four hand-written alerts each had to. + expect(alert.attributes('role')).toBe('alert') + expect(alert.text()).toContain('La boutique n’a pas répondu.') + }) + + it('speaks of danger unless told otherwise', () => { + // Most of what a shop has to say unprompted is a refusal. + expect(mount(Alert).classes()).toContain('danger') + }) + + it('derives its icon from its tone, and shows a different one per tone', () => { + // Unlike a pill: news of one kind always looks the same, so the caller has + // nothing to choose and nothing to get wrong. + const drawn = new Set( + (['danger', 'warning', 'success'] as const).map( + (tone) => mount(Alert, { props: { tone } }).find('svg path').attributes('d'), + ), + ) + + expect(drawn.size).toBe(3) + }) + + it('carries each tone it declares', () => { + for (const tone of ['danger', 'warning', 'success'] as const) { + expect(mount(Alert, { props: { tone } }).classes()).toContain(tone) + } + }) +}) diff --git a/apps/frontend/src/shared/ui/Pill.vue b/apps/frontend/src/shared/ui/Pill.vue new file mode 100644 index 0000000..9ff708f --- /dev/null +++ b/apps/frontend/src/shared/ui/Pill.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/apps/frontend/src/shared/ui/TableCell.vue b/apps/frontend/src/shared/ui/TableCell.vue index 486e943..3fcbe10 100644 --- a/apps/frontend/src/shared/ui/TableCell.vue +++ b/apps/frontend/src/shared/ui/TableCell.vue @@ -1,5 +1,6 @@ + + diff --git a/apps/frontend/src/shared/ui/icons/IconEdit.vue b/apps/frontend/src/shared/ui/icons/IconEdit.vue new file mode 100644 index 0000000..5064487 --- /dev/null +++ b/apps/frontend/src/shared/ui/icons/IconEdit.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/frontend/src/shared/ui/icons/IconError.vue b/apps/frontend/src/shared/ui/icons/IconError.vue new file mode 100644 index 0000000..dd594eb --- /dev/null +++ b/apps/frontend/src/shared/ui/icons/IconError.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/frontend/src/shared/ui/icons/IconHourglass.vue b/apps/frontend/src/shared/ui/icons/IconHourglass.vue new file mode 100644 index 0000000..383fce9 --- /dev/null +++ b/apps/frontend/src/shared/ui/icons/IconHourglass.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/frontend/src/shared/ui/icons/IconVisibilityOff.vue b/apps/frontend/src/shared/ui/icons/IconVisibilityOff.vue new file mode 100644 index 0000000..cbcea4a --- /dev/null +++ b/apps/frontend/src/shared/ui/icons/IconVisibilityOff.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/frontend/src/shared/ui/icons/IconWarning.vue b/apps/frontend/src/shared/ui/icons/IconWarning.vue new file mode 100644 index 0000000..2f13c7b --- /dev/null +++ b/apps/frontend/src/shared/ui/icons/IconWarning.vue @@ -0,0 +1,21 @@ + + + diff --git a/apps/frontend/src/shared/ui/table.ts b/apps/frontend/src/shared/ui/table.ts index c5969f4..c5d744e 100644 --- a/apps/frontend/src/shared/ui/table.ts +++ b/apps/frontend/src/shared/ui/table.ts @@ -16,7 +16,7 @@ import type { Component } from 'vue' */ /** A cell's tone, when it has one. Never an action's: see ADR 0003 § 4. */ -export type CellTone = 'neutral' | 'accent' +export type CellTone = 'neutral' | 'accent' | 'warning' | 'danger' /** One button inside an `actions` cell. */ export interface CellAction { @@ -41,9 +41,10 @@ export type Cell = | { kind: 'number'; value?: string } /** A reference someone reads character by character. */ | { kind: 'code'; value?: string } - /** A state. Carries a shape as well as a tone, since colour never carries - meaning alone (`docs/design/core.md` § 8). */ - | { kind: 'pill'; value?: string; tone?: CellTone } + /** A state. Carries an icon as well as a tone, since colour never carries + meaning alone (`docs/design/core.md` § 8). The icon is the caller's: + two states may share a tone and mean opposite things. */ + | { kind: 'pill'; value?: string; tone?: CellTone; icon?: Component } | { kind: 'actions'; actions: readonly CellAction[] } export interface Column { diff --git a/apps/frontend/src/surfaces/admin/CatalogueView.vue b/apps/frontend/src/surfaces/admin/CatalogueView.vue index 756ed20..0d15131 100644 --- a/apps/frontend/src/surfaces/admin/CatalogueView.vue +++ b/apps/frontend/src/surfaces/admin/CatalogueView.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' +import Alert from '@/shared/ui/Alert.vue' import Button from '@/shared/ui/Button.vue' import PageTitle from '@/shared/ui/PageTitle.vue' import ProductTable from '@/surfaces/admin/ProductTable.vue' @@ -54,13 +55,9 @@ onMounted(load) - + @@ -196,13 +193,4 @@ async function submit(): Promise { font: var(--style-caption); } -.unreachable { - margin: 0; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--colour-danger); - border-left-width: 3px; - border-radius: var(--radius-1); - color: var(--colour-danger); - font: var(--style-caption-strong); -} diff --git a/apps/frontend/src/surfaces/admin/ProductTable.vue b/apps/frontend/src/surfaces/admin/ProductTable.vue index 36c6a05..f3153ed 100644 --- a/apps/frontend/src/surfaces/admin/ProductTable.vue +++ b/apps/frontend/src/surfaces/admin/ProductTable.vue @@ -2,6 +2,9 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' +import IconCheckCircle from '@/shared/ui/icons/IconCheckCircle.vue' +import IconEdit from '@/shared/ui/icons/IconEdit.vue' +import IconVisibilityOff from '@/shared/ui/icons/IconVisibilityOff.vue' import Table from '@/shared/ui/Table.vue' import type { Column, Row } from '@/shared/ui/table' import type { ProductSummary } from '@/shared/api/catalogue' @@ -63,6 +66,14 @@ function rowOf(product: ProductSummary): Row { // Published is the state a merchant is looking for; the others are // where a product is on its way to or back from. tone: product.state === 'published' ? 'accent' : 'neutral', + // Draft and retired share the neutral tone and mean opposite things — + // still being written, versus taken out of sight. The icon is what + // tells them apart, and it is the caller's to choose. + icon: { + draft: IconEdit, + published: IconCheckCircle, + retired: IconVisibilityOff, + }[product.state], }, }, } diff --git a/apps/frontend/src/surfaces/admin/SignInView.vue b/apps/frontend/src/surfaces/admin/SignInView.vue index 8270d28..98eab41 100644 --- a/apps/frontend/src/surfaces/admin/SignInView.vue +++ b/apps/frontend/src/surfaces/admin/SignInView.vue @@ -3,6 +3,7 @@ import { computed, ref } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' +import Alert from '@/shared/ui/Alert.vue' import Button from '@/shared/ui/Button.vue' import Form from '@/shared/ui/Form.vue' import IconLock from '@/shared/ui/icons/IconLock.vue' @@ -69,13 +70,12 @@ async function submit(): Promise { - +
{ padding: var(--space-8) var(--space-4); } +/* This screen stacks in normal flow rather than with a gap, so the space below + the alert is its own to give — a margin inside `Alert` would impose one on + every caller (frontend ADR 0003 § 5). Without it the next field's notch, + which rises onto its border, lands on the alert. */ +.refusal { + margin-bottom: var(--space-4); +} + .bar { display: flex; align-items: center; @@ -152,15 +160,6 @@ h1 { font: var(--style-heading); } -.refused { - margin: 0 0 var(--space-4); - padding: var(--space-2) var(--space-3); - border: 1px solid var(--colour-danger); - border-left-width: 3px; - border-radius: var(--radius-1); - color: var(--colour-danger); - font: var(--style-caption-strong); -} .back { display: inline-block; diff --git a/apps/frontend/src/surfaces/admin/VatRates.spec.ts b/apps/frontend/src/surfaces/admin/VatRates.spec.ts index 761fed2..ae1d471 100644 --- a/apps/frontend/src/surfaces/admin/VatRates.spec.ts +++ b/apps/frontend/src/surfaces/admin/VatRates.spec.ts @@ -110,12 +110,14 @@ describe('VatRates', () => { const english = await rates('en') await english.findAll('button').filter((b) => b.attributes('aria-label'))[0].trigger('click') await flushPromises() - expect(english.find('.in-use').text()).toContain('One product carries this rate') + // The sentence a merchant reads, wherever the markup puts it — and it is + // announced, which is what makes a refusal reach someone not looking at it. + expect(english.find('[role="alert"]').text()).toContain('One product carries this rate') const french = await rates('fr') await french.findAll('button').filter((b) => b.attributes('aria-label'))[0].trigger('click') await flushPromises() - expect(french.find('.in-use').text()).toContain('Un produit utilise ce taux') + expect(french.find('[role="alert"]').text()).toContain('Un produit utilise ce taux') }) it('counts in the plural when several carry it', async () => { @@ -126,6 +128,6 @@ describe('VatRates', () => { await wrapper.findAll('button').filter((b) => b.attributes('aria-label'))[0].trigger('click') await flushPromises() - expect(wrapper.find('.in-use').text()).toContain('3 products carry this rate') + expect(wrapper.find('[role="alert"]').text()).toContain('3 products carry this rate') }) }) diff --git a/apps/frontend/src/surfaces/admin/VatRates.vue b/apps/frontend/src/surfaces/admin/VatRates.vue index 4f27c18..7a9d0bf 100644 --- a/apps/frontend/src/surfaces/admin/VatRates.vue +++ b/apps/frontend/src/surfaces/admin/VatRates.vue @@ -2,6 +2,7 @@ import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' +import Alert from '@/shared/ui/Alert.vue' import Button from '@/shared/ui/Button.vue' import VatRateTable from '@/surfaces/admin/VatRateTable.vue' import TextField from '@/shared/ui/TextField.vue' @@ -90,13 +91,9 @@ onMounted(async () => apply(await listRates())) - +
+ + + + + + Brouillon + + + Retiré + + + Publié + + + En préparation + + + Échec + + + + + + + La boutique n’a pas répondu. Réessayez. + + Deux photographies attendent encore leur texte alternatif. + + + Le taux par défaut est passé à Réduit. + + + +