Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c18ba7d085 | ||
|
|
795ffef9dc | ||
|
|
039f2fa5fe |
@@ -17,7 +17,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
run: npx vitest run --exclude 'e2e/**'
|
||||
|
||||
- name: Build library
|
||||
run: npm run build:lib
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"main": "./dist/index.es.js",
|
||||
"module": "./dist/index.es.js",
|
||||
@@ -12,8 +12,12 @@
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"sideEffects": ["*.css"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||
},
|
||||
@@ -27,7 +31,8 @@
|
||||
"build:lib": "vite build --config vite.lib.config.ts",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
@@ -40,6 +45,7 @@
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
|
||||
rowAccent,
|
||||
expandedContent,
|
||||
flush = false,
|
||||
onSortChange,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
@@ -31,14 +32,16 @@ export function DataTable<T extends { id: string }>({
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
// When onSortChange is provided (controlled mode), skip client-side sorting
|
||||
const sorted = useMemo(() => {
|
||||
if (onSortChange) return data
|
||||
if (!sortKey) return data
|
||||
return [...data].sort((a, b) => {
|
||||
const av = (a as Record<string, unknown>)[sortKey]
|
||||
const bv = (b as Record<string, unknown>)[sortKey]
|
||||
return compareValues(av, bv, sortDir)
|
||||
})
|
||||
}, [data, sortKey, sortDir])
|
||||
}, [data, sortKey, sortDir, onSortChange])
|
||||
|
||||
const totalRows = sorted.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
||||
@@ -52,13 +55,17 @@ export function DataTable<T extends { id: string }>({
|
||||
|
||||
function handleHeaderClick(col: Column<T>) {
|
||||
if (!sortable && !col.sortable) return
|
||||
let newDir: SortDir
|
||||
if (sortKey === col.key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
newDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||
setSortDir(newDir)
|
||||
} else {
|
||||
newDir = 'asc'
|
||||
setSortKey(col.key)
|
||||
setSortDir('asc')
|
||||
setSortDir(newDir)
|
||||
}
|
||||
setPage(1)
|
||||
onSortChange?.(col.key, newDir)
|
||||
}
|
||||
|
||||
function handleRowClick(row: T) {
|
||||
|
||||
@@ -20,4 +20,8 @@ export interface DataTableProps<T extends { id: string }> {
|
||||
expandedContent?: (row: T) => ReactNode | null
|
||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||
flush?: boolean
|
||||
/** Controlled sort: called when the user clicks a sortable column header.
|
||||
* When provided, the component skips client-side sorting — the caller is
|
||||
* responsible for providing `data` in the desired order. */
|
||||
onSortChange?: (key: string, dir: 'asc' | 'desc') => void
|
||||
}
|
||||
|
||||
@@ -81,6 +81,56 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.liveToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.liveToggle:hover {
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
|
||||
.liveToggleActive {
|
||||
color: var(--success);
|
||||
border-color: var(--success-border);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.liveToggleActive:hover {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.liveToggleActive .liveDot {
|
||||
background: var(--success);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.themeToggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -84,8 +84,18 @@ export function TopBar({
|
||||
onChange={globalFilters.setTimeRange}
|
||||
/>
|
||||
|
||||
{/* Right: theme toggle, env badge, user */}
|
||||
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
|
||||
<div className={styles.right}>
|
||||
<button
|
||||
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
|
||||
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
|
||||
type="button"
|
||||
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
|
||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||
>
|
||||
<span className={styles.liveDot} />
|
||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
onClick={toggleTheme}
|
||||
|
||||
@@ -16,6 +16,8 @@ interface GlobalFilterContextValue {
|
||||
toggleStatus: (status: ExchangeStatus) => void
|
||||
clearStatusFilters: () => void
|
||||
isInTimeRange: (timestamp: Date) => boolean
|
||||
autoRefresh: boolean
|
||||
setAutoRefresh: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||
@@ -27,9 +29,17 @@ function getDefaultTimeRange(): TimeRange {
|
||||
return { start, end, preset: DEFAULT_PRESET }
|
||||
}
|
||||
|
||||
function getInitialAutoRefresh(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem('cameleer:auto-refresh')
|
||||
return stored === null ? true : stored === 'true'
|
||||
} catch { return true }
|
||||
}
|
||||
|
||||
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
||||
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
||||
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
|
||||
|
||||
const setTimeRange = useCallback((range: TimeRange) => {
|
||||
setTimeRangeState(range)
|
||||
@@ -51,6 +61,11 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
setStatusFilters(new Set())
|
||||
}, [])
|
||||
|
||||
const setAutoRefresh = useCallback((enabled: boolean) => {
|
||||
setAutoRefreshState(enabled)
|
||||
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
||||
}, [])
|
||||
|
||||
const isInTimeRange = useCallback(
|
||||
(timestamp: Date) => {
|
||||
if (timeRange.preset) {
|
||||
@@ -65,7 +80,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<GlobalFilterContext.Provider
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||
>
|
||||
{children}
|
||||
</GlobalFilterContext.Provider>
|
||||
|
||||
Reference in New Issue
Block a user