diff --git a/ROADMAP.md b/ROADMAP.md index dbb3eb43f..9b46362f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # ObjectUI Development Roadmap -> **Last Updated:** February 26, 2026 +> **Last Updated:** February 27, 2026 > **Current Version:** v0.5.x > **Spec Version:** @objectstack/spec v3.0.10 > **Client Version:** @objectstack/client v3.0.10 @@ -972,6 +972,38 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [x] 411 ListView + ViewTabBar tests passing (255 plugin-list tests including 9 new toolbar/collapse tests) - [x] 11 AppSidebar tests passing +### P2.9 Platform UI Navigation & Table Optimization ✅ + +> Platform-level sidebar, navigation, and grid/table UX improvements (Issue #XX). + +**Sidebar Navigation:** +- [x] Pin icons show-on-hover: `SidebarMenuAction` in `NavigationRenderer` now uses `showOnHover={!item.pinned}` — unpinned items show pin icon only on hover; pinned items always show unpin icon. Applied to both `action` and leaf navigation item types. +- [x] Search placeholder contrast: Search icon in AppSidebar improved from `opacity-50` → `opacity-70` for better readability. +- [x] Recent section position: Recent items section moved above Record Favorites in AppSidebar for quicker access to recently visited items. +- [x] Favorites section: Already hides automatically when no pinned items exist (verified). +- [x] Resizable sidebar width: `SidebarRail` enhanced with pointer-event drag-to-resize (min 200px, max 480px). Width persisted to `localStorage`. Click toggles sidebar, double-click resets to default. `useSidebar()` hook now exposes `sidebarWidth` and `setSidebarWidth`. + +**Grid/Table Field Inference:** +- [x] Percent field auto-inference: `inferColumnType()` in ObjectGrid now detects fields with names containing `probability`, `percent`, `percentage`, `completion`, `progress`, `rate` and assigns `PercentCellRenderer` with progress bar display. +- [x] ISO datetime fallback: ObjectGrid `inferColumnType()` now detects ISO 8601 datetime strings (`YYYY-MM-DDTHH:MM`) in data values as a catch-all for fields whose names don't match date/datetime patterns. +- [x] Date/datetime human-friendly display: `DateCellRenderer` (relative format) and `DateTimeCellRenderer` (split date/time) already registered in field registry for all grid/table views. +- [x] Currency/status/boolean renderers: Already implemented with proper formatting (currency symbol, Badge colors, checkbox display). + +**Header & Breadcrumb i18n:** +- [x] AppHeader breadcrumb labels (`Dashboards`, `Pages`, `Reports`, `System`) now use `t()` translation via `useObjectTranslation`. +- [x] `console.breadcrumb` i18n keys added to all 11 locales (en, zh, ja, ko, de, fr, es, pt, ru, ar). +- [x] `header-bar` renderer: `resolveCrumbLabel()` handles both string and `I18nLabel` objects for breadcrumb labels. +- [x] `breadcrumb` renderer: `resolveItemLabel()` handles both string and `I18nLabel` objects for item labels. + +**Tests:** +- [x] 46 NavigationRenderer tests passing (pin/favorites/search/reorder) +- [x] 75 field cell renderer tests passing (date/datetime/select/boolean/percent) +- [x] 263 ObjectGrid tests passing (inference, rendering, accessibility) +- [x] 28 DataTable tests passing +- [x] 78 Layout tests passing (NavigationRenderer + AppSchemaRenderer) +- [x] 11 AppSidebar tests passing +- [x] 32 i18n tests passing + ### P2.5 PWA & Offline (Real Sync) - [ ] Background sync queue → real server sync (replace simulation) diff --git a/apps/console/src/components/AppHeader.tsx b/apps/console/src/components/AppHeader.tsx index 1e725355d..7055f7e89 100644 --- a/apps/console/src/components/AppHeader.tsx +++ b/apps/console/src/components/AppHeader.tsx @@ -35,6 +35,7 @@ import { ConnectionStatus } from './ConnectionStatus'; import { ActivityFeed, type ActivityItem } from './ActivityFeed'; import type { ConnectionState } from '../dataSource'; import { useAdapter } from '../context/AdapterProvider'; +import { useObjectTranslation } from '@object-ui/i18n'; /** Convert a slug like "crm_dashboard" or "audit-log" to "Crm Dashboard" / "Audit Log" */ function humanizeSlug(slug: string): string { @@ -55,6 +56,7 @@ export function AppHeader({ appName, objects, connectionState, presenceUsers, ac const params = useParams(); const { isOnline } = useOffline(); const dataSource = useAdapter(); + const { t } = useObjectTranslation(); const [apiPresenceUsers, setApiPresenceUsers] = useState(null); const [apiActivities, setApiActivities] = useState(null); @@ -110,22 +112,22 @@ export function AppHeader({ appName, objects, connectionState, presenceUsers, ac ]; if (routeType === 'dashboard') { - breadcrumbItems.push({ label: 'Dashboards', href: `${breadcrumbItems[0].href}` }); + breadcrumbItems.push({ label: t('console.breadcrumb.dashboards'), href: `${breadcrumbItems[0].href}` }); if (pathParts[3]) { breadcrumbItems.push({ label: humanizeSlug(pathParts[3]) }); } } else if (routeType === 'page') { - breadcrumbItems.push({ label: 'Pages', href: `${breadcrumbItems[0].href}` }); + breadcrumbItems.push({ label: t('console.breadcrumb.pages'), href: `${breadcrumbItems[0].href}` }); if (pathParts[3]) { breadcrumbItems.push({ label: humanizeSlug(pathParts[3]) }); } } else if (routeType === 'report') { - breadcrumbItems.push({ label: 'Reports', href: `${breadcrumbItems[0].href}` }); + breadcrumbItems.push({ label: t('console.breadcrumb.reports'), href: `${breadcrumbItems[0].href}` }); if (pathParts[3]) { breadcrumbItems.push({ label: humanizeSlug(pathParts[3]) }); } } else if (routeType === 'system') { - breadcrumbItems.push({ label: 'System' }); + breadcrumbItems.push({ label: t('console.breadcrumb.system') }); if (pathParts[3]) { breadcrumbItems.push({ label: humanizeSlug(pathParts[3]) }); } diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index bed4999f2..f30c125d4 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -393,7 +393,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri {/* Navigation Search */} - + - {/* Record Favorites */} - {favorites.length > 0 && ( + {/* Recent Items (elevated position for quick access) */} + {recentItems.length > 0 && ( - - - Favorites + setRecentExpanded(prev => !prev)} + > + + + Recent + {recentExpanded && ( - {favorites.slice(0, 8).map(item => ( + {recentItems.slice(0, 5).map(item => ( - {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋'} + {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'} {item.label} - { e.stopPropagation(); removeFavorite(item.id); }} - aria-label={`Remove ${item.label} from favorites`} - > - - ))} + )} )} - {/* Recent Items (default collapsed) */} - {recentItems.length > 0 && ( + {/* Record Favorites */} + {favorites.length > 0 && ( - setRecentExpanded(prev => !prev)} - > - - - Recent + + + Favorites - {recentExpanded && ( - {recentItems.slice(0, 5).map(item => ( + {favorites.slice(0, 8).map(item => ( - {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : '📄'} + {item.type === 'dashboard' ? '📊' : item.type === 'report' ? '📈' : item.type === 'page' ? '📄' : '📋'} {item.label} + { e.stopPropagation(); removeFavorite(item.id); }} + aria-label={`Remove ${item.label} from favorites`} + > + + ))} - )} )} diff --git a/packages/components/src/renderers/data-display/breadcrumb.tsx b/packages/components/src/renderers/data-display/breadcrumb.tsx index 150bfbcbb..04c497bfe 100644 --- a/packages/components/src/renderers/data-display/breadcrumb.tsx +++ b/packages/components/src/renderers/data-display/breadcrumb.tsx @@ -10,6 +10,7 @@ import { ComponentRegistry } from '@object-ui/core'; import type { BreadcrumbSchema } from '@object-ui/types'; import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from '../../ui/breadcrumb'; import { renderChildren } from '../../lib/utils'; +import { resolveI18nLabel } from '@object-ui/react'; ComponentRegistry.register('breadcrumb', ({ schema, ...props }: { schema: BreadcrumbSchema; [key: string]: any }) => { @@ -31,9 +32,9 @@ ComponentRegistry.register('breadcrumb',
{idx === (schema.items?.length || 0) - 1 ? ( - {item.label} + {resolveI18nLabel(item.label) ?? ''} ) : ( - {item.label} + {resolveI18nLabel(item.label) ?? ''} )} {idx < (schema.items?.length || 0) - 1 && } diff --git a/packages/components/src/renderers/navigation/header-bar.tsx b/packages/components/src/renderers/navigation/header-bar.tsx index 1881cb555..6ed63448b 100644 --- a/packages/components/src/renderers/navigation/header-bar.tsx +++ b/packages/components/src/renderers/navigation/header-bar.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { HeaderBarSchema } from '@object-ui/types'; +import { resolveI18nLabel } from '@object-ui/react'; import { SidebarTrigger, Separator, @@ -31,9 +32,9 @@ ComponentRegistry.register('header-bar', {idx === schema.crumbs.length - 1 ? ( - {crumb.label} + {resolveI18nLabel(crumb.label) ?? ''} ) : ( - {crumb.label} + {resolveI18nLabel(crumb.label) ?? ''} )} {idx < schema.crumbs.length - 1 && } diff --git a/packages/components/src/ui/sidebar.tsx b/packages/components/src/ui/sidebar.tsx index 914e3d4f3..2b2c6df02 100644 --- a/packages/components/src/ui/sidebar.tsx +++ b/packages/components/src/ui/sidebar.tsx @@ -39,6 +39,11 @@ const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_KEYBOARD_SHORTCUT = "b" +const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width" +const SIDEBAR_MIN_WIDTH = 200 // px +const SIDEBAR_MAX_WIDTH = 480 // px +const SIDEBAR_DEFAULT_WIDTH_PX = 256 // 16rem in px +const SIDEBAR_CLICK_THRESHOLD_PX = 5 type SidebarContextProps = { state: "expanded" | "collapsed" @@ -48,6 +53,10 @@ type SidebarContextProps = { setOpenMobile: (open: boolean) => void isMobile: boolean toggleSidebar: () => void + /** Current sidebar width in pixels (only used when resizable) */ + sidebarWidth: number | null + /** Update sidebar width (only used when resizable) */ + setSidebarWidth: (width: number | null) => void } const SidebarContext = React.createContext(null) @@ -84,6 +93,27 @@ const SidebarProvider = React.forwardRef< const isMobile = useIsMobile() const [openMobile, setOpenMobile] = React.useState(false) + // Resizable sidebar width state (persisted to localStorage) + const [sidebarWidth, setSidebarWidthState] = React.useState(() => { + try { + const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY) + if (!stored) return null + const value = Number(stored) + if (isNaN(value)) return null + return Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, value)) + } catch { return null } + }) + const setSidebarWidth = React.useCallback((width: number | null) => { + setSidebarWidthState(width) + try { + if (width != null) { + localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width)) + } else { + localStorage.removeItem(SIDEBAR_WIDTH_STORAGE_KEY) + } + } catch { /* ignore */ } + }, []) + // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen) @@ -139,8 +169,10 @@ const SidebarProvider = React.forwardRef< openMobile, setOpenMobile, toggleSidebar, + sidebarWidth, + setSidebarWidth, }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, sidebarWidth, setSidebarWidth] ) return ( @@ -149,7 +181,7 @@ const SidebarProvider = React.forwardRef<
>(({ className, ...props }, ref) => { - const { toggleSidebar } = useSidebar() + const { toggleSidebar, setSidebarWidth } = useSidebar() + const dragging = React.useRef(false) + const startX = React.useRef(0) + const startWidth = React.useRef(0) + + const handlePointerDown = React.useCallback((e: React.PointerEvent) => { + // Only initiate resize on left mouse button + if (e.button !== 0) return + e.preventDefault() + dragging.current = true + startX.current = e.clientX + + // Get the current sidebar width from computed CSS variable + const wrapper = (e.target as HTMLElement).closest('[style*="--sidebar-width"]') as HTMLElement | null + startWidth.current = wrapper + ? parseInt(getComputedStyle(wrapper).getPropertyValue('--sidebar-width')) || SIDEBAR_DEFAULT_WIDTH_PX + : SIDEBAR_DEFAULT_WIDTH_PX + + const onPointerMove = (ev: PointerEvent) => { + if (!dragging.current) return + const delta = ev.clientX - startX.current + const newWidth = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, startWidth.current + delta)) + setSidebarWidth(newWidth) + } + + const onPointerUp = () => { + dragging.current = false + document.removeEventListener('pointermove', onPointerMove) + document.removeEventListener('pointerup', onPointerUp) + } + + document.addEventListener('pointermove', onPointerMove) + document.addEventListener('pointerup', onPointerUp) + }, [setSidebarWidth]) + + const handleClick = React.useCallback((e: React.MouseEvent) => { + // Only toggle on click (not at end of drag) + if (Math.abs(e.clientX - startX.current) < SIDEBAR_CLICK_THRESHOLD_PX) { + toggleSidebar() + } + }, [toggleSidebar]) return (