Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/frontend/src/shared/ui/Alert.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed } from 'vue'

import IconCheckCircle from '@/shared/ui/icons/IconCheckCircle.vue'
import IconError from '@/shared/ui/icons/IconError.vue'
import IconWarning from '@/shared/ui/icons/IconWarning.vue'

/**
* What the shop has to say about something that just happened.
*
* Four surfaces wrote their own before this existed, under three class names,
* and two had already drifted — one had lost the weight of the others, and each
* carried its own margin. That is the trajectory frontend ADR 0003 § 3 predicts
* from the second copy on.
*
* Unlike a pill, the icon **is** derived here: a tone names the kind of news,
* and the news of one kind always looks the same. Three tones, three icons.
*
* `role="alert"` is carried by the component, so the next screen cannot forget
* it — which is what the four hand-written ones each had to remember.
*/
const props = withDefaults(
defineProps<{ tone?: 'danger' | 'warning' | 'success' }>(),
{ tone: 'danger' },
)

const icon = computed(
() => ({ danger: IconError, warning: IconWarning, success: IconCheckCircle })[props.tone],
)
</script>

<template>
<p
class="alert"
:class="tone"
role="alert"
>
<component
:is="icon"
class="icon"
/>
<span class="body"><slot /></span>
</p>
</template>

<style scoped>
.alert {
display: flex;
gap: var(--space-2);
align-items: flex-start;
margin: 0;
padding: var(--space-2) var(--space-3);
border: 1px solid currentColor;
/* Thicker on the side the eye starts from, so a wall of text still shows
where the message begins. */
border-left-width: 3px;
border-radius: var(--radius-1);
font: var(--style-caption-strong);
}

.danger {
color: var(--colour-danger);
}

.warning {
color: var(--colour-warning);
}

.success {
color: var(--colour-success);
}

.icon {
flex: none;
/* Aligned with the first line of text rather than with the box. */
margin-top: 0.1rem;
}

.body {
min-width: 0;
}
</style>
83 changes: 83 additions & 0 deletions apps/frontend/src/shared/ui/Pill.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
71 changes: 71 additions & 0 deletions apps/frontend/src/shared/ui/Pill.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { Component } from 'vue'

/**
* A state, read at a glance.
*
* A tone belongs here because a pill's identity **is** the state it names —
* the same colour on a button would be wrong, since a button's identity is the
* action it performs (frontend ADR 0003 § 4).
*
* **The icon is passed, never derived from the tone.** A product's `draft` and
* `retired` share the neutral tone and say opposite things — one is still being
* written, the other was taken out of sight. Deriving the icon would make them
* indistinguishable, which is the very fault an icon is here to prevent: colour
* never carries meaning alone (`docs/design/core.md` § 8, WCAG 1.4.1).
*
* Nothing animates. A state is read, not watched.
*/
withDefaults(
defineProps<{
/** What kind of state this is, never what it is used for. */
tone?: 'neutral' | 'accent' | 'warning' | 'danger'
/** From `shared/ui/icons`, so the whole set stays Material Symbols. */
icon?: Component
}>(),
{ tone: 'neutral', icon: undefined },
)
</script>

<template>
<span
class="pill"
:class="tone"
>
<component
:is="icon"
v-if="icon"
size="xs"
/>
<slot />
</span>
</template>

<style scoped>
.pill {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0 var(--space-2);
border: 1px solid currentColor;
border-radius: var(--radius-pill);
font: var(--style-caption-strong);
white-space: nowrap;
}

.neutral {
color: var(--colour-text-muted);
}

.accent {
color: var(--colour-accent);
}

.warning {
color: var(--colour-warning);
}

.danger {
color: var(--colour-danger);
}
</style>
25 changes: 7 additions & 18 deletions apps/frontend/src/shared/ui/TableCell.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import Button from '@/shared/ui/Button.vue'
import Pill from '@/shared/ui/Pill.vue'
import { ABSENT, type Cell } from '@/shared/ui/table'

/**
Expand Down Expand Up @@ -42,11 +43,13 @@ function text(cell: Cell): string {
</Button>
</span>

<span
<Pill
v-else-if="cell.kind === 'pill' && !isAbsent(cell)"
class="pill"
:class="cell.tone ?? 'neutral'"
>{{ text(cell) }}</span>
:tone="cell.tone"
:icon="cell.icon"
>
{{ text(cell) }}
</Pill>

<!-- An absent pill falls through to here, and reads as plain text: an
outlined pill around a dash announces a state called "—". -->
Expand Down Expand Up @@ -77,22 +80,8 @@ function text(cell: Cell): string {
color: var(--colour-text-muted);
}

.pill {
display: inline-block;
padding: 0 var(--space-2);
border: 1px solid currentColor;
border-radius: var(--radius-pill);
font: var(--style-caption-strong);
white-space: nowrap;
}

.pill.accent {
color: var(--colour-accent);
}

.pill.neutral {
color: var(--colour-text-muted);
}

.actions {
display: inline-flex;
Expand Down
21 changes: 21 additions & 0 deletions apps/frontend/src/shared/ui/icons/IconCheckCircle.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
/**
* `check_circle` from Google Material Symbols (Apache-2.0).
* https://fonts.google.com/icons?selected=Material+Symbols+Outlined:check_circle
*/
import { ICON_SIZE_PX, type IconSize } from './sizing'

withDefaults(defineProps<{ size?: IconSize }>(), { size: 'sm' })
</script>

<template>
<svg
:width="ICON_SIZE_PX[size]"
:height="ICON_SIZE_PX[size]"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="m424-296 282-282-56-56-226 226-114-114-56 56 170 170Zm56 216q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
</svg>
</template>
21 changes: 21 additions & 0 deletions apps/frontend/src/shared/ui/icons/IconEdit.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
/**
* `edit` from Google Material Symbols (Apache-2.0).
* https://fonts.google.com/icons?selected=Material+Symbols+Outlined:edit
*/
import { ICON_SIZE_PX, type IconSize } from './sizing'

withDefaults(defineProps<{ size?: IconSize }>(), { size: 'sm' })
</script>

<template>
<svg
:width="ICON_SIZE_PX[size]"
:height="ICON_SIZE_PX[size]"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M200-200h57l391-391-57-57-391 391v57Zm-80 80v-170l528-527q12-11 26.5-17t30.5-6q16 0 31 6t26 18l55 56q12 11 17.5 26t5.5 30q0 16-5.5 30.5T817-647L290-120H120Zm640-584-56-56 56 56Zm-141 85-28-29 57 57-29-28Z" />
</svg>
</template>
21 changes: 21 additions & 0 deletions apps/frontend/src/shared/ui/icons/IconError.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
/**
* `error` from Google Material Symbols (Apache-2.0).
* https://fonts.google.com/icons?selected=Material+Symbols+Outlined:error
*/
import { ICON_SIZE_PX, type IconSize } from './sizing'

withDefaults(defineProps<{ size?: IconSize }>(), { size: 'sm' })
</script>

<template>
<svg
:width="ICON_SIZE_PX[size]"
:height="ICON_SIZE_PX[size]"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" />
</svg>
</template>
21 changes: 21 additions & 0 deletions apps/frontend/src/shared/ui/icons/IconHourglass.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
/**
* `hourglass` from Google Material Symbols (Apache-2.0).
* https://fonts.google.com/icons?selected=Material+Symbols+Outlined:hourglass
*/
import { ICON_SIZE_PX, type IconSize } from './sizing'

withDefaults(defineProps<{ size?: IconSize }>(), { size: 'sm' })
</script>

<template>
<svg
:width="ICON_SIZE_PX[size]"
:height="ICON_SIZE_PX[size]"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M320-160h320v-120q0-66-47-113t-113-47q-66 0-113 47t-47 113v120Zm0-480v-120h320v120q0 66-47 113t-113 47q-66 0-113-47t-47-113ZM200-80v-80h40v-120q0-61 28.5-114.5T348-480q-51-32-79.5-85.5T240-680v-120h-40v-80h560v80h-40v120q0 61-28.5 114.5T612-480q51 32 79.5 85.5T720-280v120h40v80H200Z" />
</svg>
</template>
21 changes: 21 additions & 0 deletions apps/frontend/src/shared/ui/icons/IconVisibilityOff.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script setup lang="ts">
/**
* `visibility_off` from Google Material Symbols (Apache-2.0).
* https://fonts.google.com/icons?selected=Material+Symbols+Outlined:visibility_off
*/
import { ICON_SIZE_PX, type IconSize } from './sizing'

withDefaults(defineProps<{ size?: IconSize }>(), { size: 'sm' })
</script>

<template>
<svg
:width="ICON_SIZE_PX[size]"
:height="ICON_SIZE_PX[size]"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="m644-428-58-58q9-47-27-88t-93-32l-58-58q17-11 38-16.5t34-5.5q75 0 127.5 52.5T660-506q0 13-5.5 34T644-428Zm128 126-58-56q38-29 67.5-63.5T832-500q-50-101-143.5-160.5T480-720q-29 0-57 4t-55 12l-62-62q41-17 84-25.5t90-8.5q151 0 269 83.5T920-500q-23 59-60.5 109.5T772-302Zm20 246L624-222q-35 11-70.5 16.5T480-200q-151 0-269-83.5T40-500q21-53 53-98.5t73-81.5L56-792l56-56 736 736-56 56ZM222-624q-29 26-53 57t-41 67q50 101 143.5 160.5T480-280q20 0 39-2.5t39-5.5l-36-38q-11 3-21 4.5t-21 1.5q-75 0-127.5-52.5T300-500q0-11 1.5-21t4.5-21l-84-82Zm319 93Zm-151 75Z" />
</svg>
</template>
Loading