From bcbe56425f837a2c0970b1543ef9aa75eba05b0a Mon Sep 17 00:00:00 2001 From: cjlapao Date: Thu, 9 Apr 2026 10:03:38 +0100 Subject: [PATCH 01/13] Implementing a fix to the crypto issue --- .../ui-kit/src/components/SmartGridLayout.tsx | 14 ++++++ src/contexts/ConfigContext.tsx | 18 +++++++- src/pages/Catalogs/Catalogs.tsx | 26 ++++++++--- src/pages/Catalogs/downloadTarget.test.ts | 26 +++++++++++ src/pages/Catalogs/downloadTarget.ts | 43 +++++++++++++++++++ src/services/config/spa/SpaSecretStore.ts | 11 ++++- 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 src/pages/Catalogs/downloadTarget.test.ts create mode 100644 src/pages/Catalogs/downloadTarget.ts diff --git a/packages/ui-kit/src/components/SmartGridLayout.tsx b/packages/ui-kit/src/components/SmartGridLayout.tsx index 7212667..ed98ede 100644 --- a/packages/ui-kit/src/components/SmartGridLayout.tsx +++ b/packages/ui-kit/src/components/SmartGridLayout.tsx @@ -918,6 +918,20 @@ export const SmartGridLayout: React.FC = ({ items, persist {isEditMode && (
+ +
+

Add Items To Row

+
+ {isEditMode && rowAddTarget && (() => { + const sectionRowsList = sectionRows.get(rowAddTarget.sectionId) ?? []; + const targetRow = sectionRowsList.find((r) => r.id === rowAddTarget.rowId); + const hasItems = targetRow && targetRow.cells.length > 0; + return hasItems ? ( + + ) : null; + })()} + +
{addableItems.length === 0 ? (

No items available to add.

@@ -866,13 +1039,17 @@ export const SmartGridLayout: React.FC = ({ items, persist type="button" onClick={() => { if (!rowAddTarget) return; - addItemToRow(row.id, rowAddTarget.sectionId, rowAddTarget.rowId); + if (isSpacerId(row.id)) { + addSpacerToRow(rowAddTarget.sectionId, rowAddTarget.rowId); + } else { + addItemToRow(row.id, rowAddTarget.sectionId, rowAddTarget.rowId); + } setRowAddTarget(null); setIsAddModalOpen(false); }} className="shrink-0 rounded border border-emerald-300 px-2 py-1 text-xs font-medium text-emerald-700 dark:border-emerald-800 dark:text-emerald-300" > - Add + {isSpacerId(row.id) ? 'Add Spacer' : 'Add'}
); @@ -942,22 +1119,20 @@ export const SmartGridLayout: React.FC = ({ items, persist setEditingSectionId(sectionId); setSectionDraftTitle(section.title); }} - > - Rename - - {rows.every((row) => row.cells.length === 0) && ( - removeSection(sectionId)} - title="Remove empty section" - aria-label="Remove empty section" - /> - )} - - )} + > + Rename + + removeSection(sectionId)} + title="Remove section and all items" + aria-label="Remove section and all items" + /> + + )}
@@ -967,34 +1142,61 @@ export const SmartGridLayout: React.FC = ({ items, persist const rowContentSpan = maxColumns; const isRowPreviewActive = Boolean(isEditMode && draggingId && rowPreview && rowPreview.sectionId === sectionId && rowPreview.rowId === row.id); + const isResizePreviewActive = Boolean(resizeState && resizePreview && resizePreview.sectionId === sectionId && resizePreview.rowId === row.id && row.cells.length === 1); + const renderCells = (() => { - if (!isRowPreviewActive || !draggingId || !rowPreview) { + if (!isRowPreviewActive && !isResizePreviewActive) { return row.cells.map((cell) => ({ kind: 'item' as const, id: cell.entry.id, span: cell.span, cell })); } - const draggedState = layout.items[draggingId]; - const draggedSpan = clampSpan(draggedState?.span ?? 3, rowContentSpan); - - const withoutDragged = row.cells.filter((cell) => cell.entry.id !== draggingId); - const insertIndex = Math.max(0, Math.min(rowPreview.insertIndex, withoutDragged.length)); - - const withGhost = [ - ...withoutDragged.slice(0, insertIndex).map((cell) => ({ kind: 'item' as const, id: cell.entry.id, desiredSpan: clampSpan(cell.entry.state.span, rowContentSpan), cell })), - { kind: 'ghost' as const, id: '__ghost__', desiredSpan: draggedSpan }, - ...withoutDragged.slice(insertIndex).map((cell) => ({ kind: 'item' as const, id: cell.entry.id, desiredSpan: clampSpan(cell.entry.state.span, rowContentSpan), cell })), - ]; + if (isRowPreviewActive && draggingId && rowPreview) { + const draggedState = layout.items[draggingId]; + const draggedSpan = clampSpan(draggedState?.span ?? 3, rowContentSpan); + + const withoutDragged = row.cells.filter((cell) => cell.entry.id !== draggingId); + const insertIndex = Math.max(0, Math.min(rowPreview.insertIndex, withoutDragged.length)); + + const withGhost = [ + ...withoutDragged.slice(0, insertIndex).map((cell) => ({ kind: 'item' as const, id: cell.entry.id, desiredSpan: clampSpan(cell.entry.state.span, rowContentSpan), cell })), + { kind: 'ghost' as const, id: '__ghost__', desiredSpan: draggedSpan }, + ...withoutDragged.slice(insertIndex).map((cell) => ({ kind: 'item' as const, id: cell.entry.id, desiredSpan: clampSpan(cell.entry.state.span, rowContentSpan), cell })), + ]; + + const normalized = normalizeRowSpans( + withGhost.map((entry) => entry.desiredSpan), + rowContentSpan, + ); + + return withGhost.map((entry) => ({ + kind: entry.kind, + id: entry.id, + span: normalized[withGhost.indexOf(entry)], + cell: entry.kind === 'item' ? entry.cell : undefined, + })); + } - const normalized = normalizeRowSpans( - withGhost.map((entry) => entry.desiredSpan), - rowContentSpan, - ); + if (isResizePreviewActive && resizeState) { + const itemId = resizeState.leftId; + const currentSpan = resizeState.startLeftSpan; + const resizedSpan = Math.max(1, Math.min(resizeState.pairTotal - 1, currentSpan + Math.round((0 - resizeState.startX) / resizeState.colWidth))); + + const cell = row.cells[0]; + const emptySpaceSpan = maxColumns - resizedSpan; + + const withGhost = [ + { kind: 'item' as const, id: itemId, desiredSpan: resizedSpan, cell }, + { kind: 'ghost' as const, id: '__empty_space__', desiredSpan: emptySpaceSpan }, + ]; + + return withGhost.map((entry) => ({ + kind: entry.kind, + id: entry.id, + span: entry.desiredSpan, + cell: entry.kind === 'item' ? entry.cell : undefined, + })); + } - return withGhost.map((entry, index) => ({ - kind: entry.kind, - id: entry.id, - span: normalized[index], - cell: entry.kind === 'item' ? entry.cell : undefined, - })); + return row.cells.map((cell) => ({ kind: 'item' as const, id: cell.entry.id, span: cell.span, cell })); })(); return ( @@ -1068,38 +1270,72 @@ export const SmartGridLayout: React.FC = ({ items, persist if (rowPreview?.sectionId === sectionId && rowPreview.rowId === row.id) { setRowPreview(null); } - }} - onDrop={(event) => { - if (!isEditMode) return; - event.preventDefault(); - const sourceId = getDraggedId(event); - if (!sourceId) return; + }} + onDrop={(event) => { + if (!isEditMode) return; + event.preventDefault(); + const sourceId = getDraggedId(event); + if (!sourceId) return; + + // Clear any active row preview + if (rowPreview) { + setRowPreview(null); + } + + if (!row.isEmpty && row.cells.length > 0) { + const targetRowId = row.id; + const previewIndex = rowPreview?.sectionId === sectionId && rowPreview.rowId === targetRowId ? rowPreview.insertIndex : row.cells.length; - if (!row.isEmpty && row.cells.length > 0) { - const previewIndex = rowPreview?.sectionId === sectionId && rowPreview.rowId === row.id ? rowPreview.insertIndex : row.cells.length; + const withoutDragged = row.cells.filter((cell) => cell.entry.id !== sourceId); + const safeIndex = Math.max(0, Math.min(previewIndex, withoutDragged.length)); - const withoutDragged = row.cells.filter((cell) => cell.entry.id !== sourceId); - const safeIndex = Math.max(0, Math.min(previewIndex, withoutDragged.length)); + if (withoutDragged.length === 0) { + moveItemToSectionEnd(sourceId, sectionId, targetRowId); + resetDragState(); + return; + } + + if (safeIndex <= 0) { + reorderItems(sourceId, withoutDragged[0].entry.id, 'before'); + } else { + reorderItems(sourceId, withoutDragged[safeIndex - 1].entry.id, 'after'); + } + setItemPlacement(sourceId, sectionId, targetRowId); + + updateLayout((prev) => { + const currentRow = prev.sections[sectionId]; + if (!currentRow || !currentRow.rowOrder.includes(targetRowId)) return prev; + + const rowItemIds = withoutDragged.map((cell) => cell.entry.id); + const newSpans = normalizeRowSpans( + rowItemIds.map((id) => { + const item = prev.items[id]; + return clampSpan(item?.span, maxColumns); + }), + maxColumns, + ); + + const nextItems = { ...prev.items }; + rowItemIds.forEach((id, index) => { + nextItems[id] = { + ...nextItems[id], + span: newSpans[index], + }; + }); + + return { + ...prev, + items: nextItems, + }; + }); - if (withoutDragged.length === 0) { - moveItemToSectionEnd(sourceId, sectionId, row.id); resetDragState(); return; } - if (safeIndex <= 0) { - reorderItems(sourceId, withoutDragged[0].entry.id, 'before'); - } else { - reorderItems(sourceId, withoutDragged[safeIndex - 1].entry.id, 'after'); - } - setItemPlacement(sourceId, sectionId, row.id); + moveItemToSectionEnd(sourceId, sectionId, row.id); resetDragState(); - return; - } - - moveItemToSectionEnd(sourceId, sectionId, row.id); - resetDragState(); - }} + }} > {row.isEmpty && (
= ({ items, persist
)} - {renderCells.map((renderCell, cellIndex) => { - if (renderCell.kind === 'ghost') { - return ( -
- ); - } - - const cell = renderCell.cell; - if (!cell) return null; - - const def = byId.get(cell.entry.id); - if (!def) return null; - - const nextItemIndex = renderCells.slice(cellIndex + 1).findIndex((c) => c.kind === 'item'); - const neighbor = nextItemIndex >= 0 ? renderCells[cellIndex + 1 + nextItemIndex] : undefined; - const neighborCell = neighbor?.kind === 'item' ? neighbor.cell : undefined; - - return ( -
{ + if (renderCell.kind === 'ghost') { + return ( +
+ ); + } + + const cell = renderCell.cell; + if (!cell) return null; + + const nextItemIndex = renderCells.slice(cellIndex + 1).findIndex((c) => c.kind === 'item'); + const neighbor = nextItemIndex >= 0 ? renderCells[cellIndex + 1 + nextItemIndex] : undefined; + const neighborCell = neighbor?.kind === 'item' ? neighbor.cell : undefined; + + const def = byId.get(cell.entry.id); + if (!def && !cell.entry.isSpacer) return null; + + if (cell.entry.isSpacer) { + return ( +
{ + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', cell.entry.id); + setDraggingId(cell.entry.id); + setDragOver(null); + }} + onDragEnd={resetDragState} + onDragOver={(event) => { + if (!isEditMode || !draggingId) return; + event.preventDefault(); + event.stopPropagation(); + if (draggingId === cell.entry.id) return; + const rect = event.currentTarget.getBoundingClientRect(); + const position = event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'; + if (!dragOver || dragOver.id !== cell.entry.id || dragOver.position !== position) { + setDragOver({ id: cell.entry.id, position }); + } + const previewIndex = + position === 'before' + ? row.cells.filter((entry) => entry.entry.id !== draggingId).findIndex((entry) => entry.entry.id === cell.entry.id) + : row.cells.filter((entry) => entry.entry.id !== draggingId).findIndex((entry) => entry.entry.id === cell.entry.id) + 1; + if (previewIndex >= 0 && (!rowPreview || rowPreview.sectionId !== sectionId || rowPreview.rowId !== row.id || rowPreview.insertIndex !== previewIndex)) { + setRowPreview({ sectionId, rowId: row.id, insertIndex: previewIndex }); + } + }} + onDragLeave={(event) => { + if (!isEditMode || !draggingId) return; + const nextTarget = event.relatedTarget as Node | null; + if (nextTarget && event.currentTarget.contains(nextTarget)) return; + if (dragOver?.id === cell.entry.id) setDragOver(null); + }} + onDrop={(event) => { + if (!isEditMode) return; + event.preventDefault(); + event.stopPropagation(); + const sourceId = getDraggedId(event); + if (!sourceId || sourceId === cell.entry.id) return; + const rect = event.currentTarget.getBoundingClientRect(); + const position = event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'; + reorderItems(sourceId, cell.entry.id, position); + setItemPlacement(sourceId, sectionId, row.id); + resetDragState(); + }} + > + {isEditMode && ( +
+ removeItem(cell.entry.id)} + aria-label="Remove spacer" + title="Remove spacer" + /> +
+ )} + {isEditMode && cell.entry.item.isSpacer && neighborCell && neighbor && ( + + )} +
+ ); + } + return ( +
{ event.dataTransfer.effectAllowed = 'move'; @@ -1185,8 +1503,8 @@ export const SmartGridLayout: React.FC = ({ items, persist setItemPlacement(sourceId, sectionId, row.id); resetDragState(); }} - > - {def.render()} + > + {def!.render()} {isEditMode && (
@@ -1202,18 +1520,20 @@ export const SmartGridLayout: React.FC = ({ items, persist
)} - {isEditMode && neighborCell && neighbor && ( - - )} + {isEditMode && neighborCell && neighbor && ( + + )}
); })} @@ -1260,12 +1580,15 @@ export const SmartGridLayout: React.FC = ({ items, persist moveItemToSectionEnd(sourceId, sectionId, rowId); resetDragState(); }} - > - -

Or drop here to create a row and place item

-
+ > + +

Or drop here to add item to new row

+
)}
@@ -1294,10 +1617,15 @@ export const SmartGridLayout: React.FC = ({ items, persist moveItemToSectionEnd(sourceId, sectionId, rowId); resetDragState(); }} - > - + > +

Or drop a card here to create a section and place it there

)} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 29c9632..4d1bf40 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -177,6 +177,11 @@ export const Header: React.FC = () => { const displayLabel = session?.username || 'User'; +// Check if user is authenticated with API key (has api_key_id claim) +const isApiKeyAuth = session?.tokenPayload?.api_key_id ? true : false; + + + return (
@@ -258,10 +263,15 @@ export const Header: React.FC = () => { size={36} variant="circle" /> -
-

{displayLabel}

-

{session?.hostname ?? ''}

-
+
+

{displayLabel}

+

{session?.hostname ?? ''}

+ {isApiKeyAuth && ( +

+ API Key Authentication +

+ )} +
{/* Menu items */} diff --git a/src/interfaces/tokenTypes.ts b/src/interfaces/tokenTypes.ts index 584f3a4..6216e30 100644 --- a/src/interfaces/tokenTypes.ts +++ b/src/interfaces/tokenTypes.ts @@ -4,19 +4,18 @@ export interface JwtTokenPayload { /** Array of permission claims (e.g., "CREATE_USER", "DELETE_VM") */ claims: string[]; - /** User email address */ email: string; + /** Username */ username: string; - /** Token expiration timestamp (Unix epoch in seconds) */ exp: number; - /** Array of user roles (e.g., "SUPER_USER") */ roles: string[]; - /** User unique identifier */ uid: string; + /** API key identifier (when authenticated with API key) */ + api_key_id: string | null; } /** diff --git a/src/pages/Catalogs/Modals/CatalogManagerModals.tsx b/src/pages/Catalogs/Modals/CatalogManagerModals.tsx index 6819f25..c37e926 100644 --- a/src/pages/Catalogs/Modals/CatalogManagerModals.tsx +++ b/src/pages/Catalogs/Modals/CatalogManagerModals.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Alert, Button, DeleteConfirmModal, FormField, FormLayout, Input, Modal, ModalActions, Panel, Select, TagPicker, Toggle } from '@prl/ui-kit'; +import { Alert, Button, DeleteConfirmModal, FormField, FormLayout, Input, Modal, ModalActions, Panel, PasswordInput, Select, TagPicker, Toggle } from '@prl/ui-kit'; import { useSystemSettings } from '@/contexts/SystemSettingsContext'; import { CatalogManager } from '@/interfaces/CatalogManager'; import { CatalogManagerFormData } from '../CatalogModels'; @@ -75,12 +75,12 @@ export const CatalogManagerEditorModal: React.FC onFormChange({ ...managerForm, username: e.target.value })} /> - onFormChange({ ...managerForm, password: e.target.value })} /> + onFormChange({ ...managerForm, password: e.target.value })} /> ) : ( - onFormChange({ ...managerForm, api_key: e.target.value })} /> + onFormChange({ ...managerForm, api_key: e.target.value })} /> )} diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index bf1788d..e8ec9ff 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -161,7 +161,7 @@ export const Home: React.FC = () => { const memTotalDisplay = `${memTotal.value} ${memTotal.unit}`; const hasGraphData = history.length > 0; - const persistedLayout = getConfig(HOME_SMART_GRID_LAYOUT_SLUG, null); + const [persistedLayout, setPersistedLayout] = useState(() => getConfig(HOME_SMART_GRID_LAYOUT_SLUG, null)); const handleLayoutChange = useCallback( (layout: SmartGridLayoutState) => { @@ -184,7 +184,12 @@ export const Home: React.FC = () => { } pendingLayoutRef.current = null; setIsLayoutEditMode(false); - }, [setConfig]); + // Reload layout from config to get updated data + const newLayout = getConfig(HOME_SMART_GRID_LAYOUT_SLUG, null); + if (newLayout) { + setPersistedLayout(newLayout); + } + }, [setConfig, getConfig]); const cancelLayoutEdit = useCallback(() => { pendingLayoutRef.current = null; diff --git a/src/pages/Home/panels/CpuUtilizationPanel.tsx b/src/pages/Home/panels/CpuUtilizationPanel.tsx index 1133877..24377c7 100644 --- a/src/pages/Home/panels/CpuUtilizationPanel.tsx +++ b/src/pages/Home/panels/CpuUtilizationPanel.tsx @@ -1,4 +1,5 @@ import { StatGraphTile, Panel, Section } from '@prl/ui-kit'; +import { formatTimeRange } from '@/utils/timeRange'; export interface GraphDataPoint { timestamp: number | string; @@ -23,12 +24,14 @@ export function CpuUtilizationPanel({ hasGraphData, cpuTotal, graphData }: CpuUt ); } + const timeRange = formatTimeRange(graphData.map((d) => (typeof d.timestamp === 'number' ? d.timestamp : 0))); + return (
(typeof d.timestamp === 'number' ? d.timestamp : 0))); + return (
(typeof d.timestamp === 'number' ? d.timestamp : 0))); + return (
= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`; @@ -43,6 +44,7 @@ export function PerformanceTab({ host }: { host: DevOpsRemoteHost }) { ); const latestGraph = graphData[graphData.length - 1]; + const timeRange = formatTimeRange(graphData.map((d) => (typeof d.timestamp === 'number' ? d.timestamp : 0))); if (!latest) { return ( @@ -62,7 +64,7 @@ export function PerformanceTab({ host }: { host: DevOpsRemoteHost }) { 0 ? `of ${autoScaleBytes(totalMemoryBytes)} total` : 'Agent process heap'} + subtitle={totalMemoryBytes > 0 ? `of ${autoScaleBytes(totalMemoryBytes)} total (${timeRange})` : `Agent process heap (${timeRange})`} data={graphData} variant="sparkline" series={[{ key: 'memoryBytes', label: 'Bytes', color: 'amber' }]} @@ -100,7 +102,7 @@ export function PerformanceTab({ host }: { host: DevOpsRemoteHost }) { = ({ prefill }) => { setHostsLoading(false); }; void loadHosts(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [isLocked, config, getPasswordKey, lockedHost, lockedUsername, prefill, lockedHostname]); // eslint-disable-line react-hooks/exhaustive-deps // Populate form fields whenever the selected host changes (normal mode only) useEffect(() => { @@ -178,7 +178,7 @@ export const Login: React.FC = ({ prefill }) => { } }; void loadSecret(); - }, [selectedHostId]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isLocked, selectedHostId, hosts, hostsLoading, hosts.find]); // eslint-disable-line react-hooks/exhaustive-deps // Auto-login when both VITE_DEFAULT_USERNAME and VITE_DEFAULT_PASSWORD are set useEffect(() => { @@ -276,9 +276,26 @@ export const Login: React.FC = ({ prefill }) => { api_key: authType === 'api_key' ? apiKey : '', }); - await authService.forceReauth(hostname); - - if (keepLoggedIn) { + await authService.forceReauth(hostname); + + // After successful login, check if we used API key authentication + // and extract the api_key_id from the token for storage and audit logging + if (authType === 'api_key') { + const token = authService.getToken(hostname); + if (token) { + const tokenPayload = decodeToken(token); + if (tokenPayload && tokenPayload.api_key_id) { + // Store api_key_id for later guard checks + if (authType === 'api_key' && tokenPayload.api_key_id) { + // Log the api_key_id for audit purposes (but not the actual key) + console.info(`[Login] API Key authentication used: api_key_id=${tokenPayload.api_key_id}`); + // The api_key_id will be available in the session token payload + } + } + } + } + + if (keepLoggedIn) { if (authType === 'credentials') { await config.setSecret(getPasswordKey(hostname), password); await config.removeSecret(getApiKeyKey(hostname)); diff --git a/src/services/authService.ts b/src/services/authService.ts index bd4a1ee..2ac5cc8 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -2,11 +2,12 @@ import { Subject } from 'rxjs'; import { ApiError, ApiErrorResponse } from '../interfaces/api'; /** - * Login request matching API specification - */ + * Login request matching API specification + */ interface LoginRequest { email: string; password: string; + api_key?: string; } /** @@ -177,10 +178,11 @@ class AuthService { private async performLogin(hostname: string, credentials: HostCredentials): Promise { const loginUrl = this.buildLoginUrl(credentials.url); - const loginCredentials: LoginRequest = { - email: credentials.email, - password: credentials.password, - }; + const loginCredentials: LoginRequest = { + email: credentials.email, + password: credentials.password, + api_key: credentials.api_key, + }; try { const response = await fetch(loginUrl, { diff --git a/src/utils/timeRange.ts b/src/utils/timeRange.ts new file mode 100644 index 0000000..72a6795 --- /dev/null +++ b/src/utils/timeRange.ts @@ -0,0 +1,21 @@ +export function formatTimeRange(timestamps: number[]): string { + if (timestamps.length === 0) return 'No data'; + if (timestamps.length === 1) return 'Recent data'; + + const oldest = Math.min(...timestamps); + const newest = Math.max(...timestamps); + const diffMs = newest - oldest; + const diffSeconds = Math.round(diffMs / 1000); + + if (diffSeconds < 60) { + return `in the last ${diffSeconds} seconds`; + } + + const diffMinutes = Math.round(diffSeconds / 60); + if (diffMinutes < 60) { + return `in the last ${diffMinutes} minute${diffMinutes !== 1 ? 's' : ''}`; + } + + const diffHours = Math.round(diffMinutes / 60); + return `in the last ${diffHours} hour${diffHours !== 1 ? 's' : ''}`; +} \ No newline at end of file diff --git a/src/utils/tokenUtils.ts b/src/utils/tokenUtils.ts index b25dcf0..f887943 100644 --- a/src/utils/tokenUtils.ts +++ b/src/utils/tokenUtils.ts @@ -29,14 +29,16 @@ export function decodeToken(token: string): JwtTokenPayload | null { } // Ensure arrays exist (even if empty) - const payload: JwtTokenPayload = { + const payload: JwtTokenPayload = { claims: Array.isArray(decoded.claims) ? decoded.claims : [], email: decoded.email, exp: decoded.exp || 0, roles: Array.isArray(decoded.roles) ? decoded.roles : [], uid: decoded.uid, username: decoded.username || '', - }; + // Extract api_key_id claim if present (used for API key authentication) + api_key_id: decoded.api_key_id || null, + }; return payload; } catch (error) { From befbebfb7575b313eb31239d4c4c9b9ced7d6809 Mon Sep 17 00:00:00 2001 From: cjlapao Date: Wed, 15 Apr 2026 15:55:29 +0100 Subject: [PATCH 03/13] wip --- .../ui-kit/src/components/SmartGridLayout.tsx | 2309 ++++++++++------- .../Notification/NotificationWrapper.tsx | 8 +- src/pages/Home/index.tsx | 148 +- 3 files changed, 1547 insertions(+), 918 deletions(-) diff --git a/packages/ui-kit/src/components/SmartGridLayout.tsx b/packages/ui-kit/src/components/SmartGridLayout.tsx index 421b813..4e3896e 100644 --- a/packages/ui-kit/src/components/SmartGridLayout.tsx +++ b/packages/ui-kit/src/components/SmartGridLayout.tsx @@ -4,39 +4,57 @@ import { Button, CustomIcon, IconButton, type ThemeColor } from '@prl/ui-kit'; export interface SmartGridItemDefinition { id: string; title: string; - group?: string; description?: string; screenshot?: string; defaultSpan?: number; - defaultHidden?: boolean; - defaultRemoved?: boolean; + active: boolean; + single: boolean; render: () => React.ReactNode; isSpacer?: boolean; } -export interface SmartGridItemState { - order: number; +export interface SmartGridItem { + definitionId: string; + id: string; span: number; - hidden: boolean; - removed: boolean; - group?: string; - rowKey?: string; + order: number; + sectionId: string; + rowId: string; + isSpacer?: boolean; } -interface SmartGridSectionState { +export interface SmartGridRow { + id: string; + items: SmartGridItem[]; + order: number; +} + +export interface SmartGridSection { + id: string; title: string; + rows: SmartGridRow[]; order: number; - rowOrder: string[]; +} + +export interface SmartGridSectionDefinition { + id?: string; + title: string; + rows: SmartGridRowDefinition[]; +} + +export interface SmartGridRowDefinition { + id?: number; + itemIds: string[]; } export interface SmartGridLayoutState { - version: 1; - items: Record; - sections: Record; + version: 3; + sections: SmartGridSection[]; } interface SmartGridLayoutProps { items: SmartGridItemDefinition[]; + defaultLayout: SmartGridSectionDefinition[]; persistedLayout?: SmartGridLayoutState | null; onLayoutChange?: (layout: SmartGridLayoutState) => void; maxColumns?: number; @@ -46,26 +64,6 @@ interface SmartGridLayoutProps { onEditModeChange?: (isEditMode: boolean) => void; } -type SmartGridSeedItem = Pick; - -interface VisibleEntry { - id: string; - item: SmartGridItemDefinition; - state: SmartGridItemState; - order: number; - isSpacer?: boolean; -} - -interface PackedCell { - entry: VisibleEntry; - span: number; -} - -interface DisplayRow { - id: string; - cells: PackedCell[]; - isEmpty?: boolean; -} interface ResizeState { leftId: string; @@ -92,7 +90,6 @@ interface RowPreviewState { insertIndex: number; } -const DEFAULT_SECTION = 'General'; const GRID_GAP_PX = 16; const SPACER_PREFIX = 'spacer:'; @@ -113,12 +110,28 @@ function makeId(prefix: string): string { return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`; } -function isSpacerId(id: string): boolean { - return id.startsWith(SPACER_PREFIX); +function normalizeSectionId(title: string, existingIds: string[]): string { + let normalized = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + + if (!existingIds.includes(normalized)) { + return normalized; + } + + let counter = 1; + let id = `${normalized}_${counter}`; + while (existingIds.includes(id)) { + counter++; + id = `${normalized}_${counter}`; + } + return id; } -function createSpacerId(): string { - return `${SPACER_PREFIX}${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`; +function normalizeRowId(sectionId: string, rowIndex: number): string { + return `${sectionId}-row-${rowIndex + 1}`; +} + +function isSpacerId(id: string): boolean { + return id.startsWith(SPACER_PREFIX); } function clampSpan(span: number | undefined, maxColumns: number): number { @@ -126,6 +139,10 @@ function clampSpan(span: number | undefined, maxColumns: number): number { return Math.max(1, Math.min(maxColumns, Math.round(Number(span)))); } +function createSlug(): string { + return `item:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`; +} + function sortByOrder(rows: T[]): T[] { return [...rows].sort((a, b) => { if (a.order === b.order) return a.id.localeCompare(b.id); @@ -171,139 +188,271 @@ function normalizeRowSpans(desiredSpans: number[], maxColumns: number): number[] return normalized; } -function packUnassignedRows(entries: VisibleEntry[], maxColumns: number): DisplayRow[] { - const packed: DisplayRow[] = []; - let bucket: VisibleEntry[] = []; - let bucketTotal = 0; - - for (const entry of sortByOrder(entries.map((x) => ({ ...x, order: x.order })))) { - const desired = clampSpan(entry.state.span, maxColumns); - if (bucket.length > 0 && bucketTotal + desired > maxColumns) { - const spans = normalizeRowSpans( - bucket.map((x) => clampSpan(x.state.span, maxColumns)), - maxColumns, - ); - packed.push({ - id: makeId('auto-row'), - cells: bucket.map((row, index) => ({ entry: row, span: spans[index] })), +function normalizeLayout( + items: SmartGridItemDefinition[], + defaultLayout: SmartGridSectionDefinition[], + persistedLayout: SmartGridLayoutState | null | undefined, + maxColumns: number, + onLayoutChange?: (layout: SmartGridLayoutState) => void +): SmartGridLayoutState { + const itemsMap = new Map(items.map(i => [i.id, i])); + + // Step 1: Generate section IDs and build default layout structure + // First pass: extract all section IDs that are already provided + const providedSectionIds = defaultLayout.map(s => s.id).filter((id): id is string => id !== undefined); + + const sectionDefinitions: SmartGridSectionDefinition[] = defaultLayout.map((sectionDef, index) => { + const id = sectionDef.id + ? normalizeSectionId(sectionDef.id, providedSectionIds.filter((_, i) => i !== index)) + : normalizeSectionId(sectionDef.title, providedSectionIds.filter((_, i) => i !== index)); + + return { + ...sectionDef, + id: id as string, + rows: sectionDef.rows.map((rowDef) => ({ + id: rowDef.id, + itemIds: rowDef.itemIds + })) + }; + }); + + // Step 2: Build initial layout from default layout + const layout: SmartGridLayoutState = { + version: 3, + sections: [] + }; + + // Track single items already placed + const placedSingleItems = new Map(); + + sectionDefinitions.forEach((sectionDef, sectionIndex) => { + const section: SmartGridSection = { + id: sectionDef.id as string, + title: sectionDef.title, + rows: [], + order: sectionIndex + }; + + sectionDef.rows.forEach((rowDef, rowIndex) => { + const row: SmartGridRow = { + id: rowDef.id !== undefined ? `${rowDef.id}` : normalizeRowId(sectionDef.id as string, rowIndex), + items: [], + order: rowIndex + }; + + rowDef.itemIds.forEach((itemId, itemIndex) => { + const itemDef = itemsMap.get(itemId); + + if (!itemDef) { + console.warn(`[SmartGridLayout] Item "${itemId}" not found in available items`); + return; + } + + if (!itemDef.active) { + console.warn(`[SmartGridLayout] Item "${itemId}" is inactive and will be skipped`); + return; + } + + // Handle single=true items - replace existing instance + if (itemDef.single && placedSingleItems.has(itemId)) { + const existing = placedSingleItems.get(itemId)!; + removeLayoutItem(layout, existing.id); + } + + const layoutItem: SmartGridItem = { + definitionId: itemId, + id: createSlug(), + span: itemDef.defaultSpan ?? 4, + order: itemIndex, + sectionId: section.id, + rowId: row.id, + isSpacer: itemDef.isSpacer + }; + + if (itemDef.single) { + placedSingleItems.set(itemId, layoutItem); + } + + row.items.push(layoutItem); }); - bucket = []; - bucketTotal = 0; + + if (row.items.length > 0) { + section.rows.push(row); + } + }); + + if (section.rows.length > 0) { + layout.sections.push(section); } + }); - bucket.push(entry); - bucketTotal += desired; + // Step 3: Use persisted layout if exists (no merging with default) + if (persistedLayout && persistedLayout.version === 3) { + // Use persisted layout directly - don't merge with default + const wrappedLayout = ensureAutoRowWrapping(persistedLayout, items, maxColumns, onLayoutChange); + + // Remove empty sections/rows + const prunedLayout = pruneEmptySectionsAndRows(wrappedLayout); + + // Update orders + updateSectionRowOrders(prunedLayout); + + return prunedLayout; } - if (bucket.length > 0) { - const spans = normalizeRowSpans( - bucket.map((x) => clampSpan(x.state.span, maxColumns)), - maxColumns, - ); - packed.push({ - id: makeId('auto-row'), - cells: bucket.map((row, index) => ({ entry: row, span: spans[index] })), - }); - } + // Ensure auto-row-wrapping + const wrappedLayout = ensureAutoRowWrapping(layout, items, maxColumns, onLayoutChange); + + // Remove empty sections/rows + const prunedLayout = pruneEmptySectionsAndRows(wrappedLayout); + + // Update orders + updateSectionRowOrders(prunedLayout); - return packed; + return prunedLayout; } -function normalizeLayout(items: SmartGridSeedItem[], persistedLayout: SmartGridLayoutState | null | undefined, maxColumns: number): SmartGridLayoutState { - const persistedItems = persistedLayout?.items ?? {}; - const persistedSections = persistedLayout?.sections ?? {}; +function updateSectionRowOrders(layout: SmartGridLayoutState): void { + layout.sections.forEach((section, sectionIdx) => { + section.order = sectionIdx; + section.rows.forEach((row, rowIdx) => { + row.order = rowIdx; + row.items.forEach((item, itemIdx) => { + item.order = itemIdx; + }); + }); + }); +} - const next: SmartGridLayoutState = { - version: 1, - items: {}, - sections: {}, +function ensureAutoRowWrapping( + layout: SmartGridLayoutState, + items: SmartGridItemDefinition[], + maxColumns: number, + onLayoutChange?: (layout: SmartGridLayoutState) => void +): SmartGridLayoutState { + const itemsMap = new Map(items.map(i => [i.id, i])); + let hasChanges = false; + + // Wrap decisions must be based on the ACTUAL stored item.span (which reflects + // user resizes), NOT the item definition's defaultSpan. Falling back to + // defaultSpan only when item.span is unset (e.g. a freshly-seeded item from + // the default layout that hasn't been persisted yet). + const getSpan = (item: SmartGridItem): number => { + const fallback = itemsMap.get(item.id)?.defaultSpan ?? 4; + const raw = Number.isFinite(item.span) ? (item.span as number) : fallback; + return Math.max(1, Math.min(maxColumns, Math.round(raw))); }; - const ensureSection = (sectionId: string, fallbackTitle: string, fallbackOrder: number) => { - const existing = next.sections[sectionId]; - const persisted = persistedSections[sectionId]; - if (existing) return existing; + const newSections = layout.sections.map(section => { + const newRows: SmartGridRow[] = []; - next.sections[sectionId] = { - title: typeof persisted?.title === 'string' && persisted.title.trim().length > 0 ? persisted.title : fallbackTitle, - order: Number.isFinite(persisted?.order) ? Number(persisted.order) : fallbackOrder, - rowOrder: Array.isArray(persisted?.rowOrder) ? [...persisted.rowOrder] : [], - }; - return next.sections[sectionId]; - }; + section.rows.forEach(row => { + let currentRow: SmartGridRow | null = null; - items.forEach((item, index) => { - if (isSpacerId(item.id)) { - return; - } - const persisted = persistedItems[item.id]; - // Hidden items with no persisted placement don't pre-create their group's section. - // Their group is left undefined until the user explicitly adds them to a row. - const isUnplacedHidden = !persisted && Boolean(item.defaultHidden) && !Boolean(item.defaultRemoved); - const sectionId: string | undefined = isUnplacedHidden - ? undefined - : typeof persisted?.group === 'string' && persisted.group.trim().length > 0 - ? persisted.group - : (item.group ?? DEFAULT_SECTION); - const section = sectionId ? ensureSection(sectionId, sectionId, index) : undefined; - const rowKey = typeof persisted?.rowKey === 'string' && persisted.rowKey.trim().length > 0 ? persisted.rowKey : undefined; - - next.items[item.id] = { - order: Number.isFinite(persisted?.order) ? Number(persisted.order) : index, - span: clampSpan(persisted?.span ?? item.defaultSpan ?? 12, maxColumns), - hidden: typeof persisted?.hidden === 'boolean' ? persisted.hidden : Boolean(item.defaultHidden), - removed: typeof persisted?.removed === 'boolean' ? persisted.removed : Boolean(item.defaultRemoved), - group: sectionId, - rowKey, - }; + row.items.forEach(item => { + const span = getSpan(item); - if (rowKey && section && !section.rowOrder.includes(rowKey)) { - section.rowOrder.push(rowKey); - } - }); + if (!currentRow) { + currentRow = { + id: row.id, + items: [item], + order: row.order + }; + newRows.push(currentRow); + } else { + const currentRowSpan = currentRow.items.reduce((sum, i) => sum + getSpan(i), 0); - // Preserve spacers from persisted layout - Object.entries(persistedItems).forEach(([id, itemState]) => { - if (isSpacerId(id)) { - next.items[id] = { - order: itemState.order ?? 0, - span: clampSpan(itemState.span, maxColumns), - hidden: typeof itemState.hidden === 'boolean' ? itemState.hidden : false, - removed: typeof itemState.removed === 'boolean' ? itemState.removed : false, - group: itemState.group, - rowKey: itemState.rowKey, - }; - // Add spacer's rowKey to section if it exists - if (itemState.rowKey && itemState.group) { - const section = ensureSection(itemState.group, itemState.group, 0); - if (!section.rowOrder.includes(itemState.rowKey)) { - section.rowOrder.push(itemState.rowKey); + if (currentRowSpan + span > maxColumns) { + const newRow: SmartGridRow = { + id: makeId(`row:${section.id}`), + items: [item], + order: currentRow.order + 1 + }; + newRows.push(newRow); + currentRow = newRow; + hasChanges = true; + } else { + currentRow.items.push(item); + } } - } - } + }); + }); + + return { ...section, rows: newRows }; }); - if (Object.keys(next.sections).length === 0) { - next.sections[DEFAULT_SECTION] = { - title: DEFAULT_SECTION, - order: 0, - rowOrder: [], - }; + const result = { ...layout, sections: newSections }; + + if (hasChanges && onLayoutChange) { + onLayoutChange(result); } - const orderedSections = sortByOrder(Object.entries(next.sections).map(([id, section]) => ({ id, order: section.order }))); + return result; +} - orderedSections.forEach((entry, index) => { - next.sections[entry.id] = { - ...next.sections[entry.id], - order: index, - }; +function distributeSpans(itemIds: string[], maxColumns: number): Map { + if (itemIds.length === 0) return new Map(); + if (itemIds.length === 1) { + return new Map([[itemIds[0], maxColumns]]); + } + + // When deleting items, distribute the FULL maxColumns among remaining items + const totalSpan = maxColumns; + const baseSpan = Math.floor(totalSpan / itemIds.length); + const remainder = totalSpan % itemIds.length; + + const newSpans = new Map(); + itemIds.forEach((id, index) => { + const span = baseSpan + (index < remainder ? 1 : 0); + newSpans.set(id, span); }); - return next; + return newSpans; +} + +// This function is no longer needed - deployed state is computed from layout + +function findLayoutItem( + layout: SmartGridLayoutState, + itemId: string +): SmartGridItem | null { + for (const section of layout.sections) { + for (const row of section.rows) { + const item = row.items.find(i => i.id === itemId); + if (item) return item; + } + } + return null; +} + +function removeLayoutItem( + layout: SmartGridLayoutState, + itemId: string +): void { + layout.sections.forEach(section => { + section.rows.forEach(row => { + const idx = row.items.findIndex(i => i.id === itemId); + if (idx !== -1) { + row.items.splice(idx, 1); + } + }); + }); } -export const SmartGridLayout: React.FC = ({ items, persistedLayout, onLayoutChange, maxColumns = 12, className, editThemeColor = 'blue', isEditMode: isEditModeProp }) => { +function pruneEmptySectionsAndRows(layout: SmartGridLayoutState): SmartGridLayoutState { + const sectionsWithNonEmptyRows = layout.sections.map(section => { + const nonEmptyRows = section.rows.filter(row => row.items.length > 0); + return { ...section, rows: nonEmptyRows }; + }); + + const nonEmptySections = sectionsWithNonEmptyRows.filter(section => + section.rows.length > 0 + ); + + return { ...layout, sections: nonEmptySections }; +} + +export const SmartGridLayout: React.FC = ({ items, defaultLayout, persistedLayout, onLayoutChange, maxColumns = 12, className, editThemeColor = 'blue', isEditMode: isEditModeProp }) => { const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [draggingId, setDraggingId] = useState(null); @@ -315,27 +464,20 @@ export const SmartGridLayout: React.FC = ({ items, persist const [rowPreview, setRowPreview] = useState(null); const [resizeState, setResizeState] = useState(null); - const [resizePreview, setResizePreview] = useState<{ sectionId: string; rowId: string; insertIndex: number } | null>(null); const [editingSectionId, setEditingSectionId] = useState(null); const [sectionDraftTitle, setSectionDraftTitle] = useState(''); const isEditMode = Boolean(isEditModeProp); - const layoutIdentity = useMemo(() => items.map((item) => `${item.id}:${item.defaultSpan ?? ''}:${item.defaultHidden ? 1 : 0}:${item.defaultRemoved ? 1 : 0}:${item.group ?? ''}`).join('|'), [items]); - - const layoutSeedItems = useMemo( - () => - items.map((item) => ({ - id: item.id, - group: item.group, - defaultSpan: item.defaultSpan, - defaultHidden: item.defaultHidden, - defaultRemoved: item.defaultRemoved, - })), - [layoutIdentity], - ); - - const normalizedLayout = useMemo(() => normalizeLayout(layoutSeedItems, persistedLayout, maxColumns), [layoutSeedItems, persistedLayout, maxColumns]); + // Compute normalized layout only when defaultLayout or persistedLayout changes (not on every items change) + const normalizedLayout = useMemo(() => { + // Create a copy of items to avoid mutating props + const itemsCopy = items.map(item => ({ ...item })); + return normalizeLayout(itemsCopy, defaultLayout, persistedLayout, maxColumns, onLayoutChange); + }, [defaultLayout, persistedLayout, maxColumns, onLayoutChange]); + const [layout, setLayout] = useState(() => normalizedLayout); + const hasInitializedLayout = useRef(false); + const layoutLoadCounter = useRef(0); const layoutRef = useRef(normalizedLayout); const rowRefs = useRef>({}); @@ -345,155 +487,95 @@ export const SmartGridLayout: React.FC = ({ items, persist layoutRef.current = layout; }, [layout]); + // Only update layout when persistedLayout or defaultLayout changes (not on every render) + // AND only on first load - don't recalculate after initialization useEffect(() => { - const prevItems = Object.keys(layout.items); - const prevSections = Object.keys(layout.sections); - const nextItems = Object.keys(normalizedLayout.items); - const nextSections = Object.keys(normalizedLayout.sections); + // Reset initialization flag when persistedLayout changes (user saved new layout) + if (persistedLayout && layoutLoadCounter.current > 0) { + hasInitializedLayout.current = false; + layoutLoadCounter.current = 0; + } + + if (hasInitializedLayout.current) return; - const itemsEqual = prevItems.length === nextItems.length && prevItems.every((id) => nextItems.includes(id)); - const sectionsEqual = prevSections.length === nextSections.length && prevSections.every((id) => nextSections.includes(id)); + // Compare the actual layout structure + if (!layout || !normalizedLayout) return; - if (!itemsEqual || !sectionsEqual) { + const prevSections = layout.sections; + const nextSections = normalizedLayout.sections; + + const sectionsEqual = + prevSections.length === nextSections.length && + prevSections.every((s, i) => { + const next = nextSections[i]; + return ( + s.id === next.id && + s.rows.length === next.rows.length && + s.rows.every((r, j) => { + const nextRow = next.rows[j]; + return r.id === nextRow.id && r.items.length === nextRow.items.length; + }) + ); + }); + + // Only update layout if sections are different AND we're not in the middle of drag operations + if (!sectionsEqual && !isEditMode) { setLayout(normalizedLayout); } - }, [normalizedLayout]); + + hasInitializedLayout.current = true; + layoutLoadCounter.current += 1; + }, [normalizedLayout, isEditMode, persistedLayout]); + // Create items map once, only when items array reference changes const byId = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]); - const orderedItems = useMemo(() => { - return sortByOrder( - items.map((item) => ({ - id: item.id, - item, - state: layout.items[item.id], - order: layout.items[item.id]?.order ?? 0, - isSpacer: isSpacerId(item.id), - })), - ); - }, [items, layout]); - - const orderedSpacers = useMemo(() => { - return sortByOrder( - Object.entries(layout.items) - .filter(([id]) => isSpacerId(id)) - .map(([id, state]) => ({ - id, - item: { - id, - title: '', - render: () => null, - isSpacer: true, - } as SmartGridItemDefinition, - state, - order: state.order ?? 0, - isSpacer: true, - })), - ); - }, [layout]); - const activeItems = useMemo(() => { - const fromProps = orderedItems.filter((row) => !row.state?.removed); - const fromLayout = orderedSpacers.filter((row) => !row.state?.removed); - return [...fromProps, ...fromLayout]; - }, [orderedItems, orderedSpacers]); - - const visibleItems = useMemo(() => { - const fromProps = activeItems.filter((row) => !row.state?.hidden); - return fromProps; - }, [activeItems]); - - // Items available to add via the modal: anything not currently rendered (hidden or removed) - // Spacers can only be added via the dedicated "Add Spacer" button, not from the list - const addableItems = useMemo(() => { - return orderedItems.filter((row) => row.state?.removed || row.state?.hidden); - }, [orderedItems]); - const orderedSectionIds = useMemo(() => { - const sectionIdsWithContent = new Set(visibleItems.map((e) => e.state.group).filter(Boolean)); - return sortByOrder( - Object.entries(layout.sections) - .filter(([id]) => sectionIdsWithContent.has(id)) - .map(([id, section]) => ({ id, order: section.order })), - ).map((entry) => entry.id); - }, [layout, isEditMode, visibleItems]); - - const entriesBySection = useMemo(() => { - const map = new Map(); - for (const entry of visibleItems) { - const sectionId = entry.state.group ?? entry.item.group ?? DEFAULT_SECTION; - const prev = map.get(sectionId); - const entryWithFlag = { ...entry, isSpacer: isSpacerId(entry.id) }; - if (prev) prev.push(entryWithFlag); - else map.set(sectionId, [entryWithFlag]); - } - return map; - }, [visibleItems, layout]); - - const sectionRows = useMemo(() => { - const result = new Map(); - const rowColumns = maxColumns; - - for (const sectionId of orderedSectionIds) { - const section = layout.sections[sectionId]; - const entries = sortByOrder((entriesBySection.get(sectionId) ?? []).map((e) => ({ ...e, order: e.order }))); - - const byRow = new Map(); - const unassigned: VisibleEntry[] = []; - - for (const entry of entries) { - const rowKey = entry.state.rowKey; - if (rowKey && section.rowOrder.includes(rowKey)) { - const prev = byRow.get(rowKey); - if (prev) prev.push(entry); - else byRow.set(rowKey, [entry]); - } else { - unassigned.push(entry); - } - } - - const rows: DisplayRow[] = []; - section.rowOrder.forEach((rowId) => { - const rowEntries = sortByOrder((byRow.get(rowId) ?? []).map((e) => ({ ...e, order: e.order }))) - .filter((e) => !e.state.removed); - if (rowEntries.length === 0) { - // Skip empty managed rows — they're pruned on save and should not render - return; - } - const spans = normalizeRowSpans( - rowEntries.map((e) => clampSpan(e.state.span, rowColumns)), - rowColumns, - ); - rows.push({ - id: rowId, - cells: rowEntries.map((entry, index) => ({ entry, span: spans[index] })), + // Compute deployed single item IDs from layout + const deployedSingleItemIds = useMemo(() => { + const deployedIds = new Set(); + layout.sections.forEach(section => { + section.rows.forEach(row => { + row.items.forEach(item => { + const def = items.find(i => i.id === item.definitionId); + if (def?.single) { + deployedIds.add(item.definitionId); + } }); }); + }); + return deployedIds; + }, [layout, items]); - const packedUnassigned = packUnassignedRows(unassigned, maxColumns).map((packedRow) => { - const spans = normalizeRowSpans( - packedRow.cells.map((cell) => clampSpan(cell.entry.state.span, rowColumns)), - rowColumns, - ); - - return { - ...packedRow, - cells: packedRow.cells.map((cell, index) => ({ ...cell, span: spans[index] })), - }; - }); - - rows.push(...packedUnassigned); - result.set(sectionId, rows); - } + // Items available to add: all active items minus deployed single items + const addableItems = useMemo(() => { + return items.filter((item) => { + // Skip items already in layout if single + if (item.single && deployedSingleItemIds.has(item.id)) { + return false; + } + // Skip inactive items + if (!item.active) { + return false; + } + return true; + }); + }, [items, deployedSingleItemIds]); - return result; - }, [entriesBySection, layout.sections, maxColumns, orderedSectionIds]); + const orderedSectionIds = useMemo(() => { + return sortByOrder( + layout.sections.map((section) => ({ id: section.id, order: section.order })), + ).map((entry) => entry.id); + }, [layout, isEditMode]); const updateLayout = useCallback( (updater: (prev: SmartGridLayoutState) => SmartGridLayoutState) => { setLayout((prev) => { const next = updater(prev); - onLayoutChange?.(next); + if (next !== prev) { + onLayoutChange?.(next); + } return next; }); }, @@ -509,96 +591,110 @@ export const SmartGridLayout: React.FC = ({ items, persist updateLayout((prev) => { const populatedRowKeys = new Set( - Object.values(prev.items) - .filter((item) => !item.removed && item.rowKey) - .map((item) => item.rowKey as string), + prev.sections.flatMap((section) => section.rows.flatMap((row) => row.items)) + .map((item) => item.rowId as string), ); let changed = false; - const nextSections = { ...prev.sections }; - - Object.entries(prev.sections).forEach(([sectionId, section]) => { - const filteredRowOrder = section.rowOrder.filter((rowId) => populatedRowKeys.has(rowId)); - if (filteredRowOrder.length !== section.rowOrder.length) { - nextSections[sectionId] = { ...section, rowOrder: filteredRowOrder }; + const nextSections = prev.sections.map(section => { + const filteredRows = section.rows.filter((row: SmartGridRow) => populatedRowKeys.has(row.id)); + if (filteredRows.length !== section.rows.length) { changed = true; + return { ...section, rows: filteredRows }; } + return section; }); return changed ? { ...prev, sections: nextSections } : prev; }); }, [isEditMode, updateLayout]); - const updateItem = useCallback( - (itemId: string, updater: (prev: SmartGridItemState) => SmartGridItemState) => { - updateLayout((prev) => { - const current = prev.items[itemId]; - if (!current) return prev; - return { - ...prev, - items: { - ...prev.items, - [itemId]: updater(current), - }, - }; - }); - }, - [updateLayout], - ); - const removeItem = useCallback( (itemId: string) => { - updateItem(itemId, (prev) => ({ ...prev, removed: true, hidden: false })); + updateLayout((prev) => { + const newSections = prev.sections.map(section => { + const newRows = section.rows.map(row => { + const itemIndex = row.items.findIndex(i => i.id === itemId); + if (itemIndex === -1) return row; + + const remainingItems = row.items.filter((_, idx) => idx !== itemIndex); + const hasSpacer = remainingItems.some(i => i.isSpacer); + + if (!hasSpacer && remainingItems.length > 0) { + const remainingIds = remainingItems.map(i => i.id); + const newSpans = distributeSpans(remainingIds, maxColumns); + + const itemsWithNewSpans = row.items.map(item => { + if (item.id === itemId) return item; + const newSpan = newSpans.get(item.id); + if (newSpan !== undefined && newSpan !== item.span) { + return { ...item, span: newSpan }; + } + return item; + }); + + return { ...row, items: itemsWithNewSpans.filter((_, idx) => idx !== itemIndex) }; + } + + return { ...row, items: [...row.items.slice(0, itemIndex), ...row.items.slice(itemIndex + 1)] }; + }); + return { ...section, rows: newRows }; + }); + + return { ...prev, sections: newSections }; + }); + }, - [updateItem], + [updateLayout, maxColumns], ); const addSpacerToRow = useCallback( (sectionId: string, rowId: string, span: number = 1) => { - const spacerId = createSpacerId(); updateLayout((prev) => { - const maxOrder = Math.max(...Object.values(prev.items).map((item) => item.order), 0); + const sectionIndex = prev.sections.findIndex(s => s.id === sectionId); + if (sectionIndex === -1) return prev; - // Check if adding this spacer would overflow the row - // Get current items in the row - const rowItems = Object.entries(prev.items).filter(([, item]) => - item.rowKey === rowId && item.group === sectionId && !item.removed - ); + const rowIndex = prev.sections[sectionIndex].rows.findIndex(r => r.id === rowId); + if (rowIndex === -1) return prev; - const currentRowSpan = rowItems.reduce((sum, [, item]) => sum + item.span, 0); - const wouldOverflow = currentRowSpan + span > maxColumns; + const section = prev.sections[sectionIndex]; + const row = section.rows[rowIndex]; - // If would overflow, reduce the largest item by the overflow amount - let items = { ...prev.items }; - if (wouldOverflow) { - const overflow = currentRowSpan + span - maxColumns; - // Find the largest item to reduce - const largestItem = rowItems.reduce((largest, [, item]) => - item.span > largest.span ? item : largest - , rowItems[0][1]); - - if (largestItem.span > overflow) { - items = { - ...items, - [rowItems.find(([, item]) => item === largestItem)?.[0] as string]: { ...largestItem, span: largestItem.span - overflow } - }; - } + // If row has items, reduce the rightmost item's span to make room for the spacer + const newItems = [...row.items]; + if (newItems.length > 0) { + const lastItemIndex = newItems.length - 1; + const lastItem = newItems[lastItemIndex]; + const newLastSpan = Math.max(1, lastItem.span - span); + newItems[lastItemIndex] = { ...lastItem, span: newLastSpan }; } - return { - ...prev, - items: { - ...items, - [spacerId]: { - order: maxOrder + 1, - span, - hidden: false, - removed: false, - group: sectionId, - rowKey: rowId, - }, - }, + // Calculate max order in the row + const maxOrder = newItems.length > 0 + ? Math.max(...newItems.map(i => i.order)) + 1 + : 0; + + const newItem: SmartGridItem = { + definitionId: createSlug(), + id: createSlug(), + span, + order: maxOrder, + sectionId: sectionId, + rowId: rowId, + isSpacer: true }; + + newItems.push(newItem); + + const newSection = { ...section }; + const newRow = { ...row }; + newRow.items = newItems; + newSection.rows[rowIndex] = newRow; + + const newSections = [...prev.sections]; + newSections[sectionIndex] = newSection; + + return { ...prev, sections: newSections }; }); }, [updateLayout], @@ -606,48 +702,122 @@ export const SmartGridLayout: React.FC = ({ items, persist const addItemToRow = useCallback( (itemId: string, sectionId: string, rowId: string) => { + console.log('[ADD ITEM] Called with:', { itemId, sectionId, rowId }); + if (isSpacerId(itemId)) { + console.log('[ADD ITEM] Adding spacer'); addSpacerToRow(sectionId, rowId); return; } + updateLayout((prev) => { - const current = prev.items[itemId]; - if (!current) return prev; - const maxOrder = Math.max(...Object.values(prev.items).map((row) => row.order), 0); - return { - ...prev, - items: { - ...prev.items, - [itemId]: { - ...current, - removed: false, - hidden: false, - order: maxOrder + 1, - group: sectionId, - rowKey: rowId, - }, - }, + const itemDef = items.find(i => i.id === itemId); + if (!itemDef) { + console.log('[ADD ITEM] Item definition not found'); + return prev; + } + + console.log('[ADD ITEM] Item definition found:', itemDef); + + // Check if item already exists in the target row BEFORE making changes + const targetSection = prev.sections.find(s => s.id === sectionId); + if (targetSection) { + const targetRow = targetSection.rows.find(r => r.id === rowId); + if (targetRow) { + const alreadyExists = targetRow.items.some(item => item.definitionId === itemId); + if (alreadyExists) { + console.log('[ADD ITEM] Item already exists in target row, ABORTING'); + return prev; + } + } + } + + let workingSections = prev.sections; + + // Handle single=true items - replace existing instance + if (itemDef.single) { + // Find and remove existing instance + const existingItem = findLayoutItem(prev, itemId); + console.log('[ADD ITEM] Existing single item:', existingItem); + if (existingItem) { + console.log('[ADD ITEM] Removing existing item with slug:', existingItem.id); + workingSections = prev.sections.map(section => ({ + ...section, + rows: section.rows.map(row => ({ + ...row, + items: row.items.filter(i => i.id !== existingItem.id) + })) + })); + } + } + + const sectionIndex = workingSections.findIndex(s => s.id === sectionId); + if (sectionIndex === -1) { + console.log('[ADD ITEM] Section not found'); + return prev; + } + + const rowIndex = workingSections[sectionIndex].rows.findIndex(r => r.id === rowId); + if (rowIndex === -1) { + console.log('[ADD ITEM] Row not found'); + return prev; + } + + const section = workingSections[sectionIndex]; + const row = section.rows[rowIndex]; + + console.log('[ADD ITEM] Before adding, row has', row.items.length, 'items'); + + // Calculate max order in the row + const maxOrder = row.items.length > 0 + ? Math.max(...row.items.map(i => i.order)) + 1 + : 0; + + const newSlug = createSlug(); + const newItem: SmartGridItem = { + definitionId: itemId, + id: newSlug, + span: itemDef.defaultSpan ?? 4, + order: maxOrder, + sectionId: sectionId, + rowId: rowId, + isSpacer: itemDef.isSpacer }; + + console.log('[ADD ITEM] Creating new item with slug:', newSlug); + + const newSection = { ...section }; + const newRow = { ...row }; + newRow.items.push(newItem); + newSection.rows[rowIndex] = newRow; + + const newSections = [...workingSections]; + newSections[sectionIndex] = newSection; + + console.log('[ADD ITEM] After adding, row has', newRow.items.length, 'items'); + + return { ...prev, sections: newSections }; }); }, - [updateLayout, addSpacerToRow], + [updateLayout, addSpacerToRow, items], ); const ensureSection = useCallback( (sectionId: string, title?: string) => { updateLayout((prev) => { - if (prev.sections[sectionId]) return prev; - const maxOrder = Math.max(...Object.values(prev.sections).map((section) => section.order), -1); + if (prev.sections.find(s => s.id === sectionId)) return prev; + const maxOrder = Math.max(...prev.sections.map((section) => section.order), -1); return { ...prev, - sections: { + sections: [ ...prev.sections, - [sectionId]: { + { + id: sectionId, title: title ?? sectionId, - order: maxOrder + 1, - rowOrder: [], - }, - }, + rows: [], + order: maxOrder + 1 + } + ] }; }); }, @@ -657,18 +827,11 @@ export const SmartGridLayout: React.FC = ({ items, persist const renameSection = useCallback( (sectionId: string, title: string) => { updateLayout((prev) => { - const section = prev.sections[sectionId]; - if (!section) return prev; - return { - ...prev, - sections: { - ...prev.sections, - [sectionId]: { - ...section, - title, - }, - }, - }; + const sectionIndex = prev.sections.findIndex(s => s.id === sectionId); + if (sectionIndex === -1) return prev; + const newSections = [...prev.sections]; + newSections[sectionIndex] = { ...newSections[sectionIndex], title }; + return { ...prev, sections: newSections }; }); }, [updateLayout], @@ -678,19 +841,19 @@ export const SmartGridLayout: React.FC = ({ items, persist (sectionId: string): string => { const rowId = makeId(`row:${sectionId}`); updateLayout((prev) => { - const section = prev.sections[sectionId]; - if (!section) return prev; - if (section.rowOrder.includes(rowId)) return prev; - return { - ...prev, - sections: { - ...prev.sections, - [sectionId]: { - ...section, - rowOrder: [...section.rowOrder, rowId], - }, - }, + const sectionIndex = prev.sections.findIndex(s => s.id === sectionId); + if (sectionIndex === -1) return prev; + + const section = prev.sections[sectionIndex]; + if (section.rows.some((r) => r.id === rowId)) return prev; + + const newSections = [...prev.sections]; + newSections[sectionIndex] = { + ...section, + rows: [...section.rows, { id: rowId, items: [], order: section.rows.length }] }; + + return { ...prev, sections: newSections }; }); return rowId; }, @@ -700,44 +863,52 @@ export const SmartGridLayout: React.FC = ({ items, persist const removeRowItems = useCallback( (sectionId: string, rowId: string, itemIds: string[], isManagedRow: boolean) => { updateLayout((prev) => { - const nextItems = { ...prev.items }; - let changed = false; - - itemIds.forEach((itemId) => { - const itemState = nextItems[itemId]; - if (!itemState || itemState.removed) return; - nextItems[itemId] = { - ...itemState, - removed: true, - hidden: false, - }; - changed = true; - }); - - let nextSections = prev.sections; - if (isManagedRow) { - const section = prev.sections[sectionId]; - if (section && section.rowOrder.includes(rowId)) { - nextSections = { - ...prev.sections, - [sectionId]: { - ...section, - rowOrder: section.rowOrder.filter((id) => id !== rowId), - }, - }; - changed = true; + const newSections = prev.sections.map(section => { + if (section.id !== sectionId) return section; + + const newRow = section.rows.find(r => r.id === rowId); + if (!newRow) return section; + + const remainingItems = newRow.items.filter(i => !itemIds.includes(i.id)); + const hasSpacer = remainingItems.some(i => i.isSpacer); + + let updatedItems: SmartGridItem[]; + if (!hasSpacer && remainingItems.length > 0) { + const remainingIds = remainingItems.map(i => i.id); + const newSpans = distributeSpans(remainingIds, maxColumns); + + updatedItems = newRow.items + .filter(i => !itemIds.includes(i.id)) + .map(item => { + const newSpan = newSpans.get(item.id); + if (newSpan !== undefined && newSpan !== item.span) { + return { ...item, span: newSpan }; + } + return item; + }); + } else { + updatedItems = newRow.items.filter(i => !itemIds.includes(i.id)); } - } - - if (!changed) return prev; - return { - ...prev, - items: nextItems, - sections: nextSections, - }; + + const newRows = section.rows.map(r => { + if (r.id === rowId) { + return { ...r, items: updatedItems }; + } + return r; + }); + + if (isManagedRow) { + const filteredRows = newRows.filter(r => r.id !== rowId); + return { ...section, rows: filteredRows }; + } + + return { ...section, rows: newRows }; + }); + + return { ...prev, sections: newSections }; }); }, - [updateLayout], + [updateLayout, maxColumns], ); const createSection = useCallback((): string => { @@ -749,37 +920,49 @@ export const SmartGridLayout: React.FC = ({ items, persist const removeSection = useCallback( (sectionId: string) => { updateLayout((prev) => { - const nextSections = { ...prev.sections }; - delete nextSections[sectionId]; - // Clear group for any items that belonged to this section so they reappear in the add modal cleanly - const nextItems = { ...prev.items }; - Object.entries(nextItems).forEach(([itemId, item]) => { - if (item.group === sectionId) { - nextItems[itemId] = { ...item, group: undefined, rowKey: undefined }; - } + const sectionIndex = prev.sections.findIndex(s => s.id === sectionId); + if (sectionIndex === -1) return prev; + + const newSections = [...prev.sections]; + newSections.splice(sectionIndex, 1); + + // Reorder remaining sections + newSections.forEach((section, idx) => { + section.order = idx; }); - return { ...prev, sections: nextSections, items: nextItems }; + + return { ...prev, sections: newSections }; }); }, [updateLayout], ); const setItemPlacement = useCallback( - (itemId: string, sectionId: string, rowKey?: string) => { + (itemId: string, sectionId: string, rowId?: string) => { updateLayout((prev) => { - const current = prev.items[itemId]; - if (!current) return prev; - return { - ...prev, - items: { - ...prev.items, - [itemId]: { - ...current, - group: sectionId, - rowKey, - }, - }, - }; + let found = false; + const newSections = prev.sections.map(section => { + const newRows = section.rows.map(row => { + const itemIndex = row.items.findIndex(i => i.id === itemId); + if (itemIndex !== -1) { + found = true; + const item = row.items[itemIndex]; + return { + ...row, + items: [ + ...row.items.slice(0, itemIndex), + { ...item, sectionId, rowId: rowId ?? item.rowId }, + ...row.items.slice(itemIndex + 1) + ] + }; + } + return row; + }); + return { ...section, rows: newRows }; + }); + + if (!found) return prev; + return { ...prev, sections: newSections }; }); }, [updateLayout], @@ -789,61 +972,373 @@ export const SmartGridLayout: React.FC = ({ items, persist (sourceId: string, targetId: string, position: 'before' | 'after') => { if (sourceId === targetId) return; updateLayout((prev) => { - const activeIds = sortByOrder( - Object.entries(prev.items) - .filter(([, row]) => !row.removed) - .map(([id, row]) => ({ id, order: row.order })), - ).map((row) => row.id); - - if (!activeIds.includes(sourceId) || !activeIds.includes(targetId)) return prev; - - const nextOrder = activeIds.filter((id) => id !== sourceId); - const targetIndex = nextOrder.indexOf(targetId); - if (targetIndex < 0) return prev; - - const insertIndex = position === 'before' ? targetIndex : targetIndex + 1; - nextOrder.splice(insertIndex, 0, sourceId); - - const nextItems = { ...prev.items }; - nextOrder.forEach((id, index) => { - nextItems[id] = { - ...nextItems[id], - order: index, - }; + // Find source and target positions (read-only — do not mutate prev) + let sourceSectionIndex = -1; + let sourceRowIndex = -1; + let sourceItemIndex = -1; + let targetSectionIndex = -1; + let targetRowIndex = -1; + let targetItemIndex = -1; + + prev.sections.forEach((section, sIdx) => { + section.rows.forEach((row, rIdx) => { + const srcIdx = row.items.findIndex((i) => i.id === sourceId); + if (srcIdx !== -1) { + sourceSectionIndex = sIdx; + sourceRowIndex = rIdx; + sourceItemIndex = srcIdx; + } + const tgtIdx = row.items.findIndex((i) => i.id === targetId); + if (tgtIdx !== -1) { + targetSectionIndex = sIdx; + targetRowIndex = rIdx; + targetItemIndex = tgtIdx; + } + }); }); - return { - ...prev, - items: nextItems, - }; + if (sourceSectionIndex === -1 || targetSectionIndex === -1) return prev; + if ( + sourceSectionIndex === targetSectionIndex && + sourceRowIndex === targetRowIndex && + sourceItemIndex === targetItemIndex + ) { + return prev; + } + + const sourceItem = + prev.sections[sourceSectionIndex].rows[sourceRowIndex].items[sourceItemIndex]; + if (!sourceItem) return prev; + + const sameRow = + sourceSectionIndex === targetSectionIndex && sourceRowIndex === targetRowIndex; + + // Build next state immutably. Only touch the sections that change. + const newSections = prev.sections.map((section, sIdx) => { + if (sIdx !== sourceSectionIndex && sIdx !== targetSectionIndex) { + return section; + } + + const newRows = section.rows.map((row, rIdx) => { + // Same-row move: remove source and re-insert around target in one step + if (sameRow && sIdx === sourceSectionIndex && rIdx === sourceRowIndex) { + const filtered = row.items.filter((_, i) => i !== sourceItemIndex); + let insertAt = + sourceItemIndex < targetItemIndex ? targetItemIndex - 1 : targetItemIndex; + if (position === 'after') insertAt += 1; + insertAt = Math.max(0, Math.min(filtered.length, insertAt)); + const movedItem = { ...sourceItem, sectionId: section.id, rowId: row.id }; + const nextItems = [ + ...filtered.slice(0, insertAt), + movedItem, + ...filtered.slice(insertAt), + ].map((item, i) => ({ ...item, order: i })); + return { ...row, items: nextItems }; + } + + // Different row: remove source from its row + if (sIdx === sourceSectionIndex && rIdx === sourceRowIndex) { + const remainingItems = row.items.filter((_, i) => i !== sourceItemIndex); + const hasSpacer = remainingItems.some(i => i.isSpacer); + + let nextItems: SmartGridItem[]; + if (!hasSpacer && remainingItems.length > 0) { + const remainingIds = remainingItems.map(i => i.id); + const newSpans = distributeSpans(remainingIds, maxColumns); + + nextItems = row.items + .filter((_, i) => i !== sourceItemIndex) + .map(item => { + const newSpan = newSpans.get(item.id); + if (newSpan !== undefined && newSpan !== item.span) { + return { ...item, span: newSpan }; + } + return item; + }) + .map((item, i) => ({ ...item, order: i })); + } else { + nextItems = row.items + .filter((_, i) => i !== sourceItemIndex) + .map((item, i) => ({ ...item, order: i })); + } + + return { ...row, items: nextItems }; + } + + // Different row: insert source into the target row + if (sIdx === targetSectionIndex && rIdx === targetRowIndex) { + let insertAt = targetItemIndex; + if (position === 'after') insertAt += 1; + insertAt = Math.max(0, Math.min(row.items.length, insertAt)); + const movedItem = { ...sourceItem, sectionId: section.id, rowId: row.id }; + const nextItems = [ + ...row.items.slice(0, insertAt), + movedItem, + ...row.items.slice(insertAt), + ].map((item, i) => ({ ...item, order: i })); + return { ...row, items: nextItems }; + } + + return row; + }); + + return { ...section, rows: newRows }; + }); + + return { ...prev, sections: newSections }; }); }, [updateLayout], ); const moveItemToSectionEnd = useCallback( - (itemId: string, sectionId: string, rowKey: string) => { + (itemId: string, sectionId: string) => { updateLayout((prev) => { - const current = prev.items[itemId]; - if (!current) return prev; - const maxOrder = Math.max(...Object.values(prev.items).map((row) => row.order), 0); - return { - ...prev, - items: { - ...prev.items, - [itemId]: { - ...current, - order: maxOrder + 1, - group: sectionId, - rowKey, - }, - }, + // Locate source (read-only — do not mutate prev) + let sourceSectionIndex = -1; + let sourceRowIndex = -1; + let sourceItemIndex = -1; + + prev.sections.forEach((section, sIdx) => { + section.rows.forEach((row, rIdx) => { + const itemIndex = row.items.findIndex((i) => i.id === itemId); + if (itemIndex !== -1) { + sourceSectionIndex = sIdx; + sourceRowIndex = rIdx; + sourceItemIndex = itemIndex; + } + }); + }); + + if (sourceSectionIndex === -1) return prev; + + const targetSectionIndex = prev.sections.findIndex((s) => s.id === sectionId); + if (targetSectionIndex === -1) return prev; + + const sourceItem = + prev.sections[sourceSectionIndex].rows[sourceRowIndex].items[sourceItemIndex]; + if (!sourceItem) return prev; + + const targetSection = prev.sections[targetSectionIndex]; + // Find last non-empty row in target section (may be -1 if none) + let targetRowIndex = -1; + for (let i = targetSection.rows.length - 1; i >= 0; i--) { + if (targetSection.rows[i].items.length > 0) { + targetRowIndex = i; + break; + } + } + + const sameSection = sourceSectionIndex === targetSectionIndex; + const sameRow = sameSection && sourceRowIndex === targetRowIndex; + + // If the item is already the last one in the last non-empty row of the target section, + // nothing to do. + if ( + sameRow && + sourceItemIndex === targetSection.rows[targetRowIndex].items.length - 1 + ) { + return prev; + } + + // Prepare the moved item with updated parent refs. + // The rowId may change below if we create a new row. + const newRowIdForEmptyTarget = + targetRowIndex === -1 ? makeId(`row:${sectionId}`) : null; + + const movedItem: SmartGridItem = { + ...sourceItem, + sectionId, + rowId: newRowIdForEmptyTarget ?? targetSection.rows[targetRowIndex].id, }; + + // Build next state immutably. + const newSections = prev.sections.map((section, sIdx) => { + if (sIdx !== sourceSectionIndex && sIdx !== targetSectionIndex) { + return section; + } + + // Same-section case: one map pass removes source and appends target + if (sameSection && sIdx === sourceSectionIndex) { + // Remove source from its row first + const rowsWithSourceRemoved = section.rows.map((row, rIdx) => { + if (rIdx !== sourceRowIndex) return row; + const nextItems = row.items + .filter((_, i) => i !== sourceItemIndex) + .map((item, i) => ({ ...item, order: i })); + return { ...row, items: nextItems }; + }); + + // After removal, recompute target row index if we need to create a fresh row + if (newRowIdForEmptyTarget) { + return { + ...section, + rows: [ + ...rowsWithSourceRemoved, + { + id: newRowIdForEmptyTarget, + items: [{ ...movedItem, order: 0 }], + order: rowsWithSourceRemoved.length, + }, + ], + }; + } + + const targetRowId = targetSection.rows[targetRowIndex].id; + const rowsWithTarget = rowsWithSourceRemoved.map((row) => { + if (row.id !== targetRowId) return row; + const appended = [...row.items, { ...movedItem, order: row.items.length }]; + return { ...row, items: appended }; + }); + return { ...section, rows: rowsWithTarget }; + } + + // Different sections: source section just removes the item + if (sIdx === sourceSectionIndex) { + const newRows = section.rows.map((row, rIdx) => { + if (rIdx !== sourceRowIndex) return row; + const nextItems = row.items + .filter((_, i) => i !== sourceItemIndex) + .map((item, i) => ({ ...item, order: i })); + return { ...row, items: nextItems }; + }); + return { ...section, rows: newRows }; + } + + // Different sections: target section appends the item + if (sIdx === targetSectionIndex) { + if (newRowIdForEmptyTarget) { + return { + ...section, + rows: [ + ...section.rows, + { + id: newRowIdForEmptyTarget, + items: [{ ...movedItem, order: 0 }], + order: section.rows.length, + }, + ], + }; + } + const targetRowId = targetSection.rows[targetRowIndex].id; + const newRows = section.rows.map((row) => { + if (row.id !== targetRowId) return row; + const appended = [...row.items, { ...movedItem, order: row.items.length }]; + return { ...row, items: appended }; + }); + return { ...section, rows: newRows }; + } + + return section; + }); + + return { ...prev, sections: newSections }; }); }, [updateLayout], ); + // Atomically remove an item from its current row and place it in a newly-appended + // row at the end of the target section. Used by the "drop here to add item to new + // row" / "drop to create a new section" drop zones, where the user's intent is for + // the dragged item to appear inside the freshly-created row — not to fall back into + // an existing non-empty row the way moveItemToSectionEnd would. + const moveItemToNewRow = useCallback( + (itemId: string, sectionId: string) => { + const newRowId = makeId(`row:${sectionId}`); + updateLayout((prev) => { + const targetSectionIndex = prev.sections.findIndex((s) => s.id === sectionId); + if (targetSectionIndex === -1) return prev; + + let sourceSectionIndex = -1; + let sourceRowIndex = -1; + let sourceItemIndex = -1; + + prev.sections.forEach((section, sIdx) => { + section.rows.forEach((row, rIdx) => { + const idx = row.items.findIndex((i) => i.id === itemId); + if (idx !== -1) { + sourceSectionIndex = sIdx; + sourceRowIndex = rIdx; + sourceItemIndex = idx; + } + }); + }); + + if (sourceSectionIndex === -1) return prev; + + const sourceSection = prev.sections[sourceSectionIndex]; + const sourceRow = sourceSection.rows[sourceRowIndex]; + const sourceItem = sourceRow.items[sourceItemIndex]; + if (!sourceItem) return prev; + + // Resize items in source row + const remainingItems = sourceRow.items.filter((_, i) => i !== sourceItemIndex); + const hasSpacer = remainingItems.some(i => i.isSpacer); + + let nextItems: SmartGridItem[]; + if (!hasSpacer && remainingItems.length > 0) { + const remainingIds = remainingItems.map(i => i.id); + const newSpans = distributeSpans(remainingIds, maxColumns); + + nextItems = sourceRow.items + .filter((_, i) => i !== sourceItemIndex) + .map(item => { + const newSpan = newSpans.get(item.id); + if (newSpan !== undefined && newSpan !== item.span) { + return { ...item, span: newSpan }; + } + return item; + }) + .map((item, i) => ({ ...item, order: i })); + } else { + nextItems = sourceRow.items + .filter((_, i) => i !== sourceItemIndex) + .map((item, i) => ({ ...item, order: i })); + } + + const movedItem: SmartGridItem = { + ...sourceItem, + definitionId: sourceItem.definitionId, + id: createSlug(), + sectionId, + rowId: newRowId, + order: 0, + span: maxColumns, // New row item takes full width + }; + + const newSections = prev.sections.map((section, sIdx) => { + if (sIdx !== sourceSectionIndex && sIdx !== targetSectionIndex) return section; + + let newRows = section.rows; + + if (sIdx === sourceSectionIndex) { + newRows = newRows.map((row, rIdx) => { + if (rIdx !== sourceRowIndex) return row; + return { ...row, items: nextItems }; + }); + } + + if (sIdx === targetSectionIndex) { + newRows = [ + ...newRows, + { + id: newRowId, + items: [movedItem], + order: newRows.length, + }, + ]; + } + + return { ...section, rows: newRows }; + }); + + return { ...prev, sections: newSections }; + }); + }, + [updateLayout, maxColumns], + ); + const beginResize = useCallback( (event: React.MouseEvent, rowDomKey: string, leftId: string, rightId: string, leftSpan: number, rightSpan: number) => { if (!isEditMode) return; @@ -879,42 +1374,52 @@ export const SmartGridLayout: React.FC = ({ items, persist const nextRight = resizeState.pairTotal - nextLeft; setLayout((prev) => { - const left = prev.items[resizeState.leftId]; - const right = prev.items[resizeState.rightId]; - if (!left || !right) return prev; + let leftItem: SmartGridItem | undefined; + let rightItem: SmartGridItem | undefined; + + prev.sections.forEach(section => { + section.rows.forEach(row => { + if (!leftItem) { + leftItem = row.items.find(i => i.id === resizeState.leftId); + } + if (!rightItem) { + rightItem = row.items.find(i => i.id === resizeState.rightId); + } + }); + }); + + if (!leftItem || !rightItem) return prev; let shouldUpdate = false; if (resizeState.leftId === resizeState.rightId) { - if (left.span === nextLeft) return prev; + if (leftItem.span === nextLeft) return prev; shouldUpdate = true; } else { - if (left.span === nextLeft && right.span === nextRight) return prev; + if (leftItem.span === nextLeft && rightItem.span === nextRight) return prev; shouldUpdate = true; } resizeChangedRef.current = true; if (shouldUpdate) { - const newLayout = { - ...prev, - items: { - ...prev.items, - [resizeState.leftId]: { - ...left, - span: nextLeft, - }, - }, - }; - - if (resizeState.leftId !== resizeState.rightId) { - newLayout.items[resizeState.rightId] = { - ...right, - span: nextRight, - }; - } - - return newLayout; + const newSections = prev.sections.map(section => ({ + ...section, + rows: section.rows.map(row => ({ + ...row, + items: row.items.map(item => { + if (item.id === resizeState.leftId) { + return { ...item, span: nextLeft }; + } + if (resizeState.leftId !== resizeState.rightId && item.id === resizeState.rightId) { + return { ...item, span: nextRight }; + } + return item; + }) + })) + })); + + return { ...prev, sections: newSections }; } return prev; @@ -923,7 +1428,6 @@ export const SmartGridLayout: React.FC = ({ items, persist const onMouseUp = () => { setResizeState(null); - setResizePreview(null); if (resizeChangedRef.current) { onLayoutChange?.(layoutRef.current); } @@ -948,7 +1452,6 @@ export const SmartGridLayout: React.FC = ({ items, persist setSectionBottomDropTarget(null); setNewSectionDropTarget(false); setRowPreview(null); - setResizePreview(null); }, []); useEffect(() => { @@ -973,14 +1476,14 @@ export const SmartGridLayout: React.FC = ({ items, persist {isAddModalOpen && (
-
-

Add Items To Row

-
- {isEditMode && rowAddTarget && (() => { - const sectionRowsList = sectionRows.get(rowAddTarget.sectionId) ?? []; - const targetRow = sectionRowsList.find((r) => r.id === rowAddTarget.rowId); - const hasItems = targetRow && targetRow.cells.length > 0; - return hasItems ? ( +
+

Add Items To Row

+
+ {isEditMode && rowAddTarget && (() => { + const section = layout.sections.find(s => s.id === rowAddTarget.sectionId); + const targetRow = section?.rows.find(r => r.id === rowAddTarget.rowId); + const hasItems = targetRow && targetRow.items.length > 0; + return hasItems ? (
- {addableItems.length === 0 ? ( -

No items available to add.

- ) : ( -
- {addableItems.map((row) => { - const categoryLabel = row.item.group ?? layout.sections[row.state.group ?? '']?.title ?? row.state.group ?? DEFAULT_SECTION; - return ( -
- {/* Thumbnail */} -
- {row.item.screenshot ? ( - {row.item.title} - ) : ( -
- + {addableItems.length === 0 ? ( +

No items available to add.

+ ) : ( +
+ {addableItems.map((item) => { + return ( +
+ {/* Thumbnail */} +
+ {item.screenshot ? ( + {item.title} + ) : ( +
+ +
+ )} +
+
+
+ {item.title} + · {item.title} +
+ {item.description && ( +
{item.description}
+ )} +
+
- )} -
-
-
- {row.item.title} - · {categoryLabel} -
- {row.item.description && ( -
{row.item.description}
- )} -
- -
- ); - })} + ); + })}
)}
)} - {orderedSectionIds.map((sectionId) => { - const section = layout.sections[sectionId]; - const rows = sectionRows.get(sectionId) ?? []; + {orderedSectionIds.map((sectionId) => { + const section = layout.sections.find(s => s.id === sectionId); + if (!section) return null; + const rows = section.rows; - return ( -
+ return ( +
{isEditMode && editingSectionId === sectionId ? (
@@ -1137,207 +1640,263 @@ export const SmartGridLayout: React.FC = ({ items, persist
{rows.map((row, rowIndex) => { - const rowDomKey = `${sectionId}-${row.id}-${rowIndex}`; - const isManagedRow = section.rowOrder.includes(row.id); - const rowContentSpan = maxColumns; - const isRowPreviewActive = Boolean(isEditMode && draggingId && rowPreview && rowPreview.sectionId === sectionId && rowPreview.rowId === row.id); - - const isResizePreviewActive = Boolean(resizeState && resizePreview && resizePreview.sectionId === sectionId && resizePreview.rowId === row.id && row.cells.length === 1); - - const renderCells = (() => { - if (!isRowPreviewActive && !isResizePreviewActive) { - return row.cells.map((cell) => ({ kind: 'item' as const, id: cell.entry.id, span: cell.span, cell })); - } - - if (isRowPreviewActive && draggingId && rowPreview) { - const draggedState = layout.items[draggingId]; - const draggedSpan = clampSpan(draggedState?.span ?? 3, rowContentSpan); - - const withoutDragged = row.cells.filter((cell) => cell.entry.id !== draggingId); - const insertIndex = Math.max(0, Math.min(rowPreview.insertIndex, withoutDragged.length)); - - const withGhost = [ - ...withoutDragged.slice(0, insertIndex).map((cell) => ({ kind: 'item' as const, id: cell.entry.id, desiredSpan: clampSpan(cell.entry.state.span, rowContentSpan), cell })), - { kind: 'ghost' as const, id: '__ghost__', desiredSpan: draggedSpan }, - ...withoutDragged.slice(insertIndex).map((cell) => ({ kind: 'item' as const, id: cell.entry.id, desiredSpan: clampSpan(cell.entry.state.span, rowContentSpan), cell })), - ]; - - const normalized = normalizeRowSpans( - withGhost.map((entry) => entry.desiredSpan), - rowContentSpan, - ); - - return withGhost.map((entry) => ({ - kind: entry.kind, - id: entry.id, - span: normalized[withGhost.indexOf(entry)], - cell: entry.kind === 'item' ? entry.cell : undefined, - })); - } - - if (isResizePreviewActive && resizeState) { - const itemId = resizeState.leftId; - const currentSpan = resizeState.startLeftSpan; - const resizedSpan = Math.max(1, Math.min(resizeState.pairTotal - 1, currentSpan + Math.round((0 - resizeState.startX) / resizeState.colWidth))); - - const cell = row.cells[0]; - const emptySpaceSpan = maxColumns - resizedSpan; - - const withGhost = [ - { kind: 'item' as const, id: itemId, desiredSpan: resizedSpan, cell }, - { kind: 'ghost' as const, id: '__empty_space__', desiredSpan: emptySpaceSpan }, - ]; - - return withGhost.map((entry) => ({ - kind: entry.kind, - id: entry.id, - span: entry.desiredSpan, - cell: entry.kind === 'item' ? entry.cell : undefined, - })); - } - - return row.cells.map((cell) => ({ kind: 'item' as const, id: cell.entry.id, span: cell.span, cell })); - })(); - - return ( -
- {isEditMode && row.cells.length > 0 && ( -
- - removeRowItems( - sectionId, - row.id, - row.cells.map((cell) => cell.entry.id), - isManagedRow, - ) - } - aria-label="Remove row" - title="Remove row" - /> -
- )} -
{ - rowRefs.current[rowDomKey] = element; - }} - className="relative grid flex-1 gap-4 rounded-lg" - style={{ gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))` }} - onDragOver={(event) => { - if (!isEditMode) return; - const sourceId = getDraggedId(event); - if (!sourceId) return; - event.preventDefault(); - - if (row.isEmpty) { - if (emptyRowDropTarget !== rowDomKey) setEmptyRowDropTarget(rowDomKey); - if (!rowPreview || rowPreview.sectionId !== sectionId || rowPreview.rowId !== row.id || rowPreview.insertIndex !== 0) { - setRowPreview({ sectionId, rowId: row.id, insertIndex: 0 }); - } - return; - } - - if (emptyRowDropTarget === rowDomKey) setEmptyRowDropTarget(null); - - const rowEl = rowRefs.current[rowDomKey]; - const rowCellsWithoutDragged = row.cells.filter((cell) => cell.entry.id !== sourceId); - let nextIndex = rowCellsWithoutDragged.length; - - for (let i = 0; i < rowCellsWithoutDragged.length; i += 1) { - const candidate = rowCellsWithoutDragged[i]; - const candidateEl = rowEl?.querySelector(`[data-sg-item-id="${candidate.entry.id}"]`) as HTMLElement | null; - if (!candidateEl) continue; - const rect = candidateEl.getBoundingClientRect(); - if (event.clientX < rect.left + rect.width / 2) { - nextIndex = i; - break; - } - } - - if (!rowPreview || rowPreview.sectionId !== sectionId || rowPreview.rowId !== row.id || rowPreview.insertIndex !== nextIndex) { - setRowPreview({ sectionId, rowId: row.id, insertIndex: nextIndex }); - } - }} - onDragLeave={(event) => { - if (!isEditMode) return; - const nextTarget = event.relatedTarget as Node | null; - if (nextTarget && event.currentTarget.contains(nextTarget)) return; - if (emptyRowDropTarget === rowDomKey) setEmptyRowDropTarget(null); - if (rowPreview?.sectionId === sectionId && rowPreview.rowId === row.id) { - setRowPreview(null); - } - }} - onDrop={(event) => { + const rowDomKey = `${sectionId}-${row.id}-${rowIndex}`; + const isManagedRow = section.rows.some((r: SmartGridRow) => r.id === row.id); + const rowContentSpan = maxColumns; + const isRowPreviewActive = Boolean(isEditMode && draggingId && rowPreview && rowPreview.sectionId === sectionId && rowPreview.rowId === row.id); + + // Compute cells from row.items + const cells = row.items.map(item => { + // Spacers don't have a definition in byId, they're special + if (item.isSpacer) { + return { + kind: 'item' as const, + id: item.id, + entry: { + id: item.id, + item: { + id: item.id, + title: 'Spacer', + active: true, + single: false, + render: () => null, + isSpacer: true + } as SmartGridItemDefinition, + state: item, + order: item.order, + sectionId: section.id, + rowId: row.id, + isSpacer: true + }, + span: clampSpan(item.span, rowContentSpan) + }; + } + + const itemDef = byId.get(item.definitionId); + if (!itemDef) return null; + return { + kind: 'item' as const, + id: item.id, + entry: { + id: item.id, + item: itemDef, + state: item, + order: item.order, + sectionId: section.id, + rowId: row.id, + isSpacer: item.isSpacer + }, + span: clampSpan(item.span, rowContentSpan) + }; + }).filter((cell): cell is NonNullable => cell !== null); + + const isResizePreviewActive = false; // Resize preview not implemented for v3 yet + + const renderCells = (() => { + if (!isRowPreviewActive && !isResizePreviewActive) { + return cells.map((cell) => ({ kind: cell.kind, id: cell.id, span: cell.span, cell })); + } + + if (isRowPreviewActive && draggingId && rowPreview) { + const draggedItem = row.items.find(i => i.id === draggingId); + const draggedSpan = clampSpan(draggedItem?.span ?? 3, rowContentSpan); + + const withoutDragged = cells.filter(cell => cell.id !== draggingId); + const insertIndex = Math.max(0, Math.min(rowPreview.insertIndex, withoutDragged.length)); + + const withGhost = [ + ...withoutDragged.slice(0, insertIndex).map(cell => ({ kind: cell.kind, id: cell.id, desiredSpan: clampSpan(cell.entry.state.span, rowContentSpan), cell })), + { kind: 'ghost' as const, id: '__ghost__', desiredSpan: draggedSpan }, + ...withoutDragged.slice(insertIndex).map(cell => ({ kind: cell.kind, id: cell.id, desiredSpan: clampSpan(cell.entry.state.span, rowContentSpan), cell })), + ]; + + const normalized = normalizeRowSpans( + withGhost.map(entry => entry.desiredSpan), + rowContentSpan, + ); + + return withGhost.map((entry) => ({ + kind: entry.kind, + id: entry.id, + span: normalized[withGhost.indexOf(entry)], + cell: entry.kind === 'item' ? entry.cell : undefined, + })); + } + + if (isResizePreviewActive && resizeState && cells.length > 0) { + const itemId = resizeState.leftId; + const currentSpan = resizeState.startLeftSpan; + const resizedSpan = Math.max(1, Math.min(resizeState.pairTotal - 1, currentSpan + Math.round((0 - resizeState.startX) / resizeState.colWidth))); + + const cell = cells[0]; + const emptySpaceSpan = maxColumns - resizedSpan; + + const withGhost = [ + { kind: 'item' as const, id: itemId, desiredSpan: resizedSpan, cell }, + { kind: 'ghost' as const, id: '__empty_space__', desiredSpan: emptySpaceSpan }, + ]; + + return withGhost.map((entry) => ({ + kind: entry.kind, + id: entry.id, + span: entry.desiredSpan, + cell: entry.kind === 'item' ? entry.cell : undefined, + })); + } + + return cells.map(cell => ({ kind: cell.kind, id: cell.id, span: cell.span, cell })); + })(); + + return ( +
+ {isEditMode && cells.length > 0 && ( +
+ + removeRowItems( + sectionId, + row.id, + cells.map(cell => cell.id), + isManagedRow, + ) + } + aria-label="Remove row" + title="Remove row" + /> +
+ )} +
{ + rowRefs.current[rowDomKey] = element; + }} + className="relative grid flex-1 gap-4 rounded-lg" + style={{ gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))` }} + onDragOver={(event) => { if (!isEditMode) return; - event.preventDefault(); const sourceId = getDraggedId(event); if (!sourceId) return; + event.preventDefault(); - // Clear any active row preview - if (rowPreview) { - setRowPreview(null); - } + if (cells.length === 0) { + if (emptyRowDropTarget !== rowDomKey) setEmptyRowDropTarget(rowDomKey); + if (!rowPreview || rowPreview.sectionId !== sectionId || rowPreview.rowId !== row.id || rowPreview.insertIndex !== 0) { + setRowPreview({ sectionId, rowId: row.id, insertIndex: 0 }); + } + return; + } - if (!row.isEmpty && row.cells.length > 0) { - const targetRowId = row.id; - const previewIndex = rowPreview?.sectionId === sectionId && rowPreview.rowId === targetRowId ? rowPreview.insertIndex : row.cells.length; + if (emptyRowDropTarget === rowDomKey) setEmptyRowDropTarget(null); + + const rowEl = rowRefs.current[rowDomKey]; + const rowCellsWithoutDragged = cells.filter(cell => cell.id !== sourceId); + let nextIndex = rowCellsWithoutDragged.length; + + for (let i = 0; i < rowCellsWithoutDragged.length; i += 1) { + const candidate = rowCellsWithoutDragged[i]; + const candidateEl = rowEl?.querySelector(`[data-sg-item-id="${candidate.id}"]`) as HTMLElement | null; + if (!candidateEl) continue; + const rect = candidateEl.getBoundingClientRect(); + if (event.clientX < rect.left + rect.width / 2) { + nextIndex = i; + break; + } + } - const withoutDragged = row.cells.filter((cell) => cell.entry.id !== sourceId); - const safeIndex = Math.max(0, Math.min(previewIndex, withoutDragged.length)); + if (!rowPreview || rowPreview.sectionId !== sectionId || rowPreview.rowId !== row.id || rowPreview.insertIndex !== nextIndex) { + setRowPreview({ sectionId, rowId: row.id, insertIndex: nextIndex }); + } + }} + onDragLeave={(event) => { + if (!isEditMode) return; + const nextTarget = event.relatedTarget as Node | null; + if (nextTarget && event.currentTarget.contains(nextTarget)) return; + if (emptyRowDropTarget === rowDomKey) setEmptyRowDropTarget(null); + if (rowPreview?.sectionId === sectionId && rowPreview.rowId === row.id) { + setRowPreview(null); + } + }} + onDrop={(event) => { + if (!isEditMode) return; + event.preventDefault(); + const sourceId = getDraggedId(event); + if (!sourceId) return; + + // Clear any active row preview + if (rowPreview) { + setRowPreview(null); + } + + if (cells.length > 0) { + const targetRowId = row.id; + const previewIndex = rowPreview?.sectionId === sectionId && rowPreview.rowId === targetRowId ? rowPreview.insertIndex : cells.length; + + const withoutDragged = cells.filter(cell => cell.id !== sourceId); + const safeIndex = Math.max(0, Math.min(previewIndex, withoutDragged.length)); if (withoutDragged.length === 0) { - moveItemToSectionEnd(sourceId, sectionId, targetRowId); + moveItemToSectionEnd(sourceId, sectionId); resetDragState(); return; } if (safeIndex <= 0) { - reorderItems(sourceId, withoutDragged[0].entry.id, 'before'); + reorderItems(sourceId, withoutDragged[0].id, 'before'); } else { - reorderItems(sourceId, withoutDragged[safeIndex - 1].entry.id, 'after'); + reorderItems(sourceId, withoutDragged[safeIndex - 1].id, 'after'); } setItemPlacement(sourceId, sectionId, targetRowId); + // After reorder, re-balance the spans of ALL items now in the + // target row (including the one we just moved) so the row sums to + // maxColumns — matching the drag-preview behavior. Without this, + // the pre-existing items would get normalized without the moved + // item and grow to fill the row, pushing the moved item to wrap. updateLayout((prev) => { - const currentRow = prev.sections[sectionId]; - if (!currentRow || !currentRow.rowOrder.includes(targetRowId)) return prev; - - const rowItemIds = withoutDragged.map((cell) => cell.entry.id); - const newSpans = normalizeRowSpans( - rowItemIds.map((id) => { - const item = prev.items[id]; - return clampSpan(item?.span, maxColumns); - }), - maxColumns, - ); - - const nextItems = { ...prev.items }; - rowItemIds.forEach((id, index) => { - nextItems[id] = { - ...nextItems[id], - span: newSpans[index], - }; - }); - - return { - ...prev, - items: nextItems, - }; - }); + const targetSection = prev.sections.find((s) => s.id === sectionId); + const targetRow = targetSection?.rows.find((r) => r.id === targetRowId); + if (!targetRow || targetRow.items.length === 0) return prev; + + const currentSpans = targetRow.items.map((i) => + clampSpan(i.span, maxColumns), + ); + const newSpans = normalizeRowSpans(currentSpans, maxColumns); + + // Skip update if nothing would actually change + if (currentSpans.every((s, i) => s === newSpans[i])) return prev; + + const newSections = prev.sections.map((section) => { + if (section.id !== sectionId) return section; + return { + ...section, + rows: section.rows.map((r) => { + if (r.id !== targetRowId) return r; + return { + ...r, + items: r.items.map((item, idx) => ({ + ...item, + span: newSpans[idx], + })), + }; + }), + }; + }); + + return { ...prev, sections: newSections }; + }); resetDragState(); return; } - moveItemToSectionEnd(sourceId, sectionId, row.id); + moveItemToSectionEnd(sourceId, sectionId); resetDragState(); }} > - {row.isEmpty && ( + {cells.length === 0 && (
= ({ items, persist ); } - const cell = renderCell.cell; - if (!cell) return null; - - const nextItemIndex = renderCells.slice(cellIndex + 1).findIndex((c) => c.kind === 'item'); - const neighbor = nextItemIndex >= 0 ? renderCells[cellIndex + 1 + nextItemIndex] : undefined; - const neighborCell = neighbor?.kind === 'item' ? neighbor.cell : undefined; - - const def = byId.get(cell.entry.id); - if (!def && !cell.entry.isSpacer) return null; - - if (cell.entry.isSpacer) { + const cell = renderCell.cell; + if (!cell) return null; + + const nextItemIndex = renderCells.slice(cellIndex + 1).findIndex((c) => c.kind === 'item'); + const neighbor = nextItemIndex >= 0 ? renderCells[cellIndex + 1 + nextItemIndex] : undefined; + const neighborCell = neighbor?.kind === 'item' ? neighbor.cell : undefined; + + const def = byId.get(cell.entry.item.id); + if (!def && !cell.entry.isSpacer) return null; + + if (cell.entry.isSpacer) { + return ( +
{ + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('text/plain', cell.entry.id); + setDraggingId(cell.entry.id); + setDragOver(null); + }} + onDragEnd={resetDragState} + onDragOver={(event) => { + if (!isEditMode || !draggingId) return; + event.preventDefault(); + event.stopPropagation(); + if (draggingId === cell.entry.id) return; + const rect = event.currentTarget.getBoundingClientRect(); + const position = event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'; + if (!dragOver || dragOver.id !== cell.entry.id || dragOver.position !== position) { + setDragOver({ id: cell.entry.id, position }); + } + const previewIndex = + position === 'before' + ? cells.filter(entry => entry.id !== draggingId).findIndex(entry => entry.id === cell.entry.id) + : cells.filter(entry => entry.id !== draggingId).findIndex(entry => entry.id === cell.entry.id) + 1; + if (previewIndex >= 0 && (!rowPreview || rowPreview.sectionId !== sectionId || rowPreview.rowId !== row.id || rowPreview.insertIndex !== previewIndex)) { + setRowPreview({ sectionId, rowId: row.id, insertIndex: previewIndex }); + } + }} + onDragLeave={(event) => { + if (!isEditMode || !draggingId) return; + const nextTarget = event.relatedTarget as Node | null; + if (nextTarget && event.currentTarget.contains(nextTarget)) return; + if (dragOver?.id === cell.entry.id) setDragOver(null); + }} + onDrop={(event) => { + if (!isEditMode) return; + event.preventDefault(); + event.stopPropagation(); + const sourceId = getDraggedId(event); + if (!sourceId || sourceId === cell.entry.id) return; + const rect = event.currentTarget.getBoundingClientRect(); + const position = event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'; + reorderItems(sourceId, cell.entry.id, position); + resetDragState(); + }} + > + {isEditMode && ( + <> +
+ removeItem(cell.entry.id)} + aria-label="Remove spacer" + title="Remove spacer" + /> +
+ {neighborCell && neighbor && ( + + )} + + )} +
+ ); + } return ( -
{ - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', cell.entry.id); - setDraggingId(cell.entry.id); - setDragOver(null); - }} - onDragEnd={resetDragState} - onDragOver={(event) => { - if (!isEditMode || !draggingId) return; - event.preventDefault(); - event.stopPropagation(); - if (draggingId === cell.entry.id) return; - const rect = event.currentTarget.getBoundingClientRect(); - const position = event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'; - if (!dragOver || dragOver.id !== cell.entry.id || dragOver.position !== position) { - setDragOver({ id: cell.entry.id, position }); - } - const previewIndex = - position === 'before' - ? row.cells.filter((entry) => entry.entry.id !== draggingId).findIndex((entry) => entry.entry.id === cell.entry.id) - : row.cells.filter((entry) => entry.entry.id !== draggingId).findIndex((entry) => entry.entry.id === cell.entry.id) + 1; - if (previewIndex >= 0 && (!rowPreview || rowPreview.sectionId !== sectionId || rowPreview.rowId !== row.id || rowPreview.insertIndex !== previewIndex)) { - setRowPreview({ sectionId, rowId: row.id, insertIndex: previewIndex }); - } - }} - onDragLeave={(event) => { - if (!isEditMode || !draggingId) return; - const nextTarget = event.relatedTarget as Node | null; - if (nextTarget && event.currentTarget.contains(nextTarget)) return; - if (dragOver?.id === cell.entry.id) setDragOver(null); - }} - onDrop={(event) => { - if (!isEditMode) return; - event.preventDefault(); - event.stopPropagation(); - const sourceId = getDraggedId(event); - if (!sourceId || sourceId === cell.entry.id) return; - const rect = event.currentTarget.getBoundingClientRect(); - const position = event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'; - reorderItems(sourceId, cell.entry.id, position); - setItemPlacement(sourceId, sectionId, row.id); - resetDragState(); - }} - > - {isEditMode && ( -
- removeItem(cell.entry.id)} - aria-label="Remove spacer" - title="Remove spacer" - /> -
- )} - {isEditMode && cell.entry.item.isSpacer && neighborCell && neighbor && ( - - )} -
- ); - } - return ( -
= ({ items, persist setDragOver({ id: cell.entry.id, position }); } - const previewIndex = - position === 'before' - ? row.cells.filter((entry) => entry.entry.id !== draggingId).findIndex((entry) => entry.entry.id === cell.entry.id) - : row.cells.filter((entry) => entry.entry.id !== draggingId).findIndex((entry) => entry.entry.id === cell.entry.id) + 1; + const previewIndex = + position === 'before' + ? cells.filter(entry => entry.id !== draggingId).findIndex(entry => entry.id === cell.entry.id) + : cells.filter(entry => entry.id !== draggingId).findIndex(entry => entry.id === cell.entry.id) + 1; if (previewIndex >= 0 && (!rowPreview || rowPreview.sectionId !== sectionId || rowPreview.rowId !== row.id || rowPreview.insertIndex !== previewIndex)) { setRowPreview({ sectionId, rowId: row.id, insertIndex: previewIndex }); @@ -1500,13 +2060,12 @@ export const SmartGridLayout: React.FC = ({ items, persist const position = event.clientX < rect.left + rect.width / 2 ? 'before' : 'after'; reorderItems(sourceId, cell.entry.id, position); - setItemPlacement(sourceId, sectionId, row.id); resetDragState(); }} > - {def!.render()} + {def!.render()} - {isEditMode && ( + {isEditMode && (
= ({ items, persist if (nextTarget && event.currentTarget.contains(nextTarget)) return; if (sectionBottomDropTarget === sectionId) setSectionBottomDropTarget(null); }} - onDrop={(event) => { - event.preventDefault(); - const sourceId = getDraggedId(event); - if (!sourceId) return; - const rowId = createRow(sectionId); - moveItemToSectionEnd(sourceId, sectionId, rowId); - resetDragState(); - }} + onDrop={(event) => { + event.preventDefault(); + const sourceId = getDraggedId(event); + if (!sourceId) return; + moveItemToNewRow(sourceId, sectionId); + resetDragState(); + }} >
); From 894ede699d396693ab6f15c251ed2c1fa3726512 Mon Sep 17 00:00:00 2001 From: cjlapao Date: Thu, 16 Apr 2026 11:45:06 +0100 Subject: [PATCH 04/13] wip --- packages/ui-kit/src/icons/components/Aws.tsx | 4 ++-- packages/ui-kit/src/icons/components/Cache.tsx | 4 ++-- packages/ui-kit/src/icons/components/Calendar.tsx | 2 +- .../ui-kit/src/icons/components/CatalogManifest.tsx | 2 +- .../ui-kit/src/icons/components/CatalogVersion.tsx | 2 +- packages/ui-kit/src/icons/components/Claim.tsx | 6 +++--- packages/ui-kit/src/icons/components/Claims.tsx | 6 +++--- packages/ui-kit/src/icons/components/CleanBrush.tsx | 2 +- packages/ui-kit/src/icons/components/Database.tsx | 2 +- packages/ui-kit/src/icons/components/File.tsx | 2 +- packages/ui-kit/src/icons/components/Folder.tsx | 2 +- packages/ui-kit/src/icons/components/HealthCheck.tsx | 2 +- packages/ui-kit/src/icons/components/Host.tsx | 2 +- packages/ui-kit/src/icons/components/KeyManagement.tsx | 4 ++-- packages/ui-kit/src/icons/components/Library.tsx | 4 ++-- packages/ui-kit/src/icons/components/Orchestrator.tsx | 2 +- packages/ui-kit/src/icons/components/PodmanDesktop.tsx | 4 ++-- packages/ui-kit/src/icons/components/Pull.tsx | 2 +- packages/ui-kit/src/icons/components/Push.tsx | 2 +- packages/ui-kit/src/icons/components/RemoteHost.tsx | 2 +- packages/ui-kit/src/icons/components/Revoke.tsx | 2 +- packages/ui-kit/src/icons/components/Role.tsx | 6 +++--- packages/ui-kit/src/icons/components/Roles.tsx | 6 +++--- packages/ui-kit/src/icons/components/Taint.tsx | 2 +- packages/ui-kit/src/icons/components/Unlock.tsx | 2 +- .../ui-kit/src/icons/components/VirtualMachine.tsx | 2 +- src/components/Notification/NotificationWrapper.tsx | 10 ---------- 27 files changed, 39 insertions(+), 49 deletions(-) diff --git a/packages/ui-kit/src/icons/components/Aws.tsx b/packages/ui-kit/src/icons/components/Aws.tsx index 5accffe..c2615c9 100644 --- a/packages/ui-kit/src/icons/components/Aws.tsx +++ b/packages/ui-kit/src/icons/components/Aws.tsx @@ -5,10 +5,10 @@ export const Aws = forwardRef>((props, re - - diff --git a/packages/ui-kit/src/icons/components/Cache.tsx b/packages/ui-kit/src/icons/components/Cache.tsx index b29b3b1..6d652c9 100644 --- a/packages/ui-kit/src/icons/components/Cache.tsx +++ b/packages/ui-kit/src/icons/components/Cache.tsx @@ -3,10 +3,10 @@ import { forwardRef, type SVGProps } from 'react'; export const Cache = forwardRef>((props, ref) => ( catalog_cache - + + id="Combined-Shape" fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Calendar.tsx b/packages/ui-kit/src/icons/components/Calendar.tsx index c091c2a..d0f210f 100644 --- a/packages/ui-kit/src/icons/components/Calendar.tsx +++ b/packages/ui-kit/src/icons/components/Calendar.tsx @@ -5,7 +5,7 @@ export const Calendar = forwardRef>((prop diff --git a/packages/ui-kit/src/icons/components/CatalogManifest.tsx b/packages/ui-kit/src/icons/components/CatalogManifest.tsx index cf1c05b..ebe836d 100644 --- a/packages/ui-kit/src/icons/components/CatalogManifest.tsx +++ b/packages/ui-kit/src/icons/components/CatalogManifest.tsx @@ -4,7 +4,7 @@ export const CatalogManifest = forwardRef + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/CatalogVersion.tsx b/packages/ui-kit/src/icons/components/CatalogVersion.tsx index 095f959..6001b13 100644 --- a/packages/ui-kit/src/icons/components/CatalogVersion.tsx +++ b/packages/ui-kit/src/icons/components/CatalogVersion.tsx @@ -4,7 +4,7 @@ export const CatalogVersion = forwardRef> + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Claim.tsx b/packages/ui-kit/src/icons/components/Claim.tsx index d375a4b..a9c535c 100644 --- a/packages/ui-kit/src/icons/components/Claim.tsx +++ b/packages/ui-kit/src/icons/components/Claim.tsx @@ -3,11 +3,11 @@ import { forwardRef, type SVGProps } from 'react'; export const Claim = forwardRef>((props, ref) => ( remote_hosts_management_claim - + + id="Shape" fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Claims.tsx b/packages/ui-kit/src/icons/components/Claims.tsx index 8e12396..5238832 100644 --- a/packages/ui-kit/src/icons/components/Claims.tsx +++ b/packages/ui-kit/src/icons/components/Claims.tsx @@ -3,11 +3,11 @@ import { forwardRef, type SVGProps } from 'react'; export const Claims = forwardRef>((props, ref) => ( remote_hosts_management_claims - + + id="Shape" fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/CleanBrush.tsx b/packages/ui-kit/src/icons/components/CleanBrush.tsx index 7b9789c..0640070 100644 --- a/packages/ui-kit/src/icons/components/CleanBrush.tsx +++ b/packages/ui-kit/src/icons/components/CleanBrush.tsx @@ -4,7 +4,7 @@ export const CleanBrush = forwardRef>((pr + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Database.tsx b/packages/ui-kit/src/icons/components/Database.tsx index 33f7a37..823ae0b 100644 --- a/packages/ui-kit/src/icons/components/Database.tsx +++ b/packages/ui-kit/src/icons/components/Database.tsx @@ -3,7 +3,7 @@ import { forwardRef, type SVGProps } from 'react'; export const Database = forwardRef>((props, ref) => ( >((props, r + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Folder.tsx b/packages/ui-kit/src/icons/components/Folder.tsx index 3457f18..d997894 100644 --- a/packages/ui-kit/src/icons/components/Folder.tsx +++ b/packages/ui-kit/src/icons/components/Folder.tsx @@ -5,7 +5,7 @@ export const Folder = forwardRef>((props, diff --git a/packages/ui-kit/src/icons/components/HealthCheck.tsx b/packages/ui-kit/src/icons/components/HealthCheck.tsx index e3901cc..ac8b3af 100644 --- a/packages/ui-kit/src/icons/components/HealthCheck.tsx +++ b/packages/ui-kit/src/icons/components/HealthCheck.tsx @@ -5,7 +5,7 @@ export const HealthCheck = forwardRef>((p >((props, ref) => ( catalog_provider - + diff --git a/packages/ui-kit/src/icons/components/KeyManagement.tsx b/packages/ui-kit/src/icons/components/KeyManagement.tsx index 2c3671f..da86ecb 100644 --- a/packages/ui-kit/src/icons/components/KeyManagement.tsx +++ b/packages/ui-kit/src/icons/components/KeyManagement.tsx @@ -3,10 +3,10 @@ import { forwardRef, type SVGProps } from 'react'; export const KeyManagement = forwardRef>((props, ref) => ( remote_hosts_management_key - + diff --git a/packages/ui-kit/src/icons/components/Library.tsx b/packages/ui-kit/src/icons/components/Library.tsx index 0734601..4af3bdd 100644 --- a/packages/ui-kit/src/icons/components/Library.tsx +++ b/packages/ui-kit/src/icons/components/Library.tsx @@ -3,10 +3,10 @@ import { forwardRef, type SVGProps } from 'react'; export const Library = forwardRef>((props, ref) => ( Library - + + id="Combined-Shape" fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Orchestrator.tsx b/packages/ui-kit/src/icons/components/Orchestrator.tsx index 43466e8..debb8fe 100644 --- a/packages/ui-kit/src/icons/components/Orchestrator.tsx +++ b/packages/ui-kit/src/icons/components/Orchestrator.tsx @@ -5,7 +5,7 @@ export const Orchestrator = forwardRef>(( )); diff --git a/packages/ui-kit/src/icons/components/PodmanDesktop.tsx b/packages/ui-kit/src/icons/components/PodmanDesktop.tsx index f4b979f..40af869 100644 --- a/packages/ui-kit/src/icons/components/PodmanDesktop.tsx +++ b/packages/ui-kit/src/icons/components/PodmanDesktop.tsx @@ -6,7 +6,7 @@ export const PodmanDesktop = forwardRef>( d="m327.87 17.267c-3.379-1.689-7.355-1.689-10.734 0l-290.5 145.25c-4.065 2.033-6.633 6.188-6.633 10.733v287.67c0 4.545 2.568 8.7 6.633 10.733l290.5 145.25c3.379 1.689 7.355 1.689 10.734 0l290.5-145.25c4.065-2.033 6.633-6.188 6.633-10.733v-287.67c0-4.545-2.568-8.7-6.633-10.733z" clip-rule="evenodd" fill="url(#paint1_linear_1_2)" - fill-rule="evenodd" + fillRule="evenodd" style={{ fill: 'url(#paint1_linear_1_2)' }} /> @@ -35,7 +35,7 @@ export const PodmanDesktop = forwardRef>( d="m297.09 421.02c0-7.732 6.268-14 14-14h400.58c7.732 0 14 6.268 14 14v186.29c0 7.732-6.268 14-14 14h-400.58c-7.732 0-14-6.268-14-14zm35.714 26.715c0-2.762 2.239-5 5-5h25.715c2.762 0 5 2.238 5 5v97.145c0 2.761-2.238 5-5 5h-25.715c-2.761 0-5-2.239-5-5zm76.43-5c-2.761 0-5 2.238-5 5v97.145c0 2.761 2.239 5 5 5h25.715c2.761 0 5-2.239 5-5v-97.145c0-2.762-2.239-5-5-5zm66.43 5c0-2.762 2.239-5 5-5h25.715c2.761 0 5 2.238 5 5v97.145c0 2.761-2.239 5-5 5h-25.715c-2.761 0-5-2.239-5-5zm76.601-5.161c-2.762 0-5 2.238-5 5v97.145c0 2.761 2.238 5 5 5h25.715c2.761 0 5-2.239 5-5v-97.145c0-2.762-2.239-5-5-5z" clip-rule="evenodd" fill="url(#paint7_linear_1_2)" - fill-rule="evenodd" + fillRule="evenodd" style={{ fill: 'url(#paint7_linear_1_2)' }} /> diff --git a/packages/ui-kit/src/icons/components/Pull.tsx b/packages/ui-kit/src/icons/components/Pull.tsx index a7c9c57..25c089e 100644 --- a/packages/ui-kit/src/icons/components/Pull.tsx +++ b/packages/ui-kit/src/icons/components/Pull.tsx @@ -4,7 +4,7 @@ export const Pull = forwardRef>((props, r + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Push.tsx b/packages/ui-kit/src/icons/components/Push.tsx index 6c08ed3..fec9205 100644 --- a/packages/ui-kit/src/icons/components/Push.tsx +++ b/packages/ui-kit/src/icons/components/Push.tsx @@ -4,7 +4,7 @@ export const Push = forwardRef>((props, r + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/RemoteHost.tsx b/packages/ui-kit/src/icons/components/RemoteHost.tsx index fd09a04..f858895 100644 --- a/packages/ui-kit/src/icons/components/RemoteHost.tsx +++ b/packages/ui-kit/src/icons/components/RemoteHost.tsx @@ -3,7 +3,7 @@ import { forwardRef, type SVGProps } from 'react'; export const RemoteHost = forwardRef>((props, ref) => ( >((props, + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Role.tsx b/packages/ui-kit/src/icons/components/Role.tsx index d2a1cba..6ff5330 100644 --- a/packages/ui-kit/src/icons/components/Role.tsx +++ b/packages/ui-kit/src/icons/components/Role.tsx @@ -3,11 +3,11 @@ import { forwardRef, type SVGProps } from 'react'; export const Role = forwardRef>((props, ref) => ( remote_hosts_management_role - + + id="Fill-1" fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Roles.tsx b/packages/ui-kit/src/icons/components/Roles.tsx index a4a1719..ffdca11 100644 --- a/packages/ui-kit/src/icons/components/Roles.tsx +++ b/packages/ui-kit/src/icons/components/Roles.tsx @@ -3,11 +3,11 @@ import { forwardRef, type SVGProps } from 'react'; export const Roles = forwardRef>((props, ref) => ( remote_hosts_management_roles - + + id="Shape" fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Taint.tsx b/packages/ui-kit/src/icons/components/Taint.tsx index 17f77e0..4ed5d86 100644 --- a/packages/ui-kit/src/icons/components/Taint.tsx +++ b/packages/ui-kit/src/icons/components/Taint.tsx @@ -4,7 +4,7 @@ export const Taint = forwardRef>((props, + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/Unlock.tsx b/packages/ui-kit/src/icons/components/Unlock.tsx index 92df77c..d2beb56 100644 --- a/packages/ui-kit/src/icons/components/Unlock.tsx +++ b/packages/ui-kit/src/icons/components/Unlock.tsx @@ -4,7 +4,7 @@ export const Unlock = forwardRef>((props, + fill="currentColor" fillRule="nonzero"> )); diff --git a/packages/ui-kit/src/icons/components/VirtualMachine.tsx b/packages/ui-kit/src/icons/components/VirtualMachine.tsx index 927d3bb..4bf870c 100644 --- a/packages/ui-kit/src/icons/components/VirtualMachine.tsx +++ b/packages/ui-kit/src/icons/components/VirtualMachine.tsx @@ -3,7 +3,7 @@ import { forwardRef, type SVGProps } from 'react'; export const VirtualMachine = forwardRef>((props, ref) => ( virtual_machine - + diff --git a/src/components/Notification/NotificationWrapper.tsx b/src/components/Notification/NotificationWrapper.tsx index 9bde6e0..fa087cf 100644 --- a/src/components/Notification/NotificationWrapper.tsx +++ b/src/components/Notification/NotificationWrapper.tsx @@ -76,16 +76,6 @@ export const NotificationWrapper: React.FC = ({ } }, [layoutEnabled, layoutOpen, isOpen, togglePanelContext, channelFilter]); - useEffect(() => { - console.log('NotificationWrapper', 'resolvedIsOpen changed', { - channel: channelFilter, - resolvedIsOpen, - layoutKey: layoutEnabled ? layoutKey : null, - contextOpen: isOpen, - layoutOpen, - }); - }, [resolvedIsOpen, channelFilter, layoutEnabled, layoutKey, isOpen, layoutOpen]); - const contextValue = useMemo( () => ({ channel: channelFilter, From b9ede61d1e19158b973b812c896bd972db29abb1 Mon Sep 17 00:00:00 2001 From: cjlapao Date: Thu, 16 Apr 2026 14:50:46 +0100 Subject: [PATCH 05/13] wip --- .vscode/launch.json | 66 +- .vscode/tasks.json | 68 +- CHANGELOG.md | 3 +- README.md | 55 +- docs/mvp.md | 103 +- helm/prl-devops-ui/templates/service.yaml | 12 +- helm/prl-devops-ui/values.yaml | 2 +- index.html | 4 +- packages/ui-kit/package.json | 2 +- packages/ui-kit/scripts/generate-icons.ts | 267 +- .../ui-kit/src/components/AccessMatrix.tsx | 108 +- packages/ui-kit/src/components/Accordion.tsx | 93 +- packages/ui-kit/src/components/Alert.tsx | 124 +- .../ui-kit/src/components/ApiErrorState.tsx | 6 +- packages/ui-kit/src/components/AppDivider.tsx | 4 +- packages/ui-kit/src/components/Badge.tsx | 21 +- packages/ui-kit/src/components/BadgeIcon.tsx | 34 +- packages/ui-kit/src/components/Button.tsx | 83 +- .../ui-kit/src/components/ButtonSelector.tsx | 282 +- packages/ui-kit/src/components/Checkbox.tsx | 216 +- .../src/components/CollapsibleHelpText.tsx | 67 +- .../src/components/CollapsiblePanel.tsx | 60 +- packages/ui-kit/src/components/Combobox.tsx | 331 ++- .../ConnectionFlow/ConnectionFlow.tsx | 1236 +++++---- .../ConnectionFlow/ConnectionFlowColumn.tsx | 508 ++-- .../ConnectionFlowConnector.tsx | 1179 +++++---- .../ConnectionFlowParallelGroup.tsx | 251 +- .../src/components/ConnectionFlow/index.ts | 15 +- .../src/components/ConnectionFlow/types.ts | 444 ++-- packages/ui-kit/src/components/CustomIcon.tsx | 8 +- .../ui-kit/src/components/DetailItemCard.tsx | 37 +- .../ui-kit/src/components/DropdownButton.tsx | 18 +- .../ui-kit/src/components/DropdownMenu.tsx | 106 +- .../src/components/DynamicFormField.tsx | 77 +- packages/ui-kit/src/components/DynamicImg.tsx | 20 +- packages/ui-kit/src/components/EmptyState.tsx | 82 +- packages/ui-kit/src/components/FormField.tsx | 98 +- packages/ui-kit/src/components/FormLayout.tsx | 21 +- .../ui-kit/src/components/FormSection.tsx | 51 +- .../ui-kit/src/components/HeaderGroup.tsx | 5 +- packages/ui-kit/src/components/HelpButton.tsx | 428 +++- packages/ui-kit/src/components/Hero.tsx | 196 +- packages/ui-kit/src/components/IconButton.tsx | 52 +- .../src/components/InfiniteScrollPanel.tsx | 57 +- packages/ui-kit/src/components/InfoRow.tsx | 614 +++-- .../ui-kit/src/components/InlinePanel.tsx | 237 +- packages/ui-kit/src/components/Input.tsx | 363 +-- packages/ui-kit/src/components/InputGroup.tsx | 59 +- .../src/components/KeyValueArrayField.tsx | 68 +- packages/ui-kit/src/components/Loader.tsx | 33 +- .../ui-kit/src/components/MarkdownEditor.tsx | 114 +- packages/ui-kit/src/components/MetricBar.tsx | 63 +- packages/ui-kit/src/components/Modal.tsx | 247 +- .../src/components/MultiProgressBar.tsx | 401 +-- .../src/components/MultiSelectPills.tsx | 231 +- .../ui-kit/src/components/MultiToggle.tsx | 562 ++-- .../src/components/NotificationModal.tsx | 41 +- packages/ui-kit/src/components/PagedPanel.tsx | 37 +- packages/ui-kit/src/components/Panel.tsx | 434 +++- .../ui-kit/src/components/PasswordInput.tsx | 54 +- packages/ui-kit/src/components/Picker.tsx | 867 +++++-- packages/ui-kit/src/components/Pill.tsx | 6 +- packages/ui-kit/src/components/Progress.tsx | 65 +- packages/ui-kit/src/components/SearchBar.tsx | 357 +-- packages/ui-kit/src/components/Section.tsx | 289 ++- .../ui-kit/src/components/SectionCard.tsx | 153 +- packages/ui-kit/src/components/Select.tsx | 254 +- packages/ui-kit/src/components/SideMenu.tsx | 549 ++-- .../ui-kit/src/components/SideMenuLayout.tsx | 85 +- packages/ui-kit/src/components/SidePanel.tsx | 336 +-- .../ui-kit/src/components/SmartGridLayout.tsx | 2271 +++++++++++------ packages/ui-kit/src/components/SmartInput.tsx | 88 +- packages/ui-kit/src/components/SmartValue.tsx | 40 +- packages/ui-kit/src/components/Spinner.tsx | 63 +- packages/ui-kit/src/components/SplitView.tsx | 869 +++++-- .../src/components/StartupStageStepper.css | 7 +- .../src/components/StartupStageStepper.tsx | 256 +- .../ui-kit/src/components/StatChartTile.tsx | 107 +- .../ui-kit/src/components/StatCountTile.tsx | 82 +- .../ui-kit/src/components/StatGoalTile.tsx | 50 +- .../ui-kit/src/components/StatGraphTile.tsx | 257 +- packages/ui-kit/src/components/StatTile.tsx | 121 +- .../ui-kit/src/components/StatusSpinner.tsx | 55 +- packages/ui-kit/src/components/Stepper.tsx | 370 ++- packages/ui-kit/src/components/Table.tsx | 1883 +++++++++----- packages/ui-kit/src/components/Tabs.tsx | 136 +- packages/ui-kit/src/components/TagPanel.tsx | 70 +- packages/ui-kit/src/components/TagPicker.tsx | 915 ++++--- packages/ui-kit/src/components/Textarea.tsx | 103 +- .../TimelinePanel/TimelinePanel.tsx | 396 ++- .../src/components/TimelinePanel/index.ts | 4 +- .../src/components/TimelinePanel/types.ts | 18 +- packages/ui-kit/src/components/Toggle.tsx | 68 +- packages/ui-kit/src/components/Tooltip.tsx | 32 +- .../ui-kit/src/components/TooltipWrapper.tsx | 43 +- .../src/components/TreeView/TreeFlowSvg.tsx | 824 +++--- .../src/components/TreeView/TreeItemCard.tsx | 419 +-- .../src/components/TreeView/TreeView.tsx | 1347 +++++----- .../ui-kit/src/components/TreeView/index.ts | 28 +- .../src/components/TreeView/toneColors.ts | 546 ++-- .../ui-kit/src/components/TreeView/types.ts | 246 +- .../ui-kit/src/components/TruncatedText.tsx | 26 +- packages/ui-kit/src/components/UserAvatar.tsx | 19 +- .../ui-kit/src/components/VariablePicker.tsx | 96 +- packages/ui-kit/src/components/index.ts | 357 ++- .../src/components/progress-animations.css | 25 +- .../src/contexts/BottomSheetContext.tsx | 151 +- .../src/contexts/SideMenuActionsContext.tsx | 46 +- packages/ui-kit/src/declarations.d.ts | 2 +- packages/ui-kit/src/hooks/useAccordion.ts | 14 +- packages/ui-kit/src/hooks/useStepper.ts | 46 +- packages/ui-kit/src/icons/components/Add.tsx | 27 +- .../ui-kit/src/icons/components/Apple.tsx | 23 +- .../src/icons/components/ArrowChevronLeft.tsx | 29 +- .../icons/components/ArrowChevronRight.tsx | 29 +- .../ui-kit/src/icons/components/ArrowDown.tsx | 28 +- .../ui-kit/src/icons/components/ArrowLeft.tsx | 27 +- .../src/icons/components/ArrowRight.tsx | 31 +- .../ui-kit/src/icons/components/ArrowUp.tsx | 28 +- .../src/icons/components/Artifactory.tsx | 25 +- .../ui-kit/src/icons/components/Attached.tsx | 29 +- .../src/icons/components/Attachment.tsx | 29 +- packages/ui-kit/src/icons/components/Aws.tsx | 40 +- .../ui-kit/src/icons/components/Azure.tsx | 23 +- packages/ui-kit/src/icons/components/Back.tsx | 27 +- .../ui-kit/src/icons/components/Blueprint.tsx | 31 +- packages/ui-kit/src/icons/components/Bug.tsx | 29 +- .../ui-kit/src/icons/components/Cache.tsx | 43 +- .../ui-kit/src/icons/components/Calendar.tsx | 34 +- .../src/icons/components/CatalogManifest.tsx | 23 +- .../src/icons/components/CatalogVersion.tsx | 29 +- .../ui-kit/src/icons/components/CentOS.tsx | 23 +- packages/ui-kit/src/icons/components/Chat.tsx | 29 +- .../ui-kit/src/icons/components/Check.tsx | 26 +- .../src/icons/components/CheckCircle.tsx | 27 +- .../src/icons/components/ChevronLeft.tsx | 35 +- .../src/icons/components/ChevronRight.tsx | 33 +- .../ui-kit/src/icons/components/Claim.tsx | 44 +- .../ui-kit/src/icons/components/Claims.tsx | 44 +- .../ui-kit/src/icons/components/Clean.tsx | 27 +- .../src/icons/components/CleanBrush.tsx | 30 +- .../ui-kit/src/icons/components/Clone.tsx | 33 +- .../ui-kit/src/icons/components/Close.tsx | 29 +- .../ui-kit/src/icons/components/Close1.tsx | 32 +- .../ui-kit/src/icons/components/CloudOff.tsx | 33 +- packages/ui-kit/src/icons/components/Cog.tsx | 29 +- .../ui-kit/src/icons/components/Complete.tsx | 25 +- .../ui-kit/src/icons/components/Container.tsx | 31 +- packages/ui-kit/src/icons/components/Copy.tsx | 26 +- .../src/icons/components/CopyClipboard.tsx | 29 +- .../ui-kit/src/icons/components/Dashboard.tsx | 27 +- .../ui-kit/src/icons/components/Database.tsx | 32 +- .../ui-kit/src/icons/components/Debian.tsx | 24 +- .../ui-kit/src/icons/components/Details.tsx | 46 +- .../ui-kit/src/icons/components/Docker.tsx | 26 +- .../src/icons/components/DockerCopy.tsx | 26 +- packages/ui-kit/src/icons/components/Dots.tsx | 27 +- .../ui-kit/src/icons/components/Download.tsx | 30 +- packages/ui-kit/src/icons/components/Drag.tsx | 32 +- packages/ui-kit/src/icons/components/Edit.tsx | 37 +- .../ui-kit/src/icons/components/Equal.tsx | 38 +- .../ui-kit/src/icons/components/Error.tsx | 27 +- .../ui-kit/src/icons/components/Export.tsx | 27 +- .../ui-kit/src/icons/components/EyeClosed.tsx | 32 +- .../ui-kit/src/icons/components/EyeOpen.tsx | 38 +- .../ui-kit/src/icons/components/Fedora.tsx | 25 +- packages/ui-kit/src/icons/components/File.tsx | 30 +- .../ui-kit/src/icons/components/Folder.tsx | 34 +- .../ui-kit/src/icons/components/Globe.tsx | 33 +- .../ui-kit/src/icons/components/Group.tsx | 24 +- .../src/icons/components/HealthCheck.tsx | 40 +- packages/ui-kit/src/icons/components/Help.tsx | 28 +- packages/ui-kit/src/icons/components/Host.tsx | 42 +- packages/ui-kit/src/icons/components/Idea.tsx | 24 +- .../ui-kit/src/icons/components/Image.tsx | 32 +- packages/ui-kit/src/icons/components/Info.tsx | 38 +- packages/ui-kit/src/icons/components/Jobs.tsx | 35 +- .../ui-kit/src/icons/components/KaliLinux.tsx | 25 +- packages/ui-kit/src/icons/components/Key.tsx | 25 +- .../src/icons/components/KeyManagement.tsx | 45 +- packages/ui-kit/src/icons/components/LXC.tsx | 28 +- .../ui-kit/src/icons/components/LXCOld.tsx | 117 +- .../ui-kit/src/icons/components/Library.tsx | 43 +- packages/ui-kit/src/icons/components/Live.tsx | 25 +- packages/ui-kit/src/icons/components/Log.tsx | 27 +- .../ui-kit/src/icons/components/Login.tsx | 38 +- .../ui-kit/src/icons/components/Logout.tsx | 38 +- .../ui-kit/src/icons/components/Minio.tsx | 29 +- packages/ui-kit/src/icons/components/Moon.tsx | 27 +- .../src/icons/components/Notification.tsx | 27 +- .../ui-kit/src/icons/components/Official.tsx | 30 +- .../ui-kit/src/icons/components/Offline.tsx | 30 +- .../ui-kit/src/icons/components/OpenApp.tsx | 27 +- .../src/icons/components/Orchestrator.tsx | 32 +- .../ui-kit/src/icons/components/Parameter.tsx | 43 +- .../ui-kit/src/icons/components/Pause.tsx | 27 +- packages/ui-kit/src/icons/components/Pin.tsx | 27 +- .../ui-kit/src/icons/components/Podman.tsx | 28 +- .../src/icons/components/PodmanDesktop.tsx | 351 ++- .../ui-kit/src/icons/components/Praise.tsx | 29 +- packages/ui-kit/src/icons/components/Pull.tsx | 30 +- packages/ui-kit/src/icons/components/Push.tsx | 30 +- .../ui-kit/src/icons/components/RedHat.tsx | 26 +- .../ui-kit/src/icons/components/Refresh.tsx | 27 +- .../src/icons/components/RemoteHost.tsx | 32 +- .../src/icons/components/ReportFeedback.tsx | 24 +- .../ui-kit/src/icons/components/Reset.tsx | 27 +- .../ui-kit/src/icons/components/Restart.tsx | 27 +- .../src/icons/components/ReverseProxy.tsx | 29 +- .../src/icons/components/ReverseProxyCORS.tsx | 25 +- .../src/icons/components/ReverseProxyFrom.tsx | 22 +- .../src/icons/components/ReverseProxyHTTP.tsx | 29 +- .../components/ReverseProxyHeadersRequest.tsx | 29 +- .../ReverseProxyHeadersResponse.tsx | 29 +- .../icons/components/ReverseProxyRoutes.tsx | 34 +- .../src/icons/components/ReverseProxyTCP.tsx | 28 +- .../src/icons/components/ReverseProxyTLS.tsx | 22 +- .../src/icons/components/ReverseProxyTo.tsx | 23 +- .../src/icons/components/ReverseProxy_Old.tsx | 36 +- .../ui-kit/src/icons/components/Revert.tsx | 33 +- .../ui-kit/src/icons/components/Revoke.tsx | 30 +- .../ui-kit/src/icons/components/Rocket.tsx | 37 +- packages/ui-kit/src/icons/components/Role.tsx | 44 +- .../ui-kit/src/icons/components/Roles.tsx | 44 +- packages/ui-kit/src/icons/components/Run.tsx | 27 +- packages/ui-kit/src/icons/components/Save.tsx | 29 +- .../ui-kit/src/icons/components/Scale.tsx | 25 +- .../ui-kit/src/icons/components/Script.tsx | 27 +- .../ui-kit/src/icons/components/Search.tsx | 27 +- packages/ui-kit/src/icons/components/Send.tsx | 34 +- .../ui-kit/src/icons/components/Settings.tsx | 51 +- packages/ui-kit/src/icons/components/Shop.tsx | 29 +- .../ui-kit/src/icons/components/Snapshot.tsx | 30 +- packages/ui-kit/src/icons/components/Star.tsx | 27 +- packages/ui-kit/src/icons/components/Stop.tsx | 27 +- packages/ui-kit/src/icons/components/Sun.tsx | 23 +- .../ui-kit/src/icons/components/Suspend.tsx | 27 +- .../ui-kit/src/icons/components/Taint.tsx | 30 +- .../ui-kit/src/icons/components/ThemeAuto.tsx | 26 +- .../ui-kit/src/icons/components/ThemeDark.tsx | 26 +- .../src/icons/components/ThemeLight.tsx | 26 +- .../ui-kit/src/icons/components/Trash.tsx | 27 +- packages/ui-kit/src/icons/components/UX.tsx | 28 +- .../ui-kit/src/icons/components/Ubuntu.tsx | 24 +- .../ui-kit/src/icons/components/Unlock.tsx | 30 +- packages/ui-kit/src/icons/components/User.tsx | 32 +- .../ui-kit/src/icons/components/Users.tsx | 42 +- .../ui-kit/src/icons/components/Verified.tsx | 29 +- .../ui-kit/src/icons/components/ViewGrid.tsx | 27 +- .../ui-kit/src/icons/components/ViewRows.tsx | 27 +- .../src/icons/components/VirtualMachine.tsx | 31 +- .../ui-kit/src/icons/components/Warning.tsx | 125 +- .../ui-kit/src/icons/components/Windows.tsx | 36 +- packages/ui-kit/src/icons/index.ts | 280 +- packages/ui-kit/src/icons/registry.ts | 571 ++--- .../src/pages/UxDemo/PlaygroundSection.tsx | 242 +- packages/ui-kit/src/pages/UxDemo/UxDemo.tsx | 294 ++- packages/ui-kit/src/pages/UxDemo/constants.ts | 320 +-- .../pages/UxDemo/demos/AccessMatrixDemo.tsx | 207 +- .../src/pages/UxDemo/demos/AccordionDemo.tsx | 389 +-- .../src/pages/UxDemo/demos/AlertDemo.tsx | 295 ++- .../src/pages/UxDemo/demos/AppDividerDemo.tsx | 44 +- .../src/pages/UxDemo/demos/BadgeDemo.tsx | 104 +- .../src/pages/UxDemo/demos/BadgeIconDemo.tsx | 146 +- .../pages/UxDemo/demos/BottomSheetDemo.tsx | 264 +- .../src/pages/UxDemo/demos/ButtonDemo.tsx | 293 ++- .../src/pages/UxDemo/demos/CheckboxDemo.tsx | 264 +- .../UxDemo/demos/CollapsibleHelpDemo.tsx | 188 +- .../UxDemo/demos/CollapsiblePanelDemo.tsx | 219 +- .../src/pages/UxDemo/demos/CustomIconDemo.tsx | 100 +- .../pages/UxDemo/demos/DetailItemCardDemo.tsx | 155 +- .../pages/UxDemo/demos/DropdownButtonDemo.tsx | 415 +-- .../src/pages/UxDemo/demos/DynamicImgDemo.tsx | 93 +- .../src/pages/UxDemo/demos/EmptyStateDemo.tsx | 346 +-- .../src/pages/UxDemo/demos/FormDemo.tsx | 249 +- .../pages/UxDemo/demos/HeaderGroupDemo.tsx | 76 +- .../src/pages/UxDemo/demos/IconButtonDemo.tsx | 213 +- .../pages/UxDemo/demos/InfiniteScrollDemo.tsx | 192 +- .../src/pages/UxDemo/demos/InputDemo.tsx | 211 +- .../src/pages/UxDemo/demos/InputGroupDemo.tsx | 150 +- .../pages/UxDemo/demos/KeyValueFieldDemo.tsx | 68 +- .../src/pages/UxDemo/demos/ModalDemo.tsx | 158 +- .../UxDemo/demos/MultiSelectPillsDemo.tsx | 195 +- .../src/pages/UxDemo/demos/PanelDemo.tsx | 477 ++-- .../src/pages/UxDemo/demos/PillDemo.tsx | 213 +- .../src/pages/UxDemo/demos/ProgressDemo.tsx | 253 +- .../src/pages/UxDemo/demos/SearchBarDemo.tsx | 153 +- .../src/pages/UxDemo/demos/SelectDemo.tsx | 223 +- .../src/pages/UxDemo/demos/SpinnerDemo.tsx | 211 +- .../pages/UxDemo/demos/StatusSpinnerDemo.tsx | 147 +- .../src/pages/UxDemo/demos/StepperDemo.tsx | 476 ++-- .../src/pages/UxDemo/demos/TableDemo.tsx | 275 +- .../src/pages/UxDemo/demos/TabsDemo.tsx | 508 ++-- .../src/pages/UxDemo/demos/TextareaDemo.tsx | 204 +- .../pages/UxDemo/demos/TimelinePanelDemo.tsx | 315 ++- .../src/pages/UxDemo/demos/ToggleDemo.tsx | 239 +- .../pages/UxDemo/mocks/NotificationService.ts | 12 +- packages/ui-kit/src/theme/ButtonTypes.ts | 12 +- packages/ui-kit/src/theme/Theme.ts | 873 ++++--- packages/ui-kit/src/theme/index.ts | 8 +- packages/ui-kit/src/theme/randomThemeColor.ts | 94 +- .../ui-kit/src/theme/tailwind-safelist.ts | 270 +- packages/ui-kit/src/types/App.ts | 36 +- .../ui-kit/src/types/BottomSheetContext.ts | 34 +- packages/ui-kit/src/types/CapsuleBlueprint.ts | 42 +- packages/ui-kit/src/types/Icon.ts | 2 +- packages/ui-kit/src/types/Toast.ts | 61 +- packages/ui-kit/src/utils/bytesUtils.ts | 54 +- packages/ui-kit/src/utils/dependencyUtils.ts | 450 ++-- packages/ui-kit/src/utils/durationUtils.ts | 23 +- packages/ui-kit/src/utils/focusUtils.ts | 10 +- packages/ui-kit/src/utils/gravatar.ts | 12 +- packages/ui-kit/src/utils/iconUtils.ts | 53 +- packages/ui-kit/src/utils/renderIcon.tsx | 32 +- packages/ui-kit/src/utils/smartVariables.ts | 61 +- packages/ui-kit/src/utils/stringUtils.ts | 27 +- packages/ui-kit/src/utils/toastUtils.ts | 4 +- packages/ui-kit/tsconfig.json | 16 +- packages/ui-kit/tsup.config.ts | 22 +- postcss.config.js | 10 +- src-tauri/capabilities/default.json | 6 +- .../AppIcon.appiconset/Contents.json | 153 +- 322 files changed, 26823 insertions(+), 16711 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 91c3234..14d396c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,34 +1,34 @@ { - "version": "0.2.0", - "configurations": [ - { - "type": "pwa-chrome", - "request": "launch", - "name": "Launch Chrome", - "url": "http://localhost:1421", - "webRoot": "${workspaceFolder}/src", - "sourceMaps": true, - "preLaunchTask": "tauri: dev", - "envFile": "${workspaceFolder}/.env" - }, - { - "type": "lldb", - "request": "launch", - "name": "Tauri Development Debug", - "cargo": { - "args": [ - "build", - "--manifest-path=./src-tauri/Cargo.toml", - "--no-default-features" - ] - }, - "task": "tauri: dev", - "env": { - "VITE_DEVOPS_API_URL": "${env:VITE_DEVOPS_API_URL}", - "VITE_DEVOPS_USERNAME": "${env:VITE_DEVOPS_USERNAME}", - "VITE_DEVOPS_PASSWORD": "${env:VITE_DEVOPS_PASSWORD}", - "VITE_DEVOPS_EMAIL": "${env:VITE_DEVOPS_EMAIL}" - } - } - ] -} \ No newline at end of file + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-chrome", + "request": "launch", + "name": "Launch Chrome", + "url": "http://localhost:1421", + "webRoot": "${workspaceFolder}/src", + "sourceMaps": true, + "preLaunchTask": "tauri: dev", + "envFile": "${workspaceFolder}/.env" + }, + { + "type": "lldb", + "request": "launch", + "name": "Tauri Development Debug", + "cargo": { + "args": [ + "build", + "--manifest-path=./src-tauri/Cargo.toml", + "--no-default-features" + ] + }, + "task": "tauri: dev", + "env": { + "VITE_DEVOPS_API_URL": "${env:VITE_DEVOPS_API_URL}", + "VITE_DEVOPS_USERNAME": "${env:VITE_DEVOPS_USERNAME}", + "VITE_DEVOPS_PASSWORD": "${env:VITE_DEVOPS_PASSWORD}", + "VITE_DEVOPS_EMAIL": "${env:VITE_DEVOPS_EMAIL}" + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5c0335a..edd8c59 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,38 +1,38 @@ { - "version": "2.0.0", - "tasks": [ + "version": "2.0.0", + "tasks": [ + { + "label": "tauri: build:debug", + "type": "shell", + "command": "npm run tauri build -- --debug", + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "tauri: dev", + "type": "shell", + "isBackground": true, + "command": "npm run tauri dev", + "problemMatcher": [ { - "label": "tauri: build:debug", - "type": "shell", - "command": "npm run tauri build -- --debug", - "problemMatcher": [], - "group": { - "kind": "build", - "isDefault": true + "pattern": [ + { + "regexp": ".", + "file": 1, + "location": 2, + "message": 3 } - }, - { - "label": "tauri: dev", - "type": "shell", - "isBackground": true, - "command": "npm run tauri dev", - "problemMatcher": [ - { - "pattern": [ - { - "regexp": ".", - "file": 1, - "location": 2, - "message": 3 - } - ], - "background": { - "activeOnStart": true, - "beginsPattern": "[a-zA-Z]+ \\d+.\\d+.\\d+ [a-zA-Z]+", - "endsPattern": "Local:\\s+http://localhost:\\d+/" - } - } - ] + ], + "background": { + "activeOnStart": true, + "beginsPattern": "[a-zA-Z]+ \\d+.\\d+.\\d+ [a-zA-Z]+", + "endsPattern": "Local:\\s+http://localhost:\\d+/" + } } - ] -} \ No newline at end of file + ] + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 041fd8e..0b6e807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,12 @@ All notable changes to Parallels DevOps UI are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + ## [0.2.0] - 2026-03-31 Parallels DevOps UI introduces its first release candidate with a modernized technology stack, upgrading to TypeScript 6.0 and Node 25 for improved performance and security. This update includes comprehensive dependency refreshes across the application to ensure compatibility and access to the latest features of core libraries. End users can expect a stable, production-ready interface for managing Parallels virtual machine infrastructure with enhanced reliability. -- feat: UI rc1 +- feat: UI rc1 - deps: bump typescript from 5.9.3 to 6.0.2 in the major group across 1 directory - deps: bump the minor-and-patch group with 8 updates - deps: bump the minor-and-patch group with 6 updates diff --git a/README.md b/README.md index d808dc0..01afb99 100644 --- a/README.md +++ b/README.md @@ -24,27 +24,29 @@ A hybrid desktop and web application for managing Parallels DevOps products. Bui ### Required (all platforms) -| Tool | Version | Notes | -|------|---------|-------| -| Node.js | v24+ | Use nvm or fnm for version management | -| npm | bundled with Node | Workspaces support required | -| Git | any | | +| Tool | Version | Notes | +| ------- | ----------------- | ------------------------------------- | +| Node.js | v24+ | Use nvm or fnm for version management | +| npm | bundled with Node | Workspaces support required | +| Git | any | | ### Required for desktop (Tauri) builds -| Tool | Notes | -|------|-------| +| Tool | Notes | +| ------------- | ---------------------------------------- | | Rust (stable) | Install via [rustup](https://rustup.rs/) | -| Tauri CLI | Installed automatically via npm | +| Tauri CLI | Installed automatically via npm | #### Platform-specific Tauri requirements **macOS** + ```bash xcode-select --install ``` **Linux (Debian/Ubuntu)** + ```bash sudo apt update sudo apt install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ @@ -52,6 +54,7 @@ sudo apt install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3- ``` **Windows** + - Install [Microsoft C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) - Install WebView2 (bundled on Windows 11; download for Windows 10) @@ -129,12 +132,14 @@ See [Environment Variables](#environment-variables) for the full list. ### 4. Start the development server **Web only (fastest):** + ```bash npm run dev # App runs at http://localhost:1421 ``` **Tauri desktop app:** + ```bash make dev # or: npm run tauri dev @@ -148,30 +153,30 @@ Create a `.env` file in the project root. All variables are prefixed with `VITE_ ### Required -| Variable | Description | Example | -|----------|-------------|---------| -| `VITE_DEVOPS_API_URL` | Backend API base URL | `http://localhost:5680` | -| `VITE_DEVOPS_USERNAME` | Default API username | `admin` | -| `VITE_DEVOPS_PASSWORD` | Default API password | `changeme` | -| `VITE_DEVOPS_EMAIL` | Default API user email | `admin@example.com` | +| Variable | Description | Example | +| ---------------------- | ---------------------- | ----------------------- | +| `VITE_DEVOPS_API_URL` | Backend API base URL | `http://localhost:5680` | +| `VITE_DEVOPS_USERNAME` | Default API username | `admin` | +| `VITE_DEVOPS_PASSWORD` | Default API password | `changeme` | +| `VITE_DEVOPS_EMAIL` | Default API user email | `admin@example.com` | ### Optional -| Variable | Description | Default | -|----------|-------------|---------| -| `VITE_DEV_PORT` | Dev server port | `1421` | -| `VITE_DEFAULT_HOST_URL` | Locks the host URL field in the UI | _(unset)_ | -| `VITE_DEFAULT_USERNAME` | Pre-fills username for locked deployment | _(unset)_ | -| `VITE_DEFAULT_PASSWORD` | Pre-fills password for locked deployment | _(unset)_ | -| `VITE_IS_DEVELOPMENT` | Enables development-mode features | _(unset)_ | -| `VITE_CHANNEL` | Release channel (`stable`, `beta`, `canary`) | _(unset)_ | +| Variable | Description | Default | +| ----------------------- | -------------------------------------------- | --------- | +| `VITE_DEV_PORT` | Dev server port | `1421` | +| `VITE_DEFAULT_HOST_URL` | Locks the host URL field in the UI | _(unset)_ | +| `VITE_DEFAULT_USERNAME` | Pre-fills username for locked deployment | _(unset)_ | +| `VITE_DEFAULT_PASSWORD` | Pre-fills password for locked deployment | _(unset)_ | +| `VITE_IS_DEVELOPMENT` | Enables development-mode features | _(unset)_ | +| `VITE_CHANNEL` | Release channel (`stable`, `beta`, `canary`) | _(unset)_ | > **Note:** The dev server proxies all `/api` requests to `VITE_DEVOPS_API_URL` (default: `http://localhost:5680`). You need the Parallels DevOps backend running locally or pointed at via that variable. ### Docker-only -| Variable | Description | Values | -|----------|-------------|--------| +| Variable | Description | Values | +| --------- | ------------------- | --------------------------------------------- | | `APP_ENV` | Runtime environment | `production`, `canary`, `beta`, `development` | --- @@ -321,7 +326,7 @@ npm run lint -w packages/ui-kit ### Importing in the app ```typescript -import { Button, Card } from '@prl/ui-kit'; +import { Button, Card } from "@prl/ui-kit"; ``` The root `tsconfig.json` and Vite config resolve `@prl/ui-kit` directly from `packages/ui-kit/src` via path aliases — no build step needed during development. diff --git a/docs/mvp.md b/docs/mvp.md index 36daf23..795c980 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -1,6 +1,7 @@ # MVP ## Initial Screen + - User will see a onboarding screen - User will be asked to add a url for DevOps Service - User will be asked to name the connection @@ -8,73 +9,71 @@ - User will be asked for a Username - User will be asked for a Password - User will need to test the connection - - A test button will be active if all fields are filled in - - A Save button will be active if the test is successful + - A test button will be active if all fields are filled in + - A Save button will be active if the test is successful - User will be asked to save the connection - User will then be moved to the homescreen - ## Homescreen -- User will see a section where it shows all of the hosts/orchestrators that are defined - - User will have the ability to add a new host/orchestrator - - User will have the ability to set the default host/orchestrator - - We will define a icon for each host/orchestrator based on the name - - We will be able to visually see if the connection is active or not and if the host/orchestrator is healthy +- User will see a section where it shows all of the hosts/orchestrators that are defined + - User will have the ability to add a new host/orchestrator + - User will have the ability to set the default host/orchestrator + - We will define a icon for each host/orchestrator based on the name + - We will be able to visually see if the connection is active or not and if the host/orchestrator is healthy - User will see a section where we show a collapsed panel like section for the selected orchestrator - - User will have the ability to expand the panel to see the details of each section - - The sections will be: - - Information - - Management - - We have Users - - We have Roles - - We have Claims - - Cache - - Resources - - We have a dashboard for each resource with colors for thresholds: green, amber, red - - We list all the data for the resources that composed the charts - - Hosts (Only for Orchestrator) - - We have a list of all the hosts - - We will allow drilldown to the host - - We will show the same information as the orchestrator but for the host - - Virtual Machines - - We list all the virtual machines on that host/orchestrator as a table - - We will show a header with panels for each of the states of the virtual machines - - like 5 started, 4 stopped, 1 paused - - We will have a button on the row for showing the details of the virtual machine - - This will pop up a modal with the details of the virtual machine - - Logs - - We will show in the panel a live view of the logs with the ability to filter by level or search work - - We will limit to a 500 lines of logs then we will implement a fifo - - We will allow the user to clear the logs - - We will allow the user to download the logs + - User will have the ability to expand the panel to see the details of each section + - The sections will be: + - Information + - Management + - We have Users + - We have Roles + - We have Claims + - Cache + - Resources + - We have a dashboard for each resource with colors for thresholds: green, amber, red + - We list all the data for the resources that composed the charts + - Hosts (Only for Orchestrator) + - We have a list of all the hosts + - We will allow drilldown to the host + - We will show the same information as the orchestrator but for the host + - Virtual Machines + - We list all the virtual machines on that host/orchestrator as a table + - We will show a header with panels for each of the states of the virtual machines + - like 5 started, 4 stopped, 1 paused + - We will have a button on the row for showing the details of the virtual machine + - This will pop up a modal with the details of the virtual machine + - Logs + - We will show in the panel a live view of the logs with the ability to filter by level or search work + - We will limit to a 500 lines of logs then we will implement a fifo + - We will allow the user to clear the logs + - We will allow the user to download the logs ## Settings - User will see a button in the header for settings - - User will be able to edit the connection - - User will be able to delete the connection - - User will be able to test the connection - - User will be able to save the connection + - User will be able to edit the connection + - User will be able to delete the connection + - User will be able to test the connection + - User will be able to save the connection ## Status Bar - User will be able to see in the status bar - - User will see the condition of the current websocket connection to the host/orchestrator - - User will see the version of the devops service underlying - + - User will see the condition of the current websocket connection to the host/orchestrator + - User will see the version of the devops service underlying ## Planning - Sai will create a service for the devops service - - Service will have a login endpoint to get a JWT token (initially we will use static username and password) - - Service will have a endpoint to get the information of the host/orchestrator (Look at VSCode Extension for reference) - - Service will have a endpoint to get the users and apply CRUD operations - - Service will have a endpoint to get the roles and apply CRUD operations - - Service will have a endpoint to get the claims and apply CRUD operations - - Service will have a endpoint to manage cache - - Service will have a endpoint to clear the cache - - Service will have a endpoint to clear individual cache items - - Service will have a endpoint to delete a cache item - - Service will have a endpoint to list resources (Look at VSCode Extension for reference) - - Service will have a endpoint to get filtered resources for the dashboard + - Service will have a login endpoint to get a JWT token (initially we will use static username and password) + - Service will have a endpoint to get the information of the host/orchestrator (Look at VSCode Extension for reference) + - Service will have a endpoint to get the users and apply CRUD operations + - Service will have a endpoint to get the roles and apply CRUD operations + - Service will have a endpoint to get the claims and apply CRUD operations + - Service will have a endpoint to manage cache + - Service will have a endpoint to clear the cache + - Service will have a endpoint to clear individual cache items + - Service will have a endpoint to delete a cache item + - Service will have a endpoint to list resources (Look at VSCode Extension for reference) + - Service will have a endpoint to get filtered resources for the dashboard diff --git a/helm/prl-devops-ui/templates/service.yaml b/helm/prl-devops-ui/templates/service.yaml index 8f58c79..5984560 100644 --- a/helm/prl-devops-ui/templates/service.yaml +++ b/helm/prl-devops-ui/templates/service.yaml @@ -1,15 +1,13 @@ apiVersion: v1 kind: Service metadata: - name: {{ include "prl-devops-ui.fullname" . }} - labels: - {{- include "prl-devops-ui.labels" . | nindent 4 }} + name: { { include "prl-devops-ui.fullname" . } } + labels: { { - include "prl-devops-ui.labels" . | nindent 4 } } spec: - type: {{ .Values.service.type }} + type: { { .Values.service.type } } ports: - - port: {{ .Values.service.port }} + - port: { { .Values.service.port } } targetPort: http protocol: TCP name: http - selector: - {{- include "prl-devops-ui.selectorLabels" . | nindent 4 }} + selector: { { - include "prl-devops-ui.selectorLabels" . | nindent 4 } } diff --git a/helm/prl-devops-ui/values.yaml b/helm/prl-devops-ui/values.yaml index c89a607..8525d9a 100644 --- a/helm/prl-devops-ui/values.yaml +++ b/helm/prl-devops-ui/values.yaml @@ -3,7 +3,7 @@ replicaCount: 1 image: repository: prl-devops-ui pullPolicy: IfNotPresent - tag: "" # defaults to .Chart.AppVersion + tag: "" # defaults to .Chart.AppVersion imagePullSecrets: [] diff --git a/index.html b/index.html index 4dd0418..e6f0810 100644 --- a/index.html +++ b/index.html @@ -5,8 +5,8 @@ diff --git a/packages/ui-kit/package.json b/packages/ui-kit/package.json index 35391ae..6ca3d78 100644 --- a/packages/ui-kit/package.json +++ b/packages/ui-kit/package.json @@ -45,4 +45,4 @@ "tsx": "^4.21.0", "typescript": "6.0.2" } -} \ No newline at end of file +} diff --git a/packages/ui-kit/scripts/generate-icons.ts b/packages/ui-kit/scripts/generate-icons.ts index 8d23d12..7eb4afa 100644 --- a/packages/ui-kit/scripts/generate-icons.ts +++ b/packages/ui-kit/scripts/generate-icons.ts @@ -1,127 +1,130 @@ - -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const ICONS_DIR = path.resolve(__dirname, '../src/assets/icons'); -const OUTPUT_DIR = path.resolve(__dirname, '../src/icons'); -const COMPONENT_OUTPUT_DIR = path.join(OUTPUT_DIR, 'components'); +const ICONS_DIR = path.resolve(__dirname, "../src/assets/icons"); +const OUTPUT_DIR = path.resolve(__dirname, "../src/icons"); +const COMPONENT_OUTPUT_DIR = path.join(OUTPUT_DIR, "components"); // Function to convert kebab-case or snake_case file names to PascalCase for component names function toPascalCase(str: string): string { - // Handle some specific cases or just standard conversion - // Remove extension first - const name = str.replace(/\.svg$/, ''); - - return name - .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')) - .replace(/^./, (c) => c.toUpperCase()) - .replace(/ /g, ''); + // Handle some specific cases or just standard conversion + // Remove extension first + const name = str.replace(/\.svg$/, ""); + + return name + .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")) + .replace(/^./, (c) => c.toUpperCase()) + .replace(/ /g, ""); } // Simple SVG cleaner/optimizer (very basic, relies on just wrapping what's inside) // Realistically, we might want to strip 'width', 'height' attributes to let CSS control them, // or set them to '1em'/'currentColor'. function processSvgContent(content: string): string { - // Remove XML declaration - let clean = content.replace(/<\?xml.*?\?>/, ''); - // Remove DOCTYPE - clean = clean.replace(//, ''); - - // Remove style tags (causes JSX issues and global pollution) - clean = clean.replace(/ - + ); const DisabledCell: React.FC = () => ( - - + + ); @@ -72,8 +100,8 @@ const DisabledCell: React.FC = () => ( const AccessMatrix: React.FC = ({ permissions, limit = 5, - variant = 'default', - tone = 'neutral', + variant = "default", + tone = "neutral", striped = false, noBorders = false, fullHeight = false, @@ -82,7 +110,9 @@ const AccessMatrix: React.FC = ({ hoverable = false, }) => { const [expanded, setExpanded] = useState(false); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [collapsedGroups, setCollapsedGroups] = useState>( + new Set(), + ); // Unique actions in insertion order → become the action columns const actions = useMemo(() => { @@ -149,7 +179,8 @@ const AccessMatrix: React.FC = ({ _resource: resource, }; for (const action of actions) { - row[action] = lookup.get(`${group}::${resource}::${action}`) ?? false; + row[action] = + lookup.get(`${group}::${resource}::${action}`) ?? false; } result.push(row); } @@ -185,25 +216,32 @@ const AccessMatrix: React.FC = ({ const columns = useMemo((): TableColumn[] => { // Resource column — sticky left, no expand-spacer sibling so it lands at left-0 const resourceCol: TableColumn = { - id: '_resource', - header: 'Resource', + id: "_resource", + header: "Resource", minWidth: 140, - sticky: 'left', + sticky: "left", sortable: false, resizable: false, hideable: false, - stickyBackgroundFn: (row) => (row._isGroupHeader ? 'bg-neutral-50 dark:bg-neutral-800/40' : undefined), + stickyBackgroundFn: (row) => + row._isGroupHeader ? "bg-neutral-50 dark:bg-neutral-800/40" : undefined, render: (row) => { if (row._isGroupHeader) { return ( - {row._resource} + + {row._resource} + ); } - return {row._resource}; + return ( + + {row._resource} + + ); }, }; @@ -211,7 +249,7 @@ const AccessMatrix: React.FC = ({ const actionCols: TableColumn[] = actions.map((action) => ({ id: action, header: action, - align: 'center' as const, + align: "center" as const, sortable: false, resizable: false, hideable: false, @@ -226,7 +264,9 @@ const AccessMatrix: React.FC = ({ }, [actions, stickyBackground, tone, collapsedGroups]); return ( -
+
columns={columns} data={rows} @@ -237,22 +277,32 @@ const AccessMatrix: React.FC = ({ noBorders={noBorders} hoverable={hoverable} fullHeight={fullHeight} - className={fullHeight ? 'flex-1 min-h-0' : undefined} + className={fullHeight ? "flex-1 min-h-0" : undefined} stickyHeader onRowClick={(row) => { if (row._isGroupHeader) toggleGroup(row._group); }} rowClassName={(row) => { if (row._isGroupHeader) { - return 'cursor-pointer select-none border-b border-neutral-100 bg-neutral-50 hover:bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800/40 dark:hover:bg-neutral-700/50'; + return "cursor-pointer select-none border-b border-neutral-100 bg-neutral-50 hover:bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800/40 dark:hover:bg-neutral-700/50"; } - return striped && stripeMap.get(row._key) ? 'bg-neutral-100 dark:bg-neutral-800/40' : ''; + return striped && stripeMap.get(row._key) + ? "bg-neutral-100 dark:bg-neutral-800/40" + : ""; }} /> {hiddenCount > 0 && (
-
)} diff --git a/packages/ui-kit/src/components/Accordion.tsx b/packages/ui-kit/src/components/Accordion.tsx index accde7e..d6e0081 100644 --- a/packages/ui-kit/src/components/Accordion.tsx +++ b/packages/ui-kit/src/components/Accordion.tsx @@ -6,7 +6,12 @@ import { useAccordion, type UseAccordionOptions } from "../hooks/useAccordion"; import type { PanelTone } from "./Panel"; import { useIconRenderer } from "../contexts/IconContext"; -export type AccordionVariant = "default" | "bordered" | "minimal" | "tonal" | "ghost"; +export type AccordionVariant = + | "default" + | "bordered" + | "minimal" + | "tonal" + | "ghost"; export type AccordionSize = "sm" | "md" | "lg"; export type AccordionIndicator = "chevron" | "plus-minus" | "caret" | "none"; export type AccordionChevronPlacement = "left" | "right"; @@ -24,7 +29,9 @@ export interface AccordionItem { loading?: boolean; } -export interface AccordionProps extends Omit, "onChange">, UseAccordionOptions { +export interface AccordionProps + extends Omit, "onChange">, + UseAccordionOptions { items: AccordionItem[]; variant?: AccordionVariant; tone?: PanelTone; @@ -136,14 +143,18 @@ const variantClasses: Record< }; const toneClasses: Partial< - Record + Record< + PanelTone, + { header: string; indicator: string; icon: string; badge: string } + > > = { neutral: { header: "bg-neutral-50/50 text-neutral-900 shadow-sm hover:bg-neutral-100/80 dark:bg-neutral-900/40 dark:text-neutral-100 dark:hover:bg-neutral-800/60", indicator: "text-neutral-400 dark:text-neutral-500", icon: "text-neutral-500 dark:text-neutral-400", - badge: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300", + badge: + "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300", }, info: { header: @@ -157,14 +168,16 @@ const toneClasses: Partial< "bg-emerald-50/50 text-emerald-900 shadow-sm hover:bg-emerald-100/60 ring-1 ring-emerald-100/50 dark:bg-emerald-950/20 dark:text-emerald-100 dark:ring-emerald-900/30 dark:hover:bg-emerald-900/30", indicator: "text-emerald-500 dark:text-emerald-500", icon: "text-emerald-600 dark:text-emerald-400", - badge: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/50 dark:text-emerald-300", + badge: + "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/50 dark:text-emerald-300", }, warning: { header: "bg-amber-50/50 text-amber-900 shadow-sm hover:bg-amber-100/60 ring-1 ring-amber-100/50 dark:bg-amber-950/20 dark:text-amber-100 dark:ring-amber-900/30 dark:hover:bg-amber-900/30", indicator: "text-amber-500 dark:text-amber-500", icon: "text-amber-600 dark:text-amber-400", - badge: "bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300", + badge: + "bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300", }, danger: { header: @@ -178,7 +191,8 @@ const toneClasses: Partial< "bg-indigo-50/50 text-indigo-900 shadow-sm hover:bg-indigo-100/60 ring-1 ring-indigo-100/50 dark:bg-indigo-950/20 dark:text-indigo-100 dark:ring-indigo-900/30 dark:hover:bg-indigo-900/30", indicator: "text-indigo-400 dark:text-indigo-500", icon: "text-indigo-600 dark:text-indigo-400", - badge: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-300", + badge: + "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-300", }, }; @@ -229,7 +243,12 @@ export const Accordion: React.FC = ({ ...rest }) => { const renderIcon = useIconRenderer(); - const accordion = useAccordion({ defaultOpenIds, openIds, onChange, multiple }); + const accordion = useAccordion({ + defaultOpenIds, + openIds, + onChange, + multiple, + }); const sizeToken = sizeTokens[size]; const variantToken = variantClasses[variant]; @@ -246,7 +265,7 @@ export const Accordion: React.FC = ({ "relative flex w-full flex-col overflow-hidden", variantToken.root, divider && "divide-y divide-neutral-200 dark:divide-neutral-800", - className + className, )} style={style} aria-busy={loading} @@ -260,7 +279,13 @@ export const Accordion: React.FC = ({ const isLoading = Boolean(item.loading); const indicatorRotation = - indicator === "plus-minus" ? (isOpen ? "rotate-45" : "") : isOpen ? "-rotate-180" : ""; + indicator === "plus-minus" + ? isOpen + ? "rotate-45" + : "" + : isOpen + ? "-rotate-180" + : ""; const indicatorButton = showIndicator && indicatorIcon ? ( @@ -274,7 +299,7 @@ export const Accordion: React.FC = ({ "pointer-events-none text-neutral-400 dark:text-neutral-300", toneToken.indicator, indicatorRotationClass[indicator], - indicatorRotation + indicatorRotation, )} aria-hidden="true" tabIndex={-1} @@ -289,7 +314,7 @@ export const Accordion: React.FC = ({ "relative flex flex-col", variantToken.item, isDisabled && "opacity-60", - itemClassName + itemClassName, )} >
{item.actions ? ( @@ -351,7 +386,9 @@ export const Accordion: React.FC = ({
) : null} {chevronPlacement === "right" && indicatorButton ? ( -
{indicatorButton}
+
+ {indicatorButton} +
) : null}
= ({ "overflow-hidden", animated && contentTransitionClass, contentClassName, - animated && `duration-[${transitionMs}ms]` + animated && `duration-[${transitionMs}ms]`, )} style={animated ? { maxHeight: undefined } : undefined} data-open={isOpen} @@ -370,7 +407,9 @@ export const Accordion: React.FC = ({
@@ -378,7 +417,7 @@ export const Accordion: React.FC = ({ className={classNames( "overflow-hidden", sizeToken.content, - "text-sm leading-6 text-neutral-600 dark:text-neutral-300" + "text-sm leading-6 text-neutral-600 dark:text-neutral-300", )} > {item.content} @@ -386,7 +425,15 @@ export const Accordion: React.FC = ({
- {isLoading && } + {isLoading && ( + + )}
); })} diff --git a/packages/ui-kit/src/components/Alert.tsx b/packages/ui-kit/src/components/Alert.tsx index bd72182..3c53c82 100644 --- a/packages/ui-kit/src/components/Alert.tsx +++ b/packages/ui-kit/src/components/Alert.tsx @@ -1,9 +1,9 @@ -import React from 'react'; -import classNames from 'classnames'; -import { useIconRenderer } from '../contexts/IconContext'; -import { type ThemeColor, getAlertColorClasses } from '../theme/Theme'; +import React from "react"; +import classNames from "classnames"; +import { useIconRenderer } from "../contexts/IconContext"; +import { type ThemeColor, getAlertColorClasses } from "../theme/Theme"; -export type AlertVariant = 'subtle' | 'solid' | 'outline'; +export type AlertVariant = "subtle" | "solid" | "outline"; export interface AlertProps extends React.HTMLAttributes { // @deprecated Use color instead @@ -19,53 +19,83 @@ export interface AlertProps extends React.HTMLAttributes { } const defaultIcons: Partial> = { - neutral: 'Info', - info: 'Info', - success: 'CheckCircle', - warning: 'Chat', - danger: 'Error', - theme: 'Info', + neutral: "Info", + info: "Info", + success: "CheckCircle", + warning: "Chat", + danger: "Error", + theme: "Info", }; -const Alert = React.forwardRef(({ tone, color, variant = 'subtle', title, description, icon, actions, dismissible = false, onDismiss, className, ...rest }, ref) => { - const renderIcon = useIconRenderer(); - const effectiveColor = color ?? tone ?? 'neutral'; - const tokens = getAlertColorClasses(effectiveColor); - const base = classNames( - 'relative flex w-full gap-3 rounded-2xl border px-4 py-3 shadow-sm transition', - variant === 'subtle' && tokens.subtle, - variant === 'solid' && tokens.solid, - variant === 'outline' && [tokens.outline, tokens.border], - className, - ); +const Alert = React.forwardRef( + ( + { + tone, + color, + variant = "subtle", + title, + description, + icon, + actions, + dismissible = false, + onDismiss, + className, + ...rest + }, + ref, + ) => { + const renderIcon = useIconRenderer(); + const effectiveColor = color ?? tone ?? "neutral"; + const tokens = getAlertColorClasses(effectiveColor); + const base = classNames( + "relative flex w-full gap-3 rounded-2xl border px-4 py-3 shadow-sm transition", + variant === "subtle" && tokens.subtle, + variant === "solid" && tokens.solid, + variant === "outline" && [tokens.outline, tokens.border], + className, + ); - const resolvedIcon = icon === false ? null : (icon ?? defaultIcons[effectiveColor]); + const resolvedIcon = + icon === false ? null : (icon ?? defaultIcons[effectiveColor]); - return ( -
- {resolvedIcon &&
{renderIcon(resolvedIcon, 'md')}
} -
- {title &&
{title}
} - {description &&
{description}
} - {actions &&
{actions}
} -
- {dismissible && ( - - )} -
- ); -}); + {actions &&
{actions}
} +
+ {dismissible && ( + + )} +
+ ); + }, +); -Alert.displayName = 'Alert'; +Alert.displayName = "Alert"; export default Alert; diff --git a/packages/ui-kit/src/components/ApiErrorState.tsx b/packages/ui-kit/src/components/ApiErrorState.tsx index 6ac8331..f2a1733 100644 --- a/packages/ui-kit/src/components/ApiErrorState.tsx +++ b/packages/ui-kit/src/components/ApiErrorState.tsx @@ -2,7 +2,11 @@ import React from "react"; import { EmptyState, type EmptyStateProps } from "."; import { type IconName } from "../icons/registry"; -export interface ApiErrorStateProps extends Omit { +export interface ApiErrorStateProps + extends Omit< + EmptyStateProps, + "title" | "tone" | "icon" | "onAction" | "buttonText" + > { onRetry?: () => void; title?: React.ReactNode; isError?: boolean; diff --git a/packages/ui-kit/src/components/AppDivider.tsx b/packages/ui-kit/src/components/AppDivider.tsx index f76306e..01cfa51 100644 --- a/packages/ui-kit/src/components/AppDivider.tsx +++ b/packages/ui-kit/src/components/AppDivider.tsx @@ -17,7 +17,9 @@ export interface AppDividerProps { export const AppDivider: React.FC = ({ className = "" }) => { return (
-
+
); }; diff --git a/packages/ui-kit/src/components/Badge.tsx b/packages/ui-kit/src/components/Badge.tsx index 9fdee29..16e8e33 100644 --- a/packages/ui-kit/src/components/Badge.tsx +++ b/packages/ui-kit/src/components/Badge.tsx @@ -57,9 +57,19 @@ export const Badge: React.FC = ({ let content: React.ReactNode; if (dot) { - content =
); }; diff --git a/packages/ui-kit/src/components/CollapsiblePanel.tsx b/packages/ui-kit/src/components/CollapsiblePanel.tsx index 08a63e0..d40d84b 100644 --- a/packages/ui-kit/src/components/CollapsiblePanel.tsx +++ b/packages/ui-kit/src/components/CollapsiblePanel.tsx @@ -4,7 +4,11 @@ import Panel, { PanelProps, paddingStyles } from "./Panel"; import { useIconRenderer } from "../contexts/IconContext"; // Override specific props for CollapsiblePanel -export interface CollapsiblePanelProps extends Omit { +export interface CollapsiblePanelProps + extends Omit< + PanelProps, + "title" | "subtitle" | "actions" | "children" | "onToggle" + > { title: React.ReactNode; subtitle?: React.ReactNode; actions?: React.ReactNode; @@ -35,10 +39,10 @@ const CollapsiblePanel: React.FC = ({ fillHeight = false, className, disabled, - variant = 'elevated', - tone = 'neutral', - padding = 'md', - corner = 'rounded-sm', + variant = "elevated", + tone = "neutral", + padding = "md", + corner = "rounded-sm", hoverable = false, ...panelProps }) => { @@ -59,7 +63,11 @@ const CollapsiblePanel: React.FC = ({ return ( = ({ scrollable={false} {...panelProps} > -
+
{/* Header Button */} @@ -98,10 +118,15 @@ const CollapsiblePanel: React.FC = ({ className={classNames( "overflow-hidden transition-[max-height,opacity,margin] duration-300 ease-in-out", fillHeight && isExpanded && "flex-1 min-h-0", - isExpanded ? "opacity-100" : "max-h-0 opacity-0 m-0" + isExpanded ? "opacity-100" : "max-h-0 opacity-0 m-0", )} style={{ - maxHeight: isExpanded && !fillHeight ? `calc(${computedContentMaxHeight} + ${typeof minExpandedHeight === 'number' ? minExpandedHeight + 'px' : minExpandedHeight || '0px'} + 4rem)` : isExpanded ? undefined : "0px", + maxHeight: + isExpanded && !fillHeight + ? `calc(${computedContentMaxHeight} + ${typeof minExpandedHeight === "number" ? minExpandedHeight + "px" : minExpandedHeight || "0px"} + 4rem)` + : isExpanded + ? undefined + : "0px", }} >
= ({ resolvedPadding, "pt-0", isExpanded && !fillHeight - ? 'overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-200 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-700 hover:[&::-webkit-scrollbar-thumb]:bg-neutral-300 dark:hover:[&::-webkit-scrollbar-thumb]:bg-neutral-600 [&::-webkit-scrollbar-track]:bg-transparent' - : 'overflow-hidden', - contentClassName + ? "overflow-y-auto [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-neutral-200 dark:[&::-webkit-scrollbar-thumb]:bg-neutral-700 hover:[&::-webkit-scrollbar-thumb]:bg-neutral-300 dark:hover:[&::-webkit-scrollbar-thumb]:bg-neutral-600 [&::-webkit-scrollbar-track]:bg-transparent" + : "overflow-hidden", + contentClassName, )} style={{ - maxHeight: isExpanded && !fillHeight ? computedContentMaxHeight : undefined, - minHeight: isExpanded ? minExpandedHeight : undefined + maxHeight: + isExpanded && !fillHeight + ? computedContentMaxHeight + : undefined, + minHeight: isExpanded ? minExpandedHeight : undefined, }} > {children} diff --git a/packages/ui-kit/src/components/Combobox.tsx b/packages/ui-kit/src/components/Combobox.tsx index 958713f..1773b1f 100644 --- a/packages/ui-kit/src/components/Combobox.tsx +++ b/packages/ui-kit/src/components/Combobox.tsx @@ -1,159 +1,252 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; -import classNames from 'classnames'; -import { useIconRenderer } from '../contexts/IconContext'; -import IconButton from './IconButton'; -import { type ThemeColor } from '../theme/Theme'; +import React, { useState, useRef, useEffect, useMemo } from "react"; +import classNames from "classnames"; +import { useIconRenderer } from "../contexts/IconContext"; +import IconButton from "./IconButton"; +import { type ThemeColor } from "../theme/Theme"; -const toneTokens: Record = { +const toneTokens: Record< + ThemeColor, + { focusRing: string; optionHover: string; optionSelected: string } +> = { parallels: { - focusRing: 'focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40', - optionHover: 'hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300', - optionSelected: 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300', + focusRing: + "focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40", + optionHover: + "hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300", + optionSelected: + "bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300", }, brand: { - focusRing: 'focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40', - optionHover: 'hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300', - optionSelected: 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300', + focusRing: + "focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40", + optionHover: + "hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300", + optionSelected: + "bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300", }, theme: { - focusRing: 'focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40', - optionHover: 'hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300', - optionSelected: 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300', + focusRing: + "focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40", + optionHover: + "hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300", + optionSelected: + "bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300", }, red: { - focusRing: 'focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40', - optionHover: 'hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300', - optionSelected: 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300', + focusRing: + "focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40", + optionHover: + "hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300", + optionSelected: + "bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300", }, orange: { - focusRing: 'focus:border-orange-500 focus:ring-orange-200 dark:focus:border-orange-400 dark:focus:ring-orange-900/40', - optionHover: 'hover:bg-orange-50 hover:text-orange-700 dark:hover:bg-orange-900/30 dark:hover:text-orange-300', - optionSelected: 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300', + focusRing: + "focus:border-orange-500 focus:ring-orange-200 dark:focus:border-orange-400 dark:focus:ring-orange-900/40", + optionHover: + "hover:bg-orange-50 hover:text-orange-700 dark:hover:bg-orange-900/30 dark:hover:text-orange-300", + optionSelected: + "bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", }, amber: { - focusRing: 'focus:border-amber-500 focus:ring-amber-200 dark:focus:border-amber-400 dark:focus:ring-amber-900/40', - optionHover: 'hover:bg-amber-50 hover:text-amber-700 dark:hover:bg-amber-900/30 dark:hover:text-amber-300', - optionSelected: 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + focusRing: + "focus:border-amber-500 focus:ring-amber-200 dark:focus:border-amber-400 dark:focus:ring-amber-900/40", + optionHover: + "hover:bg-amber-50 hover:text-amber-700 dark:hover:bg-amber-900/30 dark:hover:text-amber-300", + optionSelected: + "bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300", }, yellow: { - focusRing: 'focus:border-yellow-500 focus:ring-yellow-200 dark:focus:border-yellow-400 dark:focus:ring-yellow-900/40', - optionHover: 'hover:bg-yellow-50 hover:text-yellow-700 dark:hover:bg-yellow-900/30 dark:hover:text-yellow-300', - optionSelected: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300', + focusRing: + "focus:border-yellow-500 focus:ring-yellow-200 dark:focus:border-yellow-400 dark:focus:ring-yellow-900/40", + optionHover: + "hover:bg-yellow-50 hover:text-yellow-700 dark:hover:bg-yellow-900/30 dark:hover:text-yellow-300", + optionSelected: + "bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300", }, lime: { - focusRing: 'focus:border-lime-500 focus:ring-lime-200 dark:focus:border-lime-400 dark:focus:ring-lime-900/40', - optionHover: 'hover:bg-lime-50 hover:text-lime-700 dark:hover:bg-lime-900/30 dark:hover:text-lime-300', - optionSelected: 'bg-lime-50 text-lime-700 dark:bg-lime-900/30 dark:text-lime-300', + focusRing: + "focus:border-lime-500 focus:ring-lime-200 dark:focus:border-lime-400 dark:focus:ring-lime-900/40", + optionHover: + "hover:bg-lime-50 hover:text-lime-700 dark:hover:bg-lime-900/30 dark:hover:text-lime-300", + optionSelected: + "bg-lime-50 text-lime-700 dark:bg-lime-900/30 dark:text-lime-300", }, green: { - focusRing: 'focus:border-emerald-500 focus:ring-emerald-200 dark:focus:border-emerald-400 dark:focus:ring-emerald-900/40', - optionHover: 'hover:bg-emerald-50 hover:text-emerald-700 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-300', - optionSelected: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', + focusRing: + "focus:border-emerald-500 focus:ring-emerald-200 dark:focus:border-emerald-400 dark:focus:ring-emerald-900/40", + optionHover: + "hover:bg-emerald-50 hover:text-emerald-700 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-300", + optionSelected: + "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", }, emerald: { - focusRing: 'focus:border-emerald-500 focus:ring-emerald-200 dark:focus:border-emerald-400 dark:focus:ring-emerald-900/40', - optionHover: 'hover:bg-emerald-50 hover:text-emerald-700 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-300', - optionSelected: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', + focusRing: + "focus:border-emerald-500 focus:ring-emerald-200 dark:focus:border-emerald-400 dark:focus:ring-emerald-900/40", + optionHover: + "hover:bg-emerald-50 hover:text-emerald-700 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-300", + optionSelected: + "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", }, teal: { - focusRing: 'focus:border-teal-500 focus:ring-teal-200 dark:focus:border-teal-400 dark:focus:ring-teal-900/40', - optionHover: 'hover:bg-teal-50 hover:text-teal-700 dark:hover:bg-teal-900/30 dark:hover:text-teal-300', - optionSelected: 'bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300', + focusRing: + "focus:border-teal-500 focus:ring-teal-200 dark:focus:border-teal-400 dark:focus:ring-teal-900/40", + optionHover: + "hover:bg-teal-50 hover:text-teal-700 dark:hover:bg-teal-900/30 dark:hover:text-teal-300", + optionSelected: + "bg-teal-50 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300", }, cyan: { - focusRing: 'focus:border-cyan-500 focus:ring-cyan-200 dark:focus:border-cyan-400 dark:focus:ring-cyan-900/40', - optionHover: 'hover:bg-cyan-50 hover:text-cyan-700 dark:hover:bg-cyan-900/30 dark:hover:text-cyan-300', - optionSelected: 'bg-cyan-50 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300', + focusRing: + "focus:border-cyan-500 focus:ring-cyan-200 dark:focus:border-cyan-400 dark:focus:ring-cyan-900/40", + optionHover: + "hover:bg-cyan-50 hover:text-cyan-700 dark:hover:bg-cyan-900/30 dark:hover:text-cyan-300", + optionSelected: + "bg-cyan-50 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300", }, sky: { - focusRing: 'focus:border-sky-500 focus:ring-sky-200 dark:focus:border-sky-400 dark:focus:ring-sky-900/40', - optionHover: 'hover:bg-sky-50 hover:text-sky-700 dark:hover:bg-sky-900/30 dark:hover:text-sky-300', - optionSelected: 'bg-sky-50 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300', + focusRing: + "focus:border-sky-500 focus:ring-sky-200 dark:focus:border-sky-400 dark:focus:ring-sky-900/40", + optionHover: + "hover:bg-sky-50 hover:text-sky-700 dark:hover:bg-sky-900/30 dark:hover:text-sky-300", + optionSelected: + "bg-sky-50 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300", }, blue: { - focusRing: 'focus:border-blue-500 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900/40', - optionHover: 'hover:bg-blue-50 hover:text-blue-700 dark:hover:bg-blue-900/30 dark:hover:text-blue-300', - optionSelected: 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + focusRing: + "focus:border-blue-500 focus:ring-blue-200 dark:focus:border-blue-400 dark:focus:ring-blue-900/40", + optionHover: + "hover:bg-blue-50 hover:text-blue-700 dark:hover:bg-blue-900/30 dark:hover:text-blue-300", + optionSelected: + "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", }, indigo: { - focusRing: 'focus:border-indigo-500 focus:ring-indigo-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-900/40', - optionHover: 'hover:bg-indigo-50 hover:text-indigo-700 dark:hover:bg-indigo-900/30 dark:hover:text-indigo-300', - optionSelected: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300', + focusRing: + "focus:border-indigo-500 focus:ring-indigo-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-900/40", + optionHover: + "hover:bg-indigo-50 hover:text-indigo-700 dark:hover:bg-indigo-900/30 dark:hover:text-indigo-300", + optionSelected: + "bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300", }, violet: { - focusRing: 'focus:border-violet-500 focus:ring-violet-200 dark:focus:border-violet-400 dark:focus:ring-violet-900/40', - optionHover: 'hover:bg-violet-50 hover:text-violet-700 dark:hover:bg-violet-900/30 dark:hover:text-violet-300', - optionSelected: 'bg-violet-50 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300', + focusRing: + "focus:border-violet-500 focus:ring-violet-200 dark:focus:border-violet-400 dark:focus:ring-violet-900/40", + optionHover: + "hover:bg-violet-50 hover:text-violet-700 dark:hover:bg-violet-900/30 dark:hover:text-violet-300", + optionSelected: + "bg-violet-50 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300", }, purple: { - focusRing: 'focus:border-purple-500 focus:ring-purple-200 dark:focus:border-purple-400 dark:focus:ring-purple-900/40', - optionHover: 'hover:bg-purple-50 hover:text-purple-700 dark:hover:bg-purple-900/30 dark:hover:text-purple-300', - optionSelected: 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', + focusRing: + "focus:border-purple-500 focus:ring-purple-200 dark:focus:border-purple-400 dark:focus:ring-purple-900/40", + optionHover: + "hover:bg-purple-50 hover:text-purple-700 dark:hover:bg-purple-900/30 dark:hover:text-purple-300", + optionSelected: + "bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", }, fuchsia: { - focusRing: 'focus:border-fuchsia-500 focus:ring-fuchsia-200 dark:focus:border-fuchsia-400 dark:focus:ring-fuchsia-900/40', - optionHover: 'hover:bg-fuchsia-50 hover:text-fuchsia-700 dark:hover:bg-fuchsia-900/30 dark:hover:text-fuchsia-300', - optionSelected: 'bg-fuchsia-50 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-300', + focusRing: + "focus:border-fuchsia-500 focus:ring-fuchsia-200 dark:focus:border-fuchsia-400 dark:focus:ring-fuchsia-900/40", + optionHover: + "hover:bg-fuchsia-50 hover:text-fuchsia-700 dark:hover:bg-fuchsia-900/30 dark:hover:text-fuchsia-300", + optionSelected: + "bg-fuchsia-50 text-fuchsia-700 dark:bg-fuchsia-900/30 dark:text-fuchsia-300", }, pink: { - focusRing: 'focus:border-pink-500 focus:ring-pink-200 dark:focus:border-pink-400 dark:focus:ring-pink-900/40', - optionHover: 'hover:bg-pink-50 hover:text-pink-700 dark:hover:bg-pink-900/30 dark:hover:text-pink-300', - optionSelected: 'bg-pink-50 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300', + focusRing: + "focus:border-pink-500 focus:ring-pink-200 dark:focus:border-pink-400 dark:focus:ring-pink-900/40", + optionHover: + "hover:bg-pink-50 hover:text-pink-700 dark:hover:bg-pink-900/30 dark:hover:text-pink-300", + optionSelected: + "bg-pink-50 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", }, rose: { - focusRing: 'focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40', - optionHover: 'hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300', - optionSelected: 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300', + focusRing: + "focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40", + optionHover: + "hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300", + optionSelected: + "bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300", }, slate: { - focusRing: 'focus:border-slate-500 focus:ring-slate-200 dark:focus:border-slate-400 dark:focus:ring-slate-900/40', - optionHover: 'hover:bg-slate-50 hover:text-slate-700 dark:hover:bg-slate-900/30 dark:hover:text-slate-300', - optionSelected: 'bg-slate-50 text-slate-700 dark:bg-slate-900/30 dark:text-slate-300', + focusRing: + "focus:border-slate-500 focus:ring-slate-200 dark:focus:border-slate-400 dark:focus:ring-slate-900/40", + optionHover: + "hover:bg-slate-50 hover:text-slate-700 dark:hover:bg-slate-900/30 dark:hover:text-slate-300", + optionSelected: + "bg-slate-50 text-slate-700 dark:bg-slate-900/30 dark:text-slate-300", }, gray: { - focusRing: 'focus:border-gray-500 focus:ring-gray-200 dark:focus:border-gray-400 dark:focus:ring-gray-900/40', - optionHover: 'hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-900/30 dark:hover:text-gray-300', - optionSelected: 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300', + focusRing: + "focus:border-gray-500 focus:ring-gray-200 dark:focus:border-gray-400 dark:focus:ring-gray-900/40", + optionHover: + "hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-900/30 dark:hover:text-gray-300", + optionSelected: + "bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300", }, zinc: { - focusRing: 'focus:border-zinc-500 focus:ring-zinc-200 dark:focus:border-zinc-400 dark:focus:ring-zinc-900/40', - optionHover: 'hover:bg-zinc-50 hover:text-zinc-700 dark:hover:bg-zinc-900/30 dark:hover:text-zinc-300', - optionSelected: 'bg-zinc-50 text-zinc-700 dark:bg-zinc-900/30 dark:text-zinc-300', + focusRing: + "focus:border-zinc-500 focus:ring-zinc-200 dark:focus:border-zinc-400 dark:focus:ring-zinc-900/40", + optionHover: + "hover:bg-zinc-50 hover:text-zinc-700 dark:hover:bg-zinc-900/30 dark:hover:text-zinc-300", + optionSelected: + "bg-zinc-50 text-zinc-700 dark:bg-zinc-900/30 dark:text-zinc-300", }, neutral: { - focusRing: 'focus:border-neutral-500 focus:ring-neutral-200 dark:focus:border-neutral-400 dark:focus:ring-neutral-900/40', - optionHover: 'hover:bg-neutral-50 hover:text-neutral-700 dark:hover:bg-neutral-900/30 dark:hover:text-neutral-300', - optionSelected: 'bg-neutral-50 text-neutral-700 dark:bg-neutral-900/30 dark:text-neutral-300', + focusRing: + "focus:border-neutral-500 focus:ring-neutral-200 dark:focus:border-neutral-400 dark:focus:ring-neutral-900/40", + optionHover: + "hover:bg-neutral-50 hover:text-neutral-700 dark:hover:bg-neutral-900/30 dark:hover:text-neutral-300", + optionSelected: + "bg-neutral-50 text-neutral-700 dark:bg-neutral-900/30 dark:text-neutral-300", }, stone: { - focusRing: 'focus:border-stone-500 focus:ring-stone-200 dark:focus:border-stone-400 dark:focus:ring-stone-900/40', - optionHover: 'hover:bg-stone-50 hover:text-stone-700 dark:hover:bg-stone-900/30 dark:hover:text-stone-300', - optionSelected: 'bg-stone-50 text-stone-700 dark:bg-stone-900/30 dark:text-stone-300', + focusRing: + "focus:border-stone-500 focus:ring-stone-200 dark:focus:border-stone-400 dark:focus:ring-stone-900/40", + optionHover: + "hover:bg-stone-50 hover:text-stone-700 dark:hover:bg-stone-900/30 dark:hover:text-stone-300", + optionSelected: + "bg-stone-50 text-stone-700 dark:bg-stone-900/30 dark:text-stone-300", }, white: { - focusRing: 'focus:border-slate-500 focus:ring-slate-200 dark:focus:border-slate-400 dark:focus:ring-slate-900/40', - optionHover: 'hover:bg-slate-50 hover:text-slate-700 dark:hover:bg-slate-900/30 dark:hover:text-slate-300', - optionSelected: 'bg-slate-50 text-slate-700 dark:bg-slate-900/30 dark:text-slate-300', + focusRing: + "focus:border-slate-500 focus:ring-slate-200 dark:focus:border-slate-400 dark:focus:ring-slate-900/40", + optionHover: + "hover:bg-slate-50 hover:text-slate-700 dark:hover:bg-slate-900/30 dark:hover:text-slate-300", + optionSelected: + "bg-slate-50 text-slate-700 dark:bg-slate-900/30 dark:text-slate-300", }, info: { - focusRing: 'focus:border-sky-500 focus:ring-sky-200 dark:focus:border-sky-400 dark:focus:ring-sky-900/40', - optionHover: 'hover:bg-sky-50 hover:text-sky-700 dark:hover:bg-sky-900/30 dark:hover:text-sky-300', - optionSelected: 'bg-sky-50 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300', + focusRing: + "focus:border-sky-500 focus:ring-sky-200 dark:focus:border-sky-400 dark:focus:ring-sky-900/40", + optionHover: + "hover:bg-sky-50 hover:text-sky-700 dark:hover:bg-sky-900/30 dark:hover:text-sky-300", + optionSelected: + "bg-sky-50 text-sky-700 dark:bg-sky-900/30 dark:text-sky-300", }, success: { - focusRing: 'focus:border-emerald-500 focus:ring-emerald-200 dark:focus:border-emerald-400 dark:focus:ring-emerald-900/40', - optionHover: 'hover:bg-emerald-50 hover:text-emerald-700 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-300', - optionSelected: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', + focusRing: + "focus:border-emerald-500 focus:ring-emerald-200 dark:focus:border-emerald-400 dark:focus:ring-emerald-900/40", + optionHover: + "hover:bg-emerald-50 hover:text-emerald-700 dark:hover:bg-emerald-900/30 dark:hover:text-emerald-300", + optionSelected: + "bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300", }, warning: { - focusRing: 'focus:border-yellow-500 focus:ring-yellow-200 dark:focus:border-yellow-400 dark:focus:ring-yellow-900/40', - optionHover: 'hover:bg-yellow-50 hover:text-yellow-700 dark:hover:bg-yellow-900/30 dark:hover:text-yellow-300', - optionSelected: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300', + focusRing: + "focus:border-yellow-500 focus:ring-yellow-200 dark:focus:border-yellow-400 dark:focus:ring-yellow-900/40", + optionHover: + "hover:bg-yellow-50 hover:text-yellow-700 dark:hover:bg-yellow-900/30 dark:hover:text-yellow-300", + optionSelected: + "bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300", }, danger: { - focusRing: 'focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40', - optionHover: 'hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300', - optionSelected: 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300', + focusRing: + "focus:border-rose-500 focus:ring-rose-200 dark:focus:border-rose-400 dark:focus:ring-rose-900/40", + optionHover: + "hover:bg-rose-50 hover:text-rose-700 dark:hover:bg-rose-900/30 dark:hover:text-rose-300", + optionSelected: + "bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300", }, }; @@ -170,19 +263,19 @@ export interface ComboboxProps { } export const Combobox: React.FC = ({ - value = '', + value = "", onChange, options = [], placeholder, className, disabled = false, error = false, - emptyMessage = 'No matching options found. You can keep typing to create a custom one.', - color = 'blue', + emptyMessage = "No matching options found. You can keep typing to create a custom one.", + color = "blue", }) => { const renderIcon = useIconRenderer(); const [isOpen, setIsOpen] = useState(false); - const [filter, setFilter] = useState(''); + const [filter, setFilter] = useState(""); const containerRef = useRef(null); const inputRef = useRef(null); const colorTokens = toneTokens[color] ?? toneTokens.theme; @@ -199,13 +292,16 @@ export const Combobox: React.FC = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { setIsOpen(false); } }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const handleInputChange = (e: React.ChangeEvent) => { @@ -228,7 +324,10 @@ export const Combobox: React.FC = ({ }; return ( -
+
= ({ placeholder={placeholder} disabled={disabled} className={classNames( - 'block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500', - error ? 'border-red-300 focus:border-red-500 focus:ring-red-200' : `border-gray-300 ${colorTokens.focusRing}`, + "block w-full rounded-lg border px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500", + error + ? "border-red-300 focus:border-red-500 focus:ring-red-200" + : `border-gray-300 ${colorTokens.focusRing}`, )} />
@@ -251,14 +352,16 @@ export const Combobox: React.FC = ({ size="sm" className="text-gray-400 hover:text-gray-600" onClick={() => { - onChange(''); - setFilter(''); + onChange(""); + setFilter(""); inputRef.current?.focus(); }} aria-label="Clear" /> )} -
{renderIcon('ArrowDown', 'sm', 'h-4 w-4')}
+
+ {renderIcon("ArrowDown", "sm", "h-4 w-4")} +
@@ -271,14 +374,18 @@ export const Combobox: React.FC = ({ onClick={() => handleOptionClick(option)} className={classNames( `cursor-pointer px-4 py-2 text-sm ${colorTokens.optionHover}`, - option === value ? `${colorTokens.optionSelected} font-medium` : 'text-gray-900 dark:text-gray-100', + option === value + ? `${colorTokens.optionSelected} font-medium` + : "text-gray-900 dark:text-gray-100", )} > {option}
)) ) : ( -
{emptyMessage}
+
+ {emptyMessage} +
)}
)} @@ -286,6 +393,6 @@ export const Combobox: React.FC = ({ ); }; -Combobox.displayName = 'Combobox'; +Combobox.displayName = "Combobox"; export default Combobox; diff --git a/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlow.tsx b/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlow.tsx index 5d52931..c65e8d8 100644 --- a/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlow.tsx +++ b/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlow.tsx @@ -1,35 +1,51 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import classNames from 'classnames'; -import ConnectionFlowConnector from './ConnectionFlowConnector'; -import ConnectionFlowColumn, { type ColumnGeometry } from './ConnectionFlowColumn'; -import ConnectionFlowParallelGroup from './ConnectionFlowParallelGroup'; -import type { ConnectionFlowItem, ConnectionFlowProps, ConnectionState } from './types'; -import type { TreeTone } from '../TreeView/types'; -import { getTreeColorTokens } from '../TreeView/toneColors'; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import classNames from "classnames"; +import ConnectionFlowConnector from "./ConnectionFlowConnector"; +import ConnectionFlowColumn, { + type ColumnGeometry, +} from "./ConnectionFlowColumn"; +import ConnectionFlowParallelGroup from "./ConnectionFlowParallelGroup"; +import type { + ConnectionFlowItem, + ConnectionFlowProps, + ConnectionState, +} from "./types"; +import type { TreeTone } from "../TreeView/types"; +import { getTreeColorTokens } from "../TreeView/toneColors"; // ── useIsDark (local copy — mirrors ConnectionFlowConnector's) ───────────────── function useIsDark(): boolean { - const detect = (): boolean => { - if (typeof document === 'undefined') return false; - const probe = document.createElement('div'); - probe.className = 'hidden dark:block'; - document.body.appendChild(probe); - const dark = window.getComputedStyle(probe).display === 'block'; - probe.remove(); - return dark; + const detect = (): boolean => { + if (typeof document === "undefined") return false; + const probe = document.createElement("div"); + probe.className = "hidden dark:block"; + document.body.appendChild(probe); + const dark = window.getComputedStyle(probe).display === "block"; + probe.remove(); + return dark; + }; + const [isDark, setIsDark] = useState(() => detect()); + useEffect(() => { + const update = () => setIsDark(detect()); + const obs = new MutationObserver(update); + obs.observe(document.documentElement, { attributeFilter: ["class"] }); + const media = window.matchMedia("(prefers-color-scheme: dark)"); + media.addEventListener("change", update); + update(); + return () => { + media.removeEventListener("change", update); + obs.disconnect(); }; - const [isDark, setIsDark] = useState(() => detect()); - useEffect(() => { - const update = () => setIsDark(detect()); - const obs = new MutationObserver(update); - obs.observe(document.documentElement, { attributeFilter: ['class'] }); - const media = window.matchMedia('(prefers-color-scheme: dark)'); - media.addEventListener('change', update); - update(); - return () => { media.removeEventListener('change', update); obs.disconnect(); }; - }, []); - return isDark; + }, []); + return isDark; } // ── Bypass path data ────────────────────────────────────────────────────────── @@ -38,18 +54,18 @@ function useIsDark(): boolean { // stepped path with rounded corners, staying just above the card tops. interface BypassArcData { - /** top-right connection point of source card — inset from corner */ - sx: number; - /** top edge of source card */ - sy: number; - /** top-left connection point of destination card — inset from corner */ - dx: number; - /** top edge of destination card */ - dy: number; - sourceTone: TreeTone; - destTone: TreeTone; - /** True when at least one item in the destination group is currently active (running). */ - destActive: boolean; + /** top-right connection point of source card — inset from corner */ + sx: number; + /** top edge of source card */ + sy: number; + /** top-left connection point of destination card — inset from corner */ + dx: number; + /** top edge of destination card */ + dy: number; + sourceTone: TreeTone; + destTone: TreeTone; + /** True when at least one item in the destination group is currently active (running). */ + destActive: boolean; } /** How many px above the card tops the bypass path travels. */ @@ -61,579 +77,669 @@ const CARD_CORNER_INSET = 19; /** How many px the ring base overlaps into the card so the card border is fully covered. */ const RING_OVERLAP = 1; -export type { ConnectionState, ConnectionFlowConnectorConfig, ConnectionFlowItem, ConnectionFlowProps } from './types'; +export type { + ConnectionState, + ConnectionFlowConnectorConfig, + ConnectionFlowItem, + ConnectionFlowProps, +} from "./types"; // ── Column group — single item or parallel cluster ──────────────────────────── type ColumnGroup = - | { kind: 'single'; item: ConnectionFlowItem } - | { kind: 'parallel'; items: ConnectionFlowItem[] }; + | { kind: "single"; item: ConnectionFlowItem } + | { kind: "parallel"; items: ConnectionFlowItem[] }; function buildGroups(items: ConnectionFlowItem[]): ColumnGroup[] { - const groups: ColumnGroup[] = []; - let i = 0; - while (i < items.length) { - if (items[i].parallel) { - const batch: ConnectionFlowItem[] = []; - while (i < items.length && items[i].parallel) { - batch.push(items[i]); - i++; - } - groups.push({ kind: 'parallel', items: batch }); - } else { - groups.push({ kind: 'single', item: items[i] }); - i++; - } + const groups: ColumnGroup[] = []; + let i = 0; + while (i < items.length) { + if (items[i].parallel) { + const batch: ConnectionFlowItem[] = []; + while (i < items.length && items[i].parallel) { + batch.push(items[i]); + i++; + } + groups.push({ kind: "parallel", items: batch }); + } else { + groups.push({ kind: "single", item: items[i] }); + i++; } - return groups; + } + return groups; } /** Returns a stable key for a group */ function groupKey(g: ColumnGroup): string { - return g.kind === 'single' ? g.item.id : g.items.map(it => it.id).join('|'); + return g.kind === "single" ? g.item.id : g.items.map((it) => it.id).join("|"); } /** True when a group does NOT emit a right-side connector */ function isTerminal(g: ColumnGroup): boolean { - return g.kind === 'single' - ? (g.item.terminal ?? false) - : (g.items[g.items.length - 1]?.terminal ?? false); + return g.kind === "single" + ? (g.item.terminal ?? false) + : (g.items[g.items.length - 1]?.terminal ?? false); } /** True when every item in the group is flagged as skipped */ function isGroupSkipped(g: ColumnGroup): boolean { - return g.kind === 'single' - ? (g.item.skipped ?? false) - : g.items.every(it => it.skipped ?? false); + return g.kind === "single" + ? (g.item.skipped ?? false) + : g.items.every((it) => it.skipped ?? false); } /** Returns the effective tone of the first item in a group (neutral when unset). */ function effectiveTone(g: ColumnGroup): TreeTone { - return (g.kind === 'single' ? g.item.tone : g.items[0]?.tone) ?? 'neutral'; + return (g.kind === "single" ? g.item.tone : g.items[0]?.tone) ?? "neutral"; } // ── ConnectionFlow ──────────────────────────────────────────────────────────── const ConnectionFlow: React.FC = ({ - items, - flowState = 'flowing', - flowIcon, - connectorWidth = 56, - animated = true, - dotSpacing = 60, - connectorBorderSize = 'xs', - connectorHalf = true, - showLine = true, - childIndent = 'xs', - childRowGap = 8, - allowScroll = false, - itemWidth, - autoScale = false, - minScale = 0.55, - className, - rightAction, - hoverable = false, - autoConnectorState = false, - animateCompleted = false, - fullWidthConnectors = false, + items, + flowState = "flowing", + flowIcon, + connectorWidth = 56, + animated = true, + dotSpacing = 60, + connectorBorderSize = "xs", + connectorHalf = true, + showLine = true, + childIndent = "xs", + childRowGap = 8, + allowScroll = false, + itemWidth, + autoScale = false, + minScale = 0.55, + className, + rightAction, + hoverable = false, + autoConnectorState = false, + animateCompleted = false, + fullWidthConnectors = false, }) => { - const isDark = useIsDark(); - - // Track geometry per column group - const [colGeo, setColGeo] = useState>({}); - - const handleGeo = useCallback((idx: number, geo: ColumnGeometry) => { - setColGeo((prev) => { - const cur = prev[idx]; - if (cur && cur.totalHeight === geo.totalHeight && - cur.isParallelGroup === geo.isParallelGroup && - cur.anchors.length === geo.anchors.length && - cur.anchors.every((a, i) => a === geo.anchors[i])) return prev; - return { ...prev, [idx]: geo }; - }); - }, []); - - // ── Auto-scale ──────────────────────────────────────────────────────────── - const containerRef = useRef(null); - const contentRef = useRef(null); - const [scale, setScale] = useState(1); - const [scaledHeight, setScaledHeight] = useState(undefined); - - useLayoutEffect(() => { - if (!autoScale) { - setScale(1); - setScaledHeight(undefined); - return; - } + const isDark = useIsDark(); + + // Track geometry per column group + const [colGeo, setColGeo] = useState>({}); + + const handleGeo = useCallback((idx: number, geo: ColumnGeometry) => { + setColGeo((prev) => { + const cur = prev[idx]; + if ( + cur && + cur.totalHeight === geo.totalHeight && + cur.isParallelGroup === geo.isParallelGroup && + cur.anchors.length === geo.anchors.length && + cur.anchors.every((a, i) => a === geo.anchors[i]) + ) + return prev; + return { ...prev, [idx]: geo }; + }); + }, []); + + // ── Auto-scale ──────────────────────────────────────────────────────────── + const containerRef = useRef(null); + const contentRef = useRef(null); + const [scale, setScale] = useState(1); + const [scaledHeight, setScaledHeight] = useState( + undefined, + ); + + useLayoutEffect(() => { + if (!autoScale) { + setScale(1); + setScaledHeight(undefined); + return; + } + + const container = containerRef.current; + const content = contentRef.current; + if (!container || !content) return; + + const measure = () => { + // Temporarily reset transform so we read the natural (scale=1) dimensions + content.style.transform = ""; + const containerW = container.offsetWidth; + const naturalW = content.scrollWidth; + const naturalH = content.scrollHeight; + if (!containerW || !naturalW) return; + + const raw = containerW / naturalW; + const s = Math.min(1, Math.max(minScale, raw)); + setScale(s); + setScaledHeight(s < 1 ? Math.ceil(naturalH * s) : undefined); + }; + + measure(); - const container = containerRef.current; - const content = contentRef.current; - if (!container || !content) return; - - const measure = () => { - // Temporarily reset transform so we read the natural (scale=1) dimensions - content.style.transform = ''; - const containerW = container.offsetWidth; - const naturalW = content.scrollWidth; - const naturalH = content.scrollHeight; - if (!containerW || !naturalW) return; - - const raw = containerW / naturalW; - const s = Math.min(1, Math.max(minScale, raw)); - setScale(s); - setScaledHeight(s < 1 ? Math.ceil(naturalH * s) : undefined); - }; - - measure(); - - const ro = new ResizeObserver(measure); - ro.observe(container); - return () => ro.disconnect(); + const ro = new ResizeObserver(measure); + ro.observe(container); + return () => ro.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoScale, minScale, items]); - - // ── Bypass arc state ────────────────────────────────────────────────────── - // One ref per group (index = group index); tracks only the card div, not the connector. - const groupDivRefs = useRef<(HTMLDivElement | null)[]>([]); - const [bypassArcs, setBypassArcs] = useState([]); - - // ── Pre-process items into groups ───────────────────────────────────────── - const groups = buildGroups(items); - - // ── Auto-skipped set ────────────────────────────────────────────────────── - // When autoConnectorState is on, derive which groups are "skipped" from their - // tone alone (neutral tone with at least one non-neutral successor). - // The result is stable across renders where items haven't changed. - // Explicit skipped:false always takes precedence — an item that was executed - // but happens to have a neutral tone must not be auto-treated as bypassed. - const autoSkippedSet = useMemo(() => { - const set = new Set(); - if (!autoConnectorState) return set; - const grps = buildGroups(items); - grps.forEach((g, gi) => { - if (effectiveTone(g) !== 'neutral') return; - // If any item in the group explicitly declares skipped:false it ran — - // honour that and skip the auto-detection for this group. - const explicitlyNotSkipped = g.kind === 'single' - ? g.item.skipped === false - : g.items.some(it => it.skipped === false); - if (explicitlyNotSkipped) return; - const hasNonNeutralAfter = grps.slice(gi + 1).some(sg => effectiveTone(sg) !== 'neutral'); - if (hasNonNeutralAfter) set.add(gi); - }); - return set; + }, [autoScale, minScale, items]); + + // ── Bypass arc state ────────────────────────────────────────────────────── + // One ref per group (index = group index); tracks only the card div, not the connector. + const groupDivRefs = useRef<(HTMLDivElement | null)[]>([]); + const [bypassArcs, setBypassArcs] = useState([]); + + // ── Pre-process items into groups ───────────────────────────────────────── + const groups = buildGroups(items); + + // ── Auto-skipped set ────────────────────────────────────────────────────── + // When autoConnectorState is on, derive which groups are "skipped" from their + // tone alone (neutral tone with at least one non-neutral successor). + // The result is stable across renders where items haven't changed. + // Explicit skipped:false always takes precedence — an item that was executed + // but happens to have a neutral tone must not be auto-treated as bypassed. + const autoSkippedSet = useMemo(() => { + const set = new Set(); + if (!autoConnectorState) return set; + const grps = buildGroups(items); + grps.forEach((g, gi) => { + if (effectiveTone(g) !== "neutral") return; + // If any item in the group explicitly declares skipped:false it ran — + // honour that and skip the auto-detection for this group. + const explicitlyNotSkipped = + g.kind === "single" + ? g.item.skipped === false + : g.items.some((it) => it.skipped === false); + if (explicitlyNotSkipped) return; + const hasNonNeutralAfter = grps + .slice(gi + 1) + .some((sg) => effectiveTone(sg) !== "neutral"); + if (hasNonNeutralAfter) set.add(gi); + }); + return set; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items, autoConnectorState]); - - // Compute bypass arc positions from DOM measurements. - // Uses `items` (not derived `groups`) in deps so the callback is only recreated - // when the actual items or scale change, not on every render. - const computeBypassArcs = useCallback(() => { - if (!contentRef.current) return; - const contentEl = contentRef.current; - const contentRect = contentEl.getBoundingClientRect(); - if (!contentRect.width) return; - - const grps = buildGroups(items); - const arcs: BypassArcData[] = []; - let skipStart = -1; - - for (let gi = 0; gi <= grps.length; gi++) { - const skipped = gi < grps.length && (isGroupSkipped(grps[gi]) || autoSkippedSet.has(gi)); - - if (skipped && skipStart === -1) { - skipStart = gi; - } else if (!skipped && skipStart !== -1) { - // Arc from group[skipStart-1] to group[gi] - const prevEl = groupDivRefs.current[skipStart - 1]; - const nextEl = groupDivRefs.current[gi]; - - if (prevEl && nextEl) { - const prevRect = prevEl.getBoundingClientRect(); - const nextRect = nextEl.getBoundingClientRect(); - // Convert screen coords → content's natural (pre-scale) coordinate space. - // Connection points are on the TOP edge of each card, inset from the corners - // so they sit inside the rounded-corner border (not at the very tip). - const sx = (prevRect.right - CARD_CORNER_INSET - contentRect.left) / scale; - const sy = (prevRect.top - contentRect.top) / scale; - const dx = (nextRect.left + CARD_CORNER_INSET - contentRect.left) / scale; - const dy = (nextRect.top - contentRect.top) / scale; - - const prevGroup = grps[skipStart - 1]; - const srcTone: TreeTone = prevGroup - ? ((prevGroup.kind === 'single' ? prevGroup.item.tone : prevGroup.items[0]?.tone) ?? 'neutral') - : 'neutral'; - const dstGroup = grps[gi]; - const dstTone: TreeTone = dstGroup - ? ((dstGroup.kind === 'single' ? dstGroup.item.tone : dstGroup.items[0]?.tone) ?? 'neutral') - : 'neutral'; - const destActive = dstGroup - ? (dstGroup.kind === 'single' - ? (dstGroup.item.active ?? false) - : dstGroup.items.some(it => it.active ?? false)) - : false; - - arcs.push({ sx, sy, dx, dy, sourceTone: srcTone, destTone: dstTone, destActive }); - } - skipStart = -1; - } + }, [items, autoConnectorState]); + + // Compute bypass arc positions from DOM measurements. + // Uses `items` (not derived `groups`) in deps so the callback is only recreated + // when the actual items or scale change, not on every render. + const computeBypassArcs = useCallback(() => { + if (!contentRef.current) return; + const contentEl = contentRef.current; + const contentRect = contentEl.getBoundingClientRect(); + if (!contentRect.width) return; + + const grps = buildGroups(items); + const arcs: BypassArcData[] = []; + let skipStart = -1; + + for (let gi = 0; gi <= grps.length; gi++) { + const skipped = + gi < grps.length && + (isGroupSkipped(grps[gi]) || autoSkippedSet.has(gi)); + + if (skipped && skipStart === -1) { + skipStart = gi; + } else if (!skipped && skipStart !== -1) { + // Arc from group[skipStart-1] to group[gi] + const prevEl = groupDivRefs.current[skipStart - 1]; + const nextEl = groupDivRefs.current[gi]; + + if (prevEl && nextEl) { + const prevRect = prevEl.getBoundingClientRect(); + const nextRect = nextEl.getBoundingClientRect(); + // Convert screen coords → content's natural (pre-scale) coordinate space. + // Connection points are on the TOP edge of each card, inset from the corners + // so they sit inside the rounded-corner border (not at the very tip). + const sx = + (prevRect.right - CARD_CORNER_INSET - contentRect.left) / scale; + const sy = (prevRect.top - contentRect.top) / scale; + const dx = + (nextRect.left + CARD_CORNER_INSET - contentRect.left) / scale; + const dy = (nextRect.top - contentRect.top) / scale; + + const prevGroup = grps[skipStart - 1]; + const srcTone: TreeTone = prevGroup + ? ((prevGroup.kind === "single" + ? prevGroup.item.tone + : prevGroup.items[0]?.tone) ?? "neutral") + : "neutral"; + const dstGroup = grps[gi]; + const dstTone: TreeTone = dstGroup + ? ((dstGroup.kind === "single" + ? dstGroup.item.tone + : dstGroup.items[0]?.tone) ?? "neutral") + : "neutral"; + const destActive = dstGroup + ? dstGroup.kind === "single" + ? (dstGroup.item.active ?? false) + : dstGroup.items.some((it) => it.active ?? false) + : false; + + arcs.push({ + sx, + sy, + dx, + dy, + sourceTone: srcTone, + destTone: dstTone, + destActive, + }); } + skipStart = -1; + } + } - setBypassArcs(arcs); + setBypassArcs(arcs); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [items, scale, autoSkippedSet]); - - useLayoutEffect(() => { - computeBypassArcs(); - const ro = new ResizeObserver(computeBypassArcs); - if (contentRef.current) ro.observe(contentRef.current); - return () => ro.disconnect(); - }, [computeBypassArcs]); - - if (groups.length === 0) return null; - - // Which groups emit a right-side connector - const emitsConnector = groups.map((g, gi) => !isTerminal(g) && gi < groups.length - 1); - - // When autoScale is active and scale < 1 the content must overflow the clipped - // outer div — we allow horizontal scroll only if scale is pinned at minScale. - const needsScroll = autoScale && scale <= minScale; - - const outerClass = classNames( - autoScale ? 'relative w-full' : 'flex items-start', - !autoScale && fullWidthConnectors && 'w-full', - !autoScale && allowScroll && 'overflow-auto max-w-full', - needsScroll && 'overflow-x-auto', - !autoScale && !allowScroll && !needsScroll && 'overflow-hidden', - className, - ); - - const contentStyle: React.CSSProperties | undefined = - autoScale && scale < 1 - ? { transform: `scale(${scale})`, transformOrigin: 'left top' } + }, [items, scale, autoSkippedSet]); + + useLayoutEffect(() => { + computeBypassArcs(); + const ro = new ResizeObserver(computeBypassArcs); + if (contentRef.current) ro.observe(contentRef.current); + return () => ro.disconnect(); + }, [computeBypassArcs]); + + if (groups.length === 0) return null; + + // Which groups emit a right-side connector + const emitsConnector = groups.map( + (g, gi) => !isTerminal(g) && gi < groups.length - 1, + ); + + // When autoScale is active and scale < 1 the content must overflow the clipped + // outer div — we allow horizontal scroll only if scale is pinned at minScale. + const needsScroll = autoScale && scale <= minScale; + + const outerClass = classNames( + autoScale ? "relative w-full" : "flex items-start", + !autoScale && fullWidthConnectors && "w-full", + !autoScale && allowScroll && "overflow-auto max-w-full", + needsScroll && "overflow-x-auto", + !autoScale && !allowScroll && !needsScroll && "overflow-hidden", + className, + ); + + const contentStyle: React.CSSProperties | undefined = + autoScale && scale < 1 + ? { transform: `scale(${scale})`, transformOrigin: "left top" } + : undefined; + + const content = ( +
+ {groups.map((group, gi) => { + const prevGroup = groups[gi - 1]; + const isFirst = gi === 0; + const renderConnector = + !isFirst && (prevGroup ? emitsConnector[gi - 1] : false); + + // Resolve connector config — from the group's "first receiver" item + const receiverItem = + group.kind === "single" ? group.item : group.items[0]; + const connCfg = receiverItem.connector; + + // Connector state: explicit override → auto-derived (if autoConnectorState) → global flowState + const state: ConnectionState = + connCfg?.state ?? + (autoConnectorState && prevGroup + ? effectiveTone(prevGroup) !== "neutral" + ? "stopped" + : "disabled" + : flowState); + const icon = connCfg?.icon ?? flowIcon; + const cWidth = connCfg?.width ?? connectorWidth; + const cAnimated = connCfg?.animated ?? animated; + const cDotSpace = connCfg?.dotSpacing ?? dotSpacing; + const cBorder = connCfg?.borderSize ?? connectorBorderSize; + const cHalf = connCfg?.halfRing ?? connectorHalf; + const cShowLine = connCfg?.showLine ?? showLine; + + // Suppress animateCompleted on connectors adjacent to skipped groups — + // the bypass arc already carries the animated flow over those steps. + const thisGroupSkipped = + isGroupSkipped(group) || autoSkippedSet.has(gi); + const prevGroupSkipped = + gi > 0 && + (isGroupSkipped(groups[gi - 1]) || autoSkippedSet.has(gi - 1)); + const cAnimateCompleted = + thisGroupSkipped || prevGroupSkipped + ? false + : (connCfg?.animateCompleted ?? animateCompleted); + + // Source tone (from the outgoing / previous group) + const prevFirstItem = prevGroup + ? prevGroup.kind === "single" + ? prevGroup.item + : prevGroup.items[0] + : undefined; + const srcTone: TreeTone = + connCfg?.sourceTone ?? prevFirstItem?.tone ?? "neutral"; + + // Target tone (from this group's first item) + const dstTone: TreeTone = + connCfg?.targetTone ?? receiverItem.tone ?? "neutral"; + + const prevGeo = colGeo[gi - 1]; + const curGeo = colGeo[gi]; + + // Left anchors — all prev anchors (handles both single and parallel source) + const leftAnchors = prevGeo?.anchors; + + // Right anchors — for fan-out into a parallel group + const rightAnchors = + curGeo?.isParallelGroup && curGeo.anchors.length > 1 + ? curGeo.anchors : undefined; - const content = ( -
- {groups.map((group, gi) => { - const prevGroup = groups[gi - 1]; - const isFirst = gi === 0; - const renderConnector = !isFirst && (prevGroup ? emitsConnector[gi - 1] : false); - - // Resolve connector config — from the group's "first receiver" item - const receiverItem = group.kind === 'single' ? group.item : group.items[0]; - const connCfg = receiverItem.connector; - - // Connector state: explicit override → auto-derived (if autoConnectorState) → global flowState - const state: ConnectionState = connCfg?.state ?? - (autoConnectorState && prevGroup - ? (effectiveTone(prevGroup) !== 'neutral' ? 'stopped' : 'disabled') - : flowState); - const icon = connCfg?.icon ?? flowIcon; - const cWidth = connCfg?.width ?? connectorWidth; - const cAnimated = connCfg?.animated ?? animated; - const cDotSpace = connCfg?.dotSpacing ?? dotSpacing; - const cBorder = connCfg?.borderSize ?? connectorBorderSize; - const cHalf = connCfg?.halfRing ?? connectorHalf; - const cShowLine = connCfg?.showLine ?? showLine; - - // Suppress animateCompleted on connectors adjacent to skipped groups — - // the bypass arc already carries the animated flow over those steps. - const thisGroupSkipped = isGroupSkipped(group) || autoSkippedSet.has(gi); - const prevGroupSkipped = gi > 0 && (isGroupSkipped(groups[gi - 1]) || autoSkippedSet.has(gi - 1)); - const cAnimateCompleted = (thisGroupSkipped || prevGroupSkipped) - ? false - : (connCfg?.animateCompleted ?? animateCompleted); - - // Source tone (from the outgoing / previous group) - const prevFirstItem = prevGroup - ? (prevGroup.kind === 'single' ? prevGroup.item : prevGroup.items[0]) - : undefined; - const srcTone: TreeTone = connCfg?.sourceTone ?? prevFirstItem?.tone ?? 'neutral'; - - // Target tone (from this group's first item) - const dstTone: TreeTone = connCfg?.targetTone ?? receiverItem.tone ?? 'neutral'; - - const prevGeo = colGeo[gi - 1]; - const curGeo = colGeo[gi]; - - // Left anchors — all prev anchors (handles both single and parallel source) - const leftAnchors = prevGeo?.anchors; - - // Right anchors — for fan-out into a parallel group - const rightAnchors = (curGeo?.isParallelGroup && curGeo.anchors.length > 1) - ? curGeo.anchors - : undefined; - - // Right anchor Y for single target (not fan-out) - const rightAnchorY = rightAnchors ? undefined : curGeo?.anchors[0]; - - // Connector height: span the taller of the two adjacent groups - const connectorHeight = (prevGeo && curGeo) - ? Math.max(prevGeo.totalHeight, curGeo.totalHeight) - : (prevGeo?.totalHeight ?? curGeo?.totalHeight); - - // Extra source tones for multi-source rings (fan-in from prev parallel group, or children) - const extraSourceTones: TreeTone[] = prevGroup?.kind === 'parallel' - ? prevGroup.items.slice(1).map(it => it.tone ?? 'neutral') - : (prevGroup?.kind === 'single' - ? (prevGroup.item.children?.map(c => c.connector?.sourceTone ?? c.tone ?? 'neutral') ?? []) - : []); - - // Right anchor tones for fan-out rings - const rightAnchorTones: TreeTone[] = rightAnchors - ? (group as { kind: 'parallel'; items: ConnectionFlowItem[] }).items.map(it => it.tone ?? 'neutral') - : []; - - // Right anchor states for per-lane animation in fan-out - const rightAnchorStates = rightAnchors - ? (group as { kind: 'parallel'; items: ConnectionFlowItem[] }).items.map((it): ConnectionState => { - if (it.connector?.state !== undefined) return it.connector.state; - if (autoConnectorState) { - if (it.active) return 'flowing'; - const tone = it.tone ?? 'neutral'; - return tone !== 'neutral' ? 'stopped' : 'disabled'; - } - return state; - }) - : []; - - return ( - - {/* Connector leading INTO this group */} - {renderConnector && ( - - )} - - {/* Column or parallel group — wrapped in a ref div for bypass arc measurement */} -
{ groupDivRefs.current[gi] = el; }} - className={(itemWidth || fullWidthConnectors) ? 'shrink-0' : 'flex-1 min-w-0'} - style={itemWidth - ? { width: typeof itemWidth === 'number' ? `${itemWidth}px` : itemWidth } - : undefined} - > - {group.kind === 'single' ? ( - handleGeo(gi, geo)} - itemWidth={itemWidth} - hoverable={hoverable} - /> - ) : ( - handleGeo(gi, geo)} - /> - )} -
-
- ); - })} - - {rightAction && ( -
- {rightAction} -
- )} + // Right anchor Y for single target (not fan-out) + const rightAnchorY = rightAnchors ? undefined : curGeo?.anchors[0]; + + // Connector height: span the taller of the two adjacent groups + const connectorHeight = + prevGeo && curGeo + ? Math.max(prevGeo.totalHeight, curGeo.totalHeight) + : (prevGeo?.totalHeight ?? curGeo?.totalHeight); + + // Extra source tones for multi-source rings (fan-in from prev parallel group, or children) + const extraSourceTones: TreeTone[] = + prevGroup?.kind === "parallel" + ? prevGroup.items.slice(1).map((it) => it.tone ?? "neutral") + : prevGroup?.kind === "single" + ? (prevGroup.item.children?.map( + (c) => c.connector?.sourceTone ?? c.tone ?? "neutral", + ) ?? []) + : []; + + // Right anchor tones for fan-out rings + const rightAnchorTones: TreeTone[] = rightAnchors + ? ( + group as { kind: "parallel"; items: ConnectionFlowItem[] } + ).items.map((it) => it.tone ?? "neutral") + : []; + + // Right anchor states for per-lane animation in fan-out + const rightAnchorStates = rightAnchors + ? ( + group as { kind: "parallel"; items: ConnectionFlowItem[] } + ).items.map((it): ConnectionState => { + if (it.connector?.state !== undefined) return it.connector.state; + if (autoConnectorState) { + if (it.active) return "flowing"; + const tone = it.tone ?? "neutral"; + return tone !== "neutral" ? "stopped" : "disabled"; + } + return state; + }) + : []; - {/* ── Bypass arc overlay ──────────────────────────────────────────── */} - {bypassArcs.length > 0 && ( - - {bypassArcs.map((arc, i) => { - const ci = isDark ? 1 : 0; - const srcTokens = getTreeColorTokens(arc.sourceTone); - const dstTokens = getTreeColorTokens(arc.destTone); - const lineColor = srcTokens.connBorder[ci]; - const dotFill = srcTokens.connDot[ci]; - - // Path starts/ends RING_OVERLAP px inside the card top edge so the - // ring fill covers the card border (same technique as left/right rings). - const sBase = arc.sy + RING_OVERLAP; - const dBase = arc.dy + RING_OVERLAP; - - // Orthogonal stepped path with rounded corners: - // (sx, sBase) → up → rounded bend → right → rounded bend → down to (dx, dBase) - const aboveY = Math.min(arc.sy, arc.dy) - BYPASS_LIFT; - const CR = BYPASS_CORNER_R; - const d = [ - `M ${arc.sx} ${sBase}`, - `V ${aboveY + CR}`, - `Q ${arc.sx} ${aboveY} ${arc.sx + CR} ${aboveY}`, - `H ${arc.dx - CR}`, - `Q ${arc.dx} ${aboveY} ${arc.dx} ${aboveY + CR}`, - `V ${dBase}`, - ].join(' '); - - // Approximate path length for dot timing - const legV1 = Math.abs(sBase - aboveY - CR); - const legH = Math.max(0, arc.dx - arc.sx - 2 * CR); - const legV2 = Math.abs(dBase - aboveY - CR); - const corners = Math.PI * 0.5 * CR * 2; // two quarter-circle corners - const pathLen = legV1 + legH + legV2 + corners; - - const DOT_VELOCITY = 35; - const DOT_GAP = dotSpacing / DOT_VELOCITY; - const numDots = Math.max(1, Math.ceil(pathLen / dotSpacing)); - const virtualLen = numDots * dotSpacing; - const dur = numDots * DOT_GAP; - - // Add overflow extension AFTER the destination so dots follow the correct - // arc shape (including the destination corner and descent) before the - // invisible loop-back segment begins. Extending the H segment rightward - // (old approach) caused dots to travel past the destination horizontally - // and miss the descent curve entirely. - const overflow = Math.max(0, virtualLen - pathLen); - const dMotion = overflow > 0 ? [ - `M ${arc.sx} ${sBase}`, - `V ${aboveY + CR}`, - `Q ${arc.sx} ${aboveY} ${arc.sx + CR} ${aboveY}`, - `H ${arc.dx - CR}`, - `Q ${arc.dx} ${aboveY} ${arc.dx} ${aboveY + CR}`, - `V ${dBase}`, - `v ${overflow}`, - ].join(' ') : d; - - const fadeOutEnd = pathLen / virtualLen; - const fadeOutStart = Math.max(0, fadeOutEnd - 10 / virtualLen); - const fadeInEnd = Math.min(fadeOutStart, 4 / virtualLen); - const opTimes = `0;${fadeInEnd.toFixed(4)};${fadeOutStart.toFixed(4)};${fadeOutEnd.toFixed(4)};1`; - - // Ring visual constants (match ConnectionFlowConnector) - const ringR = 5.5; - const bw = 1.5; - - // Source ring — half-circle bumping upward, base sits RING_OVERLAP px - // inside the card so the fill covers the card's top border. - const srcFill = srcTokens.connFill[ci]; - const srcBorder = srcTokens.connBorder[ci]; - const srcDot = srcTokens.connDot[ci]; - const srcArcD = `M ${arc.sx - ringR} ${sBase} A ${ringR} ${ringR} 0 0 1 ${arc.sx + ringR} ${sBase}`; - - // Destination ring — same treatment. - const dstFill = dstTokens.connFill[ci]; - const dstBorder = dstTokens.connBorder[ci]; - const dstDot = dstTokens.connDot[ci]; - const dstArcD = `M ${arc.dx - ringR} ${dBase} A ${ringR} ${ringR} 0 0 1 ${arc.dx + ringR} ${dBase}`; - - return ( - - {/* Bypass path */} - - - {/* Source ring endpoint */} - - - - - {/* Destination ring endpoint */} - - - - - {/* Animated dots — only when bypass is actively being traversed - (source done, destination not yet reached), or when - animateCompleted is on (completed arcs also animate). */} - {(() => { - // Animate when source is done AND destination is either - // not yet reached (neutral) or currently running (destActive). - const arcActive = arc.sourceTone !== 'neutral' && (arc.destTone === 'neutral' || arc.destActive); - const arcCompleted = arc.sourceTone !== 'neutral' && arc.destTone !== 'neutral' && !arc.destActive; - return animated && (arcActive || (animateCompleted && arcCompleted)); - })() && Array.from({ length: numDots }, (_, di) => ( - - - - - ))} - - ); - })} - + return ( + + {/* Connector leading INTO this group */} + {renderConnector && ( + )} -
- ); - if (autoScale) { - return ( + {/* Column or parallel group — wrapped in a ref div for bypass arc measurement */}
{ + groupDivRefs.current[gi] = el; + }} + className={ + itemWidth || fullWidthConnectors ? "shrink-0" : "flex-1 min-w-0" + } + style={ + itemWidth + ? { + width: + typeof itemWidth === "number" + ? `${itemWidth}px` + : itemWidth, + } + : undefined + } > - {content} + {group.kind === "single" ? ( + handleGeo(gi, geo)} + itemWidth={itemWidth} + hoverable={hoverable} + /> + ) : ( + handleGeo(gi, geo)} + /> + )}
+ ); - } + })} - return ( -
- {content} + {rightAction && ( +
+ {rightAction}
+ )} + + {/* ── Bypass arc overlay ──────────────────────────────────────────── */} + {bypassArcs.length > 0 && ( + + {bypassArcs.map((arc, i) => { + const ci = isDark ? 1 : 0; + const srcTokens = getTreeColorTokens(arc.sourceTone); + const dstTokens = getTreeColorTokens(arc.destTone); + const lineColor = srcTokens.connBorder[ci]; + const dotFill = srcTokens.connDot[ci]; + + // Path starts/ends RING_OVERLAP px inside the card top edge so the + // ring fill covers the card border (same technique as left/right rings). + const sBase = arc.sy + RING_OVERLAP; + const dBase = arc.dy + RING_OVERLAP; + + // Orthogonal stepped path with rounded corners: + // (sx, sBase) → up → rounded bend → right → rounded bend → down to (dx, dBase) + const aboveY = Math.min(arc.sy, arc.dy) - BYPASS_LIFT; + const CR = BYPASS_CORNER_R; + const d = [ + `M ${arc.sx} ${sBase}`, + `V ${aboveY + CR}`, + `Q ${arc.sx} ${aboveY} ${arc.sx + CR} ${aboveY}`, + `H ${arc.dx - CR}`, + `Q ${arc.dx} ${aboveY} ${arc.dx} ${aboveY + CR}`, + `V ${dBase}`, + ].join(" "); + + // Approximate path length for dot timing + const legV1 = Math.abs(sBase - aboveY - CR); + const legH = Math.max(0, arc.dx - arc.sx - 2 * CR); + const legV2 = Math.abs(dBase - aboveY - CR); + const corners = Math.PI * 0.5 * CR * 2; // two quarter-circle corners + const pathLen = legV1 + legH + legV2 + corners; + + const DOT_VELOCITY = 35; + const DOT_GAP = dotSpacing / DOT_VELOCITY; + const numDots = Math.max(1, Math.ceil(pathLen / dotSpacing)); + const virtualLen = numDots * dotSpacing; + const dur = numDots * DOT_GAP; + + // Add overflow extension AFTER the destination so dots follow the correct + // arc shape (including the destination corner and descent) before the + // invisible loop-back segment begins. Extending the H segment rightward + // (old approach) caused dots to travel past the destination horizontally + // and miss the descent curve entirely. + const overflow = Math.max(0, virtualLen - pathLen); + const dMotion = + overflow > 0 + ? [ + `M ${arc.sx} ${sBase}`, + `V ${aboveY + CR}`, + `Q ${arc.sx} ${aboveY} ${arc.sx + CR} ${aboveY}`, + `H ${arc.dx - CR}`, + `Q ${arc.dx} ${aboveY} ${arc.dx} ${aboveY + CR}`, + `V ${dBase}`, + `v ${overflow}`, + ].join(" ") + : d; + + const fadeOutEnd = pathLen / virtualLen; + const fadeOutStart = Math.max(0, fadeOutEnd - 10 / virtualLen); + const fadeInEnd = Math.min(fadeOutStart, 4 / virtualLen); + const opTimes = `0;${fadeInEnd.toFixed(4)};${fadeOutStart.toFixed(4)};${fadeOutEnd.toFixed(4)};1`; + + // Ring visual constants (match ConnectionFlowConnector) + const ringR = 5.5; + const bw = 1.5; + + // Source ring — half-circle bumping upward, base sits RING_OVERLAP px + // inside the card so the fill covers the card's top border. + const srcFill = srcTokens.connFill[ci]; + const srcBorder = srcTokens.connBorder[ci]; + const srcDot = srcTokens.connDot[ci]; + const srcArcD = `M ${arc.sx - ringR} ${sBase} A ${ringR} ${ringR} 0 0 1 ${arc.sx + ringR} ${sBase}`; + + // Destination ring — same treatment. + const dstFill = dstTokens.connFill[ci]; + const dstBorder = dstTokens.connBorder[ci]; + const dstDot = dstTokens.connDot[ci]; + const dstArcD = `M ${arc.dx - ringR} ${dBase} A ${ringR} ${ringR} 0 0 1 ${arc.dx + ringR} ${dBase}`; + + return ( + + {/* Bypass path */} + + + {/* Source ring endpoint */} + + + + + {/* Destination ring endpoint */} + + + + + {/* Animated dots — only when bypass is actively being traversed + (source done, destination not yet reached), or when + animateCompleted is on (completed arcs also animate). */} + {(() => { + // Animate when source is done AND destination is either + // not yet reached (neutral) or currently running (destActive). + const arcActive = + arc.sourceTone !== "neutral" && + (arc.destTone === "neutral" || arc.destActive); + const arcCompleted = + arc.sourceTone !== "neutral" && + arc.destTone !== "neutral" && + !arc.destActive; + return ( + animated && + (arcActive || (animateCompleted && arcCompleted)) + ); + })() && + Array.from({ length: numDots }, (_, di) => ( + + + + + ))} + + ); + })} + + )} +
+ ); + + if (autoScale) { + return ( +
+ {content} +
); + } + + return
{content}
; }; export default ConnectionFlow; diff --git a/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowColumn.tsx b/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowColumn.tsx index b135674..65f471e 100644 --- a/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowColumn.tsx +++ b/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowColumn.tsx @@ -1,120 +1,139 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import classNames from 'classnames'; -import TreeItemCard from '../TreeView/TreeItemCard'; -import TreeFlowSvg, { INDENT_PX } from '../TreeView/TreeFlowSvg'; -import type { TreeTone } from '../TreeView/types'; -import type { ConnectionFlowItem } from './types'; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import classNames from "classnames"; +import TreeItemCard from "../TreeView/TreeItemCard"; +import TreeFlowSvg, { INDENT_PX } from "../TreeView/TreeFlowSvg"; +import type { TreeTone } from "../TreeView/types"; +import type { ConnectionFlowItem } from "./types"; // ── Geometry reported by a column ───────────────────────────────────────────── export interface ColumnGeometry { - /** Total rendered height of the whole column (parent + gap + all children). */ - totalHeight: number; - /** - * Y offsets (from the TOP of the column) to the centre of each source card. - * anchors[0] = parent card, anchors[1..] = child cards (or parallel items). - */ - anchors: number[]; - /** True when this geometry represents a parallel group (not a single-item column). */ - isParallelGroup?: boolean; + /** Total rendered height of the whole column (parent + gap + all children). */ + totalHeight: number; + /** + * Y offsets (from the TOP of the column) to the centre of each source card. + * anchors[0] = parent card, anchors[1..] = child cards (or parallel items). + */ + anchors: number[]; + /** True when this geometry represents a parallel group (not a single-item column). */ + isParallelGroup?: boolean; } // ── useElementHeight ────────────────────────────────────────────────────────── function useElementHeight(ref: React.RefObject): number { - const [h, setH] = useState(0); - useEffect(() => { - const el = ref.current; - if (!el) return; - let raf: number; - const ro = new ResizeObserver(() => { - raf = requestAnimationFrame(() => { - if (el) setH(el.getBoundingClientRect().height); - }); - }); - ro.observe(el); - return () => { cancelAnimationFrame(raf); ro.disconnect(); }; - }, [ref]); - return h; + const [h, setH] = useState(0); + useEffect(() => { + const el = ref.current; + if (!el) return; + let raf: number; + const ro = new ResizeObserver(() => { + raf = requestAnimationFrame(() => { + if (el) setH(el.getBoundingClientRect().height); + }); + }); + ro.observe(el); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, [ref]); + return h; } // ── ChildRow ───────────────────────────────────────────────────────────────── interface ChildRowProps { - item: ConnectionFlowItem; - index: number; - globalTone?: TreeTone; - hoverable?: boolean; - onHeightChange: (h: number) => void; - onAnchorChange: (a: number) => void; - onToneChange: (t: TreeTone) => void; - onActiveChange: (v: boolean) => void; + item: ConnectionFlowItem; + index: number; + globalTone?: TreeTone; + hoverable?: boolean; + onHeightChange: (h: number) => void; + onAnchorChange: (a: number) => void; + onToneChange: (t: TreeTone) => void; + onActiveChange: (v: boolean) => void; } const ChildRow: React.FC = ({ - item, index, globalTone, hoverable = false, - onHeightChange, onAnchorChange, onToneChange, onActiveChange, + item, + index, + globalTone, + hoverable = false, + onHeightChange, + onAnchorChange, + onToneChange, + onActiveChange, }) => { - const rowRef = useRef(null); - const rowH = useElementHeight(rowRef); - const minHRef = useRef(null); + const rowRef = useRef(null); + const rowH = useElementHeight(rowRef); + const minHRef = useRef(null); - const resolvedTone: TreeTone = item.tone ?? globalTone ?? 'neutral'; - const isActive = item.active ?? false; + const resolvedTone: TreeTone = item.tone ?? globalTone ?? "neutral"; + const isActive = item.active ?? false; - useEffect(() => { - if (rowH > 0) { - onHeightChange(rowH); - if (minHRef.current === null || rowH < minHRef.current) { - minHRef.current = rowH; - onAnchorChange(rowH / 2); - } - } - }, [rowH, onHeightChange, onAnchorChange]); + useEffect(() => { + if (rowH > 0) { + onHeightChange(rowH); + if (minHRef.current === null || rowH < minHRef.current) { + minHRef.current = rowH; + onAnchorChange(rowH / 2); + } + } + }, [rowH, onHeightChange, onAnchorChange]); - useEffect(() => { onToneChange(resolvedTone); }, [resolvedTone, onToneChange]); - useEffect(() => { onActiveChange(isActive); }, [isActive, onActiveChange]); + useEffect(() => { + onToneChange(resolvedTone); + }, [resolvedTone, onToneChange]); + useEffect(() => { + onActiveChange(isActive); + }, [isActive, onActiveChange]); - return ( -
- -
- ); + return ( +
+ +
+ ); }; // ── ConnectionFlowColumn ────────────────────────────────────────────────────── export interface ConnectionFlowColumnProps { - item: ConnectionFlowItem; - globalTone?: TreeTone; - childIndent?: 'xs' | 'sm' | 'md' | 'lg'; - childRowGap?: number; - animated?: boolean; - showLine?: boolean; - connectorHalf?: boolean; - connectorBorderSize?: 'fit' | 'xs' | 'sm' | 'md' | 'lg'; - dotSpacing?: number; - itemWidth?: number | string; - /** Reports geometry so ConnectionFlow can build a multi-source connector. */ - onGeometryChange?: (geo: ColumnGeometry) => void; - /** When true, forces all child branches to animate (mirrors parent connection state). */ - flowActive?: boolean; - /** When true, cards show a hover lift effect. */ - hoverable?: boolean; + item: ConnectionFlowItem; + globalTone?: TreeTone; + childIndent?: "xs" | "sm" | "md" | "lg"; + childRowGap?: number; + animated?: boolean; + showLine?: boolean; + connectorHalf?: boolean; + connectorBorderSize?: "fit" | "xs" | "sm" | "md" | "lg"; + dotSpacing?: number; + itemWidth?: number | string; + /** Reports geometry so ConnectionFlow can build a multi-source connector. */ + onGeometryChange?: (geo: ColumnGeometry) => void; + /** When true, forces all child branches to animate (mirrors parent connection state). */ + flowActive?: boolean; + /** When true, cards show a hover lift effect. */ + hoverable?: boolean; } // Gap between parent card bottom and first child card top (mt-2) @@ -123,138 +142,211 @@ const CHILD_GAP_TOP = 8; const CHILD_ROW_MB = 8; const ConnectionFlowColumn: React.FC = ({ - item, globalTone, - childIndent = 'xs', childRowGap = CHILD_ROW_MB, - animated = true, showLine = true, - connectorHalf = true, connectorBorderSize = 'xs', dotSpacing = 50, - itemWidth, - onGeometryChange, flowActive = false, hoverable = false, + item, + globalTone, + childIndent = "xs", + childRowGap = CHILD_ROW_MB, + animated = true, + showLine = true, + connectorHalf = true, + connectorBorderSize = "xs", + dotSpacing = 50, + itemWidth, + onGeometryChange, + flowActive = false, + hoverable = false, }) => { - const hasChildren = !!(item.children && item.children.length > 0); - const resolvedTone: TreeTone = item.tone ?? globalTone ?? 'neutral'; - const childCount = item.children?.length ?? 0; + const hasChildren = !!(item.children && item.children.length > 0); + const resolvedTone: TreeTone = item.tone ?? globalTone ?? "neutral"; + const childCount = item.children?.length ?? 0; - // ── Refs & heights ──────────────────────────────────────────────────────── - const columnRef = useRef(null); - const parentCardRef = useRef(null); - const parentCardH = useElementHeight(parentCardRef); - const columnH = useElementHeight(columnRef); + // ── Refs & heights ──────────────────────────────────────────────────────── + const columnRef = useRef(null); + const parentCardRef = useRef(null); + const parentCardH = useElementHeight(parentCardRef); + const columnH = useElementHeight(columnRef); - // Per-child measurements - const [cardHeights, setCardHeights] = useState(() => Array(childCount).fill(0)); - const [cardAnchors, setCardAnchors] = useState(() => Array(childCount).fill(0)); - const [toneList, setToneList] = useState(() => Array(childCount).fill(globalTone ?? 'neutral')); - const [activeList, setActiveList] = useState(() => Array(childCount).fill(false)); + // Per-child measurements + const [cardHeights, setCardHeights] = useState(() => + Array(childCount).fill(0), + ); + const [cardAnchors, setCardAnchors] = useState(() => + Array(childCount).fill(0), + ); + const [toneList, setToneList] = useState(() => + Array(childCount).fill(globalTone ?? "neutral"), + ); + const [activeList, setActiveList] = useState(() => + Array(childCount).fill(false), + ); - // Reset arrays when child count changes - useEffect(() => { - setCardHeights(Array(childCount).fill(0)); - setCardAnchors(Array(childCount).fill(0)); - setToneList(Array(childCount).fill(globalTone ?? 'neutral')); - setActiveList(Array(childCount).fill(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [childCount]); + // Reset arrays when child count changes + useEffect(() => { + setCardHeights(Array(childCount).fill(0)); + setCardAnchors(Array(childCount).fill(0)); + setToneList(Array(childCount).fill(globalTone ?? "neutral")); + setActiveList(Array(childCount).fill(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [childCount]); - const updH = useCallback((i: number, h: number) => - setCardHeights(p => { if (p[i] === h) return p; const n = [...p]; n[i] = h; return n; }), []); - const updA = useCallback((i: number, a: number) => - setCardAnchors(p => { if (p[i] === a) return p; const n = [...p]; n[i] = a; return n; }), []); - const updT = useCallback((i: number, t: TreeTone) => - setToneList(p => { if (p[i] === t) return p; const n = [...p]; n[i] = t; return n; }), []); - const updAc = useCallback((i: number, v: boolean) => - setActiveList(p => { if (p[i] === v) return p; const n = [...p]; n[i] = v; return n; }), []); + const updH = useCallback( + (i: number, h: number) => + setCardHeights((p) => { + if (p[i] === h) return p; + const n = [...p]; + n[i] = h; + return n; + }), + [], + ); + const updA = useCallback( + (i: number, a: number) => + setCardAnchors((p) => { + if (p[i] === a) return p; + const n = [...p]; + n[i] = a; + return n; + }), + [], + ); + const updT = useCallback( + (i: number, t: TreeTone) => + setToneList((p) => { + if (p[i] === t) return p; + const n = [...p]; + n[i] = t; + return n; + }), + [], + ); + const updAc = useCallback( + (i: number, v: boolean) => + setActiveList((p) => { + if (p[i] === v) return p; + const n = [...p]; + n[i] = v; + return n; + }), + [], + ); - // ── Geometry → ConnectionFlow ───────────────────────────────────────────── - useEffect(() => { - if (!onGeometryChange || parentCardH === 0 || columnH === 0) return; + // ── Geometry → ConnectionFlow ───────────────────────────────────────────── + useEffect(() => { + if (!onGeometryChange || parentCardH === 0 || columnH === 0) return; - const anchors: number[] = [parentCardH / 2]; + const anchors: number[] = [parentCardH / 2]; - if (hasChildren && cardHeights.length > 0 && cardHeights.every(h => h > 0)) { - // Children start after: parentCard + mt-2 (CHILD_GAP_TOP) - const childBlockTop = parentCardH + CHILD_GAP_TOP; - let rowTop = childBlockTop; - cardHeights.forEach((h, idx) => { - anchors.push(rowTop + (cardAnchors[idx] ?? h / 2)); - rowTop += h + childRowGap; - }); - } + if ( + hasChildren && + cardHeights.length > 0 && + cardHeights.every((h) => h > 0) + ) { + // Children start after: parentCard + mt-2 (CHILD_GAP_TOP) + const childBlockTop = parentCardH + CHILD_GAP_TOP; + let rowTop = childBlockTop; + cardHeights.forEach((h, idx) => { + anchors.push(rowTop + (cardAnchors[idx] ?? h / 2)); + rowTop += h + childRowGap; + }); + } - onGeometryChange({ totalHeight: columnH, anchors }); - }, [parentCardH, columnH, cardHeights, cardAnchors, hasChildren, childRowGap, onGeometryChange]); + onGeometryChange({ totalHeight: columnH, anchors }); + }, [ + parentCardH, + columnH, + cardHeights, + cardAnchors, + hasChildren, + childRowGap, + onGeometryChange, + ]); - // Force-active merging for TreeFlowSvg - const mergedActiveList = flowActive ? activeList.map(() => true) : activeList; + // Force-active merging for TreeFlowSvg + const mergedActiveList = flowActive ? activeList.map(() => true) : activeList; - return ( -
- {/* Parent card — full width */} -
- -
+ return ( +
+ {/* Parent card — full width */} +
+ +
- {/* Children — full width, NO paddingLeft */} - {hasChildren && ( -
- {item.children!.map((child, i) => ( - updH(i, h)} - onAnchorChange={(a) => updA(i, a)} - onToneChange={(t) => updT(i, t)} - onActiveChange={(v) => updAc(i, v)} - /> - ))} -
- )} + {/* Children — full width, NO paddingLeft */} + {hasChildren && ( +
+ {item.children!.map((child, i) => ( + updH(i, h)} + onAnchorChange={(a) => updA(i, a)} + onToneChange={(t) => updT(i, t)} + onActiveChange={(v) => updAc(i, v)} + /> + ))} +
+ )} - {/* Column-level SVG: positioned to the left of the column + {/* Column-level SVG: positioned to the left of the column and draws the [ shape connecting parent left edge to children's left edges */} - {hasChildren && parentCardH > 0 && cardHeights.every(h => h > 0) && ( - - )} -
- ); + {hasChildren && parentCardH > 0 && cardHeights.every((h) => h > 0) && ( + + )} +
+ ); }; export default ConnectionFlowColumn; diff --git a/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowConnector.tsx b/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowConnector.tsx index 9fd110e..ce7fd46 100644 --- a/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowConnector.tsx +++ b/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowConnector.tsx @@ -1,549 +1,682 @@ -import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { getTreeColorTokens, NEUTRAL_TOKENS } from '../TreeView/toneColors'; -import type { TreeTone } from '../TreeView/types'; -import type { ConnectionState, ConnectionFlowConnectorConfig } from './types'; +import React, { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { getTreeColorTokens, NEUTRAL_TOKENS } from "../TreeView/toneColors"; +import type { TreeTone } from "../TreeView/types"; +import type { ConnectionState, ConnectionFlowConnectorConfig } from "./types"; // ── useIsDark ───────────────────────────────────────────────────────────────── function useIsDark(): boolean { - const detectDark = (): boolean => { - if (typeof document === 'undefined') return false; - const probe = document.createElement('div'); - probe.className = 'hidden dark:block'; - document.body.appendChild(probe); - const darkActive = window.getComputedStyle(probe).display === 'block'; - probe.remove(); - return darkActive; + const detectDark = (): boolean => { + if (typeof document === "undefined") return false; + const probe = document.createElement("div"); + probe.className = "hidden dark:block"; + document.body.appendChild(probe); + const darkActive = window.getComputedStyle(probe).display === "block"; + probe.remove(); + return darkActive; + }; + + const [isDark, setIsDark] = useState(() => detectDark()); + + useEffect(() => { + const update = () => setIsDark(detectDark()); + const obs = new MutationObserver(update); + obs.observe(document.documentElement, { attributeFilter: ["class"] }); + const media = window.matchMedia("(prefers-color-scheme: dark)"); + media.addEventListener("change", update); + update(); + return () => { + media.removeEventListener("change", update); + obs.disconnect(); }; - - const [isDark, setIsDark] = useState(() => detectDark()); - - useEffect(() => { - const update = () => setIsDark(detectDark()); - const obs = new MutationObserver(update); - obs.observe(document.documentElement, { attributeFilter: ['class'] }); - const media = window.matchMedia('(prefers-color-scheme: dark)'); - media.addEventListener('change', update); - update(); - return () => { - media.removeEventListener('change', update); - obs.disconnect(); - }; - }, []); - return isDark; + }, []); + return isDark; } // ── Border width map ────────────────────────────────────────────────────────── -const BORDER_WIDTH: Record<'fit' | 'xs' | 'sm' | 'md' | 'lg', number> = { - fit: 1, xs: 1.5, sm: 2, md: 2.5, lg: 3, +const BORDER_WIDTH: Record<"fit" | "xs" | "sm" | "md" | "lg", number> = { + fit: 1, + xs: 1.5, + sm: 2, + md: 2.5, + lg: 3, }; // ── ConnectionFlowConnector ─────────────────────────────────────────────────── export interface ConnectionFlowConnectorProps { - state?: ConnectionState; - sourceTone?: TreeTone; - targetTone?: TreeTone; - middleIcon?: React.ReactNode; - width?: number; - halfRing?: boolean; - showLine?: boolean; - animated?: boolean; - dotSpacing?: number; - borderSize?: 'fit' | 'xs' | 'sm' | 'md' | 'lg'; - // Fine-grained color overrides - sourceFill?: string; - sourceBorder?: string; - sourceDot?: string; - targetFill?: string; - targetBorder?: string; - targetDot?: string; - dotColor?: string; - /** - * Multi-source mode: Y offsets from the TOP of the connector space for each - * source card's centre (parent first, then children). - * When provided and length > 1, a vertical trunk is drawn on the left side - * connecting all source rings, and each source gets its own dotted line to - * the RIGHT entry ring. - */ - leftAnchors?: number[]; - /** - * Total height the connector should occupy. - * Defaults to the source column height; pass max(source, target) when heights differ. - */ - connectorHeight?: number; - /** - * Y offset of the TARGET card's centre from the top of the connector space. - * When different from the source anchor, a bezier curve is drawn between the - * two ring positions instead of a straight horizontal line. - * Defaults to the source anchor (straight line). - */ - rightAnchorY?: number; - /** - * Tones for extra source rings (index 0 = parent, already set via sourceTone). - * Used to colour child source rings independently. - */ - extraSourceTones?: TreeTone[]; - /** - * Fan-out mode: Y offsets from the TOP of the connector space for each TARGET card's centre. - * When provided and length > 1, a vertical trunk is drawn on the RIGHT side - * connecting all target rings, and each target gets its own dotted line from the LEFT entry ring. - */ - rightAnchors?: number[]; - /** - * Tones for each right-side ring in fan-out mode. - * Index i corresponds to rightAnchors[i]. Falls back to targetTone. - */ - rightAnchorTones?: TreeTone[]; - /** - * Per-lane active state for fan-out mode. - * Index i corresponds to rightAnchors[i]. When provided, each lane's animation is - * controlled independently. Falls back to the overall `state`. - */ - rightAnchorStates?: ConnectionState[]; - /** - * When true, flowing dots are shown even when `state` is `'stopped'` - * (already-traversed / completed connectors also animate). - * Default: false - */ - animateCompleted?: boolean; - /** - * When true, the connector stretches to fill available flex space instead of using a - * fixed pixel width. The SVG geometry is recalculated from the measured container width - * so rings stay at correct positions and the middle icon stays at the exact midpoint. - * Default: false - */ - fullWidth?: boolean; + state?: ConnectionState; + sourceTone?: TreeTone; + targetTone?: TreeTone; + middleIcon?: React.ReactNode; + width?: number; + halfRing?: boolean; + showLine?: boolean; + animated?: boolean; + dotSpacing?: number; + borderSize?: "fit" | "xs" | "sm" | "md" | "lg"; + // Fine-grained color overrides + sourceFill?: string; + sourceBorder?: string; + sourceDot?: string; + targetFill?: string; + targetBorder?: string; + targetDot?: string; + dotColor?: string; + /** + * Multi-source mode: Y offsets from the TOP of the connector space for each + * source card's centre (parent first, then children). + * When provided and length > 1, a vertical trunk is drawn on the left side + * connecting all source rings, and each source gets its own dotted line to + * the RIGHT entry ring. + */ + leftAnchors?: number[]; + /** + * Total height the connector should occupy. + * Defaults to the source column height; pass max(source, target) when heights differ. + */ + connectorHeight?: number; + /** + * Y offset of the TARGET card's centre from the top of the connector space. + * When different from the source anchor, a bezier curve is drawn between the + * two ring positions instead of a straight horizontal line. + * Defaults to the source anchor (straight line). + */ + rightAnchorY?: number; + /** + * Tones for extra source rings (index 0 = parent, already set via sourceTone). + * Used to colour child source rings independently. + */ + extraSourceTones?: TreeTone[]; + /** + * Fan-out mode: Y offsets from the TOP of the connector space for each TARGET card's centre. + * When provided and length > 1, a vertical trunk is drawn on the RIGHT side + * connecting all target rings, and each target gets its own dotted line from the LEFT entry ring. + */ + rightAnchors?: number[]; + /** + * Tones for each right-side ring in fan-out mode. + * Index i corresponds to rightAnchors[i]. Falls back to targetTone. + */ + rightAnchorTones?: TreeTone[]; + /** + * Per-lane active state for fan-out mode. + * Index i corresponds to rightAnchors[i]. When provided, each lane's animation is + * controlled independently. Falls back to the overall `state`. + */ + rightAnchorStates?: ConnectionState[]; + /** + * When true, flowing dots are shown even when `state` is `'stopped'` + * (already-traversed / completed connectors also animate). + * Default: false + */ + animateCompleted?: boolean; + /** + * When true, the connector stretches to fill available flex space instead of using a + * fixed pixel width. The SVG geometry is recalculated from the measured container width + * so rings stay at correct positions and the middle icon stays at the exact midpoint. + * Default: false + */ + fullWidth?: boolean; } const ConnectionFlowConnector: React.FC = ({ - state = 'flowing', - sourceTone = 'neutral', - targetTone = 'neutral', - middleIcon, - width = 56, - halfRing = true, - showLine = true, - animated = true, - dotSpacing = 60, - borderSize = 'xs', - sourceFill: srcFillOvr, - sourceBorder: srcBorderOvr, - sourceDot: srcDotOvr, - targetFill: dstFillOvr, - targetBorder: dstBorderOvr, - targetDot: dstDotOvr, - dotColor: dotColorOvr, - leftAnchors, - connectorHeight, - rightAnchorY, - extraSourceTones = [], - rightAnchors, - rightAnchorTones = [], - rightAnchorStates = [], - animateCompleted = false, - fullWidth = false, + state = "flowing", + sourceTone = "neutral", + targetTone = "neutral", + middleIcon, + width = 56, + halfRing = true, + showLine = true, + animated = true, + dotSpacing = 60, + borderSize = "xs", + sourceFill: srcFillOvr, + sourceBorder: srcBorderOvr, + sourceDot: srcDotOvr, + targetFill: dstFillOvr, + targetBorder: dstBorderOvr, + targetDot: dstDotOvr, + dotColor: dotColorOvr, + leftAnchors, + connectorHeight, + rightAnchorY, + extraSourceTones = [], + rightAnchors, + rightAnchorTones = [], + rightAnchorStates = [], + animateCompleted = false, + fullWidth = false, }) => { - const isDark = useIsDark(); - - // ── Full-width mode: measure the actual rendered container width ─────────── - const containerRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(width); - - useLayoutEffect(() => { - if (!fullWidth) { setMeasuredWidth(width); return; } - const el = containerRef.current; - if (!el) return; - const ro = new ResizeObserver(() => { - const w = el.offsetWidth; - if (w > 0) setMeasuredWidth(w); - }); - ro.observe(el); - const initial = el.offsetWidth; - if (initial > 0) setMeasuredWidth(initial); - return () => ro.disconnect(); - }, [fullWidth, width]); - const ci = isDark ? 1 : 0; - const bw = BORDER_WIDTH[borderSize]; - const ringR = 5.5; - - // isActive gates animated-dot rendering; extended to 'stopped' when animateCompleted is on - const isActive = state === 'flowing' || (animateCompleted && state === 'stopped'); - // Show tone colors for both 'flowing' and 'stopped' — only 'disabled' uses neutral gray - const showTone = state !== 'disabled'; - - // Resolved color tokens for sourceTone (parent) - const srcTokens = getTreeColorTokens(sourceTone); - const dstTokens = getTreeColorTokens(targetTone); - - const srcFill = srcFillOvr ?? srcTokens.connFill[ci]; - const srcBorder = srcBorderOvr ?? srcTokens.connBorder[ci]; - const srcDot = srcDotOvr ?? srcTokens.connDot[ci]; - const dstFill = dstFillOvr ?? (showTone ? dstTokens.connFill[ci] : NEUTRAL_TOKENS.connFill[ci]); - const dstBorder = dstBorderOvr ?? (showTone ? dstTokens.connBorder[ci] : NEUTRAL_TOKENS.connBorder[ci]); - const dstDot = dstDotOvr ?? (showTone ? dstTokens.connDot[ci] : NEUTRAL_TOKENS.connDot[ci]); - // Keep connector lines on the same tonal ramp as connector rings/cards. - const lineColor = showTone ? dstTokens.connBorder[ci] : NEUTRAL_TOKENS.connBorder[ci]; - const animDotColor = dotColorOvr ?? (isActive ? dstTokens.connDot[ci] : NEUTRAL_TOKENS.connDot[ci]); - - // ── Simple vs multi-source / fan-out mode ───────────────────────────────── - const isMultiSource = !!(leftAnchors && leftAnchors.length > 1); - const isMultiTarget = !!(rightAnchors && rightAnchors.length > 1); - // Connector SVG spans the full column height when geometry is known - const svgH = connectorHeight ?? (ringR * 2 + 4); - // Source anchor Y (left ring) — first left anchor or vertical centre of SVG - const sy = leftAnchors?.[0] ?? (svgH / 2); - // Target anchor Y (right ring) — when provided and different, draw a bezier curve - const ty = rightAnchorY ?? sy; - // Keep legacy name for multi-source code that uses `my` throughout - const my = sy; - - // Effective width: measured container width when fullWidth, otherwise the fixed prop - const w = fullWidth ? measuredWidth : width; - - // Fan-out: control-point X for bezier curves from source to each target lane - const fanOutCx = w / 2; - - // Global dot animation timing (px/s) - const DOT_VELOCITY = 35; - const DOT_GAP = dotSpacing / DOT_VELOCITY; - - // Arc helpers (half-rings) - // Source right-facing: opens rightward (sweep=1) - const srcArc = (y: number) => - `M 0 ${y - ringR} A ${ringR} ${ringR} 0 0 1 0 ${y + ringR}`; - // Target left-facing: opens leftward (sweep=0) — uses target anchor Y - const dstArc = `M ${w} ${ty - ringR} A ${ringR} ${ringR} 0 0 0 ${w} ${ty + ringR}`; - - // Trunk X — middle of the connector gap - const trunkX = w / 2; - // In multi-source, the vertical trunk on the left spans from first to last anchor - return ( -
- - -{/* ── Horizontal line(s) ────────────────────────────────── */} - {showLine && ( - isMultiSource ? ( - <> - {/* Bezier curves per source lane converging to the feed entry point (trunkX, sy) */} - {leftAnchors!.map((ay, idx) => ( - - ))} - {/* Feed line from trunk to right ring — straight or bezier */} - - - ) : isMultiTarget ? ( - <> - {/* One smooth bezier curve per target lane — departs and arrives horizontally */} - {rightAnchors!.map((ry, idx) => ( - - ))} - - ) : ( - - ) - )} - - {/* ── Source rings ─────────────────────────────────────── */} - {isMultiSource ? ( - leftAnchors!.map((ay, idx) => { - const tone = idx === 0 ? sourceTone : (extraSourceTones[idx - 1] ?? sourceTone); - const tok = getTreeColorTokens(tone); - const fill = idx === 0 ? srcFill : tok.connFill[ci]; - const border = idx === 0 ? srcBorder : tok.connBorder[ci]; - const dot = idx === 0 ? srcDot : tok.connDot[ci]; - return ( - - {halfRing ? ( - <> - - - - ) : ( - <> - - - - )} - - - ); - }) + const isDark = useIsDark(); + + // ── Full-width mode: measure the actual rendered container width ─────────── + const containerRef = useRef(null); + const [measuredWidth, setMeasuredWidth] = useState(width); + + useLayoutEffect(() => { + if (!fullWidth) { + setMeasuredWidth(width); + return; + } + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + const w = el.offsetWidth; + if (w > 0) setMeasuredWidth(w); + }); + ro.observe(el); + const initial = el.offsetWidth; + if (initial > 0) setMeasuredWidth(initial); + return () => ro.disconnect(); + }, [fullWidth, width]); + const ci = isDark ? 1 : 0; + const bw = BORDER_WIDTH[borderSize]; + const ringR = 5.5; + + // isActive gates animated-dot rendering; extended to 'stopped' when animateCompleted is on + const isActive = + state === "flowing" || (animateCompleted && state === "stopped"); + // Show tone colors for both 'flowing' and 'stopped' — only 'disabled' uses neutral gray + const showTone = state !== "disabled"; + + // Resolved color tokens for sourceTone (parent) + const srcTokens = getTreeColorTokens(sourceTone); + const dstTokens = getTreeColorTokens(targetTone); + + const srcFill = srcFillOvr ?? srcTokens.connFill[ci]; + const srcBorder = srcBorderOvr ?? srcTokens.connBorder[ci]; + const srcDot = srcDotOvr ?? srcTokens.connDot[ci]; + const dstFill = + dstFillOvr ?? + (showTone ? dstTokens.connFill[ci] : NEUTRAL_TOKENS.connFill[ci]); + const dstBorder = + dstBorderOvr ?? + (showTone ? dstTokens.connBorder[ci] : NEUTRAL_TOKENS.connBorder[ci]); + const dstDot = + dstDotOvr ?? + (showTone ? dstTokens.connDot[ci] : NEUTRAL_TOKENS.connDot[ci]); + // Keep connector lines on the same tonal ramp as connector rings/cards. + const lineColor = showTone + ? dstTokens.connBorder[ci] + : NEUTRAL_TOKENS.connBorder[ci]; + const animDotColor = + dotColorOvr ?? + (isActive ? dstTokens.connDot[ci] : NEUTRAL_TOKENS.connDot[ci]); + + // ── Simple vs multi-source / fan-out mode ───────────────────────────────── + const isMultiSource = !!(leftAnchors && leftAnchors.length > 1); + const isMultiTarget = !!(rightAnchors && rightAnchors.length > 1); + // Connector SVG spans the full column height when geometry is known + const svgH = connectorHeight ?? ringR * 2 + 4; + // Source anchor Y (left ring) — first left anchor or vertical centre of SVG + const sy = leftAnchors?.[0] ?? svgH / 2; + // Target anchor Y (right ring) — when provided and different, draw a bezier curve + const ty = rightAnchorY ?? sy; + // Keep legacy name for multi-source code that uses `my` throughout + const my = sy; + + // Effective width: measured container width when fullWidth, otherwise the fixed prop + const w = fullWidth ? measuredWidth : width; + + // Fan-out: control-point X for bezier curves from source to each target lane + const fanOutCx = w / 2; + + // Global dot animation timing (px/s) + const DOT_VELOCITY = 35; + const DOT_GAP = dotSpacing / DOT_VELOCITY; + + // Arc helpers (half-rings) + // Source right-facing: opens rightward (sweep=1) + const srcArc = (y: number) => + `M 0 ${y - ringR} A ${ringR} ${ringR} 0 0 1 0 ${y + ringR}`; + // Target left-facing: opens leftward (sweep=0) — uses target anchor Y + const dstArc = `M ${w} ${ty - ringR} A ${ringR} ${ringR} 0 0 0 ${w} ${ty + ringR}`; + + // Trunk X — middle of the connector gap + const trunkX = w / 2; + // In multi-source, the vertical trunk on the left spans from first to last anchor + return ( +
+ + {/* ── Horizontal line(s) ────────────────────────────────── */} + {showLine && + (isMultiSource ? ( + <> + {/* Bezier curves per source lane converging to the feed entry point (trunkX, sy) */} + {leftAnchors!.map((ay, idx) => ( + + ))} + {/* Feed line from trunk to right ring — straight or bezier */} + + + ) : isMultiTarget ? ( + <> + {/* One smooth bezier curve per target lane — departs and arrives horizontally */} + {rightAnchors!.map((ry, idx) => ( + + ))} + + ) : ( + + ))} + + {/* ── Source rings ─────────────────────────────────────── */} + {isMultiSource ? ( + leftAnchors!.map((ay, idx) => { + const tone = + idx === 0 + ? sourceTone + : (extraSourceTones[idx - 1] ?? sourceTone); + const tok = getTreeColorTokens(tone); + const fill = idx === 0 ? srcFill : tok.connFill[ci]; + const border = idx === 0 ? srcBorder : tok.connBorder[ci]; + const dot = idx === 0 ? srcDot : tok.connDot[ci]; + return ( + + {halfRing ? ( + <> + + + ) : ( - - {halfRing ? ( - <> - - - - ) : ( - <> - - - - )} - - + <> + + + )} - - {/* ── Target entry ring(s) ── */} - {isMultiTarget ? ( - rightAnchors!.map((ry, idx) => { - const tone = rightAnchorTones[idx] ?? targetTone; - const tok = getTreeColorTokens(tone); - const laneState = rightAnchorStates[idx] ?? state; - const laneTone = laneState !== 'disabled'; - const fill = laneTone ? tok.connFill[ci] : NEUTRAL_TOKENS.connFill[ci]; - const border = laneTone ? tok.connBorder[ci] : NEUTRAL_TOKENS.connBorder[ci]; - const dot = laneTone ? tok.connDot[ci] : NEUTRAL_TOKENS.connDot[ci]; - const arc = `M ${w} ${ry - ringR} A ${ringR} ${ringR} 0 0 0 ${w} ${ry + ringR}`; - return ( - - {halfRing ? ( - <> - - - - ) : ( - <> - - - - )} - - - ); - }) + + + ); + }) + ) : ( + + {halfRing ? ( + <> + + + + ) : ( + <> + + + + )} + + + )} + + {/* ── Target entry ring(s) ── */} + {isMultiTarget ? ( + rightAnchors!.map((ry, idx) => { + const tone = rightAnchorTones[idx] ?? targetTone; + const tok = getTreeColorTokens(tone); + const laneState = rightAnchorStates[idx] ?? state; + const laneTone = laneState !== "disabled"; + const fill = laneTone + ? tok.connFill[ci] + : NEUTRAL_TOKENS.connFill[ci]; + const border = laneTone + ? tok.connBorder[ci] + : NEUTRAL_TOKENS.connBorder[ci]; + const dot = laneTone ? tok.connDot[ci] : NEUTRAL_TOKENS.connDot[ci]; + const arc = `M ${w} ${ry - ringR} A ${ringR} ${ringR} 0 0 0 ${w} ${ry + ringR}`; + return ( + + {halfRing ? ( + <> + + + ) : ( - - {halfRing ? ( - <> - - - - ) : ( - <> - - - - )} - - + <> + + + )} - - {/* ── Animated dots ────────────────────────────────────── */} - {animated && ( - isMultiTarget ? (() => { - // Fan-out: one bezier-path dot set per ACTIVE target lane - return rightAnchors!.map((ry, idx) => { - const laneState = rightAnchorStates[idx] ?? state; - const laneActive = laneState === 'flowing'; - if (!laneActive) return null; - - // Approximate arc length: horizontal span + half the vertical delta (bezier correction) - const actualLen = (w - 2 * ringR) + Math.abs(ry - sy) * 0.5; - const numDots = Math.max(1, Math.ceil(actualLen / dotSpacing)); - const virtualLen = numDots * dotSpacing; - const pathDur = numDots * DOT_GAP; - const overflow = Math.max(0, virtualLen - actualLen); - - // Same bezier as the visual line; append a short L for overflow fade-out - const pathData = ry === sy - ? `M ${ringR} ${sy} L ${w - ringR + overflow} ${sy}` - : `M ${ringR} ${sy} C ${fanOutCx} ${sy}, ${fanOutCx} ${ry}, ${w - ringR} ${ry} L ${w - ringR + overflow} ${ry}`; - - const fadeOutEnd = actualLen / virtualLen; - const fadeOutStart = Math.max(0, fadeOutEnd - 10 / virtualLen); - const fadeInEnd = Math.min(fadeOutStart, 4 / virtualLen); - const opTimes = `0;${fadeInEnd.toFixed(4)};${fadeOutStart.toFixed(4)};${fadeOutEnd.toFixed(4)};1`; - - const tone = rightAnchorTones[idx] ?? targetTone; - const tok = getTreeColorTokens(tone); - const dotFill = tok.connDot[ci]; - - return Array.from({ length: numDots }, (_, di) => ( - - - - - )); - }); - })() : !isActive ? null : isMultiSource ? ( - // One bezier path per source lane: branch bezier → feed bezier → target - leftAnchors!.map((ay, idx) => { - // Approximate length: branch segment + feed segment - const branchLen = trunkX + Math.abs(ay - sy) * 0.5; - const feedH = w - trunkX - ringR; - const feedV = sy !== ty ? Math.abs(ty - sy) * 0.5 : 0; - const actualLen = branchLen + feedH + feedV; - - const numDots = Math.max(1, Math.ceil(actualLen / dotSpacing)); - const virtualLen = numDots * dotSpacing; - const pathDur = numDots * DOT_GAP; - const overflow = Math.max(0, virtualLen - actualLen); - - // Branch bezier mirrors visual path; feed bezier matches feed line - const cx = (trunkX + w) / 2; - const feedEndX = w - ringR + overflow; - const branchSeg = ay === sy - ? `M 0 ${ay} L ${trunkX} ${sy}` - : `M 0 ${ay} C ${trunkX / 2} ${ay}, ${trunkX / 2} ${sy}, ${trunkX} ${sy}`; - const feedSeg = sy === ty - ? ` L ${feedEndX} ${ty}` - : ` C ${cx} ${sy}, ${cx} ${ty}, ${feedEndX} ${ty}`; - const pathData = `${branchSeg}${feedSeg}`; - - const fadeOutEnd = actualLen / virtualLen; - const fadeOutStart = Math.max(0, fadeOutEnd - 10 / virtualLen); - const fadeInEnd = Math.min(fadeOutStart, 4 / virtualLen); - const opTimes = `0;${fadeInEnd.toFixed(4)};${fadeOutStart.toFixed(4)};${fadeOutEnd.toFixed(4)};1`; - - return Array.from({ length: numDots }, (_, di) => ( - - - - - )); - }) - ) : (() => { - const actualLen = w - 2 * ringR; - const simpleNumDots = Math.max(1, Math.ceil(actualLen / dotSpacing)); - const virtualLen = simpleNumDots * dotSpacing; - const simpleDur = simpleNumDots * DOT_GAP; - const overflow = Math.max(0, virtualLen - actualLen); - - const fadeOutEnd = (actualLen / virtualLen); - const fadeOutStart = Math.max(0, fadeOutEnd - (10 / virtualLen)); - const fadeInEnd = Math.min(fadeOutStart, (4 / virtualLen)); - const opTimes = `0;${fadeInEnd.toFixed(4)};${fadeOutStart.toFixed(4)};${fadeOutEnd.toFixed(4)};1`; - - // When source and target are at different Y positions use animateMotion - // along the same bezier path as the visible line so dots track it exactly. - const isAngled = Math.abs(ty - sy) > 1; - const motionPath = isAngled - ? `M ${ringR} ${sy} C ${w / 2} ${sy}, ${w / 2} ${ty}, ${w - ringR + overflow} ${ty}` - : undefined; - - return Array.from({ length: simpleNumDots }, (_, i) => ( - - {isAngled ? ( - - ) : ( - - )} - - - )); - })() - )} - - - {/* Optional middle icon (single-source only) */} - {!isMultiSource && middleIcon && ( - fullWidth ? ( -
- {middleIcon} -
- ) : ( -
- {middleIcon} -
- ) + + + ); + }) + ) : ( + + {halfRing ? ( + <> + + + + ) : ( + <> + + + )} -
- ); + + + )} + + {/* ── Animated dots ────────────────────────────────────── */} + {animated && + (isMultiTarget + ? (() => { + // Fan-out: one bezier-path dot set per ACTIVE target lane + return rightAnchors!.map((ry, idx) => { + const laneState = rightAnchorStates[idx] ?? state; + const laneActive = laneState === "flowing"; + if (!laneActive) return null; + + // Approximate arc length: horizontal span + half the vertical delta (bezier correction) + const actualLen = w - 2 * ringR + Math.abs(ry - sy) * 0.5; + const numDots = Math.max( + 1, + Math.ceil(actualLen / dotSpacing), + ); + const virtualLen = numDots * dotSpacing; + const pathDur = numDots * DOT_GAP; + const overflow = Math.max(0, virtualLen - actualLen); + + // Same bezier as the visual line; append a short L for overflow fade-out + const pathData = + ry === sy + ? `M ${ringR} ${sy} L ${w - ringR + overflow} ${sy}` + : `M ${ringR} ${sy} C ${fanOutCx} ${sy}, ${fanOutCx} ${ry}, ${w - ringR} ${ry} L ${w - ringR + overflow} ${ry}`; + + const fadeOutEnd = actualLen / virtualLen; + const fadeOutStart = Math.max( + 0, + fadeOutEnd - 10 / virtualLen, + ); + const fadeInEnd = Math.min(fadeOutStart, 4 / virtualLen); + const opTimes = `0;${fadeInEnd.toFixed(4)};${fadeOutStart.toFixed(4)};${fadeOutEnd.toFixed(4)};1`; + + const tone = rightAnchorTones[idx] ?? targetTone; + const tok = getTreeColorTokens(tone); + const dotFill = tok.connDot[ci]; + + return Array.from({ length: numDots }, (_, di) => ( + + + + + )); + }); + })() + : !isActive + ? null + : isMultiSource + ? // One bezier path per source lane: branch bezier → feed bezier → target + leftAnchors!.map((ay, idx) => { + // Approximate length: branch segment + feed segment + const branchLen = trunkX + Math.abs(ay - sy) * 0.5; + const feedH = w - trunkX - ringR; + const feedV = sy !== ty ? Math.abs(ty - sy) * 0.5 : 0; + const actualLen = branchLen + feedH + feedV; + + const numDots = Math.max( + 1, + Math.ceil(actualLen / dotSpacing), + ); + const virtualLen = numDots * dotSpacing; + const pathDur = numDots * DOT_GAP; + const overflow = Math.max(0, virtualLen - actualLen); + + // Branch bezier mirrors visual path; feed bezier matches feed line + const cx = (trunkX + w) / 2; + const feedEndX = w - ringR + overflow; + const branchSeg = + ay === sy + ? `M 0 ${ay} L ${trunkX} ${sy}` + : `M 0 ${ay} C ${trunkX / 2} ${ay}, ${trunkX / 2} ${sy}, ${trunkX} ${sy}`; + const feedSeg = + sy === ty + ? ` L ${feedEndX} ${ty}` + : ` C ${cx} ${sy}, ${cx} ${ty}, ${feedEndX} ${ty}`; + const pathData = `${branchSeg}${feedSeg}`; + + const fadeOutEnd = actualLen / virtualLen; + const fadeOutStart = Math.max( + 0, + fadeOutEnd - 10 / virtualLen, + ); + const fadeInEnd = Math.min(fadeOutStart, 4 / virtualLen); + const opTimes = `0;${fadeInEnd.toFixed(4)};${fadeOutStart.toFixed(4)};${fadeOutEnd.toFixed(4)};1`; + + return Array.from({ length: numDots }, (_, di) => ( + + + + + )); + }) + : (() => { + const actualLen = w - 2 * ringR; + const simpleNumDots = Math.max( + 1, + Math.ceil(actualLen / dotSpacing), + ); + const virtualLen = simpleNumDots * dotSpacing; + const simpleDur = simpleNumDots * DOT_GAP; + const overflow = Math.max(0, virtualLen - actualLen); + + const fadeOutEnd = actualLen / virtualLen; + const fadeOutStart = Math.max( + 0, + fadeOutEnd - 10 / virtualLen, + ); + const fadeInEnd = Math.min(fadeOutStart, 4 / virtualLen); + const opTimes = `0;${fadeInEnd.toFixed(4)};${fadeOutStart.toFixed(4)};${fadeOutEnd.toFixed(4)};1`; + + // When source and target are at different Y positions use animateMotion + // along the same bezier path as the visible line so dots track it exactly. + const isAngled = Math.abs(ty - sy) > 1; + const motionPath = isAngled + ? `M ${ringR} ${sy} C ${w / 2} ${sy}, ${w / 2} ${ty}, ${w - ringR + overflow} ${ty}` + : undefined; + + return Array.from({ length: simpleNumDots }, (_, i) => ( + + {isAngled ? ( + + ) : ( + + )} + + + )); + })())} + + + {/* Optional middle icon (single-source only) */} + {!isMultiSource && + middleIcon && + (fullWidth ? ( +
+ {middleIcon} +
+ ) : ( +
+ {middleIcon} +
+ ))} +
+ ); }; export { ConnectionFlowConnector }; diff --git a/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowParallelGroup.tsx b/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowParallelGroup.tsx index 9df1256..c95cd28 100644 --- a/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowParallelGroup.tsx +++ b/packages/ui-kit/src/components/ConnectionFlow/ConnectionFlowParallelGroup.tsx @@ -1,147 +1,150 @@ -import React, { useEffect, useRef, useState } from 'react'; -import classNames from 'classnames'; -import TreeItemCard from '../TreeView/TreeItemCard'; -import type { TreeTone } from '../TreeView/types'; -import type { ConnectionFlowItem } from './types'; -import type { ColumnGeometry } from './ConnectionFlowColumn'; +import React, { useEffect, useRef, useState } from "react"; +import classNames from "classnames"; +import TreeItemCard from "../TreeView/TreeItemCard"; +import type { TreeTone } from "../TreeView/types"; +import type { ConnectionFlowItem } from "./types"; +import type { ColumnGeometry } from "./ConnectionFlowColumn"; // Gap between parallel item cards const GROUP_ROW_GAP = 8; function useElementHeight(ref: React.RefObject): number { - const [h, setH] = useState(0); - useEffect(() => { - const el = ref.current; - if (!el) return; - let raf: number; - const ro = new ResizeObserver(() => { - raf = requestAnimationFrame(() => { - if (el) setH(el.getBoundingClientRect().height); - }); - }); - ro.observe(el); - return () => { cancelAnimationFrame(raf); ro.disconnect(); }; - }, [ref]); - return h; + const [h, setH] = useState(0); + useEffect(() => { + const el = ref.current; + if (!el) return; + let raf: number; + const ro = new ResizeObserver(() => { + raf = requestAnimationFrame(() => { + if (el) setH(el.getBoundingClientRect().height); + }); + }); + ro.observe(el); + return () => { + cancelAnimationFrame(raf); + ro.disconnect(); + }; + }, [ref]); + return h; } export interface ConnectionFlowParallelGroupProps { - items: ConnectionFlowItem[]; - globalTone?: TreeTone; - itemWidth?: number | string; - hoverable?: boolean; - onGeometryChange?: (geo: ColumnGeometry) => void; + items: ConnectionFlowItem[]; + globalTone?: TreeTone; + itemWidth?: number | string; + hoverable?: boolean; + onGeometryChange?: (geo: ColumnGeometry) => void; } -const ConnectionFlowParallelGroup: React.FC = ({ - items, - globalTone, - itemWidth, - hoverable = false, - onGeometryChange, -}) => { - const groupRef = useRef(null); - const groupH = useElementHeight(groupRef); - const itemCount = items.length; +const ConnectionFlowParallelGroup: React.FC< + ConnectionFlowParallelGroupProps +> = ({ items, globalTone, itemWidth, hoverable = false, onGeometryChange }) => { + const groupRef = useRef(null); + const groupH = useElementHeight(groupRef); + const itemCount = items.length; - // Per-item height measurements via individual refs - const itemRefs = useRef<(HTMLDivElement | null)[]>([]); - const [itemHeights, setItemHeights] = useState(() => Array(itemCount).fill(0)); + // Per-item height measurements via individual refs + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + const [itemHeights, setItemHeights] = useState(() => + Array(itemCount).fill(0), + ); - // Reset when item count changes - useEffect(() => { - setItemHeights(Array(itemCount).fill(0)); - itemRefs.current = itemRefs.current.slice(0, itemCount); - }, [itemCount]); + // Reset when item count changes + useEffect(() => { + setItemHeights(Array(itemCount).fill(0)); + itemRefs.current = itemRefs.current.slice(0, itemCount); + }, [itemCount]); - // Register a ResizeObserver per item - useEffect(() => { - const observers: ResizeObserver[] = []; - const rafs: number[] = []; - itemRefs.current.forEach((el, i) => { - if (!el) return; - const ro = new ResizeObserver(() => { - const raf = requestAnimationFrame(() => { - if (!el) return; - const h = el.getBoundingClientRect().height; - setItemHeights(prev => { - if (prev[i] === h) return prev; - const n = [...prev]; - n[i] = h; - return n; - }); - }); - rafs.push(raf); - }); - ro.observe(el); - observers.push(ro); + // Register a ResizeObserver per item + useEffect(() => { + const observers: ResizeObserver[] = []; + const rafs: number[] = []; + itemRefs.current.forEach((el, i) => { + if (!el) return; + const ro = new ResizeObserver(() => { + const raf = requestAnimationFrame(() => { + if (!el) return; + const h = el.getBoundingClientRect().height; + setItemHeights((prev) => { + if (prev[i] === h) return prev; + const n = [...prev]; + n[i] = h; + return n; + }); }); - return () => { - rafs.forEach(cancelAnimationFrame); - observers.forEach(ro => ro.disconnect()); - }; + rafs.push(raf); + }); + ro.observe(el); + observers.push(ro); + }); + return () => { + rafs.forEach(cancelAnimationFrame); + observers.forEach((ro) => ro.disconnect()); + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [itemCount]); + }, [itemCount]); - // Report geometry: anchor at the vertical centre of each item card - useEffect(() => { - if (!onGeometryChange || groupH === 0) return; - if (itemHeights.some(h => h === 0)) return; + // Report geometry: anchor at the vertical centre of each item card + useEffect(() => { + if (!onGeometryChange || groupH === 0) return; + if (itemHeights.some((h) => h === 0)) return; - const anchors: number[] = []; - let y = 0; - itemHeights.forEach(h => { - anchors.push(y + h / 2); - y += h + GROUP_ROW_GAP; - }); + const anchors: number[] = []; + let y = 0; + itemHeights.forEach((h) => { + anchors.push(y + h / 2); + y += h + GROUP_ROW_GAP; + }); - onGeometryChange({ totalHeight: groupH, anchors, isParallelGroup: true }); - }, [groupH, itemHeights, onGeometryChange]); + onGeometryChange({ totalHeight: groupH, anchors, isParallelGroup: true }); + }, [groupH, itemHeights, onGeometryChange]); - return ( -
+ {items.map((item, i) => { + const tone: TreeTone = item.tone ?? globalTone ?? "neutral"; + return ( +
{ + itemRefs.current[i] = el; }} - > - {items.map((item, i) => { - const tone: TreeTone = item.tone ?? globalTone ?? 'neutral'; - return ( -
{ itemRefs.current[i] = el; }} - > - -
- ); - })} -
- ); + > + +
+ ); + })} +
+ ); }; export default ConnectionFlowParallelGroup; diff --git a/packages/ui-kit/src/components/ConnectionFlow/index.ts b/packages/ui-kit/src/components/ConnectionFlow/index.ts index 273ee58..ef9a50e 100644 --- a/packages/ui-kit/src/components/ConnectionFlow/index.ts +++ b/packages/ui-kit/src/components/ConnectionFlow/index.ts @@ -1,5 +1,10 @@ -export { default as ConnectionFlow } from './ConnectionFlow'; -export { ConnectionFlowConnector } from './ConnectionFlowConnector'; -export { default as ConnectionFlowColumn } from './ConnectionFlowColumn'; -export { default as ConnectionFlowParallelGroup } from './ConnectionFlowParallelGroup'; -export type { ConnectionFlowProps, ConnectionFlowItem, ConnectionFlowConnectorConfig, ConnectionState } from './types'; +export { default as ConnectionFlow } from "./ConnectionFlow"; +export { ConnectionFlowConnector } from "./ConnectionFlowConnector"; +export { default as ConnectionFlowColumn } from "./ConnectionFlowColumn"; +export { default as ConnectionFlowParallelGroup } from "./ConnectionFlowParallelGroup"; +export type { + ConnectionFlowProps, + ConnectionFlowItem, + ConnectionFlowConnectorConfig, + ConnectionState, +} from "./types"; diff --git a/packages/ui-kit/src/components/ConnectionFlow/types.ts b/packages/ui-kit/src/components/ConnectionFlow/types.ts index 481f365..84c41e3 100644 --- a/packages/ui-kit/src/components/ConnectionFlow/types.ts +++ b/packages/ui-kit/src/components/ConnectionFlow/types.ts @@ -1,7 +1,7 @@ -import type React from 'react'; -import type { TreeTone } from '../TreeView/types'; +import type React from "react"; +import type { TreeTone } from "../TreeView/types"; -export type ConnectionState = 'flowing' | 'stopped' | 'disabled'; +export type ConnectionState = "flowing" | "stopped" | "disabled"; // ── Connector config — lives on the "receiver" item ───────────────────────── // The connector is the line/SVG between the previous item and this one. @@ -9,238 +9,238 @@ export type ConnectionState = 'flowing' | 'stopped' | 'disabled'; // the bridge from item[0] → item[1], etc. export interface ConnectionFlowConnectorConfig { - /** Active flow state for this connector */ - state?: ConnectionState; - /** Optional React element to render in the middle (e.g. an icon) */ - icon?: React.ReactNode; - /** Width of the connector band in px (default: 56) */ - width?: number; - /** Whether to animate the flowing dots (default: true) */ - animated?: boolean; - /** target px per dot cycle (default: 60) */ - dotSpacing?: number; - /** Connector ring border width (default: 'xs') */ - borderSize?: 'fit' | 'xs' | 'sm' | 'md' | 'lg'; - /** Whether to draw half-rings (default: true) */ - halfRing?: boolean; - /** Show the horizontal line between rings (default: true) */ - showLine?: boolean; - /** Override — force the source-side ring tone instead of inheriting from source item */ - sourceTone?: TreeTone; - /** Override — force the target-side ring tone instead of inheriting from this item */ - targetTone?: TreeTone; + /** Active flow state for this connector */ + state?: ConnectionState; + /** Optional React element to render in the middle (e.g. an icon) */ + icon?: React.ReactNode; + /** Width of the connector band in px (default: 56) */ + width?: number; + /** Whether to animate the flowing dots (default: true) */ + animated?: boolean; + /** target px per dot cycle (default: 60) */ + dotSpacing?: number; + /** Connector ring border width (default: 'xs') */ + borderSize?: "fit" | "xs" | "sm" | "md" | "lg"; + /** Whether to draw half-rings (default: true) */ + halfRing?: boolean; + /** Show the horizontal line between rings (default: true) */ + showLine?: boolean; + /** Override — force the source-side ring tone instead of inheriting from source item */ + sourceTone?: TreeTone; + /** Override — force the target-side ring tone instead of inheriting from this item */ + targetTone?: TreeTone; - // ── Fine-grained color overrides (both source and target sides) ────────── - // When set, these take precedence over anything derived from tone tokens. + // ── Fine-grained color overrides (both source and target sides) ────────── + // When set, these take precedence over anything derived from tone tokens. - /** Source ring background fill color (CSS string, e.g. '#22c55e' or 'rgba(...)') */ - sourceFill?: string; - /** Source ring stroke/border color */ - sourceBorder?: string; - /** Source ring center dot color */ - sourceDot?: string; + /** Source ring background fill color (CSS string, e.g. '#22c55e' or 'rgba(...)') */ + sourceFill?: string; + /** Source ring stroke/border color */ + sourceBorder?: string; + /** Source ring center dot color */ + sourceDot?: string; - /** Target ring background fill color */ - targetFill?: string; - /** Target ring stroke/border color */ - targetBorder?: string; - /** Target ring center dot color */ - targetDot?: string; + /** Target ring background fill color */ + targetFill?: string; + /** Target ring stroke/border color */ + targetBorder?: string; + /** Target ring center dot color */ + targetDot?: string; - /** Animated dot color (overrides what is derived from targetTone) */ - dotColor?: string; - /** - * When true, flowing dots are shown even when this connector's state is `'stopped'` - * (i.e. already-traversed / completed edges also animate). - * Default: false - */ - animateCompleted?: boolean; + /** Animated dot color (overrides what is derived from targetTone) */ + dotColor?: string; + /** + * When true, flowing dots are shown even when this connector's state is `'stopped'` + * (i.e. already-traversed / completed edges also animate). + * Default: false + */ + animateCompleted?: boolean; } // ── ConnectionFlowItem ──────────────────────────────────────────────────────── // Extends TreeItemData fields we care about, plus an optional connector config. export interface ConnectionFlowItem { - id: string; - // Icon slot - icon?: React.ReactNode; - iconClassName?: string; - // Text content - title?: React.ReactNode; - titleClassName?: string; - /** When true, the card title wraps on word boundaries up to 10 lines instead of truncating. Default: false */ - titleWrap?: boolean; - /** When true, the card title stays on one line and scrolls horizontally. Default: false */ - titleScroll?: boolean; - subtitle?: React.ReactNode; - subtitleClassName?: string; - description?: React.ReactNode; - descriptionClassName?: string; - /** Optional badge/status slot rendered below description, without tone-derived text styling. */ - badge?: React.ReactNode; - // Appearance - tone?: TreeTone; - // Expandable body - body?: React.ReactNode; - defaultExpanded?: boolean; - // Actions - actions?: React.ReactNode; - hoverActions?: React.ReactNode; - /** - * Connector config for the edge leading INTO this item. - * If not provided, inherits from the ConnectionFlow's default state. - * Ignored for the first item (it has no predecessor). - */ - connector?: ConnectionFlowConnectorConfig; - /** - * Children render vertically below this item's card using TreeView-style - * SVG connectors. The connector between this item and the next sibling - * spans the full column height (parent + children). - */ - children?: ConnectionFlowItem[]; - /** - * When true, this item has NO right-side connector to the next sibling - * (it terminates the horizontal flow after itself). - * Default: false - */ - terminal?: boolean; - /** - * Active state — drives the vertical sub-tree dot animation color. - * Default: false - */ - active?: boolean; - /** - * When true, this item is part of a parallel group. Consecutive items with - * parallel=true are rendered as a single vertical column instead of separate - * horizontal nodes. Fan-out (1→N) and fan-in (N→1) connectors are added automatically. - * Default: false - */ - parallel?: boolean; - /** - * When true, the card shows a subtle hover lift effect (shadow + translate-y). - * Default: false - */ - hoverable?: boolean; - /** - * When true, overlays a pulsing background animation on the card (uses the item's tone). - * Typically combined with `active: true` to indicate in-progress steps. - * Default: false - */ - activePulse?: boolean; - /** - * When true, this step was skipped — the execution flow bypassed it. - * A visual bypass arc is drawn from the last non-skipped predecessor to the - * first non-skipped successor, arching over all consecutive skipped items. - * Default: false - */ - skipped?: boolean; + id: string; + // Icon slot + icon?: React.ReactNode; + iconClassName?: string; + // Text content + title?: React.ReactNode; + titleClassName?: string; + /** When true, the card title wraps on word boundaries up to 10 lines instead of truncating. Default: false */ + titleWrap?: boolean; + /** When true, the card title stays on one line and scrolls horizontally. Default: false */ + titleScroll?: boolean; + subtitle?: React.ReactNode; + subtitleClassName?: string; + description?: React.ReactNode; + descriptionClassName?: string; + /** Optional badge/status slot rendered below description, without tone-derived text styling. */ + badge?: React.ReactNode; + // Appearance + tone?: TreeTone; + // Expandable body + body?: React.ReactNode; + defaultExpanded?: boolean; + // Actions + actions?: React.ReactNode; + hoverActions?: React.ReactNode; + /** + * Connector config for the edge leading INTO this item. + * If not provided, inherits from the ConnectionFlow's default state. + * Ignored for the first item (it has no predecessor). + */ + connector?: ConnectionFlowConnectorConfig; + /** + * Children render vertically below this item's card using TreeView-style + * SVG connectors. The connector between this item and the next sibling + * spans the full column height (parent + children). + */ + children?: ConnectionFlowItem[]; + /** + * When true, this item has NO right-side connector to the next sibling + * (it terminates the horizontal flow after itself). + * Default: false + */ + terminal?: boolean; + /** + * Active state — drives the vertical sub-tree dot animation color. + * Default: false + */ + active?: boolean; + /** + * When true, this item is part of a parallel group. Consecutive items with + * parallel=true are rendered as a single vertical column instead of separate + * horizontal nodes. Fan-out (1→N) and fan-in (N→1) connectors are added automatically. + * Default: false + */ + parallel?: boolean; + /** + * When true, the card shows a subtle hover lift effect (shadow + translate-y). + * Default: false + */ + hoverable?: boolean; + /** + * When true, overlays a pulsing background animation on the card (uses the item's tone). + * Typically combined with `active: true` to indicate in-progress steps. + * Default: false + */ + activePulse?: boolean; + /** + * When true, this step was skipped — the execution flow bypassed it. + * A visual bypass arc is drawn from the last non-skipped predecessor to the + * first non-skipped successor, arching over all consecutive skipped items. + * Default: false + */ + skipped?: boolean; } // ── ConnectionFlowProps ─────────────────────────────────────────────────────── export interface ConnectionFlowProps { - /** The nodes to render horizontally */ - items: ConnectionFlowItem[]; - /** - * Fallback state applied to any connector that doesn't have its own `state`. - * Default: 'flowing' - */ - flowState?: ConnectionState; - /** - * Fallback icon shown in the middle of connectors (overridden per-item). - */ - flowIcon?: React.ReactNode; - /** - * Fallback connector width in px. - * Default: 56 - */ - connectorWidth?: number; - /** - * Whether flowing-dot animation is enabled globally. - * Default: true - */ - animated?: boolean; - /** - * Target px between dots globally. - * Default: 60 - */ - dotSpacing?: number; - /** - * Ring border size globally. - * Default: 'xs' - */ - connectorBorderSize?: 'fit' | 'xs' | 'sm' | 'md' | 'lg'; - /** - * Draw half-rings (matching TreeView half-connector style). - * Default: true - */ - connectorHalf?: boolean; - /** - * Show the horizontal line between connectors. - * Default: true - */ - showLine?: boolean; - /** Indent size for children sub-trees. Default: 'xs' */ - childIndent?: 'xs' | 'sm' | 'md' | 'lg'; - /** Row gap in px between children. Default: 8 */ - childRowGap?: number; - /** - * If true, allows the flow to scroll horizontally/vertically if the container is smaller than the flow. - * Default: false - */ - allowScroll?: boolean; - /** - * Set a fixed width for all items in the flow (e.g. 250, '300px'). - * Primarily useful when `allowScroll` is enabled. - */ - itemWidth?: number | string; - /** - * When true, automatically scales the entire flow down to fit the container width. - * Once the scale would go below `minScale`, the flow is clamped and allowed to scroll. - * Default: false - */ - autoScale?: boolean; - /** - * Minimum CSS scale factor applied when `autoScale` is true. - * Below this value the flow falls back to horizontal scroll instead of shrinking further. - * Default: 0.55 - */ - minScale?: number; - className?: string; - /** Extra content to render to the right of the entire flow (e.g. an expand toggle) */ - rightAction?: React.ReactNode; - /** - * When true, connectors stretch to fill all remaining width instead of using a fixed - * pixel size. Card columns collapse to their natural content width. The connector icon - * is always positioned at the exact midpoint of the expanded connector. - * Default: false - */ - fullWidthConnectors?: boolean; - /** - * When true, all cards in the flow show a hover lift effect. - * Default: false - */ - hoverable?: boolean; - /** - * When true, flowing dots are rendered on `'stopped'` (completed / already-traversed) - * connectors in addition to `'flowing'` ones. Bypass arcs over skipped steps always - * animate when `animated` is true, regardless of this flag. - * Default: false - */ - animateCompleted?: boolean; - /** - * When true, the engine automatically manages connector states and skipped detection - * based purely on each item's `tone`: - * - * - **Connector state** (when no explicit `connector.state` is set): - * - Non-neutral source tone → `'stopped'` (solid toned line — step was traversed) - * - Neutral source tone → `'disabled'` (dashed gray — step not yet reached) - * - * - **Skipped detection**: a neutral-tone item that has at least one non-neutral - * successor is automatically treated as skipped — a bypass arc is drawn over it. - * - * Explicit per-item `connector.state` or `item.skipped` values always take precedence. - * Default: false - */ - autoConnectorState?: boolean; + /** The nodes to render horizontally */ + items: ConnectionFlowItem[]; + /** + * Fallback state applied to any connector that doesn't have its own `state`. + * Default: 'flowing' + */ + flowState?: ConnectionState; + /** + * Fallback icon shown in the middle of connectors (overridden per-item). + */ + flowIcon?: React.ReactNode; + /** + * Fallback connector width in px. + * Default: 56 + */ + connectorWidth?: number; + /** + * Whether flowing-dot animation is enabled globally. + * Default: true + */ + animated?: boolean; + /** + * Target px between dots globally. + * Default: 60 + */ + dotSpacing?: number; + /** + * Ring border size globally. + * Default: 'xs' + */ + connectorBorderSize?: "fit" | "xs" | "sm" | "md" | "lg"; + /** + * Draw half-rings (matching TreeView half-connector style). + * Default: true + */ + connectorHalf?: boolean; + /** + * Show the horizontal line between connectors. + * Default: true + */ + showLine?: boolean; + /** Indent size for children sub-trees. Default: 'xs' */ + childIndent?: "xs" | "sm" | "md" | "lg"; + /** Row gap in px between children. Default: 8 */ + childRowGap?: number; + /** + * If true, allows the flow to scroll horizontally/vertically if the container is smaller than the flow. + * Default: false + */ + allowScroll?: boolean; + /** + * Set a fixed width for all items in the flow (e.g. 250, '300px'). + * Primarily useful when `allowScroll` is enabled. + */ + itemWidth?: number | string; + /** + * When true, automatically scales the entire flow down to fit the container width. + * Once the scale would go below `minScale`, the flow is clamped and allowed to scroll. + * Default: false + */ + autoScale?: boolean; + /** + * Minimum CSS scale factor applied when `autoScale` is true. + * Below this value the flow falls back to horizontal scroll instead of shrinking further. + * Default: 0.55 + */ + minScale?: number; + className?: string; + /** Extra content to render to the right of the entire flow (e.g. an expand toggle) */ + rightAction?: React.ReactNode; + /** + * When true, connectors stretch to fill all remaining width instead of using a fixed + * pixel size. Card columns collapse to their natural content width. The connector icon + * is always positioned at the exact midpoint of the expanded connector. + * Default: false + */ + fullWidthConnectors?: boolean; + /** + * When true, all cards in the flow show a hover lift effect. + * Default: false + */ + hoverable?: boolean; + /** + * When true, flowing dots are rendered on `'stopped'` (completed / already-traversed) + * connectors in addition to `'flowing'` ones. Bypass arcs over skipped steps always + * animate when `animated` is true, regardless of this flag. + * Default: false + */ + animateCompleted?: boolean; + /** + * When true, the engine automatically manages connector states and skipped detection + * based purely on each item's `tone`: + * + * - **Connector state** (when no explicit `connector.state` is set): + * - Non-neutral source tone → `'stopped'` (solid toned line — step was traversed) + * - Neutral source tone → `'disabled'` (dashed gray — step not yet reached) + * + * - **Skipped detection**: a neutral-tone item that has at least one non-neutral + * successor is automatically treated as skipped — a bypass arc is drawn over it. + * + * Explicit per-item `connector.state` or `item.skipped` values always take precedence. + * Default: false + */ + autoConnectorState?: boolean; } diff --git a/packages/ui-kit/src/components/CustomIcon.tsx b/packages/ui-kit/src/components/CustomIcon.tsx index ff7cdbb..3d7d59f 100644 --- a/packages/ui-kit/src/components/CustomIcon.tsx +++ b/packages/ui-kit/src/components/CustomIcon.tsx @@ -45,7 +45,7 @@ export const CustomIcon: React.FC = ({ onClick, colored = false, color, - hoverColor + hoverColor, }) => { const IconComponent = iconRegistry[icon]; @@ -74,7 +74,10 @@ export const CustomIcon: React.FC = ({ return style; }, [dimension, color, hoverColor, colored]); - const fallbackSizeClass = !dimension && !hasExplicitSize(className) ? SIZE_CLASS_MAP[size] : undefined; + const fallbackSizeClass = + !dimension && !hasExplicitSize(className) + ? SIZE_CLASS_MAP[size] + : undefined; const iconClass = mergeClassTokens( "inline-flex items-center justify-center flex-shrink-0 [&>svg]:h-full [&>svg]:w-full", @@ -104,4 +107,3 @@ export const CustomIcon: React.FC = ({ }; export default CustomIcon; - diff --git a/packages/ui-kit/src/components/DetailItemCard.tsx b/packages/ui-kit/src/components/DetailItemCard.tsx index d574d57..35f936f 100644 --- a/packages/ui-kit/src/components/DetailItemCard.tsx +++ b/packages/ui-kit/src/components/DetailItemCard.tsx @@ -43,7 +43,10 @@ const DetailItemCard: React.FC = ({ }; return ( -
+
{hasDetails && (
@@ -63,18 +66,36 @@ const DetailItemCard: React.FC = ({
)}
-
{title}
- {subtitle &&
{subtitle}
} - {description &&
{description}
} - {badgesAlignment == "bottom" &&
{badges}
} - {badgesAlignment == "bottom-end" &&
{badges}
} +
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} + {description && ( +
+ {description} +
+ )} + {badgesAlignment == "bottom" && ( +
{badges}
+ )} + {badgesAlignment == "bottom-end" && ( +
{badges}
+ )}
- {badgesAlignment == "right" &&
{badges}
} + {badgesAlignment == "right" && ( +
{badges}
+ )}
{hasDetails && expanded && ( -
{children}
+
+ {children} +
)}
); diff --git a/packages/ui-kit/src/components/DropdownButton.tsx b/packages/ui-kit/src/components/DropdownButton.tsx index bb2900e..88ec952 100644 --- a/packages/ui-kit/src/components/DropdownButton.tsx +++ b/packages/ui-kit/src/components/DropdownButton.tsx @@ -8,7 +8,10 @@ export interface DropdownButtonOption extends DropdownMenuOption { } export interface DropdownButtonProps - extends Omit { + extends Omit< + ButtonProps, + "children" | "leadingIcon" | "trailingIcon" | "iconOnly" | "fullWidth" + > { label: React.ReactNode; options: DropdownButtonOption[]; onPrimaryClick?: (event: React.MouseEvent) => void; @@ -46,7 +49,11 @@ export const DropdownButton: React.FC = ({ const [open, setOpen] = useState(false); const caretRef = useRef(null); const anchorRef = useRef(null); - const containerClasses = classNames("inline-flex items-stretch", fullWidth && "w-full", className); + const containerClasses = classNames( + "inline-flex items-stretch", + fullWidth && "w-full", + className, + ); const handleSelect = (option: DropdownButtonOption) => { onOptionSelect?.(option); @@ -106,7 +113,7 @@ export const DropdownButton: React.FC = ({ className={classNames( "rounded-l-none border-l border-white/20 text-inherit dark:border-white/10", split && caretWidthMap[size], - caretIconClassMap[size] + caretIconClassMap[size], )} onClick={handleCaretToggle} disabled={disabled || options.length === 0} @@ -119,7 +126,10 @@ export const DropdownButton: React.FC = ({ variant={variant} color={color} size={size} - className={classNames(split && showCaret && "rounded-r-none", fullWidth ? "flex-1" : "")} + className={classNames( + split && showCaret && "rounded-r-none", + fullWidth ? "flex-1" : "", + )} disabled={disabled} onClick={handlePrimaryClick} {...restButtonProps} diff --git a/packages/ui-kit/src/components/DropdownMenu.tsx b/packages/ui-kit/src/components/DropdownMenu.tsx index 6193719..3c9a7a4 100644 --- a/packages/ui-kit/src/components/DropdownMenu.tsx +++ b/packages/ui-kit/src/components/DropdownMenu.tsx @@ -1,4 +1,11 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { createPortal } from "react-dom"; import classNames from "classnames"; import { useIconRenderer } from "../contexts/IconContext"; @@ -118,7 +125,7 @@ export const DropdownMenu: React.FC = ({ onSelect?.(item); onClose(); }, - [onClose, onSelect] + [onClose, onSelect], ); useEffect(() => { @@ -126,7 +133,10 @@ export const DropdownMenu: React.FC = ({ return; } const handlePointer = (event: MouseEvent) => { - if (menuRef.current?.contains(event.target as Node) || anchorRef.current?.contains(event.target as Node)) { + if ( + menuRef.current?.contains(event.target as Node) || + anchorRef.current?.contains(event.target as Node) + ) { return; } onClose(); @@ -160,8 +170,11 @@ export const DropdownMenu: React.FC = ({ } const anchorRect = anchorRef.current.getBoundingClientRect(); - const caretElement = anchorRef.current.querySelector("[data-dropdown-caret]"); - const alignReferenceRect = caretElement?.getBoundingClientRect() ?? anchorRect; + const caretElement = anchorRef.current.querySelector( + "[data-dropdown-caret]", + ); + const alignReferenceRect = + caretElement?.getBoundingClientRect() ?? anchorRect; const menuRect = menuRef.current.getBoundingClientRect(); const boundary = resolveBoundaryBounds(anchorRef.current); const zIndex = resolveAnchorLayerZIndex(anchorRef.current); @@ -183,7 +196,10 @@ export const DropdownMenu: React.FC = ({ const overflowForTop = (top: number): number => { const overflowTop = Math.max(0, boundary.top + minMargin - top); - const overflowBottom = Math.max(0, top + computedHeight - (boundary.bottom - minMargin)); + const overflowBottom = Math.max( + 0, + top + computedHeight - (boundary.bottom - minMargin), + ); return overflowTop + overflowBottom; }; @@ -193,18 +209,28 @@ export const DropdownMenu: React.FC = ({ const belowOverflow = overflowForTop(belowTop); const aboveOverflow = overflowForTop(aboveTop); - if (aboveOverflow < belowOverflow) return { top: aboveTop, isTopSide: true }; + if (aboveOverflow < belowOverflow) + return { top: aboveTop, isTopSide: true }; return { top: belowTop, isTopSide: false }; }; const verticalChoice = chooseTop(); const clampedTop = Math.min( Math.max(verticalChoice.top, boundary.top + minMargin), - Math.max(boundary.top + minMargin, boundary.bottom - computedHeight - minMargin), + Math.max( + boundary.top + minMargin, + boundary.bottom - computedHeight - minMargin, + ), ); - const availableBelow = Math.max(120, boundary.bottom - minMargin - belowTop); - const availableAbove = Math.max(120, anchorRect.top - offset - (boundary.top + minMargin)); + const availableBelow = Math.max( + 120, + boundary.bottom - minMargin - belowTop, + ); + const availableAbove = Math.max( + 120, + anchorRect.top - offset - (boundary.top + minMargin), + ); const nextMaxHeight = Math.max( 120, Math.min( @@ -217,18 +243,25 @@ export const DropdownMenu: React.FC = ({ const endLeft = alignReferenceRect.right - computedWidth; const overflowForLeft = (left: number): number => { const overflowLeft = Math.max(0, boundary.left + minMargin - left); - const overflowRight = Math.max(0, left + computedWidth - (boundary.right - minMargin)); + const overflowRight = Math.max( + 0, + left + computedWidth - (boundary.right - minMargin), + ); return overflowLeft + overflowRight; }; const preferredLeft = align === "start" ? startLeft : endLeft; const alternateLeft = align === "start" ? endLeft : startLeft; - const leftCandidate = overflowForLeft(preferredLeft) <= overflowForLeft(alternateLeft) - ? preferredLeft - : alternateLeft; + const leftCandidate = + overflowForLeft(preferredLeft) <= overflowForLeft(alternateLeft) + ? preferredLeft + : alternateLeft; const clampedLeft = Math.min( Math.max(leftCandidate, boundary.left + minMargin), - Math.max(boundary.left + minMargin, boundary.right - computedWidth - minMargin), + Math.max( + boundary.left + minMargin, + boundary.right - computedWidth - minMargin, + ), ); const nextStyle: React.CSSProperties = { @@ -248,10 +281,14 @@ export const DropdownMenu: React.FC = ({ setStyle((prev) => { const prevTop = typeof prev?.top === "string" ? prev.top : ""; const prevLeft = typeof prev?.left === "string" ? prev.left : ""; - const prevWidth = typeof prev?.width === "number" ? prev.width : undefined; - const prevMinWidth = typeof prev?.minWidth === "number" ? prev.minWidth : undefined; - const prevMaxWidth = typeof prev?.maxWidth === "string" ? prev.maxWidth : ""; - const prevZIndex = typeof prev?.zIndex === "number" ? prev.zIndex : undefined; + const prevWidth = + typeof prev?.width === "number" ? prev.width : undefined; + const prevMinWidth = + typeof prev?.minWidth === "number" ? prev.minWidth : undefined; + const prevMaxWidth = + typeof prev?.maxWidth === "string" ? prev.maxWidth : ""; + const prevZIndex = + typeof prev?.zIndex === "number" ? prev.zIndex : undefined; if ( prevTop === nextStyle.top && @@ -289,9 +326,10 @@ export const DropdownMenu: React.FC = ({ window.addEventListener("resize", handleResize); window.addEventListener("scroll", handleScroll, true); - const resizeObserver = typeof ResizeObserver !== "undefined" - ? new ResizeObserver(() => scheduleUpdate()) - : undefined; + const resizeObserver = + typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => scheduleUpdate()) + : undefined; if (resizeObserver) { if (anchorRef.current) resizeObserver.observe(anchorRef.current); @@ -322,10 +360,14 @@ export const DropdownMenu: React.FC = ({ className={classNames( "fixed min-w-[10rem] overflow-hidden rounded-lg border border-neutral-200 bg-white/95 p-1 text-sm shadow-xl ring-1 ring-black/5 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/95", !style && "invisible opacity-0", - className + className, )} > -
    event.stopPropagation()}> +
      event.stopPropagation()} + > {items.map((item) => (
    • @@ -362,7 +410,7 @@ export const DropdownMenu: React.FC = ({ ))}
, - PORTAL_ROOT + PORTAL_ROOT, ); }; diff --git a/packages/ui-kit/src/components/DynamicFormField.tsx b/packages/ui-kit/src/components/DynamicFormField.tsx index a050423..997d847 100644 --- a/packages/ui-kit/src/components/DynamicFormField.tsx +++ b/packages/ui-kit/src/components/DynamicFormField.tsx @@ -9,13 +9,20 @@ type DynamicValue = string | boolean; export interface DynamicFormFieldProps { parameter: CapsuleBlueprintParameter; value: DynamicValue; - onChange: (serviceName: string, key: string, value: DynamicValue, triggerDependencyEvaluation?: boolean) => void; + onChange: ( + serviceName: string, + key: string, + value: DynamicValue, + triggerDependencyEvaluation?: boolean, + ) => void; error?: string; className?: string; isVisible?: boolean; } -const normalizeOptions = (options: CapsuleBlueprintParameter["options"]): Array<{ id: string; label: string; value: string }> => { +const normalizeOptions = ( + options: CapsuleBlueprintParameter["options"], +): Array<{ id: string; label: string; value: string }> => { if (!options) { return []; } @@ -42,17 +49,33 @@ const normalizeOptions = (options: CapsuleBlueprintParameter["options"]): Array< })); }; -const DynamicFormField: React.FC = ({ parameter, value, onChange, error, className, isVisible = true }) => { +const DynamicFormField: React.FC = ({ + parameter, + value, + onChange, + error, + className, + isVisible = true, +}) => { const { name, key, hint, is_required, options, is_secret, help } = parameter; const normalizedOptions = useMemo(() => normalizeOptions(options), [options]); const handleChange = (fieldValue: DynamicValue, trigger?: boolean) => { - onChange(parameter.service_name || "global", key, fieldValue, trigger ?? true); + onChange( + parameter.service_name || "global", + key, + fieldValue, + trigger ?? true, + ); }; const handleBlur = () => { - const hasDependencies = parameter.depends_on && parameter.depends_on.length > 0; - if (hasDependencies && parameter.value_type === CapsuleBlueprintValueType.String) { + const hasDependencies = + parameter.depends_on && parameter.depends_on.length > 0; + if ( + hasDependencies && + parameter.value_type === CapsuleBlueprintValueType.String + ) { handleChange(value, true); } }; @@ -72,7 +95,13 @@ const DynamicFormField: React.FC = ({ parameter, value, o required={is_required} validationStatus={error ? "error" : "none"} /> - {(error || hint) &&

{error || hint}

} + {(error || hint) && ( +

+ {error || hint} +

+ )}
); @@ -83,7 +112,14 @@ const DynamicFormField: React.FC = ({ parameter, value, o case CapsuleBlueprintValueType.Int: return renderTextField("number"); case CapsuleBlueprintValueType.Boolean: - return handleChange(event.target.checked)} label={name} description={hint} />; + return ( + handleChange(event.target.checked)} + label={name} + description={hint} + /> + ); case CapsuleBlueprintValueType.Select: return (
@@ -91,14 +127,25 @@ const DynamicFormField: React.FC = ({ parameter, value, o {name} {is_required && *} - handleChange(event.target.value ?? "")} + required={is_required} + validationStatus={error ? "error" : "none"} + > {normalizedOptions.map((option) => ( ))} - {(error || hint) &&

{error || hint}

} + {(error || hint) && ( +

+ {error || hint} +

+ )}
); default: @@ -116,7 +163,15 @@ const DynamicFormField: React.FC = ({ parameter, value, o return (
{renderField()} - {help && } + {help && ( + + )}
); }; diff --git a/packages/ui-kit/src/components/DynamicImg.tsx b/packages/ui-kit/src/components/DynamicImg.tsx index 05697d7..8e814aa 100644 --- a/packages/ui-kit/src/components/DynamicImg.tsx +++ b/packages/ui-kit/src/components/DynamicImg.tsx @@ -19,7 +19,15 @@ const sizeClasses = { xl: "h-10 w-10", }; -const DynamicImg: React.FC = ({ base64, fill, stroke, className, title, size = "md", style }) => { +const DynamicImg: React.FC = ({ + base64, + fill, + stroke, + className, + title, + size = "md", + style, +}) => { const renderIcon = useIconRenderer(); if (!base64) { @@ -27,7 +35,15 @@ const DynamicImg: React.FC = ({ base64, fill, stroke, className } if (base64.toLowerCase().includes("data:image/png;base64,")) { - return Dynamic Image; + return ( + Dynamic Image + ); } if (!base64.toLowerCase().includes("data:image/svg+xml;base64,")) { diff --git a/packages/ui-kit/src/components/EmptyState.tsx b/packages/ui-kit/src/components/EmptyState.tsx index 860d250..5d5c635 100644 --- a/packages/ui-kit/src/components/EmptyState.tsx +++ b/packages/ui-kit/src/components/EmptyState.tsx @@ -1,12 +1,15 @@ import classNames from "classnames"; import React from "react"; -import Button, { type ButtonVariant, type ButtonSize, type ButtonColor } from "./Button"; +import Button, { + type ButtonVariant, + type ButtonSize, + type ButtonColor, +} from "./Button"; import { type IconSize } from "../types/Icon"; import { useIconRenderer } from "../contexts/IconContext"; import { ThemeSize, type ThemeColor } from "../theme/Theme"; export type TextSize = "xs" | "sm" | "md" | "lg" | "xl"; - const iconSizes: Record = { xs: "h-6 w-6", sm: "h-8 w-8", @@ -23,7 +26,6 @@ const textSizes: Record = { xl: "text-2xl", }; - /** Accepts all theme colors. The original five semantic names (neutral/info/success/warning/danger) are preserved unchanged. */ export type EmptyStateTone = ThemeColor; @@ -31,14 +33,22 @@ export type EmptyStateTone = ThemeColor; const resolveColor = (color: ThemeColor): string => { switch (color) { - case "brand": return "blue"; - case "info": return "sky"; - case "success": return "emerald"; - case "warning": return "amber"; - case "danger": return "rose"; - case "theme": return "neutral"; - case "parallels": return "red"; - default: return color; + case "brand": + return "blue"; + case "info": + return "sky"; + case "success": + return "emerald"; + case "warning": + return "amber"; + case "danger": + return "rose"; + case "theme": + return "neutral"; + case "parallels": + return "red"; + default: + return color; } }; @@ -142,7 +152,8 @@ function buildToneClasses(color: ThemeColor): ToneConfig { // ── Props ─────────────────────────────────────────────────────────────────── -export interface EmptyStateProps extends Omit, "title"> { +export interface EmptyStateProps + extends Omit, "title"> { title: React.ReactNode; subtitle?: React.ReactNode; actionLabel?: string; @@ -193,7 +204,16 @@ const EmptyState: React.FC = ({ const palette = buildToneClasses(tone); // lets make the subtitle text size smaller than the title text size - const subtitleTextSize = textSize === "xs" ? "xs" : textSize === "sm" ? "xs" : textSize === "md" ? "sm" : textSize === "lg" ? "md" : "lg"; + const subtitleTextSize = + textSize === "xs" + ? "xs" + : textSize === "sm" + ? "xs" + : textSize === "md" + ? "sm" + : textSize === "lg" + ? "md" + : "lg"; const iconPallete = !iconColor ? palette : buildToneClasses(iconColor); return ( @@ -206,22 +226,48 @@ const EmptyState: React.FC = ({ sizes[size], fullWidth && "w-full", fullHeight && "h-full", - className + className, )} {...rest} > {showIcon && (
- {React.isValidElement(icon) ? icon : renderIcon(icon, iconSize, iconSizes[iconSize])} + {React.isValidElement(icon) + ? icon + : renderIcon(icon, iconSize, iconSizes[iconSize])}
)}
-

{title}

- {subtitle &&

{subtitle}

} +

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )}
{actionLabel && onAction && (
-
diff --git a/packages/ui-kit/src/components/FormField.tsx b/packages/ui-kit/src/components/FormField.tsx index 1a36699..faeedf4 100644 --- a/packages/ui-kit/src/components/FormField.tsx +++ b/packages/ui-kit/src/components/FormField.tsx @@ -1,9 +1,9 @@ -import classNames from 'classnames'; -import React, { type ReactNode, useId } from 'react'; +import classNames from "classnames"; +import React, { type ReactNode, useId } from "react"; -type FormFieldLayout = 'stacked' | 'inline'; -type FormFieldValidationStatus = 'none' | 'error' | 'success'; -type FormFieldWidth = 'auto' | 'full'; +type FormFieldLayout = "stacked" | "inline"; +type FormFieldValidationStatus = "none" | "error" | "success"; +type FormFieldWidth = "auto" | "full"; export interface FormFieldProps { label?: ReactNode; @@ -22,10 +22,10 @@ export interface FormFieldProps { width?: FormFieldWidth; } -const descriptionColor = 'text-neutral-600 dark:text-neutral-300'; -const hintColor = 'text-neutral-500 dark:text-neutral-400'; -const errorColor = 'text-rose-600 dark:text-rose-400'; -const successColor = 'text-emerald-600 dark:text-emerald-400'; +const descriptionColor = "text-neutral-600 dark:text-neutral-300"; +const hintColor = "text-neutral-500 dark:text-neutral-400"; +const errorColor = "text-rose-600 dark:text-rose-400"; +const successColor = "text-emerald-600 dark:text-emerald-400"; const FormField: React.FC = ({ label, @@ -33,24 +33,26 @@ const FormField: React.FC = ({ description, hint, error, - validationStatus = 'none', + validationStatus = "none", required = false, optionalLabel, labelAction, - layout = 'stacked', + layout = "stacked", children, className, helpText, - width = 'auto', + width = "auto", }) => { const fieldId = useId(); type ChildElementProps = { id?: string; - 'aria-describedby'?: string; - 'aria-invalid'?: string; + "aria-describedby"?: string; + "aria-invalid"?: string; }; - const childElement = React.isValidElement(children) ? (children as React.ReactElement) : null; + const childElement = React.isValidElement(children) + ? (children as React.ReactElement) + : null; const controlId = labelFor ?? childElement?.props?.id ?? `field-${fieldId}`; @@ -59,26 +61,49 @@ const FormField: React.FC = ({ const errorId = error ? `${controlId}-error` : undefined; const helpId = helpText ? `${controlId}-help` : undefined; - const describedBy = [descriptionId, hintId, errorId, helpId].filter(Boolean).join(' ').trim(); + const describedBy = [descriptionId, hintId, errorId, helpId] + .filter(Boolean) + .join(" ") + .trim(); const child = childElement && describedBy ? React.cloneElement(childElement, { id: controlId, - 'aria-describedby': classNames(childElement.props['aria-describedby'], describedBy), - 'aria-invalid': validationStatus === 'error' ? 'true' : (childElement.props['aria-invalid'] ?? undefined), + "aria-describedby": classNames( + childElement.props["aria-describedby"], + describedBy, + ), + "aria-invalid": + validationStatus === "error" + ? "true" + : (childElement.props["aria-invalid"] ?? undefined), }) : children; - const layoutClasses = layout === 'inline' ? 'sm:grid sm:grid-cols-3 sm:items-start sm:gap-6' : 'flex flex-col gap-2 justify-start h-full'; + const layoutClasses = + layout === "inline" + ? "sm:grid sm:grid-cols-3 sm:items-start sm:gap-6" + : "flex flex-col gap-2 justify-start h-full"; - const widthClasses = width === 'full' ? 'w-full' : 'w-auto'; + const widthClasses = width === "full" ? "w-full" : "w-auto"; - const labelWrapperClasses = layout === 'inline' ? 'sm:col-span-1 flex flex-col' : 'flex items-center justify-between gap-2'; + const labelWrapperClasses = + layout === "inline" + ? "sm:col-span-1 flex flex-col" + : "flex items-center justify-between gap-2"; - const controlWrapperClasses = layout === 'inline' ? 'sm:col-span-2 mt-2 sm:mt-0' : 'p-1 flex flex-col gap-2'; + const controlWrapperClasses = + layout === "inline" + ? "sm:col-span-2 mt-2 sm:mt-0" + : "p-1 flex flex-col gap-2"; - const statusColor = validationStatus === 'error' ? errorColor : validationStatus === 'success' ? successColor : hintColor; + const statusColor = + validationStatus === "error" + ? errorColor + : validationStatus === "success" + ? successColor + : hintColor; return (
@@ -86,20 +111,33 @@ const FormField: React.FC = ({ {(label || optionalLabel) && (
{label && ( -
)} {description && ( -

+

{description}

)} @@ -109,16 +147,16 @@ const FormField: React.FC = ({
{child}
{helpText && ( -

+

{helpText}

)} {error ? ( -

+

{error}

) : hint ? ( -

+

{hint}

) : null} diff --git a/packages/ui-kit/src/components/FormLayout.tsx b/packages/ui-kit/src/components/FormLayout.tsx index c57cd93..8b6d8ce 100644 --- a/packages/ui-kit/src/components/FormLayout.tsx +++ b/packages/ui-kit/src/components/FormLayout.tsx @@ -36,8 +36,25 @@ const alignItemsClasses: Record = { 3: "items-center", }; -const FormLayout: React.FC = ({ columns = 1, gap = "md", children, className, verticalPadding = "sm" }) => ( -
{children}
+const FormLayout: React.FC = ({ + columns = 1, + gap = "md", + children, + className, + verticalPadding = "sm", +}) => ( +
+ {children} +
); export default FormLayout; diff --git a/packages/ui-kit/src/components/FormSection.tsx b/packages/ui-kit/src/components/FormSection.tsx index 5707eb8..586f196 100644 --- a/packages/ui-kit/src/components/FormSection.tsx +++ b/packages/ui-kit/src/components/FormSection.tsx @@ -10,7 +10,10 @@ export interface FormSectionProps { padding?: "sm" | "md" | "lg"; } -const paddingMap: Record<"sm" | "md" | "lg", { body: string; header: string; footer: string }> = { +const paddingMap: Record< + "sm" | "md" | "lg", + { body: string; header: string; footer: string } +> = { sm: { header: "px-4 py-4", body: "px-4 py-4", @@ -28,20 +31,54 @@ const paddingMap: Record<"sm" | "md" | "lg", { body: string; header: string; foo }, }; -const FormSection: React.FC = ({ title, description, footer, children, className, padding = "md" }) => { +const FormSection: React.FC = ({ + title, + description, + footer, + children, + className, + padding = "md", +}) => { const pad = paddingMap[padding]; return ( -
+
{(title || description) && ( -
+
- {title &&

{title}

} - {description &&

{description}

} + {title && ( +

+ {title} +

+ )} + {description && ( +

+ {description} +

+ )}
)}
{children}
- {footer &&
{footer}
} + {footer && ( +
+ {footer} +
+ )}
); }; diff --git a/packages/ui-kit/src/components/HeaderGroup.tsx b/packages/ui-kit/src/components/HeaderGroup.tsx index abdb993..b8113a4 100644 --- a/packages/ui-kit/src/components/HeaderGroup.tsx +++ b/packages/ui-kit/src/components/HeaderGroup.tsx @@ -5,7 +5,10 @@ export interface HeaderGroupProps { className?: string; } -export const HeaderGroup: React.FC = ({ children, className = "" }) => { +export const HeaderGroup: React.FC = ({ + children, + className = "", +}) => { return (
> = { - blue: { strip: 'border-t-blue-500 bg-blue-50/70 dark:bg-blue-950/40', accent: 'text-blue-700 dark:text-blue-300', iconBg: 'bg-blue-100/80 dark:bg-blue-900/40' }, - indigo: { strip: 'border-t-indigo-500 bg-indigo-50/70 dark:bg-indigo-950/40', accent: 'text-indigo-700 dark:text-indigo-300', iconBg: 'bg-indigo-100/80 dark:bg-indigo-900/40' }, - violet: { strip: 'border-t-violet-500 bg-violet-50/70 dark:bg-violet-950/40', accent: 'text-violet-700 dark:text-violet-300', iconBg: 'bg-violet-100/80 dark:bg-violet-900/40' }, - sky: { strip: 'border-t-sky-500 bg-sky-50/70 dark:bg-sky-950/40', accent: 'text-sky-700 dark:text-sky-300', iconBg: 'bg-sky-100/80 dark:bg-sky-900/40' }, - cyan: { strip: 'border-t-cyan-500 bg-cyan-50/70 dark:bg-cyan-950/40', accent: 'text-cyan-700 dark:text-cyan-300', iconBg: 'bg-cyan-100/80 dark:bg-cyan-900/40' }, - teal: { strip: 'border-t-teal-500 bg-teal-50/70 dark:bg-teal-950/40', accent: 'text-teal-700 dark:text-teal-300', iconBg: 'bg-teal-100/80 dark:bg-teal-900/40' }, - emerald: { strip: 'border-t-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40', accent: 'text-emerald-700 dark:text-emerald-300', iconBg: 'bg-emerald-100/80 dark:bg-emerald-900/40' }, - green: { strip: 'border-t-green-500 bg-green-50/70 dark:bg-green-950/40', accent: 'text-green-700 dark:text-green-300', iconBg: 'bg-green-100/80 dark:bg-green-900/40' }, - amber: { strip: 'border-t-amber-500 bg-amber-50/70 dark:bg-amber-950/40', accent: 'text-amber-700 dark:text-amber-300', iconBg: 'bg-amber-100/80 dark:bg-amber-900/40' }, - orange: { strip: 'border-t-orange-500 bg-orange-50/70 dark:bg-orange-950/40', accent: 'text-orange-700 dark:text-orange-300', iconBg: 'bg-orange-100/80 dark:bg-orange-900/40' }, - rose: { strip: 'border-t-rose-500 bg-rose-50/70 dark:bg-rose-950/40', accent: 'text-rose-700 dark:text-rose-300', iconBg: 'bg-rose-100/80 dark:bg-rose-900/40' }, - pink: { strip: 'border-t-pink-500 bg-pink-50/70 dark:bg-pink-950/40', accent: 'text-pink-700 dark:text-pink-300', iconBg: 'bg-pink-100/80 dark:bg-pink-900/40' }, - slate: { strip: 'border-t-slate-400 bg-slate-50/80 dark:bg-slate-800/50', accent: 'text-slate-700 dark:text-slate-300', iconBg: 'bg-slate-100/80 dark:bg-slate-800' }, + blue: { + strip: "border-t-blue-500 bg-blue-50/70 dark:bg-blue-950/40", + accent: "text-blue-700 dark:text-blue-300", + iconBg: "bg-blue-100/80 dark:bg-blue-900/40", + }, + indigo: { + strip: "border-t-indigo-500 bg-indigo-50/70 dark:bg-indigo-950/40", + accent: "text-indigo-700 dark:text-indigo-300", + iconBg: "bg-indigo-100/80 dark:bg-indigo-900/40", + }, + violet: { + strip: "border-t-violet-500 bg-violet-50/70 dark:bg-violet-950/40", + accent: "text-violet-700 dark:text-violet-300", + iconBg: "bg-violet-100/80 dark:bg-violet-900/40", + }, + sky: { + strip: "border-t-sky-500 bg-sky-50/70 dark:bg-sky-950/40", + accent: "text-sky-700 dark:text-sky-300", + iconBg: "bg-sky-100/80 dark:bg-sky-900/40", + }, + cyan: { + strip: "border-t-cyan-500 bg-cyan-50/70 dark:bg-cyan-950/40", + accent: "text-cyan-700 dark:text-cyan-300", + iconBg: "bg-cyan-100/80 dark:bg-cyan-900/40", + }, + teal: { + strip: "border-t-teal-500 bg-teal-50/70 dark:bg-teal-950/40", + accent: "text-teal-700 dark:text-teal-300", + iconBg: "bg-teal-100/80 dark:bg-teal-900/40", + }, + emerald: { + strip: "border-t-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40", + accent: "text-emerald-700 dark:text-emerald-300", + iconBg: "bg-emerald-100/80 dark:bg-emerald-900/40", + }, + green: { + strip: "border-t-green-500 bg-green-50/70 dark:bg-green-950/40", + accent: "text-green-700 dark:text-green-300", + iconBg: "bg-green-100/80 dark:bg-green-900/40", + }, + amber: { + strip: "border-t-amber-500 bg-amber-50/70 dark:bg-amber-950/40", + accent: "text-amber-700 dark:text-amber-300", + iconBg: "bg-amber-100/80 dark:bg-amber-900/40", + }, + orange: { + strip: "border-t-orange-500 bg-orange-50/70 dark:bg-orange-950/40", + accent: "text-orange-700 dark:text-orange-300", + iconBg: "bg-orange-100/80 dark:bg-orange-900/40", + }, + rose: { + strip: "border-t-rose-500 bg-rose-50/70 dark:bg-rose-950/40", + accent: "text-rose-700 dark:text-rose-300", + iconBg: "bg-rose-100/80 dark:bg-rose-900/40", + }, + pink: { + strip: "border-t-pink-500 bg-pink-50/70 dark:bg-pink-950/40", + accent: "text-pink-700 dark:text-pink-300", + iconBg: "bg-pink-100/80 dark:bg-pink-900/40", + }, + slate: { + strip: "border-t-slate-400 bg-slate-50/80 dark:bg-slate-800/50", + accent: "text-slate-700 dark:text-slate-300", + iconBg: "bg-slate-100/80 dark:bg-slate-800", + }, // Semantic aliases - info: { strip: 'border-t-sky-500 bg-sky-50/70 dark:bg-sky-950/40', accent: 'text-sky-700 dark:text-sky-300', iconBg: 'bg-sky-100/80 dark:bg-sky-900/40' }, - success: { strip: 'border-t-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40', accent: 'text-emerald-700 dark:text-emerald-300', iconBg: 'bg-emerald-100/80 dark:bg-emerald-900/40' }, - warning: { strip: 'border-t-amber-500 bg-amber-50/70 dark:bg-amber-950/40', accent: 'text-amber-700 dark:text-amber-300', iconBg: 'bg-amber-100/80 dark:bg-amber-900/40' }, - danger: { strip: 'border-t-rose-500 bg-rose-50/70 dark:bg-rose-950/40', accent: 'text-rose-700 dark:text-rose-300', iconBg: 'bg-rose-100/80 dark:bg-rose-900/40' }, - brand: { strip: 'border-t-blue-500 bg-blue-50/70 dark:bg-blue-950/40', accent: 'text-blue-700 dark:text-blue-300', iconBg: 'bg-blue-100/80 dark:bg-blue-900/40' }, + info: { + strip: "border-t-sky-500 bg-sky-50/70 dark:bg-sky-950/40", + accent: "text-sky-700 dark:text-sky-300", + iconBg: "bg-sky-100/80 dark:bg-sky-900/40", + }, + success: { + strip: "border-t-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40", + accent: "text-emerald-700 dark:text-emerald-300", + iconBg: "bg-emerald-100/80 dark:bg-emerald-900/40", + }, + warning: { + strip: "border-t-amber-500 bg-amber-50/70 dark:bg-amber-950/40", + accent: "text-amber-700 dark:text-amber-300", + iconBg: "bg-amber-100/80 dark:bg-amber-900/40", + }, + danger: { + strip: "border-t-rose-500 bg-rose-50/70 dark:bg-rose-950/40", + accent: "text-rose-700 dark:text-rose-300", + iconBg: "bg-rose-100/80 dark:bg-rose-900/40", + }, + brand: { + strip: "border-t-blue-500 bg-blue-50/70 dark:bg-blue-950/40", + accent: "text-blue-700 dark:text-blue-300", + iconBg: "bg-blue-100/80 dark:bg-blue-900/40", + }, }; const fallbackTone: ToneTokens = { - strip: 'border-t-neutral-400 bg-neutral-50/80 dark:bg-neutral-800/50', - accent: 'text-neutral-700 dark:text-neutral-300', - iconBg: 'bg-neutral-100 dark:bg-neutral-800', + strip: "border-t-neutral-400 bg-neutral-50/80 dark:bg-neutral-800/50", + accent: "text-neutral-700 dark:text-neutral-300", + iconBg: "bg-neutral-100 dark:bg-neutral-800", }; /* ------------------------------------------------------------------ */ @@ -90,17 +162,53 @@ const mdComponents: Components = { {children} ), - p: ({ children }) =>

{children}

, - h1: ({ children }) =>

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - ul: ({ children }) =>
    {children}
, - ol: ({ children }) =>
    {children}
, + p: ({ children }) => ( +

+ {children} +

+ ), + h1: ({ children }) => ( +

+ {children} +

+ ), + h2: ({ children }) => ( +

+ {children} +

+ ), + h3: ({ children }) => ( +

+ {children} +

+ ), + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), li: ({ children }) =>
  • {children}
  • , - blockquote: ({ children }) =>
    {children}
    , + blockquote: ({ children }) => ( +
    + {children} +
    + ), hr: () =>
    , - strong: ({ children }) => {children}, - em: ({ children }) => {children}, + strong: ({ children }) => ( + + {children} + + ), + em: ({ children }) => ( + + {children} + + ), /** * Inline code has no className; fenced block code has a language-* class. * We style them differently — inline is a subtle chip, block is a scrollable box. @@ -108,22 +216,52 @@ const mdComponents: Components = { code: ({ className, children }) => { const isBlock = Boolean(className); if (isBlock) { - return {children}; + return ( + + {children} + + ); } - return {children}; + return ( + + {children} + + ); }, - pre: ({ children }) =>
    {children}
    , + pre: ({ children }) => ( +
    {children}
    + ), // remark-gfm table support table: ({ children }) => (
    - {children}
    + + {children} +
    ), - thead: ({ children }) => {children}, - tbody: ({ children }) => {children}, - tr: ({ children }) => {children}, - th: ({ children }) => {children}, - td: ({ children }) => {children}, + thead: ({ children }) => ( + {children} + ), + tbody: ({ children }) => ( + + {children} + + ), + tr: ({ children }) => ( + + {children} + + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), }; /* ------------------------------------------------------------------ */ @@ -133,7 +271,14 @@ const mdComponents: Components = { const ESTIMATED_HEIGHT = 320; const GAP = 8; -function computePopoverPosition(rect: DOMRect, placement: HelpButtonPlacement, maxWidth: number): { style: React.CSSProperties; resolvedPlacement: 'top' | 'bottom' | 'left' | 'right' } { +function computePopoverPosition( + rect: DOMRect, + placement: HelpButtonPlacement, + maxWidth: number, +): { + style: React.CSSProperties; + resolvedPlacement: "top" | "bottom" | "left" | "right"; +} { const vpW = window.innerWidth; const vpH = window.innerHeight; const W = Math.min(maxWidth, vpW - 16); @@ -141,51 +286,86 @@ function computePopoverPosition(rect: DOMRect, placement: HelpButtonPlacement, m const spaceBelow = vpH - rect.bottom - GAP; const spaceAbove = rect.top - GAP; - let resolved: 'top' | 'bottom' | 'left' | 'right'; - if (placement === 'auto' || placement === 'bottom') { - resolved = spaceBelow >= ESTIMATED_HEIGHT || spaceBelow >= spaceAbove ? 'bottom' : 'top'; - } else if (placement === 'top') { - resolved = spaceAbove >= ESTIMATED_HEIGHT || spaceAbove >= spaceBelow ? 'top' : 'bottom'; + let resolved: "top" | "bottom" | "left" | "right"; + if (placement === "auto" || placement === "bottom") { + resolved = + spaceBelow >= ESTIMATED_HEIGHT || spaceBelow >= spaceAbove + ? "bottom" + : "top"; + } else if (placement === "top") { + resolved = + spaceAbove >= ESTIMATED_HEIGHT || spaceAbove >= spaceBelow + ? "top" + : "bottom"; } else { resolved = placement; } const style: React.CSSProperties = { width: W }; - if (resolved === 'bottom') { + if (resolved === "bottom") { style.top = rect.bottom + GAP; - style.left = Math.max(8, Math.min(rect.left + rect.width / 2 - W / 2, vpW - W - 8)); - } else if (resolved === 'top') { + style.left = Math.max( + 8, + Math.min(rect.left + rect.width / 2 - W / 2, vpW - W - 8), + ); + } else if (resolved === "top") { // distance from bottom of viewport so it sits above the button style.bottom = vpH - rect.top + GAP; - style.left = Math.max(8, Math.min(rect.left + rect.width / 2 - W / 2, vpW - W - 8)); - } else if (resolved === 'right') { + style.left = Math.max( + 8, + Math.min(rect.left + rect.width / 2 - W / 2, vpW - W - 8), + ); + } else if (resolved === "right") { style.left = Math.min(rect.right + GAP, vpW - W - 8); - style.top = Math.max(8, Math.min(rect.top + rect.height / 2 - ESTIMATED_HEIGHT / 2, vpH - ESTIMATED_HEIGHT - 8)); + style.top = Math.max( + 8, + Math.min( + rect.top + rect.height / 2 - ESTIMATED_HEIGHT / 2, + vpH - ESTIMATED_HEIGHT - 8, + ), + ); } else { // left style.left = Math.max(8, rect.left - W - GAP); - style.top = Math.max(8, Math.min(rect.top + rect.height / 2 - ESTIMATED_HEIGHT / 2, vpH - ESTIMATED_HEIGHT - 8)); + style.top = Math.max( + 8, + Math.min( + rect.top + rect.height / 2 - ESTIMATED_HEIGHT / 2, + vpH - ESTIMATED_HEIGHT - 8, + ), + ); } return { style, resolvedPlacement: resolved }; } -const originClass: Record<'top' | 'bottom' | 'left' | 'right', string> = { - bottom: 'origin-top', - top: 'origin-bottom', - left: 'origin-right', - right: 'origin-left', +const originClass: Record<"top" | "bottom" | "left" | "right", string> = { + bottom: "origin-top", + top: "origin-bottom", + left: "origin-right", + right: "origin-left", }; /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ -const HelpButton: React.FC = ({ content, title, placement = 'auto', color = 'info', size = 'xs', icon = 'Help', maxWidth = 360, className }) => { +const HelpButton: React.FC = ({ + content, + title, + placement = "auto", + color = "info", + size = "xs", + icon = "Help", + maxWidth = 360, + className, +}) => { const [open, setOpen] = useState(false); const [popoverStyle, setPopoverStyle] = useState({}); - const [resolvedPlacement, setResolvedPlacement] = useState<'top' | 'bottom' | 'left' | 'right'>('bottom'); + const [resolvedPlacement, setResolvedPlacement] = useState< + "top" | "bottom" | "left" | "right" + >("bottom"); const buttonRef = useRef(null); const panelRef = useRef(null); @@ -193,7 +373,11 @@ const HelpButton: React.FC = ({ content, title, placement = 'au const recompute = useCallback(() => { if (!buttonRef.current) return; const rect = buttonRef.current.getBoundingClientRect(); - const { style, resolvedPlacement: rp } = computePopoverPosition(rect, placement, maxWidth); + const { style, resolvedPlacement: rp } = computePopoverPosition( + rect, + placement, + maxWidth, + ); setPopoverStyle(style); setResolvedPlacement(rp); }, [placement, maxWidth]); @@ -212,42 +396,55 @@ const HelpButton: React.FC = ({ content, title, placement = 'au useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { - if (!buttonRef.current?.contains(e.target as Node) && !panelRef.current?.contains(e.target as Node)) { + if ( + !buttonRef.current?.contains(e.target as Node) && + !panelRef.current?.contains(e.target as Node) + ) { setOpen(false); } }; - document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); }, [open]); // Escape key useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') close(); + if (e.key === "Escape") close(); }; - document.addEventListener('keydown', handler); - return () => document.removeEventListener('keydown', handler); + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); }, [open, close]); // Reposition on scroll or resize while open useEffect(() => { if (!open) return; - window.addEventListener('scroll', recompute, true); - window.addEventListener('resize', recompute); + window.addEventListener("scroll", recompute, true); + window.addEventListener("resize", recompute); return () => { - window.removeEventListener('scroll', recompute, true); - window.removeEventListener('resize', recompute); + window.removeEventListener("scroll", recompute, true); + window.removeEventListener("resize", recompute); }; }, [open, recompute]); const tone = toneMap[color] ?? fallbackTone; - const isMarkdown = typeof content === 'string'; + const isMarkdown = typeof content === "string"; return ( - + {/* Trigger */} - + {/* Floating panel — rendered in a portal to avoid overflow clipping */} {createPortal( @@ -255,37 +452,68 @@ const HelpButton: React.FC = ({ content, title, placement = 'au ref={panelRef} role="dialog" aria-modal="false" - aria-label={typeof title === 'string' ? title : 'Help'} - style={{ ...popoverStyle, position: 'fixed' }} + aria-label={typeof title === "string" ? title : "Help"} + style={{ ...popoverStyle, position: "fixed" }} className={classNames( - 'z-[2000] rounded-2xl border shadow-xl dark:shadow-neutral-950/60', - 'bg-white dark:bg-neutral-900', - 'border-neutral-200/70 dark:border-neutral-700/60', + "z-[2000] rounded-2xl border shadow-xl dark:shadow-neutral-950/60", + "bg-white dark:bg-neutral-900", + "border-neutral-200/70 dark:border-neutral-700/60", // Animation — opacity + scale, origin tracks resolved placement - 'transition-[opacity,transform] duration-200 ease-out', + "transition-[opacity,transform] duration-200 ease-out", originClass[resolvedPlacement], - open ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-95 pointer-events-none', + open + ? "opacity-100 scale-100 pointer-events-auto" + : "opacity-0 scale-95 pointer-events-none", )} > {/* ---- Accent header strip ---- */} -
    -
    - +
    +
    + - {title ?? 'Help'} + + {title ?? "Help"} +
    - +
    {/* ---- Content body ---- */}
    {isMarkdown ? ( - + {content as string} ) : ( -
    {content}
    +
    + {content} +
    )}
    , @@ -295,6 +523,6 @@ const HelpButton: React.FC = ({ content, title, placement = 'au ); }; -HelpButton.displayName = 'HelpButton'; +HelpButton.displayName = "HelpButton"; export default HelpButton; diff --git a/packages/ui-kit/src/components/Hero.tsx b/packages/ui-kit/src/components/Hero.tsx index b46a64d..aaefa3d 100644 --- a/packages/ui-kit/src/components/Hero.tsx +++ b/packages/ui-kit/src/components/Hero.tsx @@ -1,17 +1,18 @@ -import React from 'react'; -import classNames from 'classnames'; -import { CustomIcon } from './CustomIcon'; -import { type IconName } from '../icons/registry'; -import { type ThemeColor } from '../theme'; -import { type PanelDecoration } from './Panel'; +import React from "react"; +import classNames from "classnames"; +import { CustomIcon } from "./CustomIcon"; +import { type IconName } from "../icons/registry"; +import { type ThemeColor } from "../theme"; +import { type PanelDecoration } from "./Panel"; // ── Types ───────────────────────────────────────────────────────────────────── -export type HeroTitleSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; -export type HeroSubtitleSize = 'xs' | 'sm' | 'md'; -export type HeroPadding = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; +export type HeroTitleSize = "xs" | "sm" | "md" | "lg" | "xl"; +export type HeroSubtitleSize = "xs" | "sm" | "md"; +export type HeroPadding = "none" | "xs" | "sm" | "md" | "lg" | "xl"; -export interface HeroProps extends Omit, 'title'> { +export interface HeroProps + extends Omit, "title"> { /** Main heading text. */ title: React.ReactNode; /** Supporting text rendered below the title at lower opacity. */ @@ -43,92 +44,153 @@ export interface HeroProps extends Omit, 't // includes them without needing a safelist entry. const toneGradient: Record = { - red: 'from-red-500 to-rose-600', - orange: 'from-orange-400 to-orange-600', - amber: 'from-amber-400 to-orange-500', - yellow: 'from-yellow-400 to-amber-500', - lime: 'from-lime-500 to-green-600', - green: 'from-green-500 to-emerald-600', - emerald: 'from-emerald-500 to-teal-600', - teal: 'from-teal-500 to-cyan-600', - cyan: 'from-cyan-400 to-sky-500', - sky: 'from-sky-400 to-indigo-500', - blue: 'from-blue-500 to-indigo-600', - indigo: 'from-indigo-500 to-violet-600', - violet: 'from-violet-500 to-purple-600', - purple: 'from-purple-500 to-fuchsia-600', - fuchsia: 'from-fuchsia-500 to-pink-600', - pink: 'from-pink-500 to-rose-600', - rose: 'from-rose-500 to-red-600', - slate: 'from-slate-600 to-slate-800', - gray: 'from-gray-600 to-gray-800', - zinc: 'from-zinc-600 to-zinc-800', - neutral: 'from-neutral-600 to-neutral-800', - stone: 'from-stone-600 to-stone-800', - white: 'from-slate-500 to-slate-700', - brand: 'from-blue-500 to-indigo-600', - info: 'from-sky-400 to-blue-600', - success: 'from-emerald-500 to-teal-600', - warning: 'from-amber-400 to-orange-500', - danger: 'from-rose-500 to-red-600', - theme: 'from-neutral-600 to-neutral-800', - parallels: 'from-red-500 to-rose-600', + red: "from-red-500 to-rose-600", + orange: "from-orange-400 to-orange-600", + amber: "from-amber-400 to-orange-500", + yellow: "from-yellow-400 to-amber-500", + lime: "from-lime-500 to-green-600", + green: "from-green-500 to-emerald-600", + emerald: "from-emerald-500 to-teal-600", + teal: "from-teal-500 to-cyan-600", + cyan: "from-cyan-400 to-sky-500", + sky: "from-sky-400 to-indigo-500", + blue: "from-blue-500 to-indigo-600", + indigo: "from-indigo-500 to-violet-600", + violet: "from-violet-500 to-purple-600", + purple: "from-purple-500 to-fuchsia-600", + fuchsia: "from-fuchsia-500 to-pink-600", + pink: "from-pink-500 to-rose-600", + rose: "from-rose-500 to-red-600", + slate: "from-slate-600 to-slate-800", + gray: "from-gray-600 to-gray-800", + zinc: "from-zinc-600 to-zinc-800", + neutral: "from-neutral-600 to-neutral-800", + stone: "from-stone-600 to-stone-800", + white: "from-slate-500 to-slate-700", + brand: "from-blue-500 to-indigo-600", + info: "from-sky-400 to-blue-600", + success: "from-emerald-500 to-teal-600", + warning: "from-amber-400 to-orange-500", + danger: "from-rose-500 to-red-600", + theme: "from-neutral-600 to-neutral-800", + parallels: "from-red-500 to-rose-600", }; // ── Size maps ───────────────────────────────────────────────────────────────── const titleSizeMap: Record = { - xs: 'text-xs font-bold', - sm: 'text-sm font-bold', - md: 'text-base font-bold', - lg: 'text-lg font-semibold', - xl: 'text-xl font-semibold', + xs: "text-xs font-bold", + sm: "text-sm font-bold", + md: "text-base font-bold", + lg: "text-lg font-semibold", + xl: "text-xl font-semibold", }; const subtitleSizeMap: Record = { - xs: 'text-xs', - sm: 'text-sm', - md: 'text-base', + xs: "text-xs", + sm: "text-sm", + md: "text-base", }; const paddingMap: Record = { - none: 'p-0', - xs: 'p-2', - sm: 'p-3', - md: 'p-5', - lg: 'p-7', - xl: 'p-9', + none: "p-0", + xs: "p-2", + sm: "p-3", + md: "p-5", + lg: "p-7", + xl: "p-9", }; // ── Component ───────────────────────────────────────────────────────────────── -const Hero: React.FC = ({ title, subtitle, icon, tone = 'blue', titleSize = 'sm', subtitleSize = 'xs', padding = 'sm', rounded = true, decoration = 'both', className, ...rest }) => { - const showShapes = decoration === 'shapes' || decoration === 'both'; - const showGradient = decoration === 'gradient' || decoration === 'both'; - - const iconNode = icon ? typeof icon === 'string' ? : icon : null; +const Hero: React.FC = ({ + title, + subtitle, + icon, + tone = "blue", + titleSize = "sm", + subtitleSize = "xs", + padding = "sm", + rounded = true, + decoration = "both", + className, + ...rest +}) => { + const showShapes = decoration === "shapes" || decoration === "both"; + const showGradient = decoration === "gradient" || decoration === "both"; + + const iconNode = icon ? ( + typeof icon === "string" ? ( + + ) : ( + icon + ) + ) : null; return ( -
    +
    {/* Decoration: floating circles at white/10 opacity — same intensity on all tones */} {showShapes && ( <> -
    @@ -2155,7 +2859,7 @@ export const SmartGridLayout: React.FC = ({ items, default {isEditMode && (
    { if (!draggingId) return; event.preventDefault(); @@ -2174,16 +2878,25 @@ export const SmartGridLayout: React.FC = ({ items, default moveItemToNewRow(sourceId, sectionId); resetDragState(); }} - > - -

    Or drop a card here to create a section and place it there

    + > + +

    + Or drop a card here to create a section and place it there +

    )}
    diff --git a/packages/ui-kit/src/components/SmartInput.tsx b/packages/ui-kit/src/components/SmartInput.tsx index f9ad368..da1c3ef 100644 --- a/packages/ui-kit/src/components/SmartInput.tsx +++ b/packages/ui-kit/src/components/SmartInput.tsx @@ -51,7 +51,10 @@ export const SmartInput: React.FC = ({ const handleBlur = (e: React.FocusEvent) => { // Only stop editing if we didn't click into the picker or the toggle button - if (!containerRef.current?.contains(e.relatedTarget as Node) && !pickerRef.current?.contains(e.relatedTarget as Node)) { + if ( + !containerRef.current?.contains(e.relatedTarget as Node) && + !pickerRef.current?.contains(e.relatedTarget as Node) + ) { setIsEditing(false); setShowPicker(false); } @@ -82,7 +85,8 @@ export const SmartInput: React.FC = ({ if (input) { const start = input.selectionStart || 0; const end = input.selectionEnd || 0; - newValue = value.substring(0, start) + variable.fullToken + value.substring(end); + newValue = + value.substring(0, start) + variable.fullToken + value.substring(end); // Restore focus and cursor? // Setting state is async, so cursor restoration is tricky without effect. @@ -106,7 +110,13 @@ export const SmartInput: React.FC = ({ // Close picker if clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (showPicker && pickerRef.current && !pickerRef.current.contains(event.target as Node) && containerRef.current && !containerRef.current.contains(event.target as Node)) { + if ( + showPicker && + pickerRef.current && + !pickerRef.current.contains(event.target as Node) && + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { setShowPicker(false); } }; @@ -117,7 +127,11 @@ export const SmartInput: React.FC = ({ }; }, [showPicker]); - const resolveVariable = (type: string, source: string, name: string): { value: string; isResolved: boolean; isRuntime?: boolean } => { + const resolveVariable = ( + type: string, + source: string, + name: string, + ): { value: string; isResolved: boolean; isRuntime?: boolean } => { if (source === "global" || source === "env") { // NOTE: Current regex match groups: 1=type, 2=source, 3=name // But typically we see {{ var::global::NAME }}. @@ -127,7 +141,9 @@ export const SmartInput: React.FC = ({ let param = globalParameters.find((p) => p.key === name); if (!param) { // Fallback: Try case-insensitive comparison - param = globalParameters.find((p) => p.key.toLowerCase() === name.toLowerCase()); + param = globalParameters.find( + (p) => p.key.toLowerCase() === name.toLowerCase(), + ); } if (param) { @@ -162,7 +178,16 @@ export const SmartInput: React.FC = ({ } // Runtime Variables - const runtimeVars = ["name", "reverse_proxy_host", "ip_address", "host_gateway_ip", "capsule_id", "capsule_name", "host_ip", "app_url"]; + const runtimeVars = [ + "name", + "reverse_proxy_host", + "ip_address", + "host_gateway_ip", + "capsule_id", + "capsule_name", + "host_ip", + "app_url", + ]; if (runtimeVars.includes(lowerName)) { return { value: `[${lowerName}]`, isResolved: true, isRuntime: true }; } @@ -176,7 +201,9 @@ export const SmartInput: React.FC = ({ // Render parsed view const renderView = () => { if (!value) { - return {placeholder || "Empty"}; + return ( + {placeholder || "Empty"} + ); } const parts = []; @@ -188,7 +215,11 @@ export const SmartInput: React.FC = ({ while ((match = regex.exec(value)) !== null) { // Text before match if (match.index > lastIndex) { - parts.push({value.substring(lastIndex, match.index)}); + parts.push( + + {value.substring(lastIndex, match.index)} + , + ); } // The variable token @@ -198,7 +229,11 @@ export const SmartInput: React.FC = ({ const name = match[3]; if (viewMode === "value") { - const { value: resolvedVal, isResolved, isRuntime } = resolveVariable(type, source, name); + const { + value: resolvedVal, + isResolved, + isRuntime, + } = resolveVariable(type, source, name); const isEmpty = !resolvedVal; let badgeClass = "bg-green-50 text-green-700 border-green-200"; @@ -233,8 +268,10 @@ export const SmartInput: React.FC = ({ badgeClass = "bg-indigo-50 text-indigo-700 border-indigo-200"; } } - if (source === "system") badgeClass = "bg-amber-50 text-amber-900 border-amber-200"; - if (source === "service") badgeClass = "bg-emerald-50 text-emerald-700 border-emerald-200"; + if (source === "system") + badgeClass = "bg-amber-50 text-amber-900 border-amber-200"; + if (source === "service") + badgeClass = "bg-emerald-50 text-emerald-700 border-emerald-200"; let labelPrefix = "G"; if (source === "global") { @@ -261,7 +298,9 @@ export const SmartInput: React.FC = ({ // Remaining text if (lastIndex < value.length) { - parts.push({value.substring(lastIndex)}); + parts.push( + {value.substring(lastIndex)}, + ); } return
    {parts}
    ; @@ -283,7 +322,10 @@ export const SmartInput: React.FC = ({ autoComplete="off" /> ) : ( -
    +
    {renderView()}
    )} @@ -303,7 +345,18 @@ export const SmartInput: React.FC = ({ title={viewMode === "token" ? "Show Values" : "Show Tokens"} /> )} - +
    {/* Portal for Variable Picker to avoid z-index/overflow issues */} @@ -318,7 +371,12 @@ export const SmartInput: React.FC = ({ zIndex: 9999, }} > - setShowPicker(false)} globalParameters={globalParameters} serviceNames={serviceNames} /> + setShowPicker(false)} + globalParameters={globalParameters} + serviceNames={serviceNames} + />
    , document.body, )} diff --git a/packages/ui-kit/src/components/SmartValue.tsx b/packages/ui-kit/src/components/SmartValue.tsx index 9201932..c894c0b 100644 --- a/packages/ui-kit/src/components/SmartValue.tsx +++ b/packages/ui-kit/src/components/SmartValue.tsx @@ -14,7 +14,13 @@ export interface SmartValueProps { className?: string; } -export const SmartValue: React.FC = ({ value = "", globalParameters = [], serviceNames = [], context = {}, className = "" }) => { +export const SmartValue: React.FC = ({ + value = "", + globalParameters = [], + serviceNames = [], + context = {}, + className = "", +}) => { const [viewMode, setViewMode] = useState<"token" | "value">("token"); // Case insensitive regex match @@ -35,7 +41,11 @@ export const SmartValue: React.FC = ({ value = "", globalParame while ((match = regex.exec(value)) !== null) { // Text before match if (match.index > lastIndex) { - parts.push({value.substring(lastIndex, match.index)}); + parts.push( + + {value.substring(lastIndex, match.index)} + , + ); } const fullToken = match[0]; @@ -47,7 +57,11 @@ export const SmartValue: React.FC = ({ value = "", globalParame const ctx = { globalParameters, serviceNames, context }; // resolveVariable expects the full token usually, or we can adapt logic. // The utils resolveVariable expects the full token string to match its regex. - const { value: resolvedVal, isResolved, isRuntime } = resolveVariable(fullToken, ctx); + const { + value: resolvedVal, + isResolved, + isRuntime, + } = resolveVariable(fullToken, ctx); const isEmpty = !resolvedVal; let badgeClass = "bg-green-50 text-green-700 border-green-200"; @@ -72,9 +86,12 @@ export const SmartValue: React.FC = ({ value = "", globalParame ); } else { let badgeClass = "bg-slate-100 text-slate-700 border-slate-200"; - if (source === "global") badgeClass = "bg-indigo-50 text-indigo-700 border-indigo-200"; - if (source === "system") badgeClass = "bg-amber-50 text-amber-900 border-amber-200"; - if (source === "service") badgeClass = "bg-emerald-50 text-emerald-700 border-emerald-200"; + if (source === "global") + badgeClass = "bg-indigo-50 text-indigo-700 border-indigo-200"; + if (source === "system") + badgeClass = "bg-amber-50 text-amber-900 border-amber-200"; + if (source === "service") + badgeClass = "bg-emerald-50 text-emerald-700 border-emerald-200"; parts.push( = ({ value = "", globalParame className={`mx-0.5 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border ${badgeClass} select-none cursor-help align-middle`} title={`${type}::${source}`} > - {source === "global" ? "G" : source === "system" ? "S" : "SVC"}:{name} + {source === "global" ? "G" : source === "system" ? "S" : "SVC"}: + {name} , ); } @@ -92,7 +110,9 @@ export const SmartValue: React.FC = ({ value = "", globalParame // Remaining text if (lastIndex < value.length) { - parts.push({value.substring(lastIndex)}); + parts.push( + {value.substring(lastIndex)}, + ); } return parts; @@ -104,7 +124,9 @@ export const SmartValue: React.FC = ({ value = "", globalParame return (
    -
    {renderParts()}
    +
    + {renderParts()} +
    { label?: string; } -const sizeTokens: Record }> = { +const sizeTokens: Record< + SpinnerSize, + { diameter: string; border: Record } +> = { xs: { diameter: "h-4 w-4", border: { thin: "border", normal: "border-[2px]", thick: "border-[4px]" }, }, sm: { diameter: "h-5 w-5", - border: { thin: "border-[1.5px]", normal: "border-2", thick: "border-[4px]" }, + border: { + thin: "border-[1.5px]", + normal: "border-2", + thick: "border-[4px]", + }, }, md: { diameter: "h-6 w-6", - border: { thin: "border-3", normal: "border-[3.5px]", thick: "border-[4.5px]" }, + border: { + thin: "border-3", + normal: "border-[3.5px]", + thick: "border-[4.5px]", + }, }, lg: { diameter: "h-8 w-8", - border: { thin: "border-[3.5px]", normal: "border-[4px]", thick: "border-[5px]" }, + border: { + thin: "border-[3.5px]", + normal: "border-[4px]", + thick: "border-[5px]", + }, }, xl: { diameter: "h-10 w-10", - border: { thin: "border-[4px]", normal: "border-[4.5px]", thick: "border-[5.5px]" }, + border: { + thin: "border-[4px]", + normal: "border-[4.5px]", + thick: "border-[5.5px]", + }, }, }; const Spinner = React.forwardRef( - ({ size = "md", color = "blue", variant = "solid", thickness = "normal", label, className, ...rest }, ref) => { + ( + { + size = "md", + color = "blue", + variant = "solid", + thickness = "normal", + label, + className, + ...rest + }, + ref, + ) => { const sizeStyles = sizeTokens[size] ?? sizeTokens.md; - const borderThickness = sizeStyles.border[thickness] ?? sizeStyles.border.thin; + const borderThickness = + sizeStyles.border[thickness] ?? sizeStyles.border.thin; const colorStyles = getSpinnerColorTokens(color); const spinnerBase = classNames( "inline-flex rounded-full border-solid border-transparent", sizeStyles.diameter, borderThickness, - className + className, ); const spinnerClass = classNames( @@ -56,17 +87,25 @@ const Spinner = React.forwardRef( "transition-all duration-150 ease-in-out", variant === "segments" ? ["animate-[spin_1s_linear_infinite]", ...colorStyles] - : ["animate-spin", colorStyles[0]] + : ["animate-spin", colorStyles[0]], ); return ( - + - {label && {label}} + {label && ( + + {label} + + )} {label ?? "Loading"} ); - } + }, ); Spinner.displayName = "Spinner"; diff --git a/packages/ui-kit/src/components/SplitView.tsx b/packages/ui-kit/src/components/SplitView.tsx index b4637fb..880b277 100644 --- a/packages/ui-kit/src/components/SplitView.tsx +++ b/packages/ui-kit/src/components/SplitView.tsx @@ -1,26 +1,32 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import classNames from 'classnames'; -import { type ThemeColor, getPillColorClasses } from '../theme/Theme'; -import CustomIcon from './CustomIcon'; -import EmptyState from './EmptyState'; -import { type IconName } from '../icons/registry'; -import { useResizable } from '../hooks/useResizable'; -import Loader from './Loader'; -import IconButton from './IconButton'; -import SearchBar from './SearchBar'; -import HelpButton, { type HelpButtonProps } from './HelpButton'; -import Panel, { type PanelDecoration, type PanelVariant } from './Panel'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import classNames from "classnames"; +import { type ThemeColor, getPillColorClasses } from "../theme/Theme"; +import CustomIcon from "./CustomIcon"; +import EmptyState from "./EmptyState"; +import { type IconName } from "../icons/registry"; +import { useResizable } from "../hooks/useResizable"; +import Loader from "./Loader"; +import IconButton from "./IconButton"; +import SearchBar from "./SearchBar"; +import HelpButton, { type HelpButtonProps } from "./HelpButton"; +import Panel, { type PanelDecoration, type PanelVariant } from "./Panel"; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ -export type SplitViewSize = 'sm' | 'md' | 'lg'; +export type SplitViewSize = "sm" | "md" | "lg"; export interface SplitViewItemBadge { label: React.ReactNode; tone?: ThemeColor; - variant?: 'solid' | 'soft' | 'outline'; + variant?: "solid" | "soft" | "outline"; } export interface SplitViewItem { @@ -88,7 +94,9 @@ export interface SplitViewPanelHeaderProps { /** Second row, right-aligned actions */ bottomActions?: SplitViewHeaderSlot; /** Optional details block rendered below the header row and styled like a Panel. */ - headerDetails?: SplitViewHeaderSlot; + headerDetails?: SplitViewHeaderSlot< + SplitViewHeaderDetails | null | undefined + >; /** Optional help button inserted after title */ helper?: SplitViewHeaderSlot; border?: boolean; @@ -139,12 +147,18 @@ export interface SplitViewProps { /** Extra class for the detail panel */ panelClassName?: string; /** Content rendered above the detail panel (header area) */ - panelHeader?: React.ReactNode | ((activeItem: SplitViewItem) => React.ReactNode); + panelHeader?: + | React.ReactNode + | ((activeItem: SplitViewItem) => React.ReactNode); /** * Built-in SplitView header renderer (PageHeader-like) with support for dynamic slots. * When provided, this takes precedence over `panelHeader`. */ - panelHeaderProps?: SplitViewPanelHeaderProps | ((activeItem: SplitViewItem) => SplitViewPanelHeaderProps | null | undefined); + panelHeaderProps?: + | SplitViewPanelHeaderProps + | (( + activeItem: SplitViewItem, + ) => SplitViewPanelHeaderProps | null | undefined); /** Rendered when no items match the search filter in the list */ emptyState?: React.ReactNode; /** Action buttons rendered in the list header row (e.g. an "Add" button) */ @@ -193,153 +207,291 @@ export interface SplitViewProps { /* Style tokens */ /* ------------------------------------------------------------------ */ -const sizeTokens: Record = { - sm: { item: 'px-4 py-2.5', label: 'text-sm', subtitle: 'text-xs', badge: 'text-[10px] px-1.5 py-0' }, - md: { item: 'px-4 py-3', label: 'text-sm', subtitle: 'text-xs', badge: 'text-[11px] px-2 py-0.5' }, - lg: { item: 'px-5 py-4', label: 'text-base', subtitle: 'text-sm', badge: 'text-xs px-2.5 py-0.5' }, +const sizeTokens: Record< + SplitViewSize, + { item: string; label: string; subtitle: string; badge: string } +> = { + sm: { + item: "px-4 py-2.5", + label: "text-sm", + subtitle: "text-xs", + badge: "text-[10px] px-1.5 py-0", + }, + md: { + item: "px-4 py-3", + label: "text-sm", + subtitle: "text-xs", + badge: "text-[11px] px-2 py-0.5", + }, + lg: { + item: "px-5 py-4", + label: "text-base", + subtitle: "text-sm", + badge: "text-xs px-2.5 py-0.5", + }, }; const iconSizeClasses: Record = { - sm: 'h-4 w-4', - md: 'h-5 w-5', - lg: 'h-6 w-6', + sm: "h-4 w-4", + md: "h-5 w-5", + lg: "h-6 w-6", }; -type ActiveColorTokens = { bg: string; border: string; text: string; subtitle: string; resizer: string }; +type ActiveColorTokens = { + bg: string; + border: string; + text: string; + subtitle: string; + resizer: string; +}; // All class names must be written out as full strings so Tailwind's JIT scanner can detect them. const neutralActive: ActiveColorTokens = { - bg: 'bg-neutral-100 dark:bg-neutral-800/40', - border: 'border-l-neutral-500', - text: 'text-neutral-900 dark:text-neutral-100', - subtitle: 'text-neutral-600 dark:text-neutral-400', - resizer: 'bg-neutral-400/40', + bg: "bg-neutral-100 dark:bg-neutral-800/40", + border: "border-l-neutral-500", + text: "text-neutral-900 dark:text-neutral-100", + subtitle: "text-neutral-600 dark:text-neutral-400", + resizer: "bg-neutral-400/40", }; const activeColors: Record = { - red: { bg: 'bg-red-50 dark:bg-red-900/30', border: 'border-l-red-600', text: 'text-red-900 dark:text-red-100', subtitle: 'text-red-600 dark:text-red-400', resizer: 'bg-red-400' }, + red: { + bg: "bg-red-50 dark:bg-red-900/30", + border: "border-l-red-600", + text: "text-red-900 dark:text-red-100", + subtitle: "text-red-600 dark:text-red-400", + resizer: "bg-red-400", + }, orange: { - bg: 'bg-orange-50 dark:bg-orange-900/30', - border: 'border-l-orange-600', - text: 'text-orange-900 dark:text-orange-100', - subtitle: 'text-orange-600 dark:text-orange-400', - resizer: 'bg-orange-400', + bg: "bg-orange-50 dark:bg-orange-900/30", + border: "border-l-orange-600", + text: "text-orange-900 dark:text-orange-100", + subtitle: "text-orange-600 dark:text-orange-400", + resizer: "bg-orange-400", + }, + amber: { + bg: "bg-amber-50 dark:bg-amber-900/30", + border: "border-l-amber-600", + text: "text-amber-900 dark:text-amber-100", + subtitle: "text-amber-600 dark:text-amber-400", + resizer: "bg-amber-400", }, - amber: { bg: 'bg-amber-50 dark:bg-amber-900/30', border: 'border-l-amber-600', text: 'text-amber-900 dark:text-amber-100', subtitle: 'text-amber-600 dark:text-amber-400', resizer: 'bg-amber-400' }, yellow: { - bg: 'bg-yellow-50 dark:bg-yellow-900/30', - border: 'border-l-yellow-600', - text: 'text-yellow-900 dark:text-yellow-100', - subtitle: 'text-yellow-600 dark:text-yellow-400', - resizer: 'bg-yellow-400', - }, - lime: { bg: 'bg-lime-50 dark:bg-lime-900/30', border: 'border-l-lime-600', text: 'text-lime-900 dark:text-lime-100', subtitle: 'text-lime-600 dark:text-lime-400', resizer: 'bg-lime-400' }, - green: { bg: 'bg-green-50 dark:bg-green-900/30', border: 'border-l-green-600', text: 'text-green-900 dark:text-green-100', subtitle: 'text-green-600 dark:text-green-400', resizer: 'bg-green-400' }, + bg: "bg-yellow-50 dark:bg-yellow-900/30", + border: "border-l-yellow-600", + text: "text-yellow-900 dark:text-yellow-100", + subtitle: "text-yellow-600 dark:text-yellow-400", + resizer: "bg-yellow-400", + }, + lime: { + bg: "bg-lime-50 dark:bg-lime-900/30", + border: "border-l-lime-600", + text: "text-lime-900 dark:text-lime-100", + subtitle: "text-lime-600 dark:text-lime-400", + resizer: "bg-lime-400", + }, + green: { + bg: "bg-green-50 dark:bg-green-900/30", + border: "border-l-green-600", + text: "text-green-900 dark:text-green-100", + subtitle: "text-green-600 dark:text-green-400", + resizer: "bg-green-400", + }, emerald: { - bg: 'bg-emerald-50 dark:bg-emerald-900/30', - border: 'border-l-emerald-600', - text: 'text-emerald-900 dark:text-emerald-100', - subtitle: 'text-emerald-600 dark:text-emerald-400', - resizer: 'bg-emerald-400', - }, - teal: { bg: 'bg-teal-50 dark:bg-teal-900/30', border: 'border-l-teal-600', text: 'text-teal-900 dark:text-teal-100', subtitle: 'text-teal-600 dark:text-teal-400', resizer: 'bg-teal-400' }, - cyan: { bg: 'bg-cyan-50 dark:bg-cyan-900/30', border: 'border-l-cyan-600', text: 'text-cyan-900 dark:text-cyan-100', subtitle: 'text-cyan-600 dark:text-cyan-400', resizer: 'bg-cyan-400' }, - sky: { bg: 'bg-sky-50 dark:bg-sky-900/30', border: 'border-l-sky-600', text: 'text-sky-900 dark:text-sky-100', subtitle: 'text-sky-600 dark:text-sky-400', resizer: 'bg-sky-400' }, - blue: { bg: 'bg-blue-50 dark:bg-blue-900/30', border: 'border-l-blue-600', text: 'text-blue-900 dark:text-blue-100', subtitle: 'text-blue-600 dark:text-blue-400', resizer: 'bg-blue-400' }, + bg: "bg-emerald-50 dark:bg-emerald-900/30", + border: "border-l-emerald-600", + text: "text-emerald-900 dark:text-emerald-100", + subtitle: "text-emerald-600 dark:text-emerald-400", + resizer: "bg-emerald-400", + }, + teal: { + bg: "bg-teal-50 dark:bg-teal-900/30", + border: "border-l-teal-600", + text: "text-teal-900 dark:text-teal-100", + subtitle: "text-teal-600 dark:text-teal-400", + resizer: "bg-teal-400", + }, + cyan: { + bg: "bg-cyan-50 dark:bg-cyan-900/30", + border: "border-l-cyan-600", + text: "text-cyan-900 dark:text-cyan-100", + subtitle: "text-cyan-600 dark:text-cyan-400", + resizer: "bg-cyan-400", + }, + sky: { + bg: "bg-sky-50 dark:bg-sky-900/30", + border: "border-l-sky-600", + text: "text-sky-900 dark:text-sky-100", + subtitle: "text-sky-600 dark:text-sky-400", + resizer: "bg-sky-400", + }, + blue: { + bg: "bg-blue-50 dark:bg-blue-900/30", + border: "border-l-blue-600", + text: "text-blue-900 dark:text-blue-100", + subtitle: "text-blue-600 dark:text-blue-400", + resizer: "bg-blue-400", + }, indigo: { - bg: 'bg-indigo-50 dark:bg-indigo-900/30', - border: 'border-l-indigo-600', - text: 'text-indigo-900 dark:text-indigo-100', - subtitle: 'text-indigo-600 dark:text-indigo-400', - resizer: 'bg-indigo-400', + bg: "bg-indigo-50 dark:bg-indigo-900/30", + border: "border-l-indigo-600", + text: "text-indigo-900 dark:text-indigo-100", + subtitle: "text-indigo-600 dark:text-indigo-400", + resizer: "bg-indigo-400", }, violet: { - bg: 'bg-violet-50 dark:bg-violet-900/30', - border: 'border-l-violet-600', - text: 'text-violet-900 dark:text-violet-100', - subtitle: 'text-violet-600 dark:text-violet-400', - resizer: 'bg-violet-400', + bg: "bg-violet-50 dark:bg-violet-900/30", + border: "border-l-violet-600", + text: "text-violet-900 dark:text-violet-100", + subtitle: "text-violet-600 dark:text-violet-400", + resizer: "bg-violet-400", }, purple: { - bg: 'bg-purple-50 dark:bg-purple-900/30', - border: 'border-l-purple-600', - text: 'text-purple-900 dark:text-purple-100', - subtitle: 'text-purple-600 dark:text-purple-400', - resizer: 'bg-purple-400', + bg: "bg-purple-50 dark:bg-purple-900/30", + border: "border-l-purple-600", + text: "text-purple-900 dark:text-purple-100", + subtitle: "text-purple-600 dark:text-purple-400", + resizer: "bg-purple-400", }, fuchsia: { - bg: 'bg-fuchsia-50 dark:bg-fuchsia-900/30', - border: 'border-l-fuchsia-600', - text: 'text-fuchsia-900 dark:text-fuchsia-100', - subtitle: 'text-fuchsia-600 dark:text-fuchsia-400', - resizer: 'bg-fuchsia-400', - }, - pink: { bg: 'bg-pink-50 dark:bg-pink-900/30', border: 'border-l-pink-600', text: 'text-pink-900 dark:text-pink-100', subtitle: 'text-pink-600 dark:text-pink-400', resizer: 'bg-pink-400' }, - rose: { bg: 'bg-rose-50 dark:bg-rose-900/30', border: 'border-l-rose-600', text: 'text-rose-900 dark:text-rose-100', subtitle: 'text-rose-600 dark:text-rose-400', resizer: 'bg-rose-400' }, - slate: { bg: 'bg-slate-50 dark:bg-slate-900/30', border: 'border-l-slate-600', text: 'text-slate-900 dark:text-slate-100', subtitle: 'text-slate-600 dark:text-slate-400', resizer: 'bg-slate-400' }, - gray: { bg: 'bg-gray-50 dark:bg-gray-900/30', border: 'border-l-gray-600', text: 'text-gray-900 dark:text-gray-100', subtitle: 'text-gray-600 dark:text-gray-400', resizer: 'bg-gray-400' }, - zinc: { bg: 'bg-zinc-50 dark:bg-zinc-900/30', border: 'border-l-zinc-600', text: 'text-zinc-900 dark:text-zinc-100', subtitle: 'text-zinc-600 dark:text-zinc-400', resizer: 'bg-zinc-400' }, + bg: "bg-fuchsia-50 dark:bg-fuchsia-900/30", + border: "border-l-fuchsia-600", + text: "text-fuchsia-900 dark:text-fuchsia-100", + subtitle: "text-fuchsia-600 dark:text-fuchsia-400", + resizer: "bg-fuchsia-400", + }, + pink: { + bg: "bg-pink-50 dark:bg-pink-900/30", + border: "border-l-pink-600", + text: "text-pink-900 dark:text-pink-100", + subtitle: "text-pink-600 dark:text-pink-400", + resizer: "bg-pink-400", + }, + rose: { + bg: "bg-rose-50 dark:bg-rose-900/30", + border: "border-l-rose-600", + text: "text-rose-900 dark:text-rose-100", + subtitle: "text-rose-600 dark:text-rose-400", + resizer: "bg-rose-400", + }, + slate: { + bg: "bg-slate-50 dark:bg-slate-900/30", + border: "border-l-slate-600", + text: "text-slate-900 dark:text-slate-100", + subtitle: "text-slate-600 dark:text-slate-400", + resizer: "bg-slate-400", + }, + gray: { + bg: "bg-gray-50 dark:bg-gray-900/30", + border: "border-l-gray-600", + text: "text-gray-900 dark:text-gray-100", + subtitle: "text-gray-600 dark:text-gray-400", + resizer: "bg-gray-400", + }, + zinc: { + bg: "bg-zinc-50 dark:bg-zinc-900/30", + border: "border-l-zinc-600", + text: "text-zinc-900 dark:text-zinc-100", + subtitle: "text-zinc-600 dark:text-zinc-400", + resizer: "bg-zinc-400", + }, neutral: neutralActive, stone: neutralActive, white: neutralActive, // Semantic aliases - brand: { bg: 'bg-blue-50 dark:bg-blue-900/30', border: 'border-l-blue-600', text: 'text-blue-900 dark:text-blue-100', subtitle: 'text-blue-600 dark:text-blue-400', resizer: 'bg-blue-400' }, - info: { bg: 'bg-sky-50 dark:bg-sky-900/30', border: 'border-l-sky-600', text: 'text-sky-900 dark:text-sky-100', subtitle: 'text-sky-600 dark:text-sky-400', resizer: 'bg-sky-400' }, + brand: { + bg: "bg-blue-50 dark:bg-blue-900/30", + border: "border-l-blue-600", + text: "text-blue-900 dark:text-blue-100", + subtitle: "text-blue-600 dark:text-blue-400", + resizer: "bg-blue-400", + }, + info: { + bg: "bg-sky-50 dark:bg-sky-900/30", + border: "border-l-sky-600", + text: "text-sky-900 dark:text-sky-100", + subtitle: "text-sky-600 dark:text-sky-400", + resizer: "bg-sky-400", + }, success: { - bg: 'bg-emerald-50 dark:bg-emerald-900/30', - border: 'border-l-emerald-600', - text: 'text-emerald-900 dark:text-emerald-100', - subtitle: 'text-emerald-600 dark:text-emerald-400', - resizer: 'bg-emerald-400', + bg: "bg-emerald-50 dark:bg-emerald-900/30", + border: "border-l-emerald-600", + text: "text-emerald-900 dark:text-emerald-100", + subtitle: "text-emerald-600 dark:text-emerald-400", + resizer: "bg-emerald-400", }, warning: { - bg: 'bg-amber-50 dark:bg-amber-900/30', - border: 'border-l-amber-600', - text: 'text-amber-900 dark:text-amber-100', - subtitle: 'text-amber-600 dark:text-amber-400', - resizer: 'bg-amber-400', + bg: "bg-amber-50 dark:bg-amber-900/30", + border: "border-l-amber-600", + text: "text-amber-900 dark:text-amber-100", + subtitle: "text-amber-600 dark:text-amber-400", + resizer: "bg-amber-400", + }, + danger: { + bg: "bg-rose-50 dark:bg-rose-900/30", + border: "border-l-rose-600", + text: "text-rose-900 dark:text-rose-100", + subtitle: "text-rose-600 dark:text-rose-400", + resizer: "bg-rose-400", }, - danger: { bg: 'bg-rose-50 dark:bg-rose-900/30', border: 'border-l-rose-600', text: 'text-rose-900 dark:text-rose-100', subtitle: 'text-rose-600 dark:text-rose-400', resizer: 'bg-rose-400' }, theme: neutralActive, - parallels: { bg: 'bg-red-50 dark:bg-red-900/30', border: 'border-l-red-600', text: 'text-red-900 dark:text-red-100', subtitle: 'text-red-600 dark:text-red-400', resizer: 'bg-red-400' }, + parallels: { + bg: "bg-red-50 dark:bg-red-900/30", + border: "border-l-red-600", + text: "text-red-900 dark:text-red-100", + subtitle: "text-red-600 dark:text-red-400", + resizer: "bg-red-400", + }, }; type HighlightTokens = { bg: string; dot: string }; -const neutralHighlight: HighlightTokens = { bg: 'bg-neutral-100 dark:bg-neutral-700/50', dot: 'bg-neutral-500' }; +const neutralHighlight: HighlightTokens = { + bg: "bg-neutral-100 dark:bg-neutral-700/50", + dot: "bg-neutral-500", +}; // All class names are written as full strings so Tailwind's JIT scanner can detect them. const highlightColors: Record = { - red: { bg: 'bg-red-100 dark:bg-red-900/50', dot: 'bg-red-500' }, - orange: { bg: 'bg-orange-100 dark:bg-orange-900/50', dot: 'bg-orange-500' }, - amber: { bg: 'bg-amber-100 dark:bg-amber-900/50', dot: 'bg-amber-500' }, - yellow: { bg: 'bg-yellow-100 dark:bg-yellow-900/50', dot: 'bg-yellow-500' }, - lime: { bg: 'bg-lime-100 dark:bg-lime-900/50', dot: 'bg-lime-500' }, - green: { bg: 'bg-green-100 dark:bg-green-900/50', dot: 'bg-green-500' }, - emerald: { bg: 'bg-emerald-100 dark:bg-emerald-900/50', dot: 'bg-emerald-500' }, - teal: { bg: 'bg-teal-100 dark:bg-teal-900/50', dot: 'bg-teal-500' }, - cyan: { bg: 'bg-cyan-100 dark:bg-cyan-900/50', dot: 'bg-cyan-500' }, - sky: { bg: 'bg-sky-100 dark:bg-sky-900/50', dot: 'bg-sky-500' }, - blue: { bg: 'bg-blue-100 dark:bg-blue-900/50', dot: 'bg-blue-500' }, - indigo: { bg: 'bg-indigo-100 dark:bg-indigo-900/50', dot: 'bg-indigo-500' }, - violet: { bg: 'bg-violet-100 dark:bg-violet-900/50', dot: 'bg-violet-500' }, - purple: { bg: 'bg-purple-100 dark:bg-purple-900/50', dot: 'bg-purple-500' }, - fuchsia: { bg: 'bg-fuchsia-100 dark:bg-fuchsia-900/50', dot: 'bg-fuchsia-500' }, - pink: { bg: 'bg-pink-100 dark:bg-pink-900/50', dot: 'bg-pink-500' }, - rose: { bg: 'bg-rose-100 dark:bg-rose-900/50', dot: 'bg-rose-500' }, - slate: { bg: 'bg-slate-100 dark:bg-slate-800/50', dot: 'bg-slate-500' }, - gray: { bg: 'bg-gray-100 dark:bg-gray-800/50', dot: 'bg-gray-500' }, - zinc: { bg: 'bg-zinc-100 dark:bg-zinc-800/50', dot: 'bg-zinc-500' }, + red: { bg: "bg-red-100 dark:bg-red-900/50", dot: "bg-red-500" }, + orange: { bg: "bg-orange-100 dark:bg-orange-900/50", dot: "bg-orange-500" }, + amber: { bg: "bg-amber-100 dark:bg-amber-900/50", dot: "bg-amber-500" }, + yellow: { bg: "bg-yellow-100 dark:bg-yellow-900/50", dot: "bg-yellow-500" }, + lime: { bg: "bg-lime-100 dark:bg-lime-900/50", dot: "bg-lime-500" }, + green: { bg: "bg-green-100 dark:bg-green-900/50", dot: "bg-green-500" }, + emerald: { + bg: "bg-emerald-100 dark:bg-emerald-900/50", + dot: "bg-emerald-500", + }, + teal: { bg: "bg-teal-100 dark:bg-teal-900/50", dot: "bg-teal-500" }, + cyan: { bg: "bg-cyan-100 dark:bg-cyan-900/50", dot: "bg-cyan-500" }, + sky: { bg: "bg-sky-100 dark:bg-sky-900/50", dot: "bg-sky-500" }, + blue: { bg: "bg-blue-100 dark:bg-blue-900/50", dot: "bg-blue-500" }, + indigo: { bg: "bg-indigo-100 dark:bg-indigo-900/50", dot: "bg-indigo-500" }, + violet: { bg: "bg-violet-100 dark:bg-violet-900/50", dot: "bg-violet-500" }, + purple: { bg: "bg-purple-100 dark:bg-purple-900/50", dot: "bg-purple-500" }, + fuchsia: { + bg: "bg-fuchsia-100 dark:bg-fuchsia-900/50", + dot: "bg-fuchsia-500", + }, + pink: { bg: "bg-pink-100 dark:bg-pink-900/50", dot: "bg-pink-500" }, + rose: { bg: "bg-rose-100 dark:bg-rose-900/50", dot: "bg-rose-500" }, + slate: { bg: "bg-slate-100 dark:bg-slate-800/50", dot: "bg-slate-500" }, + gray: { bg: "bg-gray-100 dark:bg-gray-800/50", dot: "bg-gray-500" }, + zinc: { bg: "bg-zinc-100 dark:bg-zinc-800/50", dot: "bg-zinc-500" }, neutral: neutralHighlight, stone: neutralHighlight, white: neutralHighlight, - brand: { bg: 'bg-blue-100 dark:bg-blue-900/50', dot: 'bg-blue-500' }, - info: { bg: 'bg-sky-100 dark:bg-sky-900/50', dot: 'bg-sky-500' }, - success: { bg: 'bg-emerald-100 dark:bg-emerald-900/50', dot: 'bg-emerald-500' }, - warning: { bg: 'bg-amber-100 dark:bg-amber-900/50', dot: 'bg-amber-500' }, - danger: { bg: 'bg-rose-100 dark:bg-rose-900/50', dot: 'bg-rose-500' }, + brand: { bg: "bg-blue-100 dark:bg-blue-900/50", dot: "bg-blue-500" }, + info: { bg: "bg-sky-100 dark:bg-sky-900/50", dot: "bg-sky-500" }, + success: { + bg: "bg-emerald-100 dark:bg-emerald-900/50", + dot: "bg-emerald-500", + }, + warning: { bg: "bg-amber-100 dark:bg-amber-900/50", dot: "bg-amber-500" }, + danger: { bg: "bg-rose-100 dark:bg-rose-900/50", dot: "bg-rose-500" }, theme: neutralHighlight, - parallels: { bg: 'bg-red-100 dark:bg-red-900/50', dot: 'bg-red-500' }, + parallels: { bg: "bg-red-100 dark:bg-red-900/50", dot: "bg-red-500" }, }; /* ------------------------------------------------------------------ */ @@ -352,10 +504,10 @@ const SplitView: React.FC = ({ defaultValue, onChange, listTitle, - searchPlaceholder = 'Search...', + searchPlaceholder = "Search...", listWidth, - color = 'blue', - size = 'md', + color = "blue", + size = "md", autoHideList = true, collapsible = false, collapsed: controlledCollapsed, @@ -387,21 +539,33 @@ const SplitView: React.FC = ({ const isSingleVisibleItem = visibleItems.length === 1; const isNoVisibleItems = visibleItems.length === 0; // Single-item mode is now always detail-only; keep autoHideList reference for backward compatibility. - const shouldHideList = isSingleVisibleItem || (autoHideList && visibleItems.length === 1) || isNoVisibleItems; - const [internalValue, setInternalValue] = useState(defaultValue ?? visibleItems[0]?.id); + const shouldHideList = + isSingleVisibleItem || + (autoHideList && visibleItems.length === 1) || + isNoVisibleItems; + const [internalValue, setInternalValue] = useState( + defaultValue ?? visibleItems[0]?.id, + ); const activeId = value ?? internalValue; // When autoExpand=false, the detail panel is driven by a separate "expanded" id. // When autoExpand=true it always mirrors activeId. - const [internalExpandedId, setInternalExpandedId] = useState(autoExpand ? (defaultValue ?? visibleItems[0]?.id) : undefined); - const expandedId = autoExpand ? activeId : (expandedValue ?? internalExpandedId); + const [internalExpandedId, setInternalExpandedId] = useState< + string | undefined + >(autoExpand ? (defaultValue ?? visibleItems[0]?.id) : undefined); + const expandedId = autoExpand + ? activeId + : (expandedValue ?? internalExpandedId); - const [filter, setFilter] = useState(''); + const [filter, setFilter] = useState(""); /* ---- Collapse state (controlled / uncontrolled) ---- */ const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed); - const isCollapsedControlled = typeof controlledCollapsed === 'boolean'; - const isCollapsed = collapsible && !shouldHideList && (isCollapsedControlled ? controlledCollapsed : internalCollapsed); + const isCollapsedControlled = typeof controlledCollapsed === "boolean"; + const isCollapsed = + collapsible && + !shouldHideList && + (isCollapsedControlled ? controlledCollapsed : internalCollapsed); const toggleCollapsed = useCallback(() => { const next = !isCollapsed; @@ -414,7 +578,8 @@ const SplitView: React.FC = ({ const getMaxWidth = useCallback(() => { if (maxListWidthProp) return maxListWidthProp; - if (containerRef.current) return Math.floor(containerRef.current.offsetWidth * 0.5); + if (containerRef.current) + return Math.floor(containerRef.current.offsetWidth * 0.5); return 600; }, [maxListWidthProp]); @@ -452,9 +617,13 @@ const SplitView: React.FC = ({ if (!filter) return visibleItems; const lower = filter.toLowerCase(); return visibleItems.filter((item) => { - const labelText = typeof item.label === 'string' ? item.label : ''; - const subtitleText = typeof item.subtitle === 'string' ? item.subtitle : ''; - return labelText.toLowerCase().includes(lower) || subtitleText.toLowerCase().includes(lower); + const labelText = typeof item.label === "string" ? item.label : ""; + const subtitleText = + typeof item.subtitle === "string" ? item.subtitle : ""; + return ( + labelText.toLowerCase().includes(lower) || + subtitleText.toLowerCase().includes(lower) + ); }); }, [visibleItems, filter]); @@ -491,28 +660,48 @@ const SplitView: React.FC = ({ onExpand?.(item.id, item); }; - const listWidthClass = listWidth ?? 'w-72'; + const listWidthClass = listWidth ?? "w-72"; const renderBadge = (badge: SplitViewItemBadge, idx: number) => { - const pillTokens = getPillColorClasses(badge.tone ?? 'info', badge.variant ?? 'soft'); + const pillTokens = getPillColorClasses( + badge.tone ?? "info", + badge.variant ?? "soft", + ); return ( - + {badge.label} ); }; - const resolveHeaderSlot = (slot: SplitViewHeaderSlot | undefined, item: SplitViewItem): T | undefined => { - if (typeof slot === 'function') { + const resolveHeaderSlot = ( + slot: SplitViewHeaderSlot | undefined, + item: SplitViewItem, + ): T | undefined => { + if (typeof slot === "function") { return (slot as (activeItem: SplitViewItem) => T)(item); } return slot; }; - const renderBuiltInHeader = (item: SplitViewItem, options?: { promoteItemActions?: boolean }) => { + const renderBuiltInHeader = ( + item: SplitViewItem, + options?: { promoteItemActions?: boolean }, + ) => { if (panelHeaderProps === undefined) return null; - const headerProps = typeof panelHeaderProps === 'function' ? panelHeaderProps(item) : panelHeaderProps; + const headerProps = + typeof panelHeaderProps === "function" + ? panelHeaderProps(item) + : panelHeaderProps; if (!headerProps) return null; const icon = resolveHeaderSlot(headerProps.icon, item); @@ -524,8 +713,12 @@ const SplitView: React.FC = ({ const bottomActions = resolveHeaderSlot(headerProps.bottomActions, item); const headerDetails = resolveHeaderSlot(headerProps.headerDetails, item); const customActions = resolveHeaderSlot(headerProps.actions, item); - const promotedActions = options?.promoteItemActions ? item.actions : undefined; - const promotedListActions = options?.promoteItemActions ? listActions : undefined; + const promotedActions = options?.promoteItemActions + ? item.actions + : undefined; + const promotedListActions = options?.promoteItemActions + ? listActions + : undefined; const mergedActions = customActions || promotedActions || promotedListActions ? ( <> @@ -535,15 +728,25 @@ const SplitView: React.FC = ({ ) : undefined; const border = headerProps.border ?? true; - const detailsVariant = headerDetails?.variant ?? headerDetails?.variants ?? 'subtle'; - const detailsDecoration = headerDetails?.decoration ?? headerDetails?.decorations ?? 'none'; - const detailsTone = headerDetails?.tone ?? 'neutral'; - const hasCustomHeaderBody = headerDetails?.headerBody !== undefined && headerDetails?.headerBody !== null; - const hasHeaderDetailsContent = Boolean(hasCustomHeaderBody || headerDetails?.title || headerDetails?.subtitle || headerDetails?.description || headerDetails?.tags); + const detailsVariant = + headerDetails?.variant ?? headerDetails?.variants ?? "subtle"; + const detailsDecoration = + headerDetails?.decoration ?? headerDetails?.decorations ?? "none"; + const detailsTone = headerDetails?.tone ?? "neutral"; + const hasCustomHeaderBody = + headerDetails?.headerBody !== undefined && + headerDetails?.headerBody !== null; + const hasHeaderDetailsContent = Boolean( + hasCustomHeaderBody || + headerDetails?.title || + headerDetails?.subtitle || + headerDetails?.description || + headerDetails?.tags, + ); const isDetailsBordered = headerDetails?.bordered ?? true; return ( -
    +
    {icon &&
    {icon}
    }
    @@ -551,33 +754,73 @@ const SplitView: React.FC = ({ {title} {helper && } - {subtitle &&

    {subtitle}

    } + {subtitle && ( +

    + {subtitle} +

    + )}
    {body &&
    {body}
    } - {search &&
    {search}
    } - {mergedActions &&
    {mergedActions}
    } + {search && ( +
    + {search} +
    + )} + {mergedActions && ( +
    + {mergedActions} +
    + )}
    - {bottomActions &&
    {bottomActions}
    } + {bottomActions && ( +
    + {bottomActions} +
    + )} {headerDetails && hasHeaderDetailsContent && ( -
    +
    {hasCustomHeaderBody ? ( headerDetails.headerBody ) : (
    - {headerDetails.title &&
    {headerDetails.title}
    } - {headerDetails.subtitle &&
    {headerDetails.subtitle}
    } - {headerDetails.description &&
    {headerDetails.description}
    } + {headerDetails.title && ( +
    + {headerDetails.title} +
    + )} + {headerDetails.subtitle && ( +
    + {headerDetails.subtitle} +
    + )} + {headerDetails.description && ( +
    + {headerDetails.description} +
    + )}
    - {headerDetails.tags &&
    {headerDetails.tags}
    } + {headerDetails.tags && ( +
    + {headerDetails.tags} +
    + )}
    )}
    @@ -587,28 +830,50 @@ const SplitView: React.FC = ({ ); }; - const renderPanelHeader = (item: SplitViewItem, options?: { promoteItemActions?: boolean }) => { + const renderPanelHeader = ( + item: SplitViewItem, + options?: { promoteItemActions?: boolean }, + ) => { if (panelHeaderProps !== undefined) { return renderBuiltInHeader(item, options); } if (!panelHeader) return null; - return typeof panelHeader === 'function' ? panelHeader(item) : panelHeader; + return typeof panelHeader === "function" ? panelHeader(item) : panelHeader; }; const singleItem = shouldHideList ? visibleItems[0] : undefined; - const singleHeader = singleItem ? renderPanelHeader(singleItem, { promoteItemActions: true }) : null; - const activeHeader = activeItem ? renderPanelHeader(activeItem, { promoteItemActions: false }) : null; + const singleHeader = singleItem + ? renderPanelHeader(singleItem, { promoteItemActions: true }) + : null; + const activeHeader = activeItem + ? renderPanelHeader(activeItem, { promoteItemActions: false }) + : null; /* ---- List panel width ---- */ - const listPanelStyle: React.CSSProperties | undefined = isCollapsed ? { width: 48 } : resizable ? { width: resizableWidth } : undefined; + const listPanelStyle: React.CSSProperties | undefined = isCollapsed + ? { width: 48 } + : resizable + ? { width: resizableWidth } + : undefined; - const listPanelWidthClass = isCollapsed || resizable ? undefined : listWidthClass; + const listPanelWidthClass = + isCollapsed || resizable ? undefined : listWidthClass; /* ---- Overlay helper ---- */ const renderOverlay = () => { if (loading) { return (
    - {loadingState ?? } + {loadingState ?? ( + + )}
    ); } @@ -619,9 +884,13 @@ const SplitView: React.FC = ({ = ({ /* ---- Auto-hide: just render the detail panel ---- */ if (shouldHideList) { return ( -
    +
    {renderOverlay()} -
    +
    {singleItem ? ( <> {singleHeader ? (
    {singleHeader}
    ) : listActions ? ( -
    {listActions}
    +
    + {listActions} +
    ) : null} -
    {singleItem.panel}
    +
    + {singleItem.panel} +
    ) : ( panelEmptyState !== null && (
    - {panelEmptyState ?? } + {panelEmptyState ?? ( + + )}
    ) )} @@ -665,14 +963,21 @@ const SplitView: React.FC = ({ } return ( -
    +
    {renderOverlay()} {/* ---- List Panel ---- */}
    = ({ {isCollapsed ? ( /* ---- Collapsed: just an expand button ---- */
    - +
    ) : ( <> {/* Title + Actions */} {(listTitle || listActions || collapsible) && (
    - {listTitle &&

    {listTitle}

    } + {listTitle && ( +

    + {listTitle} +

    + )}
    {listActions} - {collapsible && } + {collapsible && ( + + )}
    )} @@ -698,19 +1025,28 @@ const SplitView: React.FC = ({ {/* Search — hidden when only one item since there is nothing to filter */} {visibleItems.length > 1 && (
    - +
    )} {/* Item list */}
    {filteredItems.length === 0 ? ( -
    {emptyState ?? 'No items found'}
    +
    + {emptyState ?? "No items found"} +
    ) : ( filteredItems.map((item) => { const isActive = item.id === activeId; const isExpanded = item.id === expandedId; - const hasExpandControl = !autoExpand && item.subContent !== undefined; + const hasExpandControl = + !autoExpand && item.subContent !== undefined; return (
    {/* Row wrapper – uses a div so that action/expand buttons inside are not nested buttons */} @@ -722,20 +1058,32 @@ const SplitView: React.FC = ({ if (!item.disabled) handleSelect(item); }} onKeyDown={(e) => { - if (!item.disabled && (e.key === 'Enter' || e.key === ' ')) { + if ( + !item.disabled && + (e.key === "Enter" || e.key === " ") + ) { e.preventDefault(); handleSelect(item); } }} className={classNames( - 'group/item w-full text-left border-l-3 transition-all duration-150 outline-none cursor-default', - item.disabled && 'opacity-50 cursor-not-allowed pointer-events-none', + "group/item w-full text-left border-l-3 transition-all duration-150 outline-none cursor-default", + item.disabled && + "opacity-50 cursor-not-allowed pointer-events-none", tokens.item, isActive - ? classNames(accent.bg, accent.border, 'border-l-[3px]') + ? classNames( + accent.bg, + accent.border, + "border-l-[3px]", + ) : item.highlight - ? classNames(highlightAccent.bg, accent.border, 'border-l-[3px]') - : 'border-l-[3px] border-l-transparent hover:bg-gray-100/80 dark:hover:bg-gray-800/60', + ? classNames( + highlightAccent.bg, + accent.border, + "border-l-[3px]", + ) + : "border-l-[3px] border-l-transparent hover:bg-gray-100/80 dark:hover:bg-gray-800/60", )} >
    @@ -744,22 +1092,54 @@ const SplitView: React.FC = ({
    {item.icon && (
    - +
    )}
    -
    +
    {item.label}
    {item.subtitle && ( -
    {item.subtitle}
    +
    + {item.subtitle} +
    + )} + {item.badges && item.badges.length > 0 && ( +
    + {item.badges.map((badge, idx) => + renderBadge(badge, idx), + )} +
    )} - {item.badges && item.badges.length > 0 &&
    {item.badges.map((badge, idx) => renderBadge(badge, idx))}
    }
    {/* Right rail order: actions, highlight dot, expand/collapse */} - {(item.actions || hasExpandControl || item.highlight) && ( + {(item.actions || + hasExpandControl || + item.highlight) && (
    {item.actions && (
    = ({ {item.actions}
    )} - {item.highlight && } + {item.highlight && ( + + )} {/* Expand button – only when autoExpand=false */} {hasExpandControl && ( -
    e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}> +
    e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + >
    @@ -805,14 +1214,25 @@ const SplitView: React.FC = ({ {item.subContent !== undefined && (
    -
    {item.subContent}
    +
    + {item.subContent} +
    )} @@ -829,23 +1249,50 @@ const SplitView: React.FC = ({ {resizable && !isCollapsed && (
    )} {/* ---- Detail Panel ---- */} -
    +
    {activeItem ? ( <> {/* Panel Header */} {activeHeader &&
    {activeHeader}
    } {/* Panel Body */} -
    {activeItem.panel}
    +
    + {activeItem.panel} +
    ) : ( panelEmptyState !== null && (
    - {panelEmptyState ?? } + {panelEmptyState ?? ( + + )}
    ) )} @@ -854,6 +1301,6 @@ const SplitView: React.FC = ({ ); }; -SplitView.displayName = 'SplitView'; +SplitView.displayName = "SplitView"; export default SplitView; diff --git a/packages/ui-kit/src/components/StartupStageStepper.css b/packages/ui-kit/src/components/StartupStageStepper.css index 491e49b..c272b12 100644 --- a/packages/ui-kit/src/components/StartupStageStepper.css +++ b/packages/ui-kit/src/components/StartupStageStepper.css @@ -10,7 +10,12 @@ .startup-stage-connector--animated { position: relative; overflow: hidden; - background-image: linear-gradient(90deg, rgba(56, 189, 248, 0.15) 0%, rgba(59, 130, 246, 0.7) 50%, rgba(56, 189, 248, 0.15) 100%); + background-image: linear-gradient( + 90deg, + rgba(56, 189, 248, 0.15) 0%, + rgba(59, 130, 246, 0.7) 50%, + rgba(56, 189, 248, 0.15) 100% + ); background-size: 200% 100%; animation: startup-stage-connector-slide 1.25s linear infinite; } diff --git a/packages/ui-kit/src/components/StartupStageStepper.tsx b/packages/ui-kit/src/components/StartupStageStepper.tsx index 5cdd073..1152a09 100644 --- a/packages/ui-kit/src/components/StartupStageStepper.tsx +++ b/packages/ui-kit/src/components/StartupStageStepper.tsx @@ -121,7 +121,10 @@ const STATUS_TOKENS: Record< }, }; -const renderStatusIcon = (status: StartupStageStatus, sizeClass: string): React.ReactElement => { +const renderStatusIcon = ( + status: StartupStageStatus, + sizeClass: string, +): React.ReactElement => { switch (status) { case "pending": return ( @@ -131,32 +134,74 @@ const renderStatusIcon = (status: StartupStageStatus, sizeClass: string): React. ); case "in-progress": return ( -
    -

    {title}

    -

    {selectedStage.title}

    +

    + {title} +

    +

    + {selectedStage.title} +

    +
    +
    + {selectedStatus.label}
    -
    {selectedStatus.label}
    -
    +
    {orderedStages.map((stage, index) => { const token = STATUS_TOKENS[stage.status]; const isActive = stage.id === selectedStage.id; @@ -278,7 +368,8 @@ export const StartupStageStepper: React.FC = ({ "flex-1 rounded-full transition-colors", sizeToken.connectorThickness, CONNECTOR_STYLES[connectorTone].base, - CONNECTOR_STYLES[connectorTone].animated && "startup-stage-connector--animated", + CONNECTOR_STYLES[connectorTone].animated && + "startup-stage-connector--animated", )} /> )} @@ -296,16 +387,23 @@ export const StartupStageStepper: React.FC = ({ sizeToken.nodeFont, token.pillText, { - "border-emerald-400 bg-emerald-50 dark:border-emerald-500/80 dark:bg-emerald-400/10": stage.status === "is-ok", - "border-sky-400 bg-sky-50 dark:border-sky-500/80 dark:bg-sky-400/10": stage.status === "in-progress", - "border-rose-400 bg-rose-50 dark:border-rose-500/80 dark:bg-rose-500/10": stage.status === "has-error", - "border-neutral-300 bg-white dark:border-neutral-600 dark:bg-neutral-900": stage.status === "pending", + "border-emerald-400 bg-emerald-50 dark:border-emerald-500/80 dark:bg-emerald-400/10": + stage.status === "is-ok", + "border-sky-400 bg-sky-50 dark:border-sky-500/80 dark:bg-sky-400/10": + stage.status === "in-progress", + "border-rose-400 bg-rose-50 dark:border-rose-500/80 dark:bg-rose-500/10": + stage.status === "has-error", + "border-neutral-300 bg-white dark:border-neutral-600 dark:bg-neutral-900": + stage.status === "pending", "ring-2 ring-sky-300 dark:ring-sky-500": isActive, }, )} aria-current={isActive} > - {renderStatusIcon(stage.status, classNames("text-current", sizeToken.icon))} + {renderStatusIcon( + stage.status, + classNames("text-current", sizeToken.icon), + )} {stage.title} · {token.label} @@ -316,28 +414,53 @@ export const StartupStageStepper: React.FC = ({
    -
    +

    {getFriendlyStageMessage(selectedStage)}

    {typeof selectedStage.progress?.percentage === "number" && (
    - - {selectedStage.progress?.details &&

    {selectedStage.progress.details}

    } + + {selectedStage.progress?.details && ( +

    + {selectedStage.progress.details} +

    + )}
    )} - {selectedStage.retryCount !== undefined && selectedStage.maxRetryCount !== undefined && selectedStage.retryCount > 1 && ( -

    - Retry {selectedStage.retryCount} of {selectedStage.maxRetryCount} -

    - )} + {selectedStage.retryCount !== undefined && + selectedStage.maxRetryCount !== undefined && + selectedStage.retryCount > 1 && ( +

    + Retry {selectedStage.retryCount} of {selectedStage.maxRetryCount} +

    + )} {isErrorStage && hasTechnicalDetails && (
    -

    Technical details

    - {!showErrorDetails &&

    Tap to reveal diagnostic info

    } +

    + Technical details +

    + {!showErrorDetails && ( +

    + Tap to reveal diagnostic info +

    + )}
    @@ -361,7 +490,12 @@ export const StartupStageStepper: React.FC = ({
    {showHistory && ( -
    +
    {historyOpen && ( -
      +
        {historyCandidates.map((stage) => { const token = STATUS_TOKENS[stage.status]; return ( -
      1. - {stage.title} - {token.label} +
      2. + + {stage.title} + + + {token.label} +
      3. ); })} diff --git a/packages/ui-kit/src/components/StatChartTile.tsx b/packages/ui-kit/src/components/StatChartTile.tsx index db5361d..783c59a 100644 --- a/packages/ui-kit/src/components/StatChartTile.tsx +++ b/packages/ui-kit/src/components/StatChartTile.tsx @@ -22,7 +22,8 @@ export interface StatChartDataset { items: StatChartItem[]; } -export interface StatChartTileProps extends Omit { +export interface StatChartTileProps + extends Omit { data: StatChartDataset[]; } @@ -32,10 +33,16 @@ const StatChartTile: React.FC = ({ data, ...props }) => { const resolvedItems = useMemo(() => { const palette = getColorPaletteNames(currentDataset.items.length); - return currentDataset.items.map((item, i) => ({ ...item, color: (item.color ?? palette[i]) as ThemeColor })); + return currentDataset.items.map((item, i) => ({ + ...item, + color: (item.color ?? palette[i]) as ThemeColor, + })); }, [currentDataset]); - const total = useMemo(() => currentDataset.items.reduce((acc, item) => acc + item.value, 0), [currentDataset]); + const total = useMemo( + () => currentDataset.items.reduce((acc, item) => acc + item.value, 0), + [currentDataset], + ); const handlePrev = () => { if (currentIndex > 0) { @@ -60,27 +67,27 @@ const StatChartTile: React.FC = ({ data, ...props }) => { const segments = total === 0 ? [ - { - label: "", - value: 0, - color: "neutral", - intensity: "200", - dashArray: `${circumference} ${circumference}`, - dashOffset: 0, - onClick: undefined, - }, - ] + { + label: "", + value: 0, + color: "neutral", + intensity: "200", + dashArray: `${circumference} ${circumference}`, + dashOffset: 0, + onClick: undefined, + }, + ] : resolvedItems.map((item) => { - const percent = item.value / total; - const dashArray = `${circumference * percent} ${circumference}`; - const dashOffset = -circumference * cumulativePercent; - cumulativePercent += percent; - return { - ...item, - dashArray, - dashOffset, - }; - }); + const percent = item.value / total; + const dashArray = `${circumference * percent} ${circumference}`; + const dashOffset = -circumference * cumulativePercent; + cumulativePercent += percent; + return { + ...item, + dashArray, + dashOffset, + }; + }); const [hoveredIndex, setHoveredIndex] = useState(null); @@ -96,21 +103,27 @@ const StatChartTile: React.FC = ({ data, ...props }) => { disabled={currentIndex === 0} className={classNames( "p-1 rounded-full transition-colors", - currentIndex === 0 ? "text-neutral-300 cursor-not-allowed" : "text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800", + currentIndex === 0 + ? "text-neutral-300 cursor-not-allowed" + : "text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800", data.length <= 1 && "invisible", )} > - {currentDataset.label} + + {currentDataset.label} +
    ) : ( <> -
    +
    = ({ {breakdown && breakdown.length > 0 && (
    {breakdown.map((item, idx) => ( -
    - {item.label} +
    + + {item.label} +
    - {item.value} + + {item.value} +
    ))} diff --git a/packages/ui-kit/src/components/StatGoalTile.tsx b/packages/ui-kit/src/components/StatGoalTile.tsx index 6f5cfc7..a8bbf86 100644 --- a/packages/ui-kit/src/components/StatGoalTile.tsx +++ b/packages/ui-kit/src/components/StatGoalTile.tsx @@ -16,7 +16,8 @@ export interface StatGoalItem { tooltip?: string; } -export interface StatGoalTileProps extends Omit { +export interface StatGoalTileProps + extends Omit { goals: StatGoalItem[]; } @@ -36,13 +37,26 @@ const CircularProgress: React.FC<{ // We will use standard tailwind text classes on the SVG and use `stroke="currentColor"` return ( -
    +
    {/* Background Circle */} - + {/* Progress Circle */} = ({ goals, ...props }) => { const resolvedGoals = useMemo(() => { const palette = getColorPaletteNames(goals.length); - return goals.map((g, i) => ({ ...g, color: (g.color ?? palette[i]) as ThemeColor })); + return goals.map((g, i) => ({ + ...g, + color: (g.color ?? palette[i]) as ThemeColor, + })); }, [goals]); return ( @@ -74,14 +91,27 @@ const StatGoalTile: React.FC = ({ goals, ...props }) => {
    {resolvedGoals.map((goal, idx) => ( -
    - +
    +
    - {goal.value}% - {goal.label} + + {goal.value}% + + + {goal.label} +
    - {idx < goals.length - 1 &&
    } + {idx < goals.length - 1 && ( +
    + )} ))}
    diff --git a/packages/ui-kit/src/components/StatGraphTile.tsx b/packages/ui-kit/src/components/StatGraphTile.tsx index a987a09..73e0b65 100644 --- a/packages/ui-kit/src/components/StatGraphTile.tsx +++ b/packages/ui-kit/src/components/StatGraphTile.tsx @@ -1,10 +1,26 @@ -import React, { useRef, useState, useCallback, useMemo, useEffect } from 'react'; -import { createPortal } from 'react-dom'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer, LineChart, Line, Tooltip as RechartsTooltip } from 'recharts'; -import StatTile from './StatTile'; -import type { StatTileProps } from './StatTile'; -import type { ThemeColor } from '../theme'; -import { getColorPaletteNames } from '../theme'; +import React, { + useRef, + useState, + useCallback, + useMemo, + useEffect, +} from "react"; +import { createPortal } from "react-dom"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + ResponsiveContainer, + LineChart, + Line, + Tooltip as RechartsTooltip, +} from "recharts"; +import StatTile from "./StatTile"; +import type { StatTileProps } from "./StatTile"; +import type { ThemeColor } from "../theme"; +import { getColorPaletteNames } from "../theme"; export interface StatGraphSeries { key: string; @@ -13,9 +29,13 @@ export interface StatGraphSeries { color?: ThemeColor; } -export interface StatGraphTileProps extends Omit { +export interface StatGraphTileProps + extends Omit< + StatTileProps, + "body" | "progress" | "trend" | "meta" | "footer" + > { data: any[]; - variant: 'bar' | 'sparkline'; + variant: "bar" | "sparkline"; series: StatGraphSeries[]; height?: number; showLegend?: boolean; @@ -49,7 +69,15 @@ interface TooltipSnapshot { values: string; } -function PortalTooltip({ tooltip, series, getColor }: { tooltip: TooltipState | null; series: StatGraphSeries[]; getColor: (color: string) => string }) { +function PortalTooltip({ + tooltip, + series, + getColor, +}: { + tooltip: TooltipState | null; + series: StatGraphSeries[]; + getColor: (color: string) => string; +}) { if (!tooltip || tooltip.payload.length === 0) return null; return createPortal( @@ -58,7 +86,7 @@ function PortalTooltip({ tooltip, series, getColor }: { tooltip: TooltipState | style={{ left: tooltip.x, top: tooltip.y, - transform: 'translate(-50%, calc(-100% - 12px))', + transform: "translate(-50%, calc(-100% - 12px))", }} >
    @@ -66,9 +94,18 @@ function PortalTooltip({ tooltip, series, getColor }: { tooltip: TooltipState | const s = series.find((s) => s.key === entry.dataKey); return (
    - + {entry.value} - {s && {s.label}} + {s && ( + + {s.label} + + )}
    ); })} @@ -89,14 +126,14 @@ function SilentTooltipContent() { const StatGraphTile: React.FC = ({ data, - variant = 'bar', + variant = "bar", series, height = 200, showLegend = true, showAxes = true, showGrid = true, showTooltip = true, - yDomain = [0, 'auto'], + yDomain = [0, "auto"], chartAnimation = true, chartAnimationDuration = 250, maxDataPoints = 0, @@ -119,43 +156,47 @@ const StatGraphTile: React.FC = ({ const getColor = useCallback((color: string) => { const colorMap: Record = { // Spectrum colors (ThemeMultiColor — Tailwind 500 hex values) - red: '#ef4444', - orange: '#f97316', - amber: '#f59e0b', - yellow: '#eab308', - lime: '#84cc16', - green: '#22c55e', - emerald: '#10b981', - teal: '#14b8a6', - cyan: '#06b6d4', - sky: '#0ea5e9', - blue: '#3b82f6', - indigo: '#6366f1', - violet: '#8b5cf6', - purple: '#a855f7', - fuchsia: '#d946ef', - pink: '#ec4899', - rose: '#f43f5e', - slate: '#64748b', - gray: '#6b7280', - zinc: '#71717a', - neutral: '#737373', - stone: '#78716c', + red: "#ef4444", + orange: "#f97316", + amber: "#f59e0b", + yellow: "#eab308", + lime: "#84cc16", + green: "#22c55e", + emerald: "#10b981", + teal: "#14b8a6", + cyan: "#06b6d4", + sky: "#0ea5e9", + blue: "#3b82f6", + indigo: "#6366f1", + violet: "#8b5cf6", + purple: "#a855f7", + fuchsia: "#d946ef", + pink: "#ec4899", + rose: "#f43f5e", + slate: "#64748b", + gray: "#6b7280", + zinc: "#71717a", + neutral: "#737373", + stone: "#78716c", // Semantic aliases - parallels: '#e4001b', - text: '#64748b', - grid: '#e2e8f0', + parallels: "#e4001b", + text: "#64748b", + grid: "#e2e8f0", }; - return colorMap[color] || '#3b82f6'; + return colorMap[color] || "#3b82f6"; }, []); const resolvedSeries = useMemo(() => { const palette = getColorPaletteNames(series.length); - return series.map((s, i) => ({ ...s, color: (s.color ?? palette[i]) as ThemeColor })); + return series.map((s, i) => ({ + ...s, + color: (s.color ?? palette[i]) as ThemeColor, + })); }, [series]); const chartData = useMemo(() => { - if (!maxDataPoints || maxDataPoints <= 0 || data.length <= maxDataPoints) return data; + if (!maxDataPoints || maxDataPoints <= 0 || data.length <= maxDataPoints) + return data; return data.slice(-maxDataPoints); }, [data, maxDataPoints]); @@ -178,12 +219,23 @@ const StatGraphTile: React.FC = ({ x: next.x, y: next.y, label: next.label, - keys: next.payload.map((entry: any) => String(entry.dataKey ?? '')).join('|'), - values: next.payload.map((entry: any) => String(entry.value ?? '')).join('|'), + keys: next.payload + .map((entry: any) => String(entry.dataKey ?? "")) + .join("|"), + values: next.payload + .map((entry: any) => String(entry.value ?? "")) + .join("|"), }; const prev = tooltipSnapshotRef.current; - if (prev && prev.x === snapshot.x && prev.y === snapshot.y && prev.label === snapshot.label && prev.keys === snapshot.keys && prev.values === snapshot.values) { + if ( + prev && + prev.x === snapshot.x && + prev.y === snapshot.y && + prev.label === snapshot.label && + prev.keys === snapshot.keys && + prev.values === snapshot.values + ) { return; } @@ -202,7 +254,7 @@ const StatGraphTile: React.FC = ({ const rect = wrapperEl.getBoundingClientRect(); const absX = rect.left + (coordinate.x ?? 0); const absY = rect.top + (coordinate.y ?? 0); - queueTooltipUpdate({ x: absX, y: absY, payload, label: label ?? '' }); + queueTooltipUpdate({ x: absX, y: absY, payload, label: label ?? "" }); } } else { queueTooltipUpdate(null); @@ -214,49 +266,100 @@ const StatGraphTile: React.FC = ({ // Legend (bar only) const customActions = useMemo(() => { - if (!showLegend || variant !== 'bar') return null; + if (!showLegend || variant !== "bar") return null; return (
    {resolvedSeries.map((s) => (
    -
    - {s.label} +
    + + {s.label} +
    ))}
    ); }, [showLegend, variant, resolvedSeries, getColor]); - const textColor = '#64748b'; - const gridColor = '#e2e8f0'; + const textColor = "#64748b"; + const gridColor = "#e2e8f0"; const renderChart = () => { - if (variant === 'bar') { + if (variant === "bar") { return ( -
    queueTooltipUpdate(null)}> +
    queueTooltipUpdate(null)} + > - - {showGrid && } + + {showGrid && ( + + )} {showAxes && ( <> - - + + )} - + {resolvedSeries.map((s) => ( - + ))} - +
    ); } - if (variant === 'sparkline') { + if (variant === "sparkline") { return ( -
    queueTooltipUpdate(null)}> +
    queueTooltipUpdate(null)} + > @@ -270,10 +373,20 @@ const StatGraphTile: React.FC = ({ isAnimationActive={chartAnimation} animationDuration={chartAnimation ? chartAnimationDuration : 0} /> - {showTooltip && } + {showTooltip && ( + + )} - +
    ); } @@ -286,16 +399,22 @@ const StatGraphTile: React.FC = ({ actions={customActions || props.actions} body={
    - {variant === 'sparkline' && ( + {variant === "sparkline" && (
    {renderChart()}
    -
    {props.value}
    - {props.subtitle &&
    {props.subtitle}
    } +
    + {props.value} +
    + {props.subtitle && ( +
    + {props.subtitle} +
    + )}
    )} - {variant === 'bar' &&
    {renderChart()}
    } + {variant === "bar" &&
    {renderChart()}
    }
    } /> diff --git a/packages/ui-kit/src/components/StatTile.tsx b/packages/ui-kit/src/components/StatTile.tsx index c3745d9..1cfc500 100644 --- a/packages/ui-kit/src/components/StatTile.tsx +++ b/packages/ui-kit/src/components/StatTile.tsx @@ -102,22 +102,37 @@ const StatTile: React.FC = ({ corner="rounded" padding="none" flexBody={true} - className={classNames("relative overflow-hidden transition-all duration-200", className, { - "cursor-pointer": !!onClick, - "hover:shadow-md": !!onClick && !withHoverEffect, - "hover:-translate-y-1 hover:shadow-lg": withHoverEffect, - })} + className={classNames( + "relative overflow-hidden transition-all duration-200", + className, + { + "cursor-pointer": !!onClick, + "hover:shadow-md": !!onClick && !withHoverEffect, + "hover:-translate-y-1 hover:shadow-lg": withHoverEffect, + }, + )} onClick={onClick} > {/* Loading Overlay */} {loading && ( - + )} {/* Decorative Corner & Icon */} {showDecoration && (
    {icon && (
    @@ -133,14 +148,23 @@ const StatTile: React.FC = ({
    -
    +

    {title} @@ -158,7 +182,14 @@ const StatTile: React.FC = ({

    )} -

    {error.message || "Failed to load data"}

    +

    + {error.message || "Failed to load data"} +

    {error.onRetry && ( + {showColumnSelector && + hasHideableColumns && + activeView === "table" && ( +
    + setColPanelOpen((o) => !o)} + aria-label="Toggle column visibility" + /> + {colPanelOpen && ( +
    +
    + Columns +
    +
    + {menuColumns.map((col) => { + const hideable = col.hideable !== false; + const visible = colVisibility[col.id] !== false; + const label = + typeof col.header === "string" + ? col.header + : col.id; + return ( + + ); + })} +
    +
    + +
    -
    - )} -
    - )} + )} +
    + )} {/* Group-by config button — table view only */} - {isUserGroupable && activeView === 'table' && ( + {isUserGroupable && activeView === "table" && (
    {/* Wrapper to position the active indicator dot */}
    @@ -1513,7 +1792,10 @@ function TableComponent({ {/* Active indicator dot */} {resolvedGroupBy && (
    @@ -1589,7 +1898,7 @@ function TableComponent({ )} {/* Sticky column picker — table view only */} - {userStickyColumns && activeView === 'table' && ( + {userStickyColumns && activeView === "table" && (
    ({ /> {hasStickyColumns && (
    )} @@ -1676,51 +2006,126 @@ function TableComponent({ )} {/* ── Table view ────────────────────────────────────────────────────── */} - {activeView === 'table' && visibleColumns.length > 0 && ( -
    + {activeView === "table" && visibleColumns.length > 0 && ( +
    - +
    {/* Colgroup drives precise column widths in fixed layout */} {useFixedLayout && ( - {showGroupExpandCol && } - {resolvedGroupBy && !showGroupExpandCol && } + {showGroupExpandCol && } + {resolvedGroupBy && !showGroupExpandCol && ( + + )} {orderedVisibleColumns.map((col) => { const resizedW = internalColWidths[col.id]; - if (resizedW) return ; + if (resizedW) + return ( + + ); // For non-resized columns, honour width/minWidth so fixed layout can't squeeze them below their declared minimum - const declaredW = col.width !== undefined ? (typeof col.width === 'number' ? col.width : col.width) : undefined; - const declaredMin = col.minWidth !== undefined ? (typeof col.minWidth === 'number' ? col.minWidth : col.minWidth) : undefined; + const declaredW = + col.width !== undefined + ? typeof col.width === "number" + ? col.width + : col.width + : undefined; + const declaredMin = + col.minWidth !== undefined + ? typeof col.minWidth === "number" + ? col.minWidth + : col.minWidth + : undefined; const colW = declaredW ?? declaredMin; return ( - + ); })} )} - + {/* Extra leading th for expand/collapse when grouping with group headers */} {showGroupExpandCol && ( - )} {/* Indent spacer th for grouped mode without group headers */} - {resolvedGroupBy && !showGroupExpandCol && @@ -1794,7 +2266,8 @@ function TableComponent({ {groupedData ? hasRows ? groupedData.map((group) => { - const isExpanded = expandedGroups[group.key] !== false; + const isExpanded = + expandedGroups[group.key] !== false; return ( {/* Group header row */} @@ -1803,68 +2276,126 @@ function TableComponent({ className="cursor-pointer select-none border-b border-neutral-100 bg-neutral-50 transition-colors duration-150 hover:bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-800/40 dark:hover:bg-neutral-700/50" onClick={() => toggleGroup(group.key)} > - )} {/* Sub-rows — hidden when collapsed */} - {(isExpanded || !resolvedShowGroupHeader) && group.rows.map(({ row, originalIndex }) => renderRow(row, originalIndex, true))} + {(isExpanded || !resolvedShowGroupHeader) && + group.rows.map(({ row, originalIndex }) => + renderRow(row, originalIndex, true), + )} ); }) : renderEmptyState() : /* ── Flat (ungrouped) rendering ──────────────────── */ hasRows - ? sortedData.map((row, rowIndex) => renderRow(row, rowIndex, false)) + ? sortedData.map((row, rowIndex) => + renderRow(row, rowIndex, false), + ) : renderEmptyState()}
    ({ headerBaseClasses, headerToneClasses, cellPadding, - stickyHeader && 'sticky top-0', - (isStickyLeft || isStickyRight) && 'sticky', - isStickyLeft && (showGroupExpandCol ? 'left-10' : 'left-0'), + stickyHeader && "sticky top-0", + (isStickyLeft || isStickyRight) && "sticky", + isStickyLeft && + (showGroupExpandCol ? "left-10" : "left-0"), // right position is set via inline style when offset > 0 - isStickyRight && !rightOffset && 'right-0', - stickyHeader && (isStickyLeft || isStickyRight) ? 'z-30' : stickyHeader ? 'z-20' : isStickyLeft || isStickyRight ? 'z-10' : '', + isStickyRight && !rightOffset && "right-0", + stickyHeader && (isStickyLeft || isStickyRight) + ? "z-30" + : stickyHeader + ? "z-20" + : isStickyLeft || isStickyRight + ? "z-10" + : "", getCellAlignment(column.align), - 'overflow-hidden', - isResizable && 'relative', - isStickyRight && thEffectiveSticky === 'right' && !noBorders && 'border-l border-neutral-200 dark:border-neutral-700', + "overflow-hidden", + isResizable && "relative", + isStickyRight && + thEffectiveSticky === "right" && + !noBorders && + "border-l border-neutral-200 dark:border-neutral-700", column.headerClassName, )} style={{ - ...(resizeWidth ? { width: resizeWidth, minWidth: resizeWidth, maxWidth: resizeWidth } : applyWidthStyle(column.width, column.minWidth, column.maxWidth)), - ...(isStickyRight && rightOffset !== undefined ? { right: rightOffset } : {}), + ...(resizeWidth + ? { + width: resizeWidth, + minWidth: resizeWidth, + maxWidth: resizeWidth, + } + : applyWidthStyle( + column.width, + column.minWidth, + column.maxWidth, + )), + ...(isStickyRight && rightOffset !== undefined + ? { right: rightOffset } + : {}), }} - aria-sort={sortDirection ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'} + aria-sort={ + sortDirection + ? sortDirection === "asc" + ? "ascending" + : "descending" + : "none" + } title={column.tooltip} > -
    - {column.header} +
    + + {column.header} + {column.sortable ? ( handleSortToggle(column)} aria-label="Toggle sort" /> @@ -1777,11 +2235,25 @@ function TableComponent({ aria-hidden="true" className="group/rh absolute inset-y-0 right-0 z-10 flex w-2 cursor-col-resize select-none items-center justify-center" onMouseDown={(e) => { - const minW = column.minWidth !== undefined ? (typeof column.minWidth === 'number' ? column.minWidth : parseInt(column.minWidth, 10)) : 48; - handleResizeStart(e, column.id, Math.max(48, isNaN(minW) ? 48 : minW)); + const minW = + column.minWidth !== undefined + ? typeof column.minWidth === "number" + ? column.minWidth + : parseInt(column.minWidth, 10) + : 48; + handleResizeStart( + e, + column.id, + Math.max(48, isNaN(minW) ? 48 : minW), + ); }} > -
    +
    )}
    -
    - +
    +
    + - {group.display || empty} + {group.display || ( + + empty + + )} - +
    - {loading && } + {loading && ( + + )}
    )} {/* ── Panel view ────────────────────────────────────────────────────── */} - {activeView === 'panel' && panelItem && ( -
    - {loading && } + {activeView === "panel" && panelItem && ( +
    + {loading && ( + + )} {hasRows ? (
    { - const minW = typeof panelMinItemWidth === 'number' ? `${panelMinItemWidth}px` : panelMinItemWidth; - const maxW = panelMaxItemWidth != null - ? `min(${typeof panelMaxItemWidth === 'number' ? `${panelMaxItemWidth}px` : panelMaxItemWidth}, 1fr)` - : '1fr'; + const minW = + typeof panelMinItemWidth === "number" + ? `${panelMinItemWidth}px` + : panelMinItemWidth; + const maxW = + panelMaxItemWidth != null + ? `min(${typeof panelMaxItemWidth === "number" ? `${panelMaxItemWidth}px` : panelMaxItemWidth}, 1fr)` + : "1fr"; return { gridTemplateColumns: `repeat(auto-fill, minmax(min(${minW}, 100%), ${maxW}))`, - gap: panelGap != null ? (typeof panelGap === 'number' ? `${panelGap}px` : panelGap) : '1rem', + gap: + panelGap != null + ? typeof panelGap === "number" + ? `${panelGap}px` + : panelGap + : "1rem", }; })() : undefined } > {panelRows.map((row, rowIndex) => ( - {panelItem(row, rowIndex)} + + {panelItem(row, rowIndex)} + ))}
    ) : ( @@ -1882,7 +2413,13 @@ function TableComponent({
    - Showing {(pagination.page - 1) * pagination.pageSize + 1} to {Math.min(pagination.page * pagination.pageSize, pagination.total)} of {pagination.total} results + Showing{" "} + {(pagination.page - 1) * pagination.pageSize + 1} to{" "} + {Math.min( + pagination.page * pagination.pageSize, + pagination.total, + )}{" "} + of {pagination.total} results