diff --git a/frontend/src/components/calendar/CalendarHelpPopover.tsx b/frontend/src/components/calendar/CalendarHelpPopover.tsx index 647a929..a042bbd 100644 --- a/frontend/src/components/calendar/CalendarHelpPopover.tsx +++ b/frontend/src/components/calendar/CalendarHelpPopover.tsx @@ -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' }, ] diff --git a/frontend/src/components/calendar/calendar-help-popover.css b/frontend/src/components/calendar/calendar-help-popover.css index 59be733..4fe99b2 100644 --- a/frontend/src/components/calendar/calendar-help-popover.css +++ b/frontend/src/components/calendar/calendar-help-popover.css @@ -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); } diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx index 6fe6455..eba42d8 100644 --- a/frontend/src/components/common/CalendarWeek.tsx +++ b/frontend/src/components/common/CalendarWeek.tsx @@ -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' : '', diff --git a/frontend/src/components/common/TipCardShell.test.tsx b/frontend/src/components/common/TipCardShell.test.tsx new file mode 100644 index 0000000..935e17f --- /dev/null +++ b/frontend/src/components/common/TipCardShell.test.tsx @@ -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( + +

Body

+
, + ) + 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( + +

My body

+
, + ) + 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( + +

Body

+
, + ) + expect(screen.getByRole('article')).not.toHaveAttribute('data-testid') + }) + + it('applies a testId when given', () => { + render( + +

Body

+
, + ) + expect(screen.getByTestId('my-tip')).toBe(screen.getByRole('article')) + }) + + it('applies the aria-label when given', () => { + render( + +

Body

+
, + ) + expect(screen.getByRole('article')).toHaveAttribute('aria-label', 'Tips: T') + }) + + it('appends a custom className alongside the variant class', () => { + render( + +

Body

+
, + ) + 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( + +

Body

+
, + ) + 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( + +

Body

+
, + ) + 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( + Bye}> +

Body

+
, + ) + 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( + {}} + headerExtra={Extra} + > +

Body

+
, + ) + expect(screen.getByTestId('dismiss-button')).toBeInTheDocument() + expect(screen.getByTestId('extra')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/common/TipCardShell.tsx b/frontend/src/components/common/TipCardShell.tsx new file mode 100644 index 0000000..a72e941 --- /dev/null +++ b/frontend/src/components/common/TipCardShell.tsx @@ -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 ( +
+
+

{title}

+ {onDismiss && ( + + )} + {headerExtra} +
+ {children} +
+ ) +} diff --git a/frontend/src/components/dashboard/PlanWarningCard.tsx b/frontend/src/components/dashboard/PlanWarningCard.tsx index a2644ef..b1b0276 100644 --- a/frontend/src/components/dashboard/PlanWarningCard.tsx +++ b/frontend/src/components/dashboard/PlanWarningCard.tsx @@ -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 ( -
-
-

{title}

-

{body}

-
+ ) } diff --git a/frontend/src/components/dashboard/SurplusAdviceTip.tsx b/frontend/src/components/dashboard/SurplusAdviceTip.tsx index af1af68..a0c2bc3 100644 --- a/frontend/src/components/dashboard/SurplusAdviceTip.tsx +++ b/frontend/src/components/dashboard/SurplusAdviceTip.tsx @@ -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 ( -
-
-

{title}

-

{body}

-
+ ) } diff --git a/frontend/src/components/dashboard/VacationRescueTip.tsx b/frontend/src/components/dashboard/VacationRescueTip.tsx index 891eac3..6d4f755 100644 --- a/frontend/src/components/dashboard/VacationRescueTip.tsx +++ b/frontend/src/components/dashboard/VacationRescueTip.tsx @@ -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 ( -
-
-

{title}

+ {t('planResults.vacationRescueDismiss')} -
+ } + >

{body}

+ ) } diff --git a/frontend/src/components/dashboard/YearRows.tsx b/frontend/src/components/dashboard/YearRows.tsx index f840b89..ff4a8d9 100644 --- a/frontend/src/components/dashboard/YearRows.tsx +++ b/frontend/src/components/dashboard/YearRows.tsx @@ -215,7 +215,10 @@ export function YearRows({