-
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 5 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,71 @@ | ||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||
|
|
||
| /** | ||
| * Toggle the native Fullscreen API on a container ref. SSR-safe: the listener and the | ||
| * `document` reads only run in the browser effect. `requestFullscreen` is feature-detected, | ||
| * so the button no-ops gracefully where the API is unavailable. | ||
| */ | ||
| export function useFullscreen<T extends HTMLElement>() { | ||
|
StanislavBG marked this conversation as resolved.
Outdated
|
||
| const ref = useRef<T>(null); | ||
| const [isFullscreen, setIsFullscreen] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| const onChange = () => setIsFullscreen(document.fullscreenElement === ref.current); | ||
| document.addEventListener('fullscreenchange', onChange); | ||
| return () => document.removeEventListener('fullscreenchange', onChange); | ||
| }, []); | ||
|
|
||
| const toggle = useCallback(() => { | ||
| const el = ref.current; | ||
| if (!el) return; | ||
| if (document.fullscreenElement) { | ||
| document.exitFullscreen?.(); | ||
| } else { | ||
| el.requestFullscreen?.().catch(() => {}); | ||
|
StanislavBG marked this conversation as resolved.
Outdated
|
||
| } | ||
| }, []); | ||
|
|
||
| return { ref, isFullscreen, toggle }; | ||
| } | ||
|
|
||
| export function FullscreenButton({ active, onToggle }: { active: boolean; onToggle: () => void }) { | ||
|
StanislavBG marked this conversation as resolved.
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,89 @@ | ||
| import { useEffect, useLayoutEffect, useRef, useState } from 'react'; | ||
|
|
||
| // 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); | ||
|
|
||
| useLayoutEffect(() => { | ||
|
StanislavBG marked this conversation as resolved.
Outdated
|
||
| if (!open) { | ||
| setShift(0); | ||
| return; | ||
| } | ||
| const pop = popRef.current; | ||
| if (!pop) return; | ||
| const rect = pop.getBoundingClientRect(); | ||
| const vw = document.documentElement.clientWidth; | ||
| let dx = 0; | ||
| if (rect.right > vw - 8) dx = vw - 8 - rect.right; | ||
| if (rect.left + dx < 8) dx = 8 - rect.left; | ||
| setShift(Math.round(dx)); | ||
| }, [open]); | ||
|
StanislavBG marked this conversation as resolved.
Outdated
StanislavBG marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Close on outside-click / Esc 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); | ||
| }; | ||
| const onKey = (e: KeyboardEvent) => { | ||
| if (e.key === 'Escape') setOpen(false); | ||
| }; | ||
| document.addEventListener('pointerdown', onPointer); | ||
| document.addEventListener('keydown', onKey); | ||
| return () => { | ||
| document.removeEventListener('pointerdown', onPointer); | ||
| document.removeEventListener('keydown', onKey); | ||
| }; | ||
| }, [open]); | ||
|
|
||
| return ( | ||
| <span className={`metric-info${open ? ' is-open' : ''}`} ref={ref}> | ||
| <button | ||
| type="button" | ||
| className="metric-info-btn" | ||
|
StanislavBG marked this conversation as resolved.
|
||
| aria-label={aria} | ||
| aria-expanded={open} | ||
|
StanislavBG marked this conversation as resolved.
Outdated
|
||
| 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-а визуално в реален браузър. Кликването върху |
||
| > | ||
| <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> | ||
| ); | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.