Transform CalcText from a single-document calculator into a full workspace application with multi-document support, theming, and responsive mobile experience. - Theme system: 5 presets (Light, Dark, Matrix, Midnight, Warm) + accent colors - Document model with localStorage persistence and auto-save - Tab bar with keyboard shortcuts (Ctrl+N/W/Tab/1-9), rename, close - Sidebar with search, recent, favorites, folders, templates, drag-and-drop - 5 templates: Budget, Invoice, Unit Converter, Trip Planner, Loan Calculator - Status bar with cursor position, engine status, dedication to Igor Cassel - Results panel: type-specific colors, click-to-copy, error hints - Format toolbar: H, B, I, //, color labels with live preview toggle - Syntax highlighting using theme CSS variables - Error hover tooltips - Mobile: bottom results tray, sidebar drawer, touch targets, safe areas - Docker multi-stage build (Rust WASM + Vite + Nginx) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
106 lines
3.3 KiB
TypeScript
106 lines
3.3 KiB
TypeScript
/**
|
|
* Mobile results tray — replaces the side panel on small screens.
|
|
* Collapsed: shows last result + drag handle (48px).
|
|
* Expanded: scrollable list of all results (40vh).
|
|
*/
|
|
|
|
import { useState, useCallback, useRef } from 'react'
|
|
import type { EngineLineResult } from '../engine/types.ts'
|
|
import '../styles/mobile-results-tray.css'
|
|
|
|
const DISPLAYABLE_TYPES = new Set([
|
|
'number', 'unitValue', 'currencyValue', 'dateTime', 'timeDelta', 'boolean',
|
|
])
|
|
|
|
interface MobileResultsTrayProps {
|
|
results: EngineLineResult[]
|
|
docLines: string[]
|
|
}
|
|
|
|
export function MobileResultsTray({ results, docLines }: MobileResultsTrayProps) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
|
|
const startY = useRef<number | null>(null)
|
|
|
|
// Find last displayable result
|
|
let lastResult = ''
|
|
for (let i = results.length - 1; i >= 0; i--) {
|
|
if (DISPLAYABLE_TYPES.has(results[i].type) && results[i].display) {
|
|
lastResult = results[i].display
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleCopy = useCallback((idx: number, rawValue: number | null, display: string) => {
|
|
const text = rawValue != null ? String(rawValue) : display
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
setCopiedIdx(idx)
|
|
setTimeout(() => setCopiedIdx(null), 1200)
|
|
}).catch(() => {})
|
|
}, [])
|
|
|
|
// Touch swipe handling
|
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
|
startY.current = e.touches[0].clientY
|
|
}, [])
|
|
|
|
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
|
if (startY.current === null) return
|
|
const deltaY = startY.current - e.changedTouches[0].clientY
|
|
startY.current = null
|
|
if (deltaY > 40) setExpanded(true) // swipe up
|
|
if (deltaY < -40) setExpanded(false) // swipe down
|
|
}, [])
|
|
|
|
// Build result items for expanded view
|
|
const resultItems = results.map((r, i) => {
|
|
if (!DISPLAYABLE_TYPES.has(r.type) || !r.display) return null
|
|
const expr = docLines[i]?.trim() ?? ''
|
|
const isCopied = copiedIdx === i
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={`tray-result-item ${isCopied ? 'copied' : ''}`}
|
|
onClick={() => handleCopy(i, r.rawValue, r.display)}
|
|
>
|
|
<span className="tray-result-line">Ln {i + 1}</span>
|
|
<span className="tray-result-expr">{expr}</span>
|
|
<span className="tray-result-value">{isCopied ? 'Copied!' : r.display}</span>
|
|
</div>
|
|
)
|
|
}).filter(Boolean)
|
|
|
|
return (
|
|
<div
|
|
className={`mobile-results-tray ${expanded ? 'expanded' : ''}`}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{/* Drag handle + collapsed view */}
|
|
<div
|
|
className="tray-header"
|
|
onClick={() => setExpanded(prev => !prev)}
|
|
>
|
|
<div className="tray-drag-handle" />
|
|
{!expanded && (
|
|
<span className="tray-last-result">
|
|
{lastResult ? `Last: ${lastResult}` : 'No results'}
|
|
</span>
|
|
)}
|
|
{expanded && (
|
|
<span className="tray-last-result">{resultItems.length} results</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Expanded content */}
|
|
{expanded && (
|
|
<div className="tray-content">
|
|
{resultItems.length > 0 ? resultItems : (
|
|
<div className="tray-empty">No results yet</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|