Initial commit: plan, spec, tickets and dashboard skill templates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
49f8f49b65
39 files changed
+10736
No files matched your search
@@ -0,0 +1,536 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api'
|
||||
import { BAND_VAR } from '../bands'
|
||||
import type { ReviewActions } from './ReviewPane'
|
||||
|
||||
/**
|
||||
* The source page, as pixels the page owns.
|
||||
*
|
||||
* This used to be the browser's own PDF plugin in an <iframe>. That viewer
|
||||
* cannot be driven from the page: zoom, scroll and pan all live inside it, so
|
||||
* pinch-to-zoom and drag-to-pan were impossible and the only control was a
|
||||
* `#zoom=` fragment that forced a full reload on every step. Here the page is
|
||||
* a PNG rendered by PyMuPDF on the server (already a dependency of the
|
||||
* extractor, so no new package and no bundled renderer), and zoom/pan are a
|
||||
* single CSS transform — continuous, immediate, and reversible.
|
||||
*
|
||||
* Verifying a handwritten code means getting close to it, so this is not a
|
||||
* nicety: at 100% these scans are barely readable.
|
||||
*/
|
||||
|
||||
const MIN_ZOOM = 0.25
|
||||
const MAX_ZOOM = 8
|
||||
|
||||
export function PdfPane({
|
||||
jobId,
|
||||
page,
|
||||
onPickPage,
|
||||
document: docName,
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
onExit,
|
||||
reviewActions,
|
||||
}: {
|
||||
jobId: string
|
||||
page: number | null
|
||||
onPickPage: (page: number) => void
|
||||
document: string
|
||||
fullscreen: boolean
|
||||
onToggleFullscreen: () => void
|
||||
onExit: () => void
|
||||
/** The open patient's unsaved-edit state, so Save/Discard here act on the
|
||||
* exact same drafts as the review list — one save action, not two. */
|
||||
reviewActions?: ReviewActions | null
|
||||
}) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [box, setBox] = useState({ w: 0, h: 0 })
|
||||
// Aspect ratio of the rendered page, learned from the image itself. Pages in
|
||||
// this set are not all the same shape, so it cannot be a constant.
|
||||
const [aspect, setAspect] = useState(0)
|
||||
|
||||
// A callback ref, not a plain one. Observing `ref.current` inside a
|
||||
// mount-only effect attached the ResizeObserver to whichever node existed
|
||||
// first; when React later swapped that node the observer kept watching the
|
||||
// detached original, which never resizes. The measured width then froze at
|
||||
// its first value — the page stayed sized for a 344px pane inside a 462px
|
||||
// one, leaving a dead strip down both sides. Keying the effect to the
|
||||
// element itself re-attaches the observer whenever the node changes.
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const [viewportEl, setViewportEl] = useState<HTMLDivElement | null>(null)
|
||||
const setViewport = useCallback((el: HTMLDivElement | null) => {
|
||||
viewportRef.current = el
|
||||
setViewportEl(el)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewportEl) return
|
||||
const measure = () =>
|
||||
setBox({ w: viewportEl.clientWidth, h: viewportEl.clientHeight })
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(viewportEl)
|
||||
// The observer covers pane resizes; this covers the window itself, which
|
||||
// some browsers report only here when the element's box is unchanged in
|
||||
// the same frame.
|
||||
window.addEventListener('resize', measure)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', measure)
|
||||
}
|
||||
}, [viewportEl])
|
||||
|
||||
// The page at 100% fills the pane's WIDTH, and scrolls vertically for the
|
||||
// rest. Fitting the whole page into the pane's height instead made a
|
||||
// portrait scan only ~380px wide in a ~680px pane — the page came out small
|
||||
// with a wide dead strip beside it, and the handwriting this product exists
|
||||
// to read was too small to read. A document viewer's useful default is
|
||||
// fit-width; the height is what scrolling is for.
|
||||
const PAD = 32
|
||||
const fit = (() => {
|
||||
if (!aspect || !box.w) return { w: 0, h: 0 }
|
||||
const w = Math.max(0, box.w - PAD)
|
||||
return { w, h: w / aspect }
|
||||
})()
|
||||
const scaledW = fit.w * zoom
|
||||
const scaledH = fit.h * zoom
|
||||
|
||||
const info = useQuery({
|
||||
queryKey: ['pdf-info', jobId],
|
||||
queryFn: () => api.pdfInfo(jobId),
|
||||
retry: false,
|
||||
})
|
||||
const pages = info.data?.pages ?? 0
|
||||
const current = Math.min(Math.max(page ?? 1, 1), Math.max(pages, 1))
|
||||
|
||||
// Render at a higher DPI as the reviewer zooms in, so magnification reaches
|
||||
// real detail instead of enlarging the same soft pixels. Stepped, not
|
||||
// continuous — every distinct value is a fresh request, and a smooth ramp
|
||||
// would refetch the page on every pinch frame.
|
||||
const dpi = zoom > 3 ? 300 : zoom > 1.5 ? 220 : 150
|
||||
const src = api.pageImage(jobId, current, dpi)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setZoom(1)
|
||||
const el = viewportRef.current
|
||||
if (el) el.scrollTo({ top: 0, left: 0 })
|
||||
}, [])
|
||||
|
||||
// A new page starts at the top; carrying the previous page's scroll position
|
||||
// over lands the reviewer somewhere arbitrary on a differently-sized page.
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [current, reset])
|
||||
|
||||
/**
|
||||
* Zoom about a fixed point, so the pixel under the cursor stays under it.
|
||||
*
|
||||
* The page is laid out at its real size inside a scrolling box rather than
|
||||
* being CSS-transformed inside a clipped one. That is what makes the mouse
|
||||
* wheel, the scrollbar, Page Up/Down and keyboard scrolling all work on it
|
||||
* for free — with a transform there is no scrollable overflow for any of
|
||||
* them to act on, so the only way to move the page was to drag it, and a
|
||||
* reviewer looking for the bottom of a page had nothing to grab.
|
||||
*/
|
||||
const zoomAt = useCallback((factor: number, clientX?: number, clientY?: number) => {
|
||||
const el = viewportRef.current
|
||||
setZoom((z) => {
|
||||
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z * factor))
|
||||
if (el) {
|
||||
const box = el.getBoundingClientRect()
|
||||
// Where in the CONTENT the anchor currently sits, then put that same
|
||||
// content point back under the cursor at the new scale.
|
||||
const ax = (clientX ?? box.left + box.width / 2) - box.left
|
||||
const ay = (clientY ?? box.top + box.height / 2) - box.top
|
||||
const cx = (el.scrollLeft + ax) / z
|
||||
const cy = (el.scrollTop + ay) / z
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollLeft = cx * next - ax
|
||||
el.scrollTop = cy * next - ay
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Ctrl/⌘+wheel and trackpad pinch zoom; a plain wheel is left alone so it
|
||||
// scrolls the page normally. A browser reports a trackpad pinch as a wheel
|
||||
// event with ctrlKey set — the same gesture that would otherwise zoom the
|
||||
// whole browser — so this must be a non-passive listener that can
|
||||
// preventDefault(), which React's passive onWheel cannot do.
|
||||
useEffect(() => {
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
e.preventDefault()
|
||||
zoomAt(Math.exp(-e.deltaY * 0.002), e.clientX, e.clientY)
|
||||
}
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', onWheel)
|
||||
}, [zoomAt])
|
||||
|
||||
// Drag to pan, and two-finger pinch on a touchscreen. Panning now moves the
|
||||
// scroll position rather than a transform, so it stays in sync with the
|
||||
// scrollbar and with wheel scrolling instead of fighting them.
|
||||
const pointers = useRef(new Map<number, { x: number; y: number }>())
|
||||
const pinchStart = useRef<{ dist: number; zoom: number } | null>(null)
|
||||
const dragFrom = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
// A drag starting on the scrollbar itself must stay the browser's job.
|
||||
if (e.button !== 0 && e.pointerType === 'mouse') return
|
||||
try {
|
||||
;(e.currentTarget as Element).setPointerCapture?.(e.pointerId)
|
||||
} catch {
|
||||
/* drag still works, it just stops at the pane edge */
|
||||
}
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
const el = viewportRef.current
|
||||
if (pointers.current.size === 2) {
|
||||
const [a, b] = [...pointers.current.values()]
|
||||
pinchStart.current = { dist: Math.hypot(a.x - b.x, a.y - b.y), zoom }
|
||||
dragFrom.current = null
|
||||
} else if (el) {
|
||||
dragFrom.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
left: el.scrollLeft,
|
||||
top: el.scrollTop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!pointers.current.has(e.pointerId)) return
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
const el = viewportRef.current
|
||||
if (!el) return
|
||||
|
||||
if (pointers.current.size === 2 && pinchStart.current) {
|
||||
const [a, b] = [...pointers.current.values()]
|
||||
const dist = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
const target = pinchStart.current.zoom * (dist / pinchStart.current.dist)
|
||||
setZoom(Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, target)))
|
||||
return
|
||||
}
|
||||
if (dragFrom.current) {
|
||||
el.scrollLeft = dragFrom.current.left - (e.clientX - dragFrom.current.x)
|
||||
el.scrollTop = dragFrom.current.top - (e.clientY - dragFrom.current.y)
|
||||
}
|
||||
}
|
||||
|
||||
const endPointer = (e: React.PointerEvent) => {
|
||||
pointers.current.delete(e.pointerId)
|
||||
if (pointers.current.size < 2) pinchStart.current = null
|
||||
if (pointers.current.size === 0) dragFrom.current = null
|
||||
}
|
||||
|
||||
// Prints the actual source PDF at full quality via the browser's own PDF
|
||||
// renderer, not the on-screen PNG — a reviewer printing for a paper trail
|
||||
// wants what came off the scanner, not a screenshot of whatever zoom level
|
||||
// they happened to be looking at. A hidden iframe is the standard way to
|
||||
// trigger a browser print for a document the page did not itself render.
|
||||
const printPdf = useCallback(() => {
|
||||
const frame = window.document.createElement('iframe')
|
||||
frame.style.position = 'fixed'
|
||||
frame.style.width = '0'
|
||||
frame.style.height = '0'
|
||||
frame.style.border = '0'
|
||||
frame.src = `/api/runs/${jobId}/pdf`
|
||||
frame.onload = () => {
|
||||
try {
|
||||
frame.contentWindow?.focus()
|
||||
frame.contentWindow?.print()
|
||||
} finally {
|
||||
// The print dialog is modal in most browsers, but not guaranteed to
|
||||
// be, so the frame is cleaned up on a delay rather than immediately.
|
||||
setTimeout(() => frame.remove(), 60_000)
|
||||
}
|
||||
}
|
||||
window.document.body.appendChild(frame)
|
||||
}, [jobId])
|
||||
|
||||
if (info.isError) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-surface-2 p-6 text-center">
|
||||
<p className="max-w-xs text-xs text-muted">
|
||||
No source PDF on disk for this run, so there is no page to check the
|
||||
values against.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// min-w-0 / overflow-hidden all the way down: a flex item's default
|
||||
// min-width is auto, i.e. "never smaller than my content". With a
|
||||
// nowrap page at 800% that content is thousands of pixels wide, so
|
||||
// without these the pane grew to fit the zoomed page and pushed the whole
|
||||
// app into horizontal overflow instead of scrolling inside itself.
|
||||
//
|
||||
// w-full alongside them: this element is itself a flex item, and min-w-0
|
||||
// freed it to shrink to its content — so it settled at 510px inside an
|
||||
// 891px pane and the page sat in the middle of a dead strip. min-w-0 says
|
||||
// "you may shrink"; w-full says "but fill what you are given".
|
||||
<div className="flex h-full min-h-0 w-full min-w-0 flex-col overflow-hidden bg-surface-2">
|
||||
<div className="sb-rv-toolbar shrink-0">
|
||||
{fullscreen && (
|
||||
<button onClick={onExit} className="sb-btn sb-btn-quiet sb-btn-sm">
|
||||
← Back
|
||||
</button>
|
||||
)}
|
||||
<span className="mr-auto min-w-0 flex-1 truncate text-[12px] font-medium text-ink-2" title={docName}>
|
||||
{docName}
|
||||
</span>
|
||||
|
||||
{pages > 1 && (
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<Btn label="Previous page" onClick={() => onPickPage(Math.max(1, current - 1))} disabled={current <= 1}>
|
||||
‹
|
||||
</Btn>
|
||||
<span className="tnum w-16 text-center text-[12px] text-muted">
|
||||
{current} / {pages}
|
||||
</span>
|
||||
<Btn label="Next page" onClick={() => onPickPage(Math.min(pages, current + 1))} disabled={current >= pages}>
|
||||
›
|
||||
</Btn>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
<Btn label="Zoom out" onClick={() => zoomAt(1 / 1.25)}>−</Btn>
|
||||
<span className="tnum w-12 text-center text-[12px] text-muted">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<Btn label="Zoom in" onClick={() => zoomAt(1.25)}>+</Btn>
|
||||
<Btn label="Reset zoom and position" onClick={reset} wide>Fit</Btn>
|
||||
<Btn
|
||||
label={fullscreen ? 'Leave fullscreen' : 'Fill the screen with this page'}
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
{fullscreen ? '⤡' : '⤢'}
|
||||
</Btn>
|
||||
<a
|
||||
href={`/api/runs/${jobId}/pdf`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open the PDF in a separate tab"
|
||||
title="Open in a separate tab"
|
||||
className="sb-icon-btn inline-flex items-center justify-center no-underline"
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1">
|
||||
<div
|
||||
ref={setViewport}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endPointer}
|
||||
onPointerCancel={endPointer}
|
||||
onDoubleClick={(e) => zoomAt(zoom >= MAX_ZOOM ? MIN_ZOOM / zoom : 1.8, e.clientX, e.clientY)}
|
||||
// pan-x pan-y (not none) keeps one-finger scrolling native while the
|
||||
// two-pointer pinch is still delivered here rather than zooming the
|
||||
// whole browser.
|
||||
// whitespace-nowrap stops the inline-block page being wrapped onto
|
||||
// its own line box at high zoom.
|
||||
className="relative min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain whitespace-nowrap bg-surface-2"
|
||||
style={{
|
||||
touchAction: 'pan-x pan-y',
|
||||
cursor: zoom > 1 ? 'grab' : 'default',
|
||||
// Centre the page only while it fits. Centring an overflowing
|
||||
// inline box pushes its left edge to a negative offset, which no
|
||||
// scrollbar can reach — at 800% the left third of the page became
|
||||
// permanently invisible.
|
||||
textAlign: scaledW + PAD > box.w ? 'left' : 'center',
|
||||
}}
|
||||
>
|
||||
{/* The image IS the scrollable content — no spacer, no transform.
|
||||
Earlier attempts sized a wrapper and scaled the image inside it;
|
||||
in this subtree the wrapper's inline width was simply not honoured
|
||||
(it reported 672px and laid out at 360px, and the transform came
|
||||
back as an identity matrix), which left the page drawn at fit size
|
||||
inside a much larger reserved area — the large empty gap beside a
|
||||
zoomed page. Setting the <img>'s own width/height as an
|
||||
inline-block inside a nowrap scroller is the plain version: the
|
||||
element that shows the page is also the element that defines the
|
||||
overflow, so the two cannot disagree. */}
|
||||
<img
|
||||
// Remounted on every zoom step, deliberately. Updating this node in
|
||||
// place did not resize it: its inline style reported the new width
|
||||
// while it kept laying out at the old one, and writing
|
||||
// element.style directly did not move it either — only a freshly
|
||||
// created node ever took the size. Re-keying sidesteps that
|
||||
// entirely, and costs nothing visible because the src is unchanged
|
||||
// for a given (page, dpi) so the bitmap is served from cache.
|
||||
key={`p${current}-d${dpi}-z${Math.round(zoom * 1000)}`}
|
||||
src={src}
|
||||
alt={`Page ${current} of ${docName}`}
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (el.naturalWidth && el.naturalHeight) {
|
||||
setAspect(el.naturalWidth / el.naturalHeight)
|
||||
}
|
||||
}}
|
||||
className="m-4 inline-block max-w-none select-none rounded-md bg-surface align-top shadow-2xl ring-1 ring-line"
|
||||
style={{ width: scaledW || undefined, height: scaledH || undefined }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SideRail reviewActions={reviewActions} onPrint={printPdf} />
|
||||
</div>
|
||||
|
||||
<p className="shrink-0 border-t border-line-soft bg-rail px-3 py-1.5 text-[11px] text-muted">
|
||||
Scroll or drag to move · pinch or Ctrl+scroll to zoom · double-click to zoom in
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The common controls, always in view regardless of scroll or zoom: Save the
|
||||
* open patient's edits, Print the source PDF, and the score legend. Save here
|
||||
* drives the exact same state as the bar in ReviewPane — there is one save
|
||||
* action, reachable from whichever pane the reviewer's eyes are already on.
|
||||
*/
|
||||
function SideRail({
|
||||
reviewActions,
|
||||
onPrint,
|
||||
}: {
|
||||
reviewActions?: ReviewActions | null
|
||||
onPrint: () => void
|
||||
}) {
|
||||
const dirty = (reviewActions?.dirtyCount ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="flex w-12 shrink-0 flex-col items-center gap-2 border-l border-line bg-rail py-3">
|
||||
<RailButton
|
||||
label={
|
||||
dirty
|
||||
? `Save ${reviewActions!.dirtyCount} unsaved change(s) for ${reviewActions?.patientName ?? 'this patient'}`
|
||||
: 'No unsaved changes'
|
||||
}
|
||||
onClick={() => reviewActions?.onSave()}
|
||||
disabled={!dirty || reviewActions?.saving}
|
||||
tone={dirty ? 'warn' : 'neutral'}
|
||||
>
|
||||
<span aria-hidden="true">💾</span>
|
||||
{dirty && (
|
||||
<span
|
||||
className="tnum absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full text-[9px] font-bold"
|
||||
style={{ background: 'var(--brand-grad)', color: 'var(--accent-fg)' }}
|
||||
>
|
||||
{reviewActions!.dirtyCount}
|
||||
</span>
|
||||
)}
|
||||
</RailButton>
|
||||
|
||||
{dirty && (
|
||||
<RailButton
|
||||
label={`Discard unsaved changes for ${reviewActions?.patientName ?? 'this patient'}`}
|
||||
onClick={() => reviewActions?.onDiscard()}
|
||||
tone="neutral"
|
||||
>
|
||||
<span aria-hidden="true">↺</span>
|
||||
</RailButton>
|
||||
)}
|
||||
|
||||
<div className="my-1 h-px w-6 bg-line" aria-hidden="true" />
|
||||
|
||||
<RailButton label="Print the source PDF" onClick={onPrint} tone="neutral">
|
||||
<span aria-hidden="true">🖨</span>
|
||||
</RailButton>
|
||||
|
||||
<div className="my-1 h-px w-6 bg-line" aria-hidden="true" />
|
||||
|
||||
{/* The score legend, vertical to fit this rail — same meaning as the
|
||||
horizontal one in the document list (StatsBar's BandLegend), just
|
||||
laid out for a narrow column instead of a wide header. */}
|
||||
<div
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
title="Colour reflects print clarity, not correctness. A cleanly printed value can still be misread."
|
||||
>
|
||||
<ScoreDot band="green" label="≥0.95 clean" />
|
||||
<ScoreDot band="amber" label="0.90–0.95 sample" />
|
||||
<ScoreDot band="red" label="<0.90 review" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RailButton({
|
||||
children,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
tone = 'neutral',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
tone?: 'neutral' | 'warn'
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="sb-icon-btn relative h-8 w-8 shrink-0 text-sm"
|
||||
style={
|
||||
tone === 'warn'
|
||||
? {
|
||||
borderColor: 'color-mix(in srgb, var(--verify) 45%, transparent)',
|
||||
background: 'color-mix(in srgb, var(--verify) 14%, var(--surface))',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreDot({ band, label }: { band: 'green' | 'amber' | 'red'; label: string }) {
|
||||
return (
|
||||
<span
|
||||
className="sb-swatch shrink-0"
|
||||
title={label}
|
||||
style={{ backgroundColor: BAND_VAR[band], marginRight: 0 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Btn({
|
||||
children,
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
wide,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
/** Word labels ("Fit") need more than the 26px icon square. */
|
||||
wide?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
// The visible glyph is a symbol, not a word — "−" reads as "minus sign"
|
||||
// to a screen reader, so the accessible name has to be written out.
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={`sb-icon-btn ${wide ? 'w-auto px-2' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user