-
Notifications
You must be signed in to change notification settings - Fork 43
feat(web): dashboard design-system base (MetricInfo, fullscreen, tokens, overrun index) #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 13 commits
1c678bd
24af313
6ccae13
cf06f21
957ad75
7c7e56a
9f2f4b1
a03348e
3e253bd
eb7a2e1
bc45f82
bf76e45
ffd9d84
cf53ad4
bc790e9
f2f5c41
3619625
94b6fe8
f960f4e
d6202aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| // Purely presentational — no consumer wires the native Fullscreen API to this button yet, so no | ||
| // `useFullscreen` hook lives here either (a prior version did, but with zero call sites — dead | ||
| // code). A future caller supplies `active`/`onToggle` however fits its own fullscreen strategy; | ||
| // no webkit-prefix fallback is needed until that caller and its target browser matrix exist. | ||
| export function FullscreenButton({ active, onToggle }: { active: boolean; onToggle: () => void }) { | ||
|
StanislavBG marked this conversation as resolved.
|
||
| return ( | ||
| <button | ||
| type="button" | ||
| className="fs-btn" | ||
| onClick={onToggle} | ||
| aria-pressed={active} | ||
| aria-label={active ? 'Изход от цял екран' : 'Разгледай графиката на цял екран'} | ||
| title={active ? 'Изход от цял екран' : 'На цял екран'} | ||
| > | ||
| <svg | ||
| aria-hidden="true" | ||
| width="13" | ||
| height="13" | ||
| viewBox="0 0 16 16" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| strokeWidth="1.6" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| > | ||
| {active ? ( | ||
| <> | ||
| <path d="M6 2v4H2" /> | ||
| <path d="M10 2v4h4" /> | ||
| <path d="M6 14v-4H2" /> | ||
| <path d="M10 14v-4h4" /> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <path d="M2 6V2h4" /> | ||
| <path d="M14 6V2h-4" /> | ||
| <path d="M2 10v4h4" /> | ||
| <path d="M14 10v4h-4" /> | ||
| </> | ||
| )} | ||
| </svg> | ||
| <span>{active ? 'Изход' : 'Цял екран'}</span> | ||
| </button> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| // @vitest-environment jsdom | ||
| import { cleanup, fireEvent, render } from '@testing-library/react'; | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { MetricInfo } from './MetricInfo'; | ||
|
|
||
| afterEach(cleanup); | ||
|
|
||
| describe('MetricInfo', () => { | ||
| it('toggles the popover open and closed on click', () => { | ||
| const { container, getByRole } = render(<MetricInfo title="Title" summary="Summary" />); | ||
| const root = container.querySelector('.metric-info'); | ||
| const button = getByRole('button'); | ||
|
|
||
| expect(root?.className).not.toContain('is-open'); | ||
|
|
||
| fireEvent.click(button); | ||
| expect(root?.className).toContain('is-open'); | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Тестът потвърждава само класа |
||
| fireEvent.click(button); | ||
| expect(root?.className).not.toContain('is-open'); | ||
| }); | ||
|
|
||
| it('closes a hover-opened popover on Escape', () => { | ||
| const { container } = render(<MetricInfo title="Title" summary="Summary" />); | ||
| const root = container.querySelector('.metric-info') as HTMLElement; | ||
|
|
||
| fireEvent.mouseEnter(root); | ||
| expect(root.className).not.toContain('is-dismissed'); | ||
|
|
||
| fireEvent.keyDown(document, { key: 'Escape' }); | ||
| expect(root.className).toContain('is-dismissed'); | ||
| }); | ||
|
|
||
| it('removes resize/scroll listeners on unmount that were added while visible', () => { | ||
| const addSpy = vi.spyOn(window, 'addEventListener'); | ||
| const removeSpy = vi.spyOn(window, 'removeEventListener'); | ||
|
|
||
| const { getByRole, unmount } = render(<MetricInfo title="Title" summary="Summary" />); | ||
| fireEvent.click(getByRole('button')); | ||
|
|
||
| const countByType = (calls: unknown[][], type: string) => | ||
| calls.filter(([t]) => t === type).length; | ||
|
|
||
| const addedResize = countByType(addSpy.mock.calls, 'resize'); | ||
| const addedScroll = countByType(addSpy.mock.calls, 'scroll'); | ||
| expect(addedResize).toBeGreaterThan(0); | ||
| expect(addedScroll).toBeGreaterThan(0); | ||
|
|
||
| unmount(); | ||
|
|
||
| const removedResize = countByType(removeSpy.mock.calls, 'resize'); | ||
| const removedScroll = countByType(removeSpy.mock.calls, 'scroll'); | ||
| expect(removedResize).toBe(addedResize); | ||
| expect(removedScroll).toBe(addedScroll); | ||
|
|
||
| addSpy.mockRestore(); | ||
| removeSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| import { useEffect, useLayoutEffect, useRef, useState } from 'react'; | ||
| import { clampPopoverShift } from './metric-info-clamp'; | ||
|
|
||
| // useLayoutEffect warns "does nothing on the server" under SSR; fall back to useEffect there since | ||
| // there is no layout to read/flush before paint on the server anyway. | ||
| const useIsoLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect; | ||
|
|
||
| // A small ⓘ affordance next to a metric label. For pointer users it reveals an elegant popover on | ||
| // hover or keyboard focus (pure CSS `:hover` / `:focus-within`). Because hover does not exist on | ||
| // touch, a click also toggles the popover open via an `is-open` class — and an outside-click or Esc | ||
| // closes it again. The button carries the full text as its aria-label, so screen-reader users get the | ||
| // same information without the visual popover (which is aria-hidden). SSR-safe: the initial render is | ||
| // closed and the toggle/effects only run on the client. | ||
| export function MetricInfo({ | ||
|
StanislavBG marked this conversation as resolved.
|
||
| title, | ||
| summary, | ||
| readout, | ||
| align = 'start', | ||
| }: { | ||
| title: string; | ||
| summary: string; | ||
| // Plain string so the readout is always reflected verbatim into the aria-label (all callers pass a | ||
| // string — the screen-reader text must never silently drop a non-string interpretation). | ||
| readout?: string; | ||
| // Which edge the popover anchors to — use 'end' for right-most metrics so it doesn't clip. | ||
| align?: 'start' | 'end'; | ||
| }) { | ||
| const aria = readout ? `${title}. ${summary} ${readout}`.trim() : `${title}. ${summary}`; | ||
| const [open, setOpen] = useState(false); | ||
| const ref = useRef<HTMLSpanElement>(null); | ||
| const popRef = useRef<HTMLSpanElement>(null); | ||
| // Horizontal shift (px) that keeps the click-opened popover inside the viewport on small screens | ||
| // (mobile audit: at 320px the fixed-width popover clips off-screen for edge-column metrics). | ||
| const [shift, setShift] = useState(0); | ||
| // Mirrors `shift` synchronously so `recompute` always reads the value actually applied to the | ||
| // DOM right now, not a stale render closure — see the idempotency note in `recompute` below. | ||
| const shiftRef = useRef(0); | ||
| // Keyboard focus (no click) also reveals the popover via CSS `:focus-within`; track it so | ||
| // Esc can dismiss a keyboard-opened popover the same as a click- or hover-opened one. | ||
| const [focused, setFocused] = useState(false); | ||
| // Mouse hover also reveals the popover via CSS `:hover` — track it for the same reason, and so | ||
| // Esc can dismiss a hover-only-opened popover (ARIA tooltip pattern: Esc closes it regardless | ||
| // of which trigger revealed it). | ||
| const [hovered, setHovered] = useState(false); | ||
| // Force-hides the popover (via the `is-dismissed` CSS override) after Esc, even while the mouse | ||
| // is still hovering or the trigger still has focus. Clears once the trigger state that caused it | ||
| // to show goes away, so the popover can reopen normally afterward. | ||
| const [dismissed, setDismissed] = useState(false); | ||
| const wouldBeVisible = open || focused || hovered; | ||
| const visible = wouldBeVisible && !dismissed; | ||
|
|
||
| useIsoLayoutEffect(() => { | ||
| if (!visible) { | ||
| shiftRef.current = 0; | ||
| setShift(0); | ||
| return; | ||
| } | ||
| const pop = popRef.current; | ||
| const recompute = () => { | ||
|
StanislavBG marked this conversation as resolved.
|
||
| if (!pop) return; | ||
| // getBoundingClientRect() reflects the *currently applied* `translate`, so subtract the | ||
| // shift already in effect to recover the popover's unshifted natural position before | ||
| // clamping again — otherwise each recompute clamps an already-shifted rect and a | ||
| // resize/scroll can cancel or compound the previous shift instead of converging. | ||
| const rect = pop.getBoundingClientRect(); | ||
|
StanislavBG marked this conversation as resolved.
|
||
| const naturalRect = { | ||
| left: rect.left - shiftRef.current, | ||
| right: rect.right - shiftRef.current, | ||
| }; | ||
| const vw = document.documentElement.clientWidth; | ||
| const next = clampPopoverShift(naturalRect, vw); | ||
| shiftRef.current = next; | ||
| setShift(next); | ||
| }; | ||
| recompute(); | ||
| if (!pop) return; | ||
| // Re-clamp on resize/scroll while visible (click, hover, or keyboard focus) so the popover | ||
| // can't drift out of the viewport; rAF coalesces bursts of scroll events into at most one | ||
| // recompute per frame. | ||
| let raf = 0; | ||
| const onViewportChange = () => { | ||
| if (raf) return; | ||
| raf = requestAnimationFrame(() => { | ||
| raf = 0; | ||
| recompute(); | ||
| }); | ||
| }; | ||
| window.addEventListener('resize', onViewportChange); | ||
| window.addEventListener('scroll', onViewportChange, { passive: true, capture: true }); | ||
| return () => { | ||
| if (raf) cancelAnimationFrame(raf); | ||
| window.removeEventListener('resize', onViewportChange); | ||
| window.removeEventListener('scroll', onViewportChange, { passive: true, capture: true }); | ||
| }; | ||
| }, [visible]); | ||
|
|
||
| // Close on outside-click while open (touch path — pointer users rely on CSS hover/focus). | ||
| useEffect(() => { | ||
| if (!open) return; | ||
| const onPointer = (e: PointerEvent) => { | ||
| if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); | ||
| }; | ||
| document.addEventListener('pointerdown', onPointer); | ||
| return () => document.removeEventListener('pointerdown', onPointer); | ||
| }, [open]); | ||
|
|
||
| // Esc closes the popover whenever it's visible — via click-open, keyboard focus, or mouse hover | ||
| // — not just the click-toggled `open` state. Blurring the active element also drops CSS | ||
| // `:focus-within`, and `dismissed` overrides `:hover` until the pointer actually leaves. | ||
| useEffect(() => { | ||
| if (!wouldBeVisible) return; | ||
| const onKey = (e: KeyboardEvent) => { | ||
| if (e.key !== 'Escape') return; | ||
| setOpen(false); | ||
| setDismissed(true); | ||
| // Only blur if focus is actually inside this popover's trigger — otherwise a hover-only | ||
| // dismiss here would steal focus from an unrelated input elsewhere on the page. | ||
| const active = document.activeElement; | ||
| if (active instanceof HTMLElement && ref.current?.contains(active)) active.blur(); | ||
| }; | ||
| document.addEventListener('keydown', onKey); | ||
| return () => document.removeEventListener('keydown', onKey); | ||
| }, [wouldBeVisible]); | ||
|
|
||
| // Once every trigger that made the popover visible has cleared, drop the dismissal so hovering | ||
| // or focusing again reopens it normally. | ||
| useEffect(() => { | ||
| if (!wouldBeVisible && dismissed) setDismissed(false); | ||
| }, [wouldBeVisible, dismissed]); | ||
|
|
||
| return ( | ||
| <span | ||
| className={`metric-info${open ? ' is-open' : ''}${dismissed ? ' is-dismissed' : ''}`} | ||
| ref={ref} | ||
| onMouseEnter={() => setHovered(true)} | ||
| onMouseLeave={() => setHovered(false)} | ||
| > | ||
| <button | ||
| type="button" | ||
| className="metric-info-btn" | ||
|
StanislavBG marked this conversation as resolved.
|
||
| aria-label={aria} | ||
| onClick={() => setOpen((v) => !v)} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Клик за затваряне може да не скрие popover-а визуално в реален браузър. Кликването върху |
||
| onFocus={() => setFocused(true)} | ||
| onBlur={() => setFocused(false)} | ||
| > | ||
| <span className="metric-info-glyph" aria-hidden="true"> | ||
| ⓘ | ||
| </span> | ||
| </button> | ||
| <span | ||
| className={`metric-info-pop${align === 'end' ? ' is-end' : ''}`} | ||
| aria-hidden="true" | ||
| ref={popRef} | ||
| // `translate` composes with the CSS `transform` reveal transition instead of replacing it | ||
| style={shift !== 0 ? { translate: `${shift}px 0` } : undefined} | ||
| > | ||
| <span className="metric-info-title">{title}</span> | ||
| <span className="metric-info-summary">{summary}</span> | ||
| {readout ? <span className="metric-info-readout">{readout}</span> : null} | ||
| </span> | ||
| </span> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { describe, expect, it } from 'vitest'; | ||
| import { clampPopoverShift } from './metric-info-clamp'; | ||
|
|
||
| describe('clampPopoverShift', () => { | ||
| // At a 320px viewport the pop's CSS caps its width to `100vw - 16px` (304px), so a | ||
| // start-aligned popover (left: 0) rendered at the left edge of a narrow card already fits and | ||
| // needs no shift. | ||
| it('leaves a start-aligned popover that already fits untouched', () => { | ||
| expect(clampPopoverShift({ left: 8, right: 304 }, 320)).toBe(0); | ||
| }); | ||
|
|
||
| // An end-aligned popover on a narrow (320px) viewport anchors to the card's right edge, so its | ||
| // `right` can exceed the viewport — this is the case the mobile audit found clipping off-screen. | ||
| it('shifts an end-aligned popover left until it clears the right inset', () => { | ||
| // Card sits near the right edge: pop occupies [96, 328] before clamping. | ||
| expect(clampPopoverShift({ left: 96, right: 328 }, 320)).toBe(-16); | ||
| }); | ||
|
|
||
| // The left-edge clamp must win over the right-edge clamp when both would fire, so a popover | ||
| // wider than the available space never gets pushed past the left inset while chasing the right | ||
| // one — matches the pop's `max-width: min(320px, calc(100vw - 16px))` CSS guarantee. | ||
| it('prioritizes the left clamp when both edges would otherwise clip', () => { | ||
| // Popover (340px) is wider than the 320px viewport's 304px (100vw - 16) budget, so both edge | ||
| // checks genuinely fire: the right clamp alone would want dx=-18, but the left clamp then | ||
| // overrides it entirely, landing on 18. | ||
| const rect = { left: -10, right: 330 }; | ||
| const vw = 320; | ||
| expect(rect.right > vw - 8).toBe(true); // right-edge clamp condition fires | ||
| expect(rect.left + (vw - 8 - rect.right) < 8).toBe(true); // left-edge clamp condition also fires | ||
| expect(clampPopoverShift(rect, vw)).toBe(18); | ||
| }); | ||
|
|
||
| it('is a no-op for a centered popover with room on both sides', () => { | ||
| expect(clampPopoverShift({ left: 40, right: 280 }, 320)).toBe(0); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| // Pure clamp math, split out of MetricInfo.tsx so it's unit-testable without a DOM/JSX transform: | ||
| // how far (px) to shift the popover so it stays within an 8px inset of the viewport on both | ||
| // edges. Narrow viewports (e.g. 320px) can trigger both the right-edge and left-edge clamps in | ||
| // the same computation when the popover is wider than the available space — the left clamp | ||
| // always wins in that case, matching the pop's `max-width: min(320px, calc(100vw - 16px))` CSS, | ||
| // which guarantees the popover itself never exceeds `viewportWidth - 16`. | ||
| export function clampPopoverShift( | ||
| rect: { left: number; right: number }, | ||
| viewportWidth: number, | ||
| ): number { | ||
| let dx = 0; | ||
| if (rect.right > viewportWidth - 8) dx = viewportWidth - 8 - rect.right; | ||
| if (rect.left + dx < 8) dx = 8 - rect.left; | ||
|
StanislavBG marked this conversation as resolved.
Outdated
|
||
| return Math.round(dx); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,26 @@ | ||
| // The response-affecting query params, shared by cacheKey (edge cache key) and withParams (link hrefs): | ||
| // one list means an unknown param (`?x=poison`) can neither poison the key nor ride a cached link | ||
| // (#56 / #197). The cache-key.test.ts drift guard keeps it a complete superset of what the app reads. | ||
| // Some entries below (a, b, by, cohort, cpv, metric) sit ahead of the routes that will read them | ||
| // (/compare, /overruns, /price-anomaly, /contracts cpv filter) — see cache-key.test.ts's | ||
| // info-only "stale entries" check for why that's intentional on this stacked-PR base. | ||
| export const CANONICAL_QUERY_PARAMS = new Set([ | ||
| 'a', // /compare — entity A slug | ||
|
StanislavBG marked this conversation as resolved.
StanislavBG marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Добавянето на канонични параметри преди маршрутите, които ги четат, е коментирано и защитено от drift guard теста — ОК. Само уверете се, че съответният info-only „stale entries" тест наистина покрива всичките нови ключове (a, b, by, cohort, cpv, metric), за да не се получи мълчаливо разминаване при следващите партиди. |
||
| 'authority', | ||
| 'b', // /compare — entity B slug | ||
| 'bidder', | ||
| 'bids', // single-bid filter — changes the result set + totals | ||
| 'by', // /overruns — sort dimension (absolute | percent) | ||
| 'center', | ||
| 'cohort', // /price-anomaly — selected CPV cohorts (repeatable); faceting changes the result set | ||
| 'count', | ||
| 'cpv', // /contracts — exact 5-digit CPV filter; changes the result set + headline totals | ||
| 'cursor', | ||
| 'eu', | ||
| 'funding', | ||
| 'g', | ||
| 'kind', | ||
| 'metric', // /compare leaderboard dimension | ||
| 'p', | ||
| 'page', // keyed unconditionally — harmless over-key when there's no cursor | ||
| 'procedure', | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.