+
+
+
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)
-
+
{{ t('catalogue.unreachable') }}
-
+
@@ -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 {
-