Share the tip card shell and the leave-type swatch styles

TipCardShell replaces the tip-card markup that PlanWarningCard,
SurplusAdviceTip and VacationRescueTip each copied from TipCard. The
vacation/sjukpenning/lägsta fills and cap marks move to .leave-swatch--*
in primitives.css; the calendar band, year rows and help popover keep
only their size, position and deliberate overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:27:13 +02:00
parent 4438e85175
commit 49e7c01e12
13 changed files with 328 additions and 108 deletions
@@ -16,10 +16,22 @@ const DAY_MARKS: ReadonlyArray<{ key: string; className: string; labelKey: Trans
/** The band styles: type by fill/ink/outline, owner by the left cap. */
const BAND_MARKS: ReadonlyArray<{ key: string; className: string; labelKey: TranslationKey; text?: string }> = [
{ key: 'vacation', className: 'calendar-help__band calendar-help__band--vacation', labelKey: 'calendar.helpVacation' },
{ key: 'sjukpenning', className: 'calendar-help__band calendar-help__band--sjukpenning', labelKey: 'calendar.helpSjukpenning' },
{ key: 'lagsta', className: 'calendar-help__band calendar-help__band--lagsta', labelKey: 'calendar.helpLagsta' },
{ key: 'self', className: 'calendar-help__band calendar-help__band--self', labelKey: 'calendar.helpSelf' },
{ key: 'partner', className: 'calendar-help__band calendar-help__band--partner', labelKey: 'calendar.helpPartner' },
{
key: 'sjukpenning',
className: 'calendar-help__band calendar-help__band--sjukpenning leave-swatch--sjukpenning',
labelKey: 'calendar.helpSjukpenning',
},
{
key: 'lagsta',
className: 'calendar-help__band calendar-help__band--lagsta leave-swatch--lagsta',
labelKey: 'calendar.helpLagsta',
},
{ key: 'self', className: 'calendar-help__band calendar-help__band--self leave-swatch--cap-self', labelKey: 'calendar.helpSelf' },
{
key: 'partner',
className: 'calendar-help__band calendar-help__band--partner leave-swatch--cap-partner',
labelKey: 'calendar.helpPartner',
},
{ key: 'dubbeldag', className: 'calendar-help__band calendar-help__band--dubbeldag', labelKey: 'calendar.helpDubbeldag', text: '2×' },
{ key: 'pinned', className: 'calendar-help__band calendar-help__band--pinned', labelKey: 'calendar.helpPinned' },
]
@@ -127,30 +127,19 @@
color: var(--c-leave-vacation-ink);
}
/* Fill/hatch/outline and the cap-mark shadow are the shared
* `.leave-swatch--*` recipe from primitives.css (added alongside these
* classes in CalendarHelpPopover.tsx); ink stays local since it isn't part
* of that shared recipe (the calendar band and year-row swatch don't set
* it via CSS either — see leaveTypes.ts's `getInkForType`). */
.calendar-help__band--sjukpenning {
background-color: var(--c-leave-sjukpenning);
color: var(--c-leave-sjukpenning-ink);
}
.calendar-help__band--lagsta {
background-color: var(--c-leave-lagsta);
background-image: repeating-linear-gradient(
135deg,
transparent 0 4px,
var(--c-leave-lagsta-hatch) 4px 5px
);
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
color: var(--c-leave-lagsta-ink);
}
.calendar-help__band--self {
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
}
.calendar-help__band--partner {
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
}
.calendar-help__band--pinned {
box-shadow: inset 0 -2px 0 var(--c-leave-vacation-ink);
}
@@ -402,10 +402,15 @@ export function CalendarWeek({
// see the --c-leave-* tokens) and owner by a 2px left cap
// (`--self`/`--partner`) — never by hue, so the two axes stay
// readable side by side and in greyscale.
const typeKey = getTypeKey(representativeType)
const classNames = [
'calendar-band',
`calendar-band--${getTypeKey(representativeType)}`,
`calendar-band--${typeKey}`,
// `leave-swatch--*` (primitives.css) carries the shared
// fill/hatch/outline/cap-mark recipe; `unknown` has no recipe.
typeKey !== 'unknown' ? `leave-swatch--${typeKey}` : '',
segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner',
segment.parentUuid === personalDataUuid ? 'leave-swatch--cap-self' : 'leave-swatch--cap-partner',
segment.isBandStart ? 'calendar-band--start' : '',
segment.isBandEnd ? 'calendar-band--end' : '',
segment.wishId && segment.wishId === flashWishId ? 'calendar-band--flash' : '',
@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TipCardShell } from './TipCardShell'
describe('TipCardShell', () => {
it.each([
['info', 'tip-card--info'],
['warning', 'tip-card--warning'],
['action', 'tip-card--action'],
['danger', 'tip-card--danger'],
] as const)('renders the %s variant with role="article" and the matching class', (variant, cls) => {
render(
<TipCardShell variant={variant} title="A title">
<p>Body</p>
</TipCardShell>,
)
const el = screen.getByRole('article')
expect(el).toHaveClass('tip-card', cls)
})
it('renders the title in a tip-card__title heading and the children below the header', () => {
render(
<TipCardShell variant="info" title="My title">
<p className="tip-card__body">My body</p>
</TipCardShell>,
)
const el = screen.getByRole('article')
expect(el.querySelector('.tip-card__title')?.textContent).toBe('My title')
expect(el.querySelector('.tip-card__body')?.textContent).toBe('My body')
})
it('renders without a data-testid when none is given', () => {
render(
<TipCardShell variant="info" title="T">
<p>Body</p>
</TipCardShell>,
)
expect(screen.getByRole('article')).not.toHaveAttribute('data-testid')
})
it('applies a testId when given', () => {
render(
<TipCardShell variant="info" title="T" testId="my-tip">
<p>Body</p>
</TipCardShell>,
)
expect(screen.getByTestId('my-tip')).toBe(screen.getByRole('article'))
})
it('applies the aria-label when given', () => {
render(
<TipCardShell variant="info" title="T" ariaLabel="Tips: T">
<p>Body</p>
</TipCardShell>,
)
expect(screen.getByRole('article')).toHaveAttribute('aria-label', 'Tips: T')
})
it('appends a custom className alongside the variant class', () => {
render(
<TipCardShell variant="action" title="T" className="vacation-rescue-tip">
<p>Body</p>
</TipCardShell>,
)
expect(screen.getByRole('article')).toHaveClass('tip-card', 'tip-card--action', 'vacation-rescue-tip')
})
it('does not render a dismiss button when onDismiss is not provided', () => {
render(
<TipCardShell variant="info" title="T">
<p>Body</p>
</TipCardShell>,
)
expect(screen.queryByTestId('dismiss-button')).not.toBeInTheDocument()
})
it('renders a dismiss button with the given label and calls onDismiss when clicked', () => {
const onDismiss = vi.fn()
render(
<TipCardShell variant="info" title="T" onDismiss={onDismiss} dismissLabel="Dismiss T">
<p>Body</p>
</TipCardShell>,
)
const button = screen.getByTestId('dismiss-button')
expect(button).toHaveAttribute('aria-label', 'Dismiss T')
fireEvent.click(button)
expect(onDismiss).toHaveBeenCalledTimes(1)
})
it('renders headerExtra content instead of relying on the generic dismiss button', () => {
render(
<TipCardShell variant="action" title="T" headerExtra={<button data-testid="custom-dismiss">Bye</button>}>
<p>Body</p>
</TipCardShell>,
)
expect(screen.getByTestId('custom-dismiss')).toBeInTheDocument()
expect(screen.queryByTestId('dismiss-button')).not.toBeInTheDocument()
})
it('renders both the generic dismiss button and headerExtra when both are given', () => {
render(
<TipCardShell
variant="info"
title="T"
onDismiss={() => {}}
headerExtra={<span data-testid="extra">Extra</span>}
>
<p>Body</p>
</TipCardShell>,
)
expect(screen.getByTestId('dismiss-button')).toBeInTheDocument()
expect(screen.getByTestId('extra')).toBeInTheDocument()
})
})
@@ -0,0 +1,68 @@
import { ReactNode } from 'react'
export type TipCardVariant = 'info' | 'warning' | 'action' | 'danger'
export interface TipCardShellProps {
variant: TipCardVariant
title: ReactNode
children: ReactNode
/** Renders the generic "×" dismiss button (see `TipCard`'s own use) when
* provided. Omit this and use `headerExtra` instead when a consumer needs
* a differently-styled header control, e.g. `VacationRescueTip`'s labelled
* dismiss button with its own testid/disabled state. */
onDismiss?: (() => void) | undefined
/** aria-label for the generic dismiss button; ignored without `onDismiss`. */
dismissLabel?: string | undefined
/** Custom content rendered in the header after the title, in place of the
* generic dismiss button. */
headerExtra?: ReactNode
testId?: string | undefined
ariaLabel?: string | undefined
className?: string | undefined
}
/**
* The `tip-card` / `tip-card__header` / `tip-card__title` shell shared by
* `TipCard` and the dashboard's derived tip-like cards (`PlanWarningCard`,
* `SurplusAdviceTip`, `VacationRescueTip`) — so the dashboard reads as one
* visual system. Body content (and anything beyond it, e.g.
* `VacationRescueTip`'s date list) is left to `children`, since it varies
* per consumer.
*/
export function TipCardShell({
variant,
title,
children,
onDismiss,
dismissLabel,
headerExtra,
testId,
ariaLabel,
className,
}: TipCardShellProps): ReactNode {
return (
<div
data-testid={testId}
className={className ? `tip-card tip-card--${variant} ${className}` : `tip-card tip-card--${variant}`}
role="article"
aria-label={ariaLabel}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
{onDismiss && (
<button
type="button"
className="btn btn--icon tip-card__dismiss"
onClick={onDismiss}
aria-label={dismissLabel}
data-testid="dismiss-button"
>
×
</button>
)}
{headerExtra}
</div>
{children}
</div>
)
}
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
import type { PlanWarning } from '@/types'
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
import { formatDateForDisplay } from '@/utils/dates'
import { TipCardShell } from '@/components/common/TipCardShell'
// The three `PlanWarning` kinds the backend can emit
// (`allocation_service.py`'s `_sgi_risk_warnings`, `_age4_warnings`,
@@ -88,16 +89,13 @@ export function PlanWarningCard({ warning, nameFor, index }: PlanWarningCardProp
}
return (
<div
className="tip-card tip-card--danger"
role="article"
aria-label={t('tip.aria', { title })}
data-testid={`dashboard-plan-warning-${index}`}
<TipCardShell
variant="danger"
title={title}
testId={`dashboard-plan-warning-${index}`}
ariaLabel={t('tip.aria', { title })}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
</div>
<p className="tip-card__body">{body}</p>
</div>
</TipCardShell>
)
}
@@ -1,6 +1,7 @@
import { ReactNode } from 'react'
import type { SurplusAdvice } from '@/types'
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
import { TipCardShell } from '@/components/common/TipCardShell'
// §9.2 surplus-advice kinds — `kind` -> title, `messageKey` -> the body
// template that interpolates the advice's own `data`. An unrecognised kind
@@ -51,16 +52,13 @@ export function SurplusAdviceTip({ advice, nameFor, index }: SurplusAdviceTipPro
}
return (
<div
className="tip-card tip-card--action"
role="article"
aria-label={t('tip.aria', { title })}
data-testid={`dashboard-surplus-advice-${index}`}
<TipCardShell
variant="action"
title={title}
testId={`dashboard-surplus-advice-${index}`}
ariaLabel={t('tip.aria', { title })}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
</div>
<p className="tip-card__body">{body}</p>
</div>
</TipCardShell>
)
}
@@ -3,6 +3,7 @@ import type { RescueDay } from '@/types'
import { useTranslation } from '@/i18n'
import { useRescueDayActions, RescueDismissedHandler } from '@/hooks/useRescueDayActions'
import { formatDateForDisplay } from '@/utils/dates'
import { TipCardShell } from '@/components/common/TipCardShell'
export interface VacationRescueTipProps {
workspaceId: string | null
@@ -51,14 +52,13 @@ export function VacationRescueTip({
})
return (
<div
className="tip-card tip-card--action vacation-rescue-tip"
role="article"
aria-label={t('tip.aria', { title })}
data-testid={`dashboard-vacation-rescue-${index}`}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
<TipCardShell
variant="action"
title={title}
testId={`dashboard-vacation-rescue-${index}`}
ariaLabel={t('tip.aria', { title })}
className="vacation-rescue-tip"
headerExtra={
<button
type="button"
className="btn btn--secondary btn--sm vacation-rescue-tip__dismiss"
@@ -69,7 +69,8 @@ export function VacationRescueTip({
>
{t('planResults.vacationRescueDismiss')}
</button>
</div>
}
>
<p className="tip-card__body">{body}</p>
<ul className="vacation-rescue-tip__dates">
@@ -107,6 +108,6 @@ export function VacationRescueTip({
{t('planResults.vacationRescueError')}
</p>
)}
</div>
</TipCardShell>
)
}
@@ -215,7 +215,10 @@ export function YearRows({
<ul className="year-rows__legend" data-testid="year-rows-legend">
{TYPE_ORDER.map((type) => (
<li key={type} className="year-rows__legend-item">
<span className={`year-rows__swatch year-rows__swatch--${getTypeKey(type)}`} aria-hidden="true" />
<span
className={`year-rows__swatch year-rows__swatch--${getTypeKey(type)} leave-swatch--${getTypeKey(type)}`}
aria-hidden="true"
/>
{getLabelForType(type, t)}
</li>
))}
@@ -259,7 +262,7 @@ export function YearRows({
<button
key={`${year}-${idx}`}
type="button"
className={`year-row__band year-row__band--${getTypeKey(band.dominantType)}`}
className={`year-row__band year-row__band--${getTypeKey(band.dominantType)} leave-swatch--${getTypeKey(band.dominantType)}`}
style={{ left: `${left}%`, width: `${width}%` }}
onClick={(event) => handleBandClick(event, band.startDate)}
data-testid={`year-timeline-band-${year}-${idx}`}
+9 -26
View File
@@ -1,5 +1,6 @@
import { ReactNode } from 'react'
import { useTranslation, toTranslationParams, type TranslationKey } from '@/i18n'
import { TipCardShell } from '@/components/common/TipCardShell'
export interface TipCardProps {
tip: {
@@ -79,34 +80,16 @@ export function TipCard({ tip, onDismiss }: TipCardProps): ReactNode {
const title = translationKeys ? t(translationKeys.title, params) : tip.messageKey
const body = translationKeys ? t(translationKeys.body, params) : tip.messageKey
const severityClassMap = {
info: 'tip-card--info',
warning: 'tip-card--warning',
action: 'tip-card--action',
}
return (
<div
data-testid="tip-card"
className={`tip-card ${severityClassMap[tip.severity]}`}
role="article"
aria-label={t('tip.aria', { title })}
<TipCardShell
variant={tip.severity}
title={title}
testId="tip-card"
ariaLabel={t('tip.aria', { title })}
onDismiss={onDismiss ? () => onDismiss(tip.id) : undefined}
dismissLabel={t('tip.dismissAria', { title })}
>
<div className="tip-card__header">
<h4 className="tip-card__title">{title}</h4>
{onDismiss && (
<button
type="button"
className="btn btn--icon tip-card__dismiss"
onClick={() => onDismiss(tip.id)}
aria-label={t('tip.dismissAria', { title })}
data-testid="dismiss-button"
>
×
</button>
)}
</div>
<p className="tip-card__body">{body}</p>
</div>
</TipCardShell>
)
}
+8 -22
View File
@@ -416,28 +416,14 @@
border-radius: inherit;
}
.calendar-band--self::after {
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
}
.calendar-band--partner::after {
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
}
/* Lägsta is a white fill told apart by a 1px dark outline and a hatch, not
* a hue (see the --c-leave-* tokens); the hatch sits on each lägsta tick so
* a mixed-type period still shows which days are which. */
.calendar-band--lagsta::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.calendar-band--lagsta.calendar-band--self::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-self);
}
.calendar-band--lagsta.calendar-band--partner::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-partner);
}
/* The self/partner cap-mark and lägsta's outline are the shared
* `.leave-swatch--cap-self`/`--cap-partner`/`--lagsta` recipe from
* primitives.css, scoped to `::after` here — see the comment there and on
* `.calendar-band::after` above for why. `getTypeKey`'s `--lagsta`/
* `leave-swatch--lagsta` and the owner's `--self`/`--partner`/
* `leave-swatch--cap-*` are added side by side in CalendarWeek.tsx, so
* lägsta's outline and the owner's cap keep composing even though this
* file no longer states the box-shadow values itself. */
.calendar-band__day--lagsta {
background-image: repeating-linear-gradient(
+12 -9
View File
@@ -199,27 +199,30 @@
flex-shrink: 0;
}
/* Fill/hatch/outline is the shared `.leave-swatch--*` recipe from
* primitives.css (added alongside these classes in YearRows.tsx); this file
* keeps only what's genuinely different at this swatch's small size.
*
* Vacation's bar uses a distinct, more saturated fill
* (--c-leave-vacation-bar) than the calendar band/help-popover sample —
* needed for contrast at 14px/8px. Overrides `.leave-swatch--vacation`'s
* background-color: same specificity, later in the cascade (components.css
* loads after primitives.css). */
.year-rows__swatch--vacation,
.year-row__band--vacation {
background-color: var(--c-leave-vacation-bar);
}
.year-rows__swatch--sjukpenning,
.year-row__band--sjukpenning {
background-color: var(--c-leave-sjukpenning);
}
/* Lägsta is a white fill told apart by a 1px dark outline and a hatch, not
* a hue (see the --c-leave-* tokens). */
/* Lägsta's hatch is finer here (3px/4px stops) than the calendar band/
* help-popover's 4px/5px, for the same reason. Overrides
* `.leave-swatch--lagsta`'s background-image the same way. */
.year-rows__swatch--lagsta,
.year-row__band--lagsta {
background-color: var(--c-leave-lagsta);
background-image: repeating-linear-gradient(
135deg,
transparent 0 3px,
var(--c-leave-lagsta-hatch) 3px 4px
);
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.year-row__summary {
+60
View File
@@ -587,6 +587,66 @@
}
}
/* Leave-type swatches
* ------------------------------------------------------------------------
* Leave types are told apart by fill/hatch/outline, never by hue (see the
* `--c-leave-*` tokens in global.css), and the owning parent by a 2px left
* cap-mark (`--c-band-cap-*`). This recipe used to be hand-copied across
* the calendar band, the year-row swatch/timeline and the calendar-help
* legend; consumers keep only their own size/position rule and add one of
* these classes alongside their existing BEM modifier.
*
* `--lagsta`/`--cap-self`/`--cap-partner` are defined twice: once for a
* plain element (the year-row swatch, the calendar-help sample — nothing
* paints over them) and once scoped to `::after` (the calendar band, whose
* per-day ticks sit on top of the band's own background and would hide an
* inset shadow drawn directly on it — see `.calendar-band::after` in
* calendar.css). The combined lagsta+cap-self / lagsta+cap-partner
* `::after` rules exist because `box-shadow` doesn't merge across two
* separate rules — the more specific combined selector is what lets a
* lägsta band still show its owner's cap. */
.leave-swatch--vacation {
background-color: var(--c-leave-vacation);
}
.leave-swatch--sjukpenning {
background-color: var(--c-leave-sjukpenning);
}
.leave-swatch--lagsta {
background-color: var(--c-leave-lagsta);
background-image: repeating-linear-gradient(135deg, transparent 0 4px, var(--c-leave-lagsta-hatch) 4px 5px);
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.leave-swatch--lagsta::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline);
}
.leave-swatch--cap-self {
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
}
.leave-swatch--cap-self::after {
box-shadow: inset 2px 0 0 var(--c-band-cap-self);
}
.leave-swatch--cap-partner {
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
}
.leave-swatch--cap-partner::after {
box-shadow: inset 2px 0 0 var(--c-band-cap-partner);
}
.leave-swatch--lagsta.leave-swatch--cap-self::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-self);
}
.leave-swatch--lagsta.leave-swatch--cap-partner::after {
box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-partner);
}
/* Layout helpers
* ------------------------------------------------------------------------ */
.stack {